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
4 changes: 4 additions & 0 deletions docs/catalog.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
16 changes: 16 additions & 0 deletions packages/core/src/contracts/schemas/platform-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,20 @@ export const AutoWithdrawalConfigSchema = z

export type AutoWithdrawalConfig = z.infer<typeof AutoWithdrawalConfigSchema>;

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<typeof WalletConfigSchema>;

export const PlatformConfigSchema = z
.object({
/**
Expand Down Expand Up @@ -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.
Expand Down
11 changes: 10 additions & 1 deletion packages/core/src/wallet/__tests__/rail-for.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});

Expand All @@ -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');
});
});
9 changes: 8 additions & 1 deletion packages/core/src/wallet/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)));
Expand Down
8 changes: 6 additions & 2 deletions packages/core/src/wallet/service/wallet-commands.service.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type {
PlayEligibilityPort,
PlatformConfig,
WalletCommands,
WalletDebitArgs,
WalletDebitOutcome,
Expand Down Expand Up @@ -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(
Expand All @@ -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),
});
}

Expand Down
30 changes: 19 additions & 11 deletions packages/core/src/wallet/service/wallet.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -324,7 +332,7 @@ export class WalletService {
amount,
currency,
status: 'completed',
rail: railFor(currency),
rail: this.resolveRail(currency),
providerName: provider,
providerRefId: psp.externalId,
},
Expand Down Expand Up @@ -473,7 +481,7 @@ export class WalletService {
} & ClientMeta): Promise<TransactionResult> {
await this.rateLimit(userId);
await this.assertKycForWithdrawal(userId);
if (railFor(currency) === 'crypto' && !destinationAddress) {
if (this.resolveRail(currency) === 'crypto' && !destinationAddress) {
throw new DestinationAddressRequiredError();
}

Expand Down Expand Up @@ -503,7 +511,7 @@ export class WalletService {
status: existing.status,
replayed: true,
walletId: current.id,
rail: railFor(currency),
rail: this.resolveRail(currency),
};
}
}
Expand All @@ -520,7 +528,7 @@ export class WalletService {
amount,
currency,
status: 'pending',
rail: railFor(currency),
rail: this.resolveRail(currency),
destinationAddress: destinationAddress ?? null,
},
});
Expand All @@ -531,7 +539,7 @@ export class WalletService {
status: row.status,
replayed,
walletId: current.id,
rail: railFor(currency),
rail: this.resolveRail(currency),
};
}

Expand Down Expand Up @@ -559,7 +567,7 @@ export class WalletService {
status: row.status,
replayed,
walletId: current.id,
rail: railFor(currency),
rail: this.resolveRail(currency),
};
},
);
Expand Down Expand Up @@ -1327,7 +1335,7 @@ export class WalletService {
userId,
currency,
address: issued.address,
providerName: providerNameFor(railFor(currency)),
providerName: providerNameFor(this.resolveRail(currency)),
})
.onConflictDoNothing()
.returning();
Expand Down Expand Up @@ -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,
Expand Down
Loading