From 964eb75152c1cd327592c9c917c378b65190c964 Mon Sep 17 00:00:00 2001 From: David Dal Busco Date: Fri, 27 Mar 2026 14:55:20 +0100 Subject: [PATCH 01/11] feat(frontend): exchange price proxying Binance --- src/context.ts | 2 ++ src/decorators/exchange/binance.ts | 26 ++++++++++++++++++++++ src/handlers/exchange/price.ts | 23 ++++++++++++++++++++ src/server.ts | 35 +++++++++++++++++------------- 4 files changed, 71 insertions(+), 15 deletions(-) create mode 100644 src/decorators/exchange/binance.ts create mode 100644 src/handlers/exchange/price.ts diff --git a/src/context.ts b/src/context.ts index 85fd481..e06a754 100644 --- a/src/context.ts +++ b/src/context.ts @@ -1,8 +1,10 @@ import type { Context, RouteSchema } from 'elysia'; +import type { BinanceDecorator } from './decorators/exchange/binance'; import type { GitHubDecorator } from './decorators/github'; import type { JwtDecorator } from './decorators/jwt'; export type ApiContext = Context & { github: GitHubDecorator; jwt: JwtDecorator; + binance: BinanceDecorator; }; diff --git a/src/decorators/exchange/binance.ts b/src/decorators/exchange/binance.ts new file mode 100644 index 0000000..f14f652 --- /dev/null +++ b/src/decorators/exchange/binance.ts @@ -0,0 +1,26 @@ +import { z } from 'zod'; + +const BinanceTickerPriceSchema = z.object({ + symbol: z.string(), + price: z.string() +}); + +type BinanceTickerPrice = z.infer; + +export class BinanceDecorator { + fetchTickerPrice = async ({ symbol }: { symbol: string }): Promise => { + // Market data only URL do not require an API key or attribution. + // Reference: https://developers.binance.com/docs/binance-spot-api-docs/faqs/market_data_only + const response = await fetch( + `https://data-api.binance.vision/api/v3/ticker/price?symbol=${symbol}` + ); + + if (!response.ok) { + throw new Error(`Binance API error: ${response.status}`); + } + + const data = await response.json(); + + return BinanceTickerPriceSchema.parse(data); + }; +} diff --git a/src/handlers/exchange/price.ts b/src/handlers/exchange/price.ts new file mode 100644 index 0000000..9f0a44e --- /dev/null +++ b/src/handlers/exchange/price.ts @@ -0,0 +1,23 @@ +import { t } from 'elysia'; +import type { ApiContext } from '../../context'; +import { assertNonNullish } from '../../utils/assert'; + +export const ExchangePriceSchema = t.Object({ + ledgerId: t.String() +}); + +type ExchangePrice = (typeof ExchangePriceSchema)['static']; + +export const LEDGER_TO_SYMBOL: Record = { + 'ryjl3-tyaaa-aaaaa-aaaba-cai': 'ICPUSDT' +}; + +export const exchangePrice = async ({ query, binance }: ApiContext<{ query: ExchangePrice }>) => { + const { ledgerId } = query; + + const symbol = LEDGER_TO_SYMBOL[ledgerId]; + assertNonNullish(symbol, 'Ledger ID not supported'); + + const price = await binance.fetchTickerPrice({ symbol }); + return { price }; +}; diff --git a/src/server.ts b/src/server.ts index 2506c9c..3d04755 100644 --- a/src/server.ts +++ b/src/server.ts @@ -6,12 +6,14 @@ import { GitHubDecorator } from './decorators/github'; import { JwtDecorator } from './decorators/jwt'; import { GitHubApiError, GitHubAuthUnauthorizedError, NullishError } from './errors'; import { - GitHubAuthFinalizeSchema, - GitHubAuthInitSchema, githubAuthFinalize, - githubAuthInit + GitHubAuthFinalizeSchema, + githubAuthInit, + GitHubAuthInitSchema } from './handlers/auth/github'; import { authJwks } from './handlers/auth/jwks'; +import { exchangePrice, ExchangePriceSchema } from './handlers/exchange/price'; +import { BinanceDecorator } from './decorators/exchange/binance'; const { version: appVersion, name: appName, description: appDescription } = packageJson; @@ -45,19 +47,22 @@ export const app = new Elysia() .use(cors()) .decorate('github', new GitHubDecorator()) .decorate('jwt', new JwtDecorator()) + .decorate('binance', new BinanceDecorator()) .group('/v1', (app) => - app.group('/auth', (app) => - app - .get('/certs', authJwks) - .group('/finalize', (app) => - app.post('/github', githubAuthFinalize, { - body: GitHubAuthFinalizeSchema - }) - ) - .group('/init', (app) => - app.get('/github', githubAuthInit, { query: GitHubAuthInitSchema }) - ) - ) + app + .group('/auth', (app) => + app + .get('/certs', authJwks) + .group('/finalize', (app) => + app.post('/github', githubAuthFinalize, { + body: GitHubAuthFinalizeSchema + }) + ) + .group('/init', (app) => + app.get('/github', githubAuthInit, { query: GitHubAuthInitSchema }) + ) + ) + .group('/exchange', (app) => app.get('/price', exchangePrice, { query: ExchangePriceSchema })) ) .listen(3000); From 8e51e49b0a9b6cf12591f9d6f897c3714d857e65 Mon Sep 17 00:00:00 2001 From: David Dal Busco Date: Fri, 27 Mar 2026 15:10:31 +0100 Subject: [PATCH 02/11] feat: cache --- src/decorators/exchange/binance.ts | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/src/decorators/exchange/binance.ts b/src/decorators/exchange/binance.ts index f14f652..9f1126e 100644 --- a/src/decorators/exchange/binance.ts +++ b/src/decorators/exchange/binance.ts @@ -7,7 +7,28 @@ const BinanceTickerPriceSchema = z.object({ type BinanceTickerPrice = z.infer; +const CACHE_TTL = 60_000; + export class BinanceDecorator { + #tickerPricecache = new Map< + BinanceTickerPrice['symbol'], + { price: BinanceTickerPrice; fetchedAt: number } + >(); + + fetchTickerPriceWithCache = async ({ symbol }: { symbol: string }): Promise => { + const cached = this.#tickerPricecache.get(symbol); + + if (cached !== undefined && Date.now() < cached.fetchedAt + CACHE_TTL) { + return cached.price; + } + + const price = await this.fetchTickerPrice({symbol}); + + this.#tickerPricecache.set(symbol, { price, fetchedAt: Date.now() }); + + return price; + } + fetchTickerPrice = async ({ symbol }: { symbol: string }): Promise => { // Market data only URL do not require an API key or attribution. // Reference: https://developers.binance.com/docs/binance-spot-api-docs/faqs/market_data_only From c439a3557db7ef39aa7ef791c83d00b6e8db145c Mon Sep 17 00:00:00 2001 From: David Dal Busco Date: Fri, 27 Mar 2026 15:22:02 +0100 Subject: [PATCH 03/11] feat: rename and response --- src/context.ts | 4 +- src/decorators/exchange.ts | 61 ++++++++++++++++++++++++++++++ src/decorators/exchange/binance.ts | 47 ----------------------- src/handlers/exchange/price.ts | 4 +- src/server.ts | 4 +- 5 files changed, 67 insertions(+), 53 deletions(-) create mode 100644 src/decorators/exchange.ts delete mode 100644 src/decorators/exchange/binance.ts diff --git a/src/context.ts b/src/context.ts index e06a754..a98bb4f 100644 --- a/src/context.ts +++ b/src/context.ts @@ -1,10 +1,10 @@ import type { Context, RouteSchema } from 'elysia'; -import type { BinanceDecorator } from './decorators/exchange/binance'; +import type { ExchangeDecorator } from './decorators/exchange'; import type { GitHubDecorator } from './decorators/github'; import type { JwtDecorator } from './decorators/jwt'; export type ApiContext = Context & { github: GitHubDecorator; jwt: JwtDecorator; - binance: BinanceDecorator; + exchange: ExchangeDecorator; }; diff --git a/src/decorators/exchange.ts b/src/decorators/exchange.ts new file mode 100644 index 0000000..80712e6 --- /dev/null +++ b/src/decorators/exchange.ts @@ -0,0 +1,61 @@ +import { z } from 'zod'; + +const BinanceTickerPriceSchema = z.strictObject({ + symbol: z.string(), + price: z.string() +}); + +type BinanceTickerPrice = z.infer; + +const ExchangeTickerPriceSchema = z.strictObject({ + price: BinanceTickerPriceSchema, + fetchedAt: z.iso.datetime() +}); + +type ExchangeTickerPrice = z.infer; + +const CACHE_TTL = 60_000; + +export class ExchangeDecorator { + #tickerPriceCache = new Map(); + + fetchTickerPriceWithCache = async ({ + symbol + }: { + symbol: string; + }): Promise => { + const cached = this.#tickerPriceCache.get(symbol); + + if (cached !== undefined && Date.now() < new Date(cached.fetchedAt).getTime() + CACHE_TTL) { + return cached; + } + + const price = await this.#fetchBinanceTickerPrice({ symbol }); + + const tickerPrice = { price, fetchedAt: new Date().toISOString() }; + + this.#tickerPriceCache.set(symbol, tickerPrice); + + return tickerPrice; + }; + + #fetchBinanceTickerPrice = async ({ + symbol + }: { + symbol: string; + }): Promise => { + // Market data only URL do not require an API key or attribution. + // Reference: https://developers.binance.com/docs/binance-spot-api-docs/faqs/market_data_only + const response = await fetch( + `https://data-api.binance.vision/api/v3/ticker/price?symbol=${symbol}` + ); + + if (!response.ok) { + throw new Error(`Binance API error: ${response.status}`); + } + + const data = await response.json(); + + return BinanceTickerPriceSchema.parse(data); + }; +} diff --git a/src/decorators/exchange/binance.ts b/src/decorators/exchange/binance.ts deleted file mode 100644 index 9f1126e..0000000 --- a/src/decorators/exchange/binance.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { z } from 'zod'; - -const BinanceTickerPriceSchema = z.object({ - symbol: z.string(), - price: z.string() -}); - -type BinanceTickerPrice = z.infer; - -const CACHE_TTL = 60_000; - -export class BinanceDecorator { - #tickerPricecache = new Map< - BinanceTickerPrice['symbol'], - { price: BinanceTickerPrice; fetchedAt: number } - >(); - - fetchTickerPriceWithCache = async ({ symbol }: { symbol: string }): Promise => { - const cached = this.#tickerPricecache.get(symbol); - - if (cached !== undefined && Date.now() < cached.fetchedAt + CACHE_TTL) { - return cached.price; - } - - const price = await this.fetchTickerPrice({symbol}); - - this.#tickerPricecache.set(symbol, { price, fetchedAt: Date.now() }); - - return price; - } - - fetchTickerPrice = async ({ symbol }: { symbol: string }): Promise => { - // Market data only URL do not require an API key or attribution. - // Reference: https://developers.binance.com/docs/binance-spot-api-docs/faqs/market_data_only - const response = await fetch( - `https://data-api.binance.vision/api/v3/ticker/price?symbol=${symbol}` - ); - - if (!response.ok) { - throw new Error(`Binance API error: ${response.status}`); - } - - const data = await response.json(); - - return BinanceTickerPriceSchema.parse(data); - }; -} diff --git a/src/handlers/exchange/price.ts b/src/handlers/exchange/price.ts index 9f0a44e..fabbc9e 100644 --- a/src/handlers/exchange/price.ts +++ b/src/handlers/exchange/price.ts @@ -12,12 +12,12 @@ export const LEDGER_TO_SYMBOL: Record = { 'ryjl3-tyaaa-aaaaa-aaaba-cai': 'ICPUSDT' }; -export const exchangePrice = async ({ query, binance }: ApiContext<{ query: ExchangePrice }>) => { +export const exchangePrice = async ({ query, exchange }: ApiContext<{ query: ExchangePrice }>) => { const { ledgerId } = query; const symbol = LEDGER_TO_SYMBOL[ledgerId]; assertNonNullish(symbol, 'Ledger ID not supported'); - const price = await binance.fetchTickerPrice({ symbol }); + const price = await exchange.fetchTickerPriceWithCache({ symbol }); return { price }; }; diff --git a/src/server.ts b/src/server.ts index da6cf26..b080af9 100644 --- a/src/server.ts +++ b/src/server.ts @@ -2,7 +2,7 @@ import { cors } from '@elysiajs/cors'; import { openapi } from '@elysiajs/openapi'; import { Elysia } from 'elysia'; import packageJson from '../package.json'; -import { BinanceDecorator } from './decorators/exchange/binance'; +import { ExchangeDecorator } from './decorators/exchange'; import { GitHubDecorator } from './decorators/github'; import { JwtDecorator } from './decorators/jwt'; import { GitHubApiError, GitHubAuthUnauthorizedError, NullishError } from './errors'; @@ -47,7 +47,7 @@ export const app = new Elysia() .use(cors()) .decorate('github', new GitHubDecorator()) .decorate('jwt', new JwtDecorator()) - .decorate('binance', new BinanceDecorator()) + .decorate('exchange', new ExchangeDecorator()) .group('/v1', (app) => app .group('/auth', (app) => From 51e1f67beac940a8b2c9762e40a2ee56510f5c7a Mon Sep 17 00:00:00 2001 From: David Dal Busco Date: Fri, 27 Mar 2026 15:35:23 +0100 Subject: [PATCH 04/11] test: decorator --- src/decorators/exchange.ts | 2 +- src/handlers/exchange/price.ts | 2 +- test/decorators/exchange.test.ts | 87 ++++++++++++++++++++++++++++++++ 3 files changed, 89 insertions(+), 2 deletions(-) create mode 100644 test/decorators/exchange.test.ts diff --git a/src/decorators/exchange.ts b/src/decorators/exchange.ts index 80712e6..c641b9b 100644 --- a/src/decorators/exchange.ts +++ b/src/decorators/exchange.ts @@ -19,7 +19,7 @@ const CACHE_TTL = 60_000; export class ExchangeDecorator { #tickerPriceCache = new Map(); - fetchTickerPriceWithCache = async ({ + fetchTickerPrice = async ({ symbol }: { symbol: string; diff --git a/src/handlers/exchange/price.ts b/src/handlers/exchange/price.ts index fabbc9e..b156644 100644 --- a/src/handlers/exchange/price.ts +++ b/src/handlers/exchange/price.ts @@ -18,6 +18,6 @@ export const exchangePrice = async ({ query, exchange }: ApiContext<{ query: Exc const symbol = LEDGER_TO_SYMBOL[ledgerId]; assertNonNullish(symbol, 'Ledger ID not supported'); - const price = await exchange.fetchTickerPriceWithCache({ symbol }); + const price = await exchange.fetchTickerPrice({ symbol }); return { price }; }; diff --git a/test/decorators/exchange.test.ts b/test/decorators/exchange.test.ts new file mode 100644 index 0000000..646773c --- /dev/null +++ b/test/decorators/exchange.test.ts @@ -0,0 +1,87 @@ +import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from 'bun:test'; +import { ExchangeDecorator } from '../../src/decorators/exchange'; + +describe('decorators > exchange', () => { + const mockTickerPrice = { symbol: 'ICPUSDT', price: '2.23800000' }; + + let exchange: ExchangeDecorator; + + beforeEach(() => { + exchange = new ExchangeDecorator(); + }); + + afterEach(() => { + mock.clearAllMocks(); + mock.restore(); + }); + + describe('fetchTickerPrice', () => { + it('should fetch and return ticker price', async () => { + spyOn(global, 'fetch').mockResolvedValueOnce(Response.json(mockTickerPrice)); + + const result = await exchange.fetchTickerPrice({ symbol: 'ICPUSDT' }); + + expect(result.price).toEqual(mockTickerPrice); + expect(result.fetchedAt).toBeString(); + expect(new Date(result.fetchedAt).toISOString()).toBe(result.fetchedAt); + }); + + it('should call Binance API with correct URL', async () => { + spyOn(global, 'fetch').mockResolvedValueOnce(Response.json(mockTickerPrice)); + + await exchange.fetchTickerPrice({ symbol: 'ICPUSDT' }); + + expect(global.fetch).toHaveBeenCalledWith( + 'https://data-api.binance.vision/api/v3/ticker/price?symbol=ICPUSDT' + ); + }); + + it('should return cached value within TTL', async () => { + const fetchSpy = spyOn(global, 'fetch').mockResolvedValueOnce(Response.json(mockTickerPrice)); + + await exchange.fetchTickerPrice({ symbol: 'ICPUSDT' }); + await exchange.fetchTickerPrice({ symbol: 'ICPUSDT' }); + + expect(fetchSpy).toHaveBeenCalledTimes(1); + }); + + it('should refetch after TTL expires', async () => { + const fetchSpy = spyOn(global, 'fetch') + .mockResolvedValueOnce(Response.json(mockTickerPrice)) + .mockResolvedValueOnce(Response.json(mockTickerPrice)); + + await exchange.fetchTickerPrice({ symbol: 'ICPUSDT' }); + + spyOn(Date, 'now').mockReturnValue(Date.now() + 61_000); + + await exchange.fetchTickerPrice({ symbol: 'ICPUSDT' }); + + expect(fetchSpy).toHaveBeenCalledTimes(2); + }); + + it('should cache different symbols independently', async () => { + const fetchSpy = spyOn(global, 'fetch') + .mockResolvedValueOnce(Response.json({ symbol: 'ICPUSDT', price: '2.23800000' })) + .mockResolvedValueOnce(Response.json({ symbol: 'BTCUSDT', price: '50000.00' })); + + await exchange.fetchTickerPrice({ symbol: 'ICPUSDT' }); + await exchange.fetchTickerPrice({ symbol: 'BTCUSDT' }); + + expect(fetchSpy).toHaveBeenCalledTimes(2); + }); + + it('should throw on Binance API error', async () => { + spyOn(global, 'fetch').mockResolvedValueOnce(new Response('{}', { status: 500 })); + + expect(exchange.fetchTickerPrice({ symbol: 'ICPUSDT' })).rejects.toThrow( + 'Binance API error: 500' + ); + }); + + it('should throw on invalid response schema', async () => { + spyOn(global, 'fetch').mockResolvedValueOnce(Response.json({ unexpected: 'data' })); + + expect(exchange.fetchTickerPrice({ symbol: 'ICPUSDT' })).rejects.toThrow(); + }); + }); +}); From 06cdb0d9a642d0bf659b66980df75f947bd8e076 Mon Sep 17 00:00:00 2001 From: David Dal Busco Date: Fri, 27 Mar 2026 15:49:27 +0100 Subject: [PATCH 05/11] test: error --- src/decorators/exchange.ts | 9 +++------ test/decorators/exchange.test.ts | 5 ++--- 2 files changed, 5 insertions(+), 9 deletions(-) diff --git a/src/decorators/exchange.ts b/src/decorators/exchange.ts index c641b9b..c94d000 100644 --- a/src/decorators/exchange.ts +++ b/src/decorators/exchange.ts @@ -1,4 +1,5 @@ import { z } from 'zod'; +import { FetchApiError } from '../errors'; const BinanceTickerPriceSchema = z.strictObject({ symbol: z.string(), @@ -19,11 +20,7 @@ const CACHE_TTL = 60_000; export class ExchangeDecorator { #tickerPriceCache = new Map(); - fetchTickerPrice = async ({ - symbol - }: { - symbol: string; - }): Promise => { + fetchTickerPrice = async ({ symbol }: { symbol: string }): Promise => { const cached = this.#tickerPriceCache.get(symbol); if (cached !== undefined && Date.now() < new Date(cached.fetchedAt).getTime() + CACHE_TTL) { @@ -51,7 +48,7 @@ export class ExchangeDecorator { ); if (!response.ok) { - throw new Error(`Binance API error: ${response.status}`); + throw new FetchApiError(response.status, `Binance API error: ${response.status}`); } const data = await response.json(); diff --git a/test/decorators/exchange.test.ts b/test/decorators/exchange.test.ts index 646773c..684ecb5 100644 --- a/test/decorators/exchange.test.ts +++ b/test/decorators/exchange.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from 'bun:test'; import { ExchangeDecorator } from '../../src/decorators/exchange'; +import { FetchApiError } from '../../src/errors'; describe('decorators > exchange', () => { const mockTickerPrice = { symbol: 'ICPUSDT', price: '2.23800000' }; @@ -73,9 +74,7 @@ describe('decorators > exchange', () => { it('should throw on Binance API error', async () => { spyOn(global, 'fetch').mockResolvedValueOnce(new Response('{}', { status: 500 })); - expect(exchange.fetchTickerPrice({ symbol: 'ICPUSDT' })).rejects.toThrow( - 'Binance API error: 500' - ); + expect(exchange.fetchTickerPrice({ symbol: 'ICPUSDT' })).rejects.toThrow(FetchApiError); }); it('should throw on invalid response schema', async () => { From c117d4993d996e34939effed76941ac9725a421d Mon Sep 17 00:00:00 2001 From: David Dal Busco Date: Fri, 27 Mar 2026 15:50:51 +0100 Subject: [PATCH 06/11] test: price --- test/decorators/exchange.test.ts | 1 - test/handlers/exchange/price.test.ts | 43 ++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 1 deletion(-) create mode 100644 test/handlers/exchange/price.test.ts diff --git a/test/decorators/exchange.test.ts b/test/decorators/exchange.test.ts index 684ecb5..dc462fa 100644 --- a/test/decorators/exchange.test.ts +++ b/test/decorators/exchange.test.ts @@ -13,7 +13,6 @@ describe('decorators > exchange', () => { afterEach(() => { mock.clearAllMocks(); - mock.restore(); }); describe('fetchTickerPrice', () => { diff --git a/test/handlers/exchange/price.test.ts b/test/handlers/exchange/price.test.ts new file mode 100644 index 0000000..fc64e7c --- /dev/null +++ b/test/handlers/exchange/price.test.ts @@ -0,0 +1,43 @@ +import { afterEach, describe, expect, it, mock, spyOn } from 'bun:test'; +import type { ApiContext } from '../../../src/context'; +import { ExchangeDecorator } from '../../../src/decorators/exchange'; +import { exchangePrice } from '../../../src/handlers/exchange/price'; + +describe('handlers > exchange > price', () => { + const mockTickerPrice = { symbol: 'ICPUSDT', price: '2.23800000' }; + + const mockExchangeTickerPrice = { + price: mockTickerPrice, + fetchedAt: new Date().toISOString() + }; + + afterEach(() => { + mock.clearAllMocks(); + }); + + it('should return price for supported ledger ID', async () => { + const exchange = new ExchangeDecorator(); + spyOn(exchange, 'fetchTickerPrice').mockResolvedValueOnce(mockExchangeTickerPrice); + + const context = { + exchange, + query: { ledgerId: 'ryjl3-tyaaa-aaaaa-aaaba-cai' } + } as unknown as ApiContext<{ query: { ledgerId: string } }>; + + const result = await exchangePrice(context); + + expect(result.price).toEqual(mockExchangeTickerPrice); + expect(exchange.fetchTickerPrice).toHaveBeenCalledWith({ symbol: 'ICPUSDT' }); + }); + + it('should throw for unsupported ledger ID', async () => { + const exchange = new ExchangeDecorator(); + + const context = { + exchange, + query: { ledgerId: 'unknown-ledger-id' } + } as unknown as ApiContext<{ query: { ledgerId: string } }>; + + expect(exchangePrice(context)).rejects.toThrow('Ledger ID not supported'); + }); +}); From 32d9eaae63e8f07a8af75f89d56bf08fe2502ddb Mon Sep 17 00:00:00 2001 From: David Dal Busco Date: Fri, 27 Mar 2026 15:57:44 +0100 Subject: [PATCH 07/11] test: server --- test/decorators/exchange.test.ts | 1 + test/decorators/jwt.test.ts | 7 ++++- test/server.test.ts | 49 ++++++++++++++++++++++++++++++++ 3 files changed, 56 insertions(+), 1 deletion(-) diff --git a/test/decorators/exchange.test.ts b/test/decorators/exchange.test.ts index dc462fa..684ecb5 100644 --- a/test/decorators/exchange.test.ts +++ b/test/decorators/exchange.test.ts @@ -13,6 +13,7 @@ describe('decorators > exchange', () => { afterEach(() => { mock.clearAllMocks(); + mock.restore(); }); describe('fetchTickerPrice', () => { diff --git a/test/decorators/jwt.test.ts b/test/decorators/jwt.test.ts index c0e6455..6a1bbe5 100644 --- a/test/decorators/jwt.test.ts +++ b/test/decorators/jwt.test.ts @@ -1,4 +1,4 @@ -import { beforeAll, describe, expect, it } from 'bun:test'; +import { afterEach, beforeAll, describe, expect, it, mock } from 'bun:test'; import { JwtDecorator } from '../../src/decorators/jwt'; describe('decorators > jwt', () => { @@ -8,6 +8,11 @@ describe('decorators > jwt', () => { jwt = new JwtDecorator(); }); + afterEach(() => { + mock.clearAllMocks(); + mock.restore(); + }); + describe('signOpenIdJwt', () => { it('should create valid OpenID JWT', async () => { const token = await jwt.signOpenIdJwt({ diff --git a/test/server.test.ts b/test/server.test.ts index 98be692..c3fb0c1 100644 --- a/test/server.test.ts +++ b/test/server.test.ts @@ -169,6 +169,55 @@ describe('server', () => { }); }); + describe('GET /v1/exchange/price', () => { + it('should return price for supported ledger ID', async () => { + spyOn(global, 'fetch').mockImplementation((async (url: string) => { + if (url.includes('data-api.binance.vision')) { + return Response.json({ symbol: 'ICPUSDT', price: '2.23800000' }); + } + return new Response('Not found', { status: 404 }); + }) as typeof fetch); + + const { app } = await import('../src/server'); + const response = await app.handle( + new Request('http://localhost/v1/exchange/price?ledgerId=ryjl3-tyaaa-aaaaa-aaaba-cai') + ); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.price.price.symbol).toBe('ICPUSDT'); + expect(data.price.price.price).toBe('2.23800000'); + expect(data.price.fetchedAt).toBeString(); + }); + + it('should return 500 for unsupported ledger ID', async () => { + const { app } = await import('../src/server'); + const response = await app.handle( + new Request('http://localhost/v1/exchange/price?ledgerId=unknown-ledger-id') + ); + + expect(response.status).toBe(500); + }); + + it('should return 503 on exchange API error', async () => { + spyOn(Date, 'now').mockReturnValue(Date.now() + 61_000); + + spyOn(global, 'fetch').mockImplementation((async (url: string) => { + if (url.includes('data-api.binance.vision')) { + return new Response('{}', { status: 503 }); + } + return new Response('Not found', { status: 404 }); + }) as typeof fetch); + + const { app } = await import('../src/server'); + const response = await app.handle( + new Request('http://localhost/v1/exchange/price?ledgerId=ryjl3-tyaaa-aaaaa-aaaba-cai') + ); + + expect(response.status).toBe(503); + }); + }); + describe('Error handling', () => { it('should return 404 for unknown routes', async () => { const { app } = await import('../src/server'); From 63bc28a1bc52eff0f682c619143f8e00abb55efc Mon Sep 17 00:00:00 2001 From: David Dal Busco Date: Fri, 27 Mar 2026 16:05:54 +0100 Subject: [PATCH 08/11] feat: params instead of query --- src/handlers/exchange/price.ts | 7 +++++-- src/server.ts | 4 +++- test/handlers/exchange/price.test.ts | 8 ++++---- test/server.test.ts | 6 +++--- 4 files changed, 15 insertions(+), 10 deletions(-) diff --git a/src/handlers/exchange/price.ts b/src/handlers/exchange/price.ts index b156644..a3342ed 100644 --- a/src/handlers/exchange/price.ts +++ b/src/handlers/exchange/price.ts @@ -12,8 +12,11 @@ export const LEDGER_TO_SYMBOL: Record = { 'ryjl3-tyaaa-aaaaa-aaaba-cai': 'ICPUSDT' }; -export const exchangePrice = async ({ query, exchange }: ApiContext<{ query: ExchangePrice }>) => { - const { ledgerId } = query; +export const exchangePrice = async ({ + params, + exchange +}: ApiContext<{ params: ExchangePrice }>) => { + const { ledgerId } = params; const symbol = LEDGER_TO_SYMBOL[ledgerId]; assertNonNullish(symbol, 'Ledger ID not supported'); diff --git a/src/server.ts b/src/server.ts index fd21184..f38bf1a 100644 --- a/src/server.ts +++ b/src/server.ts @@ -62,7 +62,9 @@ export const app = new Elysia() app.get('/github', githubAuthInit, { query: GitHubAuthInitSchema }) ) ) - .group('/exchange', (app) => app.get('/price', exchangePrice, { query: ExchangePriceSchema })) + .group('/exchange', (app) => + app.get('/price/:ledgerId', exchangePrice, { params: ExchangePriceSchema }) + ) ) .listen(3000); diff --git a/test/handlers/exchange/price.test.ts b/test/handlers/exchange/price.test.ts index fc64e7c..db1212f 100644 --- a/test/handlers/exchange/price.test.ts +++ b/test/handlers/exchange/price.test.ts @@ -21,8 +21,8 @@ describe('handlers > exchange > price', () => { const context = { exchange, - query: { ledgerId: 'ryjl3-tyaaa-aaaaa-aaaba-cai' } - } as unknown as ApiContext<{ query: { ledgerId: string } }>; + params: { ledgerId: 'ryjl3-tyaaa-aaaaa-aaaba-cai' } + } as unknown as ApiContext<{ params: { ledgerId: string } }>; const result = await exchangePrice(context); @@ -35,8 +35,8 @@ describe('handlers > exchange > price', () => { const context = { exchange, - query: { ledgerId: 'unknown-ledger-id' } - } as unknown as ApiContext<{ query: { ledgerId: string } }>; + params: { ledgerId: 'unknown-ledger-id' } + } as unknown as ApiContext<{ params: { ledgerId: string } }>; expect(exchangePrice(context)).rejects.toThrow('Ledger ID not supported'); }); diff --git a/test/server.test.ts b/test/server.test.ts index c3fb0c1..8c15e0f 100644 --- a/test/server.test.ts +++ b/test/server.test.ts @@ -180,7 +180,7 @@ describe('server', () => { const { app } = await import('../src/server'); const response = await app.handle( - new Request('http://localhost/v1/exchange/price?ledgerId=ryjl3-tyaaa-aaaaa-aaaba-cai') + new Request('http://localhost/v1/exchange/price/ryjl3-tyaaa-aaaaa-aaaba-cai') ); const data = await response.json(); @@ -193,7 +193,7 @@ describe('server', () => { it('should return 500 for unsupported ledger ID', async () => { const { app } = await import('../src/server'); const response = await app.handle( - new Request('http://localhost/v1/exchange/price?ledgerId=unknown-ledger-id') + new Request('http://localhost/v1/exchange/price/unknown-ledger-id') ); expect(response.status).toBe(500); @@ -211,7 +211,7 @@ describe('server', () => { const { app } = await import('../src/server'); const response = await app.handle( - new Request('http://localhost/v1/exchange/price?ledgerId=ryjl3-tyaaa-aaaaa-aaaba-cai') + new Request('http://localhost/v1/exchange/price/ryjl3-tyaaa-aaaaa-aaaba-cai') ); expect(response.status).toBe(503); From bc212da38b49dca5665042f1f40b6d38f4ce4375 Mon Sep 17 00:00:00 2001 From: David Dal Busco Date: Fri, 27 Mar 2026 16:08:53 +0100 Subject: [PATCH 09/11] feat: rename --- src/decorators/exchange.ts | 12 ++++++------ src/handlers/exchange/price.ts | 2 +- test/decorators/exchange.test.ts | 20 ++++++++++---------- test/handlers/exchange/price.test.ts | 4 ++-- 4 files changed, 19 insertions(+), 19 deletions(-) diff --git a/src/decorators/exchange.ts b/src/decorators/exchange.ts index c94d000..aaf30dd 100644 --- a/src/decorators/exchange.ts +++ b/src/decorators/exchange.ts @@ -8,20 +8,20 @@ const BinanceTickerPriceSchema = z.strictObject({ type BinanceTickerPrice = z.infer; -const ExchangeTickerPriceSchema = z.strictObject({ +const ExchangePriceSchema = z.strictObject({ price: BinanceTickerPriceSchema, fetchedAt: z.iso.datetime() }); -type ExchangeTickerPrice = z.infer; +type ExchangePrice = z.infer; const CACHE_TTL = 60_000; export class ExchangeDecorator { - #tickerPriceCache = new Map(); + #priceCache = new Map(); - fetchTickerPrice = async ({ symbol }: { symbol: string }): Promise => { - const cached = this.#tickerPriceCache.get(symbol); + fetchPrice = async ({ symbol }: { symbol: string }): Promise => { + const cached = this.#priceCache.get(symbol); if (cached !== undefined && Date.now() < new Date(cached.fetchedAt).getTime() + CACHE_TTL) { return cached; @@ -31,7 +31,7 @@ export class ExchangeDecorator { const tickerPrice = { price, fetchedAt: new Date().toISOString() }; - this.#tickerPriceCache.set(symbol, tickerPrice); + this.#priceCache.set(symbol, tickerPrice); return tickerPrice; }; diff --git a/src/handlers/exchange/price.ts b/src/handlers/exchange/price.ts index a3342ed..2d8d337 100644 --- a/src/handlers/exchange/price.ts +++ b/src/handlers/exchange/price.ts @@ -21,6 +21,6 @@ export const exchangePrice = async ({ const symbol = LEDGER_TO_SYMBOL[ledgerId]; assertNonNullish(symbol, 'Ledger ID not supported'); - const price = await exchange.fetchTickerPrice({ symbol }); + const price = await exchange.fetchPrice({ symbol }); return { price }; }; diff --git a/test/decorators/exchange.test.ts b/test/decorators/exchange.test.ts index 684ecb5..a2f76c1 100644 --- a/test/decorators/exchange.test.ts +++ b/test/decorators/exchange.test.ts @@ -20,7 +20,7 @@ describe('decorators > exchange', () => { it('should fetch and return ticker price', async () => { spyOn(global, 'fetch').mockResolvedValueOnce(Response.json(mockTickerPrice)); - const result = await exchange.fetchTickerPrice({ symbol: 'ICPUSDT' }); + const result = await exchange.fetchPrice({ symbol: 'ICPUSDT' }); expect(result.price).toEqual(mockTickerPrice); expect(result.fetchedAt).toBeString(); @@ -30,7 +30,7 @@ describe('decorators > exchange', () => { it('should call Binance API with correct URL', async () => { spyOn(global, 'fetch').mockResolvedValueOnce(Response.json(mockTickerPrice)); - await exchange.fetchTickerPrice({ symbol: 'ICPUSDT' }); + await exchange.fetchPrice({ symbol: 'ICPUSDT' }); expect(global.fetch).toHaveBeenCalledWith( 'https://data-api.binance.vision/api/v3/ticker/price?symbol=ICPUSDT' @@ -40,8 +40,8 @@ describe('decorators > exchange', () => { it('should return cached value within TTL', async () => { const fetchSpy = spyOn(global, 'fetch').mockResolvedValueOnce(Response.json(mockTickerPrice)); - await exchange.fetchTickerPrice({ symbol: 'ICPUSDT' }); - await exchange.fetchTickerPrice({ symbol: 'ICPUSDT' }); + await exchange.fetchPrice({ symbol: 'ICPUSDT' }); + await exchange.fetchPrice({ symbol: 'ICPUSDT' }); expect(fetchSpy).toHaveBeenCalledTimes(1); }); @@ -51,11 +51,11 @@ describe('decorators > exchange', () => { .mockResolvedValueOnce(Response.json(mockTickerPrice)) .mockResolvedValueOnce(Response.json(mockTickerPrice)); - await exchange.fetchTickerPrice({ symbol: 'ICPUSDT' }); + await exchange.fetchPrice({ symbol: 'ICPUSDT' }); spyOn(Date, 'now').mockReturnValue(Date.now() + 61_000); - await exchange.fetchTickerPrice({ symbol: 'ICPUSDT' }); + await exchange.fetchPrice({ symbol: 'ICPUSDT' }); expect(fetchSpy).toHaveBeenCalledTimes(2); }); @@ -65,8 +65,8 @@ describe('decorators > exchange', () => { .mockResolvedValueOnce(Response.json({ symbol: 'ICPUSDT', price: '2.23800000' })) .mockResolvedValueOnce(Response.json({ symbol: 'BTCUSDT', price: '50000.00' })); - await exchange.fetchTickerPrice({ symbol: 'ICPUSDT' }); - await exchange.fetchTickerPrice({ symbol: 'BTCUSDT' }); + await exchange.fetchPrice({ symbol: 'ICPUSDT' }); + await exchange.fetchPrice({ symbol: 'BTCUSDT' }); expect(fetchSpy).toHaveBeenCalledTimes(2); }); @@ -74,13 +74,13 @@ describe('decorators > exchange', () => { it('should throw on Binance API error', async () => { spyOn(global, 'fetch').mockResolvedValueOnce(new Response('{}', { status: 500 })); - expect(exchange.fetchTickerPrice({ symbol: 'ICPUSDT' })).rejects.toThrow(FetchApiError); + expect(exchange.fetchPrice({ symbol: 'ICPUSDT' })).rejects.toThrow(FetchApiError); }); it('should throw on invalid response schema', async () => { spyOn(global, 'fetch').mockResolvedValueOnce(Response.json({ unexpected: 'data' })); - expect(exchange.fetchTickerPrice({ symbol: 'ICPUSDT' })).rejects.toThrow(); + expect(exchange.fetchPrice({ symbol: 'ICPUSDT' })).rejects.toThrow(); }); }); }); diff --git a/test/handlers/exchange/price.test.ts b/test/handlers/exchange/price.test.ts index db1212f..ceba9ef 100644 --- a/test/handlers/exchange/price.test.ts +++ b/test/handlers/exchange/price.test.ts @@ -17,7 +17,7 @@ describe('handlers > exchange > price', () => { it('should return price for supported ledger ID', async () => { const exchange = new ExchangeDecorator(); - spyOn(exchange, 'fetchTickerPrice').mockResolvedValueOnce(mockExchangeTickerPrice); + spyOn(exchange, 'fetchPrice').mockResolvedValueOnce(mockExchangeTickerPrice); const context = { exchange, @@ -27,7 +27,7 @@ describe('handlers > exchange > price', () => { const result = await exchangePrice(context); expect(result.price).toEqual(mockExchangeTickerPrice); - expect(exchange.fetchTickerPrice).toHaveBeenCalledWith({ symbol: 'ICPUSDT' }); + expect(exchange.fetchPrice).toHaveBeenCalledWith({ symbol: 'ICPUSDT' }); }); it('should throw for unsupported ledger ID', async () => { From 387af03afce97cc786d3098aacc88d786ce84640 Mon Sep 17 00:00:00 2001 From: David Dal Busco Date: Fri, 27 Mar 2026 16:48:48 +0100 Subject: [PATCH 10/11] feat: inline price --- src/decorators/exchange.ts | 4 ++-- test/decorators/exchange.test.ts | 3 ++- test/handlers/exchange/price.test.ts | 2 +- test/server.test.ts | 4 ++-- 4 files changed, 7 insertions(+), 6 deletions(-) diff --git a/src/decorators/exchange.ts b/src/decorators/exchange.ts index aaf30dd..91f4c44 100644 --- a/src/decorators/exchange.ts +++ b/src/decorators/exchange.ts @@ -9,7 +9,7 @@ const BinanceTickerPriceSchema = z.strictObject({ type BinanceTickerPrice = z.infer; const ExchangePriceSchema = z.strictObject({ - price: BinanceTickerPriceSchema, + ...BinanceTickerPriceSchema.shape, fetchedAt: z.iso.datetime() }); @@ -29,7 +29,7 @@ export class ExchangeDecorator { const price = await this.#fetchBinanceTickerPrice({ symbol }); - const tickerPrice = { price, fetchedAt: new Date().toISOString() }; + const tickerPrice = { ...price, fetchedAt: new Date().toISOString() }; this.#priceCache.set(symbol, tickerPrice); diff --git a/test/decorators/exchange.test.ts b/test/decorators/exchange.test.ts index a2f76c1..a210a72 100644 --- a/test/decorators/exchange.test.ts +++ b/test/decorators/exchange.test.ts @@ -22,7 +22,8 @@ describe('decorators > exchange', () => { const result = await exchange.fetchPrice({ symbol: 'ICPUSDT' }); - expect(result.price).toEqual(mockTickerPrice); + expect(result.symbol).toBe('ICPUSDT'); + expect(result.price).toBe('2.23800000'); expect(result.fetchedAt).toBeString(); expect(new Date(result.fetchedAt).toISOString()).toBe(result.fetchedAt); }); diff --git a/test/handlers/exchange/price.test.ts b/test/handlers/exchange/price.test.ts index ceba9ef..e8be24e 100644 --- a/test/handlers/exchange/price.test.ts +++ b/test/handlers/exchange/price.test.ts @@ -7,7 +7,7 @@ describe('handlers > exchange > price', () => { const mockTickerPrice = { symbol: 'ICPUSDT', price: '2.23800000' }; const mockExchangeTickerPrice = { - price: mockTickerPrice, + ...mockTickerPrice, fetchedAt: new Date().toISOString() }; diff --git a/test/server.test.ts b/test/server.test.ts index 8c15e0f..b7581d9 100644 --- a/test/server.test.ts +++ b/test/server.test.ts @@ -185,8 +185,8 @@ describe('server', () => { const data = await response.json(); expect(response.status).toBe(200); - expect(data.price.price.symbol).toBe('ICPUSDT'); - expect(data.price.price.price).toBe('2.23800000'); + expect(data.price.symbol).toBe('ICPUSDT'); + expect(data.price.price).toBe('2.23800000'); expect(data.price.fetchedAt).toBeString(); }); From ae88e9733527fa9c156b90d48a16c1ec86659fdd Mon Sep 17 00:00:00 2001 From: David Dal Busco Date: Fri, 27 Mar 2026 17:24:28 +0100 Subject: [PATCH 11/11] docs: list exchange feature --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 69d2bd8..10356c5 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,8 @@ This API is designed to be deployed independently, giving you full control over - **GitHub:** - Proxy OAuth integration with JWT token generation - JWKS Endpoint: Public key discovery for the token verification by Juno's authentication module +- **Exchange:** + - ICP/USD price feed proxied from a public market data source, no API key required ## Quick Start