Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion packages/core/src/contracts/adapters/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
39 changes: 39 additions & 0 deletions packages/core/src/contracts/adapters/swap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,45 @@ export type SwapAdapter = {

export const SWAP_ADAPTER: Token<SwapAdapter> = 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(
Expand Down
19 changes: 15 additions & 4 deletions packages/core/src/wallet/router/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import {
RATE_LIMIT_KEYS,
RgLimitExceededError,
PaymentRejectedError,
SwapLimitExceededError,
SwapRefusedError,
makeRateLimitKey,
type AuditWritePort,
type JobQueueAdapter,
Expand Down Expand Up @@ -501,17 +503,26 @@ 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 }),
),
),

execute: os.swap.execute.handler(({ input, context }) =>
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.
Expand Down
126 changes: 126 additions & 0 deletions packages/testing/src/__tests__/wallet-swap-refusals.e2e.test.ts
Original file line number Diff line number Diff line change
@@ -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<Record<string, unknown>> {
const body: unknown = await res.json();
if (body === null || typeof body !== 'object') {
throw new Error('expected JSON object');
}
return body as Record<string, unknown>;
}

async function fundedPlayer(btc: string): Promise<TestClient> {
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');
});
});
70 changes: 70 additions & 0 deletions packages/testing/src/test-swap-desk-plugin.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>();
const spent = new Set<string>();
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<CoreTokenCatalog>;
Loading