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..c3879c54 --- /dev/null +++ b/packages/testing/src/__tests__/wallet-swap-refusals.e2e.test.ts @@ -0,0 +1,126 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { randomUUID } from 'node:crypto'; +import { fileURLToPath } from 'node:url'; +import { loadExtensions, moneyCompare } from '@openora/core/server'; +import { + asAdmin, + bootTestApp, + registerAndMaterializePlayer, + seedMinimal, + setupTestDb, + type TestApp, + type TestClient, + type TestDb, +} from '../index.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(btc: 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: btc, + currency: 'BTC', + reason: 'swap test funds', + idempotencyKey: randomUUID(), + }); + expect(credited.status).toBe(200); + 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']; + 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 crypto swap inside the desk limit', async () => { + const player = await fundedPlayer('0.02'); + const res = await player.post('/wallet/swap/quote', { + fromCurrency: 'BTC', + toCurrency: 'USDT', + fromAmount: '0.01', + }); + expect(res.status).toBe(200); + 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('2'); + const res = await player.post('/wallet/swap/quote', { + fromCurrency: 'BTC', + toCurrency: 'USDT', + fromAmount: '1.5', + }); + 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 BTC', async () => { + const player = await fundedPlayer('0.02'); + const quote = await json( + await player.post('/wallet/swap/quote', { + fromCurrency: 'BTC', + toCurrency: 'USDT', + fromAmount: '0.01', + }), + ); + const swap = { + 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' }); + // 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 new file mode 100644 index 00000000..3f45a825 --- /dev/null +++ b/packages/testing/src/test-swap-desk-plugin.ts @@ -0,0 +1,70 @@ +import { + moneyCompare, + moneyScaleBy, + type CoreTokenCatalog, + type Plugin, +} from '@openora/core/server'; +import { + SWAP_ADAPTER, + SwapLimitExceededError, + SwapRefusedError, + type SwapAdapter, +} from '@openora/core/contracts'; + +/** 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 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 Map(); + const spent = new Set(); + ctx.provide( + SWAP_ADAPTER, + () => + ({ + async getQuote({ fromCurrency, toCurrency, fromAmount }) { + if (moneyCompare(fromAmount, TEST_SWAP_DESK_LIMIT) > 0) { + throw new SwapLimitExceededError('over_swap_limit', 'Swap exceeds the desk limit'); + } + const quoteId = `test-${crypto.randomUUID()}`; + const toAmount = moneyScaleBy(fromAmount, TEST_SWAP_DESK_RATE); + issued.set(quoteId, toAmount); + const now = Date.now(); + return { + quoteId, + fromCurrency, + toCurrency, + fromAmount, + 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, 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 }; + }, + }) satisfies SwapAdapter, + ); + }, +} satisfies Plugin;