From f672e23d814f9e6b9061260940811418318358e4 Mon Sep 17 00:00:00 2001 From: Volodymyr Zakhovaiko Date: Thu, 24 Sep 2026 14:24:32 +0200 Subject: [PATCH 1/2] feat(wallet): typed swap refusals from the desk instead of a 500 A swap adapter had no way to refuse a quote or a fill that the router understood, so a replayed quote or an over-limit swap reached the player as a 500. The port now carries SwapRefusedError (CONFLICT) and SwapLimitExceededError (BAD_REQUEST), each with a machine-readable data.reason, mapped on both swap.quote and swap.execute. SwapFillAmountMissingError stays unmapped. Claude-Session: https://claude.ai/code/session_0178TH3L8uHsQUGnf8bsfmFF --- packages/core/src/contracts/adapters/index.ts | 9 +- packages/core/src/contracts/adapters/swap.ts | 39 ++++++ packages/core/src/wallet/router/index.ts | 19 ++- .../wallet-swap-refusals.e2e.test.ts | 117 ++++++++++++++++++ packages/testing/src/test-swap-desk-plugin.ts | 60 +++++++++ 5 files changed, 239 insertions(+), 5 deletions(-) create mode 100644 packages/testing/src/__tests__/wallet-swap-refusals.e2e.test.ts create mode 100644 packages/testing/src/test-swap-desk-plugin.ts diff --git a/packages/core/src/contracts/adapters/index.ts b/packages/core/src/contracts/adapters/index.ts index e3ab5a0a..9afbdf46 100644 --- a/packages/core/src/contracts/adapters/index.ts +++ b/packages/core/src/contracts/adapters/index.ts @@ -236,8 +236,15 @@ export type { SwapWebhookEvent, SwapAdapter, SwapWebhookVerifier, + SwapRefusalReason, + SwapLimitReason, +} from './swap.js'; +export { + SWAP_ADAPTER, + SWAP_WEBHOOK_VERIFIER, + SwapRefusedError, + SwapLimitExceededError, } from './swap.js'; -export { SWAP_ADAPTER, SWAP_WEBHOOK_VERIFIER } from './swap.js'; export type { GeoIpAdapter } from './geo-ip.js'; export { GEO_IP_ADAPTER, normalizeCountryCode } from './geo-ip.js'; diff --git a/packages/core/src/contracts/adapters/swap.ts b/packages/core/src/contracts/adapters/swap.ts index d3575227..f49183ac 100644 --- a/packages/core/src/contracts/adapters/swap.ts +++ b/packages/core/src/contracts/adapters/swap.ts @@ -86,6 +86,45 @@ export type SwapAdapter = { export const SWAP_ADAPTER: Token = createToken('SWAP_ADAPTER'); +/** + * `quote_invalid` covers a tampered quote id and one issued to another player - the caller + * must not learn which. `no_rate` means the desk could not value the swap to check a limit. + */ +export type SwapRefusalReason = + | 'quote_missing' + | 'quote_invalid' + | 'quote_expired' + | 'quote_spent' + | 'insufficient_inventory' + | 'no_rate'; + +/** + * Thrown by `getQuote`/`execute` when the desk refuses a swap the player can retry later or + * with a fresh quote. The router answers CONFLICT with `data.reason`; any other throw is a 500. + */ +export class SwapRefusedError extends Error { + readonly data: { reason: SwapRefusalReason }; + + constructor(reason: SwapRefusalReason, message: string) { + super(message); + this.name = 'SwapRefusedError'; + this.data = { reason }; + } +} + +export type SwapLimitReason = 'over_swap_limit' | 'over_daily_limit'; + +/** Thrown by `getQuote`/`execute` when the swap is larger than the desk allows; the router answers BAD_REQUEST. */ +export class SwapLimitExceededError extends Error { + readonly data: { reason: SwapLimitReason }; + + constructor(reason: SwapLimitReason, message: string) { + super(message); + this.name = 'SwapLimitExceededError'; + this.data = { reason }; + } +} + /** Its own token, not a reuse of `PAYMENT_WEBHOOK_VERIFIER`: the swap vendor signs with a different key. Fails closed. */ export type SwapWebhookVerifier = { verify( diff --git a/packages/core/src/wallet/router/index.ts b/packages/core/src/wallet/router/index.ts index 6df6809e..3c60c120 100644 --- a/packages/core/src/wallet/router/index.ts +++ b/packages/core/src/wallet/router/index.ts @@ -13,6 +13,8 @@ import { RATE_LIMIT_KEYS, RgLimitExceededError, PaymentRejectedError, + SwapLimitExceededError, + SwapRefusedError, makeRateLimitKey, type AuditWritePort, type JobQueueAdapter, @@ -501,8 +503,12 @@ export function createWalletRouter({ swap: { quote: os.swap.quote.handler(({ input, context }) => - mapErrors({ CONFLICT: [SwapUnavailableError, SwapPairUnsupportedError] }, () => - requireSwap().quote({ userId: getUserId(context), ...input }), + mapErrors( + { + CONFLICT: [SwapUnavailableError, SwapPairUnsupportedError, SwapRefusedError], + BAD_REQUEST: SwapLimitExceededError, + }, + () => requireSwap().quote({ userId: getUserId(context), ...input }), ), ), @@ -510,8 +516,13 @@ export function createWalletRouter({ mapErrors( { NOT_FOUND: WalletNotFoundError, - CONFLICT: [SwapUnavailableError, SwapPairUnsupportedError, IdempotencyKeyReuseError], - BAD_REQUEST: InsufficientBalanceError, + CONFLICT: [ + SwapUnavailableError, + SwapPairUnsupportedError, + IdempotencyKeyReuseError, + SwapRefusedError, + ], + BAD_REQUEST: [InsufficientBalanceError, SwapLimitExceededError], // SwapFillAmountMissingError is deliberately unmapped: the vendor filled but // would not say how much, which is a 500 on our side of the seam, not a 4xx // the caller can act on. diff --git a/packages/testing/src/__tests__/wallet-swap-refusals.e2e.test.ts b/packages/testing/src/__tests__/wallet-swap-refusals.e2e.test.ts new file mode 100644 index 00000000..9fde939e --- /dev/null +++ b/packages/testing/src/__tests__/wallet-swap-refusals.e2e.test.ts @@ -0,0 +1,117 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { randomUUID } from 'node:crypto'; +import { fileURLToPath } from 'node:url'; +import { loadExtensions } from '@openora/core/server'; +import { + asAdmin, + bootTestApp, + registerAndMaterializePlayer, + seedMinimal, + setupTestDb, + type TestApp, + type TestClient, + type TestDb, +} from '../index.js'; +import { TEST_SWAP_DESK_LIMIT } from '../test-swap-desk-plugin.js'; + +const swapDeskPluginPath = fileURLToPath(new URL('../test-swap-desk-plugin.ts', import.meta.url)); + +let db: TestDb; +let testApp: TestApp; + +async function json(res: Response): Promise> { + const body: unknown = await res.json(); + if (body === null || typeof body !== 'object') { + throw new Error('expected JSON object'); + } + return body as Record; +} + +async function fundedPlayer(amount: string): Promise { + const { client, userId } = await registerAndMaterializePlayer(testApp, { + email: `swap-refusal-${randomUUID()}@e2e.test`, + }); + const admin = await asAdmin(testApp.app); + const credited = await admin.post('/wallet/manual-adjustments', { + userId, + direction: 'credit', + amount, + currency: 'USD', + reason: 'swap test funds', + idempotencyKey: randomUUID(), + }); + expect(credited.status).toBe(200); + return client; +} + +beforeAll(async () => { + process.env['BETTER_AUTH_SECRET'] ??= 'e2e-test-better-auth-secret-please-change-000000'; + process.env['AUTH_SECRET'] ??= process.env['BETTER_AUTH_SECRET']; + process.env['WITHDRAWAL_PIN_HMAC_SECRET'] ??= 'e2e-test-withdrawal-pin-hmac-secret-000000'; + process.env['NODE_ENV'] ??= 'test'; + db = await setupTestDb(); + testApp = await bootTestApp({ + plugins: [...(await loadExtensions()), { id: 'testing-swap-desk', path: swapDeskPluginPath }], + databaseUrl: db.url, + }); + await seedMinimal(testApp.container, { playerCount: 0 }); +}, 60_000); + +afterAll(async () => { + await testApp?.close(); + await db?.dispose(); +}); + +describe('POST /wallet/swap/quote', () => { + it('prices a swap inside the desk limit', async () => { + const player = await fundedPlayer('10'); + const res = await player.post('/wallet/swap/quote', { + fromCurrency: 'USD', + toCurrency: 'EUR', + fromAmount: '5', + }); + expect(res.status).toBe(200); + expect((await json(res))['toAmount']).toBe('5'); + }); + + it('answers a swap over the desk limit with 400 and a reason, not a 500', async () => { + const player = await fundedPlayer('10'); + const res = await player.post('/wallet/swap/quote', { + fromCurrency: 'USD', + toCurrency: 'EUR', + fromAmount: String(TEST_SWAP_DESK_LIMIT + 1), + }); + expect(res.status).toBe(400); + expect((await json(res))['data']).toEqual({ reason: 'over_swap_limit' }); + }); +}); + +describe('POST /wallet/swap', () => { + it('fills a quote, then answers its replay with 409 and returns the held funds', async () => { + const player = await fundedPlayer('10'); + const quote = await json( + await player.post('/wallet/swap/quote', { + fromCurrency: 'USD', + toCurrency: 'EUR', + fromAmount: '5', + }), + ); + const swap = { + fromCurrency: 'USD', + toCurrency: 'EUR', + fromAmount: '5', + quoteId: quote['quoteId'], + }; + + const filled = await player.post('/wallet/swap', { ...swap, idempotencyKey: randomUUID() }); + expect(filled.status).toBe(200); + expect((await json(filled))['status']).toBe('completed'); + + const replayed = await player.post('/wallet/swap', { ...swap, idempotencyKey: randomUUID() }); + expect(replayed.status).toBe(409); + expect((await json(replayed))['data']).toEqual({ reason: 'quote_spent' }); + + const balance = await json(await player.get('/wallet/balance')); + expect(balance['balance']).toBe('5.000000000000000000'); + }); +}); diff --git a/packages/testing/src/test-swap-desk-plugin.ts b/packages/testing/src/test-swap-desk-plugin.ts new file mode 100644 index 00000000..30ec9a82 --- /dev/null +++ b/packages/testing/src/test-swap-desk-plugin.ts @@ -0,0 +1,60 @@ +import type { CoreTokenCatalog, Plugin } from '@openora/core/server'; +import { + SWAP_ADAPTER, + SwapLimitExceededError, + SwapRefusedError, + type SwapAdapter, +} from '@openora/core/contracts'; + +/** Largest swap the desk quotes, so a test can cross it. */ +export const TEST_SWAP_DESK_LIMIT = 100; + +/** + * Binds a synchronous swap desk that fills 1:1, refuses a quote above + * `TEST_SWAP_DESK_LIMIT` and fills each quote once, so a test can prove the typed + * refusals reach the caller as 4xx. Opt-in only - pass it in `config.plugins`. + */ +export default { + id: 'testing-swap-desk', + dependsOn: ['wallet'], + register(ctx) { + const issued = new Set(); + const spent = new Set(); + ctx.provide( + SWAP_ADAPTER, + () => + ({ + async getQuote({ fromCurrency, toCurrency, fromAmount }) { + if (Number(fromAmount) > TEST_SWAP_DESK_LIMIT) { + throw new SwapLimitExceededError('over_swap_limit', 'Swap exceeds the desk limit'); + } + const quoteId = `test-${crypto.randomUUID()}`; + issued.add(quoteId); + const now = Date.now(); + return { + quoteId, + fromCurrency, + toCurrency, + fromAmount, + toAmount: fromAmount, + rate: '1', + fee: '0', + feeCurrency: toCurrency, + asOf: new Date(now).toISOString(), + expiresAt: new Date(now + 30_000).toISOString(), + }; + }, + async execute({ quoteId, fromAmount, idempotencyKey }) { + if (!quoteId || !issued.has(quoteId)) { + throw new SwapRefusedError('quote_invalid', 'Unknown swap quote'); + } + if (spent.has(quoteId)) { + throw new SwapRefusedError('quote_spent', 'Swap quote already filled'); + } + spent.add(quoteId); + return { externalId: idempotencyKey, status: 'completed', toAmount: fromAmount }; + }, + }) satisfies SwapAdapter, + ); + }, +} satisfies Plugin; From a55ba3d418f2555b294c98a3d2009a436b25f2ca Mon Sep 17 00:00:00 2001 From: Volodymyr Zakhovaiko Date: Thu, 24 Sep 2026 14:44:08 +0200 Subject: [PATCH 2/2] test(wallet): swap refusals on a crypto pair with a real rate and balance checks --- .../wallet-swap-refusals.e2e.test.ts | 61 +++++++++++-------- packages/testing/src/test-swap-desk-plugin.ts | 38 +++++++----- 2 files changed, 59 insertions(+), 40 deletions(-) diff --git a/packages/testing/src/__tests__/wallet-swap-refusals.e2e.test.ts b/packages/testing/src/__tests__/wallet-swap-refusals.e2e.test.ts index 9fde939e..c3879c54 100644 --- a/packages/testing/src/__tests__/wallet-swap-refusals.e2e.test.ts +++ b/packages/testing/src/__tests__/wallet-swap-refusals.e2e.test.ts @@ -1,7 +1,7 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { randomUUID } from 'node:crypto'; import { fileURLToPath } from 'node:url'; -import { loadExtensions } from '@openora/core/server'; +import { loadExtensions, moneyCompare } from '@openora/core/server'; import { asAdmin, bootTestApp, @@ -12,7 +12,6 @@ import { type TestClient, type TestDb, } from '../index.js'; -import { TEST_SWAP_DESK_LIMIT } from '../test-swap-desk-plugin.js'; const swapDeskPluginPath = fileURLToPath(new URL('../test-swap-desk-plugin.ts', import.meta.url)); @@ -27,7 +26,7 @@ async function json(res: Response): Promise> { return body as Record; } -async function fundedPlayer(amount: string): Promise { +async function fundedPlayer(btc: string): Promise { const { client, userId } = await registerAndMaterializePlayer(testApp, { email: `swap-refusal-${randomUUID()}@e2e.test`, }); @@ -35,8 +34,8 @@ async function fundedPlayer(amount: string): Promise { const credited = await admin.post('/wallet/manual-adjustments', { userId, direction: 'credit', - amount, - currency: 'USD', + amount: btc, + currency: 'BTC', reason: 'swap test funds', idempotencyKey: randomUUID(), }); @@ -44,6 +43,14 @@ async function fundedPlayer(amount: string): Promise { return client; } +async function expectBalance(player: TestClient, currency: string, amount: string) { + const { balances } = (await json(await player.get('/wallet/balances'))) as { + balances: { currency: string; balance: string }[]; + }; + const balance = balances.find((b) => b.currency === currency)?.balance ?? '0'; + expect(moneyCompare(balance, amount), `${currency} balance ${balance}`).toBe(0); +} + beforeAll(async () => { process.env['BETTER_AUTH_SECRET'] ??= 'e2e-test-better-auth-secret-please-change-000000'; process.env['AUTH_SECRET'] ??= process.env['BETTER_AUTH_SECRET']; @@ -63,23 +70,23 @@ afterAll(async () => { }); describe('POST /wallet/swap/quote', () => { - it('prices a swap inside the desk limit', async () => { - const player = await fundedPlayer('10'); + it('prices a crypto swap inside the desk limit', async () => { + const player = await fundedPlayer('0.02'); const res = await player.post('/wallet/swap/quote', { - fromCurrency: 'USD', - toCurrency: 'EUR', - fromAmount: '5', + fromCurrency: 'BTC', + toCurrency: 'USDT', + fromAmount: '0.01', }); expect(res.status).toBe(200); - expect((await json(res))['toAmount']).toBe('5'); + expect(moneyCompare(String((await json(res))['toAmount']), '500')).toBe(0); }); it('answers a swap over the desk limit with 400 and a reason, not a 500', async () => { - const player = await fundedPlayer('10'); + const player = await fundedPlayer('2'); const res = await player.post('/wallet/swap/quote', { - fromCurrency: 'USD', - toCurrency: 'EUR', - fromAmount: String(TEST_SWAP_DESK_LIMIT + 1), + fromCurrency: 'BTC', + toCurrency: 'USDT', + fromAmount: '1.5', }); expect(res.status).toBe(400); expect((await json(res))['data']).toEqual({ reason: 'over_swap_limit' }); @@ -87,31 +94,33 @@ describe('POST /wallet/swap/quote', () => { }); describe('POST /wallet/swap', () => { - it('fills a quote, then answers its replay with 409 and returns the held funds', async () => { - const player = await fundedPlayer('10'); + it('fills a quote, then answers its replay with 409 and returns the held BTC', async () => { + const player = await fundedPlayer('0.02'); const quote = await json( await player.post('/wallet/swap/quote', { - fromCurrency: 'USD', - toCurrency: 'EUR', - fromAmount: '5', + fromCurrency: 'BTC', + toCurrency: 'USDT', + fromAmount: '0.01', }), ); const swap = { - fromCurrency: 'USD', - toCurrency: 'EUR', - fromAmount: '5', + fromCurrency: 'BTC', + toCurrency: 'USDT', + fromAmount: '0.01', quoteId: quote['quoteId'], }; const filled = await player.post('/wallet/swap', { ...swap, idempotencyKey: randomUUID() }); expect(filled.status).toBe(200); expect((await json(filled))['status']).toBe('completed'); + await expectBalance(player, 'BTC', '0.01'); + await expectBalance(player, 'USDT', '500'); const replayed = await player.post('/wallet/swap', { ...swap, idempotencyKey: randomUUID() }); expect(replayed.status).toBe(409); expect((await json(replayed))['data']).toEqual({ reason: 'quote_spent' }); - - const balance = await json(await player.get('/wallet/balance')); - expect(balance['balance']).toBe('5.000000000000000000'); + // The replay held 0.01 BTC before the desk refused it; it must come back, with no USDT paid. + await expectBalance(player, 'BTC', '0.01'); + await expectBalance(player, 'USDT', '500'); }); }); diff --git a/packages/testing/src/test-swap-desk-plugin.ts b/packages/testing/src/test-swap-desk-plugin.ts index 30ec9a82..3f45a825 100644 --- a/packages/testing/src/test-swap-desk-plugin.ts +++ b/packages/testing/src/test-swap-desk-plugin.ts @@ -1,4 +1,9 @@ -import type { CoreTokenCatalog, Plugin } from '@openora/core/server'; +import { + moneyCompare, + moneyScaleBy, + type CoreTokenCatalog, + type Plugin, +} from '@openora/core/server'; import { SWAP_ADAPTER, SwapLimitExceededError, @@ -6,53 +11,58 @@ import { type SwapAdapter, } from '@openora/core/contracts'; -/** Largest swap the desk quotes, so a test can cross it. */ -export const TEST_SWAP_DESK_LIMIT = 100; +/** Units of the target currency the desk pays per unit of the source, whatever the pair. */ +export const TEST_SWAP_DESK_RATE = '50000'; + +/** Largest swap the desk quotes, in units of the source currency. */ +export const TEST_SWAP_DESK_LIMIT = '1'; /** - * Binds a synchronous swap desk that fills 1:1, refuses a quote above - * `TEST_SWAP_DESK_LIMIT` and fills each quote once, so a test can prove the typed - * refusals reach the caller as 4xx. Opt-in only - pass it in `config.plugins`. + * Binds a synchronous swap desk that fills at `TEST_SWAP_DESK_RATE`, refuses a quote above + * `TEST_SWAP_DESK_LIMIT` and fills each quote once, so a test can prove the typed refusals + * reach the caller as 4xx. Opt-in only - pass it in `config.plugins`. */ export default { id: 'testing-swap-desk', dependsOn: ['wallet'], register(ctx) { - const issued = new Set(); + const issued = new Map(); const spent = new Set(); ctx.provide( SWAP_ADAPTER, () => ({ async getQuote({ fromCurrency, toCurrency, fromAmount }) { - if (Number(fromAmount) > TEST_SWAP_DESK_LIMIT) { + if (moneyCompare(fromAmount, TEST_SWAP_DESK_LIMIT) > 0) { throw new SwapLimitExceededError('over_swap_limit', 'Swap exceeds the desk limit'); } const quoteId = `test-${crypto.randomUUID()}`; - issued.add(quoteId); + const toAmount = moneyScaleBy(fromAmount, TEST_SWAP_DESK_RATE); + issued.set(quoteId, toAmount); const now = Date.now(); return { quoteId, fromCurrency, toCurrency, fromAmount, - toAmount: fromAmount, - rate: '1', + toAmount, + rate: TEST_SWAP_DESK_RATE, fee: '0', feeCurrency: toCurrency, asOf: new Date(now).toISOString(), expiresAt: new Date(now + 30_000).toISOString(), }; }, - async execute({ quoteId, fromAmount, idempotencyKey }) { - if (!quoteId || !issued.has(quoteId)) { + async execute({ quoteId, idempotencyKey }) { + const toAmount = quoteId ? issued.get(quoteId) : undefined; + if (!quoteId || !toAmount) { throw new SwapRefusedError('quote_invalid', 'Unknown swap quote'); } if (spent.has(quoteId)) { throw new SwapRefusedError('quote_spent', 'Swap quote already filled'); } spent.add(quoteId); - return { externalId: idempotencyKey, status: 'completed', toAmount: fromAmount }; + return { externalId: idempotencyKey, status: 'completed', toAmount }; }, }) satisfies SwapAdapter, );