From 7e1c9cb696d7fb210b55648df15ec857ff9a18ad Mon Sep 17 00:00:00 2001 From: Volodymyr Zakhovaiko Date: Wed, 5 Aug 2026 00:32:04 +0200 Subject: [PATCH] feat(wallet): make crypto rail routing config-driven railFor was a hardcoded BTC/ETH/USDT set. Add USDC to the default and let platformConfig.wallet.cryptoCurrencies override it per-operator, so adding a custody vendor's full asset list is a config change, not a core edit. --- docs/catalog.json | 4 +++ .../src/contracts/schemas/platform-config.ts | 16 ++++++++++ .../src/wallet/__tests__/rail-for.test.ts | 11 ++++++- packages/core/src/wallet/plugin.ts | 9 +++++- .../wallet/service/wallet-commands.service.ts | 8 +++-- .../core/src/wallet/service/wallet.service.ts | 30 ++++++++++++------- 6 files changed, 63 insertions(+), 15 deletions(-) diff --git a/docs/catalog.json b/docs/catalog.json index 181f99bc..b682161e 100644 --- a/docs/catalog.json +++ b/docs/catalog.json @@ -1705,6 +1705,10 @@ "name": "WalletBalanceSchema", "file": "packages/core/src/wallet/contract/index.ts" }, + { + "name": "WalletConfigSchema", + "file": "packages/core/src/contracts/schemas/platform-config.ts" + }, { "name": "WalletRailSchema", "file": "packages/core/src/contracts/schemas/wallet-tx.ts" diff --git a/packages/core/src/contracts/schemas/platform-config.ts b/packages/core/src/contracts/schemas/platform-config.ts index c63848c6..e5afc1bb 100644 --- a/packages/core/src/contracts/schemas/platform-config.ts +++ b/packages/core/src/contracts/schemas/platform-config.ts @@ -65,6 +65,20 @@ export const AutoWithdrawalConfigSchema = z export type AutoWithdrawalConfig = z.infer; +export const WalletConfigSchema = z + .object({ + /** + * Currency codes routed to the crypto rail by `railFor` (case-insensitive). + * Absent = the built-in default (BTC, ETH, USDT, USDC). Lets an operator add or + * swap crypto assets (eg a custody vendor's supported asset list) without a + * core code change. + */ + cryptoCurrencies: z.array(z.string().min(1)).optional(), + }) + .strict(); + +export type WalletConfig = z.infer; + export const PlatformConfigSchema = z .object({ /** @@ -96,6 +110,8 @@ export const PlatformConfigSchema = z kyc: KycConfigSchema.optional(), /** Auto-approve withdrawals that clear every risk gate instead of queuing them for manual review. Absent = always manual. */ autoWithdrawal: AutoWithdrawalConfigSchema.optional(), + /** Wallet rail-routing knobs (currently: the crypto currency set). Absent = built-in default. */ + wallet: WalletConfigSchema.optional(), /** * Player-facing language codes the operator supports (eg `['en', 'es']`). * Undefined or empty means no restriction - any value is accepted. diff --git a/packages/core/src/wallet/__tests__/rail-for.test.ts b/packages/core/src/wallet/__tests__/rail-for.test.ts index 01c3ef45..41783ee3 100644 --- a/packages/core/src/wallet/__tests__/rail-for.test.ts +++ b/packages/core/src/wallet/__tests__/rail-for.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect } from 'vitest'; import { railFor } from '../service/wallet.service.js'; describe('railFor', () => { - it.each(['BTC', 'ETH', 'USDT'])('routes %s to the crypto rail', (currency) => { + it.each(['BTC', 'ETH', 'USDT', 'USDC'])('routes %s to the crypto rail', (currency) => { expect(railFor(currency)).toBe('crypto'); }); @@ -23,4 +23,13 @@ describe('railFor', () => { expect(railFor('BTCX')).toBe('fiat'); expect(railFor(' BTC')).toBe('fiat'); }); + + it('overrides the built-in set when a custom currency list is passed', () => { + expect(railFor('SOL', ['SOL', 'XRP'])).toBe('crypto'); + expect(railFor('BTC', ['SOL', 'XRP'])).toBe('fiat'); + }); + + it('normalises case in the override list too', () => { + expect(railFor('sol', ['SOL'])).toBe('crypto'); + }); }); diff --git a/packages/core/src/wallet/plugin.ts b/packages/core/src/wallet/plugin.ts index 8c1c5035..3e9f83a2 100644 --- a/packages/core/src/wallet/plugin.ts +++ b/packages/core/src/wallet/plugin.ts @@ -40,7 +40,14 @@ export default definePlugin({ return new HmacPaymentWebhookVerifier(webhookSecret); }); // Other modules debit through this port within their own transaction (never importing wallet tables). See ADR-0016. - ctx.provide(WALLET_COMMANDS, (c) => new WalletCommandsService(c.get(PLAY_ELIGIBILITY))); + ctx.provide( + WALLET_COMMANDS, + (c) => + new WalletCommandsService( + c.get(PLAY_ELIGIBILITY), + c.has(PLATFORM_CONFIG) ? c.get(PLATFORM_CONFIG) : undefined, + ), + ); // Read-only queries for cross-module consumers (eg tag evaluation). Never exposes wallet internals. ctx.provide(WALLET_READER, (c) => new WalletReaderService(c.get(DRIZZLE))); ctx.provide(ADMIN_WALLET_REPORTING, (c) => new DrizzleAdminWalletReporting(c.get(DRIZZLE))); diff --git a/packages/core/src/wallet/service/wallet-commands.service.ts b/packages/core/src/wallet/service/wallet-commands.service.ts index 53007cf8..501453ba 100644 --- a/packages/core/src/wallet/service/wallet-commands.service.ts +++ b/packages/core/src/wallet/service/wallet-commands.service.ts @@ -1,5 +1,6 @@ import type { PlayEligibilityPort, + PlatformConfig, WalletCommands, WalletDebitArgs, WalletDebitOutcome, @@ -30,7 +31,10 @@ export const WalletRgRestrictedError = makeConflictError( ); export class WalletCommandsService implements WalletCommands { - constructor(private readonly playEligibility: PlayEligibilityPort) {} + constructor( + private readonly playEligibility: PlayEligibilityPort, + private readonly platformConfig?: PlatformConfig, + ) {} // Completed, internal-settlement ledger row (no provider ref) shared by every gameplay move. private writeLedgerRow( @@ -45,7 +49,7 @@ export class WalletCommandsService implements WalletCommands { amount, currency: row.currency, status: 'completed', - rail: railFor(row.currency), + rail: railFor(row.currency, this.platformConfig?.wallet?.cryptoCurrencies), }); } diff --git a/packages/core/src/wallet/service/wallet.service.ts b/packages/core/src/wallet/service/wallet.service.ts index 3c81838c..7d3f94ad 100644 --- a/packages/core/src/wallet/service/wallet.service.ts +++ b/packages/core/src/wallet/service/wallet.service.ts @@ -95,15 +95,19 @@ export const CurrencyMismatchError = createDomainError( // Crypto currencies settle on the crypto rail (Fireblocks); everything else on the // fiat rail (a PSP). The concrete provider is recorded per transaction, not here. -const CRYPTO_CURRENCIES = new Set(['BTC', 'ETH', 'USDT']); +// Overridable per-operator via `platformConfig.wallet.cryptoCurrencies` - see `railFor`. +const DEFAULT_CRYPTO_CURRENCIES = new Set(['BTC', 'ETH', 'USDT', 'USDC']); // Per-user throttle on money mutations - guards a runaway/misbehaving client, not // fraud (idempotency + the ledger guard cover correctness). An overlay rebinds // RATE_LIMITER to change the backend, not this policy. const WALLET_MUTATION_RATE_LIMIT = { limit: 30, windowMs: 60 * 1000 }; -export function railFor(currency: string): WalletRail { - return CRYPTO_CURRENCIES.has(currency.toUpperCase()) ? 'crypto' : 'fiat'; +export function railFor(currency: string, cryptoCurrencies?: readonly string[]): WalletRail { + const set = cryptoCurrencies + ? new Set(cryptoCurrencies.map((c) => c.toUpperCase())) + : DEFAULT_CRYPTO_CURRENCIES; + return set.has(currency.toUpperCase()) ? 'crypto' : 'fiat'; } // Namespace the key per operation so the same raw key on a deposit then a withdraw can't @@ -231,6 +235,10 @@ export class WalletService { this.audit = audit; } + private resolveRail(currency: string): WalletRail { + return railFor(currency, this.platformConfig?.wallet?.cryptoCurrencies); + } + private rateLimit(userId: User['id']) { return this.limiter ? assertRateLimit(this.limiter, `wallet-mutation:${userId}`, WALLET_MUTATION_RATE_LIMIT) @@ -324,7 +332,7 @@ export class WalletService { amount, currency, status: 'completed', - rail: railFor(currency), + rail: this.resolveRail(currency), providerName: provider, providerRefId: psp.externalId, }, @@ -473,7 +481,7 @@ export class WalletService { } & ClientMeta): Promise { await this.rateLimit(userId); await this.assertKycForWithdrawal(userId); - if (railFor(currency) === 'crypto' && !destinationAddress) { + if (this.resolveRail(currency) === 'crypto' && !destinationAddress) { throw new DestinationAddressRequiredError(); } @@ -503,7 +511,7 @@ export class WalletService { status: existing.status, replayed: true, walletId: current.id, - rail: railFor(currency), + rail: this.resolveRail(currency), }; } } @@ -520,7 +528,7 @@ export class WalletService { amount, currency, status: 'pending', - rail: railFor(currency), + rail: this.resolveRail(currency), destinationAddress: destinationAddress ?? null, }, }); @@ -531,7 +539,7 @@ export class WalletService { status: row.status, replayed, walletId: current.id, - rail: railFor(currency), + rail: this.resolveRail(currency), }; } @@ -559,7 +567,7 @@ export class WalletService { status: row.status, replayed, walletId: current.id, - rail: railFor(currency), + rail: this.resolveRail(currency), }; }, ); @@ -1327,7 +1335,7 @@ export class WalletService { userId, currency, address: issued.address, - providerName: providerNameFor(railFor(currency)), + providerName: providerNameFor(this.resolveRail(currency)), }) .onConflictDoNothing() .returning(); @@ -1383,7 +1391,7 @@ export class WalletService { amount: event.amount, currency: event.currency, status: 'completed', - rail: railFor(event.currency), + rail: this.resolveRail(event.currency), providerName: depositAddress.providerName, providerRefId: event.externalId, destinationAddress: event.address,