diff --git a/docs/catalog.json b/docs/catalog.json index 6eb354a6..976406a4 100644 --- a/docs/catalog.json +++ b/docs/catalog.json @@ -381,14 +381,20 @@ "tables": [ "auto_withdrawal_rule", "wallet", + "wallet_asset", "wallet_auto_withdrawal_config", "wallet_balance", + "wallet_custody_sweep", "wallet_deposit_address", + "wallet_job_run", "wallet_provider_vault", + "wallet_reconciliation_finding", "wallet_transaction" ], "routes": [ "wallet.approve", + "wallet.create", + "wallet.delete", "wallet.delete", "wallet.deposit", "wallet.get", @@ -397,13 +403,22 @@ "wallet.getBalance", "wallet.getBalances", "wallet.list", + "wallet.list", + "wallet.list", + "wallet.listAssets", "wallet.listPlayerTransactions", "wallet.listTransactions", + "wallet.manualAdjustment", "wallet.reject", + "wallet.resolve", + "wallet.run", + "wallet.run", "wallet.set", "wallet.set", "wallet.setActiveCurrency", + "wallet.update", "wallet.webhook", + "wallet.webhookForProvider", "wallet.withdraw" ] } @@ -824,6 +839,15 @@ "packages/core/src/wallet/plugin.ts" ] }, + { + "category": "wallet-asset-catalog", + "interface": "WalletAsset", + "token": "WALLET_ASSET_CATALOG", + "status": "wired", + "boundIn": [ + "packages/core/src/wallet/plugin.ts" + ] + }, { "category": "wallet-commands", "interface": "WalletDebitArgs", @@ -929,6 +953,7 @@ "tag.player.removed", "tag.rule.upserted", "wallet.deposit.completed", + "wallet.manual_adjustment.created", "wallet.withdrawal.approved", "wallet.withdrawal.completed", "wallet.withdrawal.failed", @@ -1020,6 +1045,10 @@ "name": "AdminUserSortBySchema", "file": "packages/core/src/admin-console/contract/index.ts" }, + { + "name": "AdminWalletTransactionSchema", + "file": "packages/core/src/wallet/contract/index.ts" + }, { "name": "ApproveWithdrawalInputSchema", "file": "packages/core/src/wallet/contract/index.ts" @@ -1244,6 +1273,10 @@ "name": "createTagSchema", "file": "packages/core/src/contracts/schemas/tag.ts" }, + { + "name": "CreateWalletAssetInputSchema", + "file": "packages/core/src/wallet/contract/index.ts" + }, { "name": "CurrencyCodeSchema", "file": "packages/core/src/contracts/schemas/igaming-config.ts" @@ -1480,6 +1513,10 @@ "name": "InvitationStatusSchema", "file": "packages/core/src/contracts/schemas/iam.ts" }, + { + "name": "JobRunResultSchema", + "file": "packages/core/src/wallet/contract/index.ts" + }, { "name": "KycCheckResultSchema", "file": "packages/core/src/contracts/adapters/kyc.ts" @@ -1568,6 +1605,10 @@ "name": "ListFriendRequestsInputSchema", "file": "packages/core/src/engagement/social/contract/index.ts" }, + { + "name": "ListReconciliationFindingsInputSchema", + "file": "packages/core/src/wallet/contract/index.ts" + }, { "name": "ListRgFlagsInputSchema", "file": "packages/core/src/compliance/contract/rg.ts" @@ -1588,6 +1629,14 @@ "name": "LoginSecurityStateSchema", "file": "packages/core/src/contracts/schemas/identity.ts" }, + { + "name": "ManualAdjustmentDirectionSchema", + "file": "packages/core/src/wallet/contract/index.ts" + }, + { + "name": "ManualWalletAdjustmentInputSchema", + "file": "packages/core/src/wallet/contract/index.ts" + }, { "name": "MemberSchema", "file": "packages/core/src/contracts/schemas/identity.ts" @@ -1652,6 +1701,10 @@ "name": "PaymentWebhookOutputSchema", "file": "packages/core/src/wallet/contract/index.ts" }, + { + "name": "PaymentWebhookProviderInputSchema", + "file": "packages/core/src/wallet/contract/index.ts" + }, { "name": "PermissionLevelSchema", "file": "packages/core/src/contracts/schemas/iam.ts" @@ -1788,10 +1841,18 @@ "name": "ProviderSelectionSchema", "file": "packages/core/src/contracts/schemas/igaming-config.ts" }, + { + "name": "PublicWalletAssetSchema", + "file": "packages/core/src/wallet/contract/index.ts" + }, { "name": "RainCommandMetadataSchema", "file": "packages/core/src/contracts/schemas/chat-command-metadata.ts" }, + { + "name": "ReconciliationResolutionSchema", + "file": "packages/core/src/wallet/contract/index.ts" + }, { "name": "RegisterInputSchema", "file": "packages/core/src/contracts/schemas/identity.ts" @@ -1844,6 +1905,10 @@ "name": "ResetPasswordInputSchema", "file": "packages/core/src/contracts/schemas/identity.ts" }, + { + "name": "ResolveReconciliationFindingInputSchema", + "file": "packages/core/src/wallet/contract/index.ts" + }, { "name": "RetentionCohortRowSchema", "file": "packages/core/src/admin-console/contract/index.ts" @@ -2004,6 +2069,10 @@ "name": "updateTagSchema", "file": "packages/core/src/contracts/schemas/tag.ts" }, + { + "name": "UpdateWalletAssetInputSchema", + "file": "packages/core/src/wallet/contract/index.ts" + }, { "name": "UpsertLimitInputSchema", "file": "packages/core/src/compliance/contract/limits.ts" @@ -2052,6 +2121,14 @@ "name": "VerifyPasswordResetOtpInputSchema", "file": "packages/core/src/contracts/schemas/identity.ts" }, + { + "name": "WalletAssetKeySchema", + "file": "packages/core/src/wallet/contract/index.ts" + }, + { + "name": "WalletAssetSchema", + "file": "packages/core/src/wallet/contract/index.ts" + }, { "name": "WalletAutoWithdrawalConfigSchema", "file": "packages/core/src/wallet/contract/index.ts" @@ -2068,10 +2145,30 @@ "name": "WalletConfigSchema", "file": "packages/core/src/contracts/schemas/platform-config.ts" }, + { + "name": "WalletCustodySweepStatusSchema", + "file": "packages/core/src/contracts/schemas/wallet-tx.ts" + }, + { + "name": "WalletJobRunStatusSchema", + "file": "packages/core/src/contracts/schemas/wallet-tx.ts" + }, { "name": "WalletRailSchema", "file": "packages/core/src/contracts/schemas/wallet-tx.ts" }, + { + "name": "WalletReconciliationFindingKindSchema", + "file": "packages/core/src/contracts/schemas/wallet-tx.ts" + }, + { + "name": "WalletReconciliationFindingSchema", + "file": "packages/core/src/wallet/contract/index.ts" + }, + { + "name": "WalletReconciliationFindingStatusSchema", + "file": "packages/core/src/contracts/schemas/wallet-tx.ts" + }, { "name": "WalletTransactionSchema", "file": "packages/core/src/wallet/contract/index.ts" diff --git a/packages/core/src/contracts/adapters/audit.ts b/packages/core/src/contracts/adapters/audit.ts index 02b13847..08295193 100644 --- a/packages/core/src/contracts/adapters/audit.ts +++ b/packages/core/src/contracts/adapters/audit.ts @@ -23,7 +23,8 @@ export type DirectAuditAction = | 'wallet.withdrawal.auto_approved' | 'wallet.auto_withdrawal_rule.set' | 'wallet.auto_withdrawal_rule.deleted' - | 'wallet.auto_withdrawal_config.set'; + | 'wallet.auto_withdrawal_config.set' + | 'wallet.manual_adjustment.created'; /** * Every value the audit `action` column legitimately holds: a cross-module domain diff --git a/packages/core/src/contracts/adapters/index.ts b/packages/core/src/contracts/adapters/index.ts index 3e5a71b6..4eeabdac 100644 --- a/packages/core/src/contracts/adapters/index.ts +++ b/packages/core/src/contracts/adapters/index.ts @@ -121,8 +121,23 @@ export { KycCheckResultSchema, } from './kyc.js'; -export type { PaymentAdapter, PaymentWebhookEvent, PaymentWebhookVerifier } from './payment.js'; -export { PAYMENT_ADAPTER, PAYMENT_WEBHOOK_VERIFIER } from './payment.js'; +export type { + PaymentAdapter, + PaymentWebhookEvent, + PaymentWebhookVerifier, + CustodyBalance, + PaymentProvider, + PaymentProviderRegistry, +} from './payment.js'; +export { + PAYMENT_ADAPTER, + PAYMENT_WEBHOOK_VERIFIER, + PAYMENT_PROVIDERS, + DEFAULT_PAYMENT_PROVIDER, +} from './payment.js'; + +export type { WalletAsset, WalletAssetCatalog } from './wallet-asset-catalog.js'; +export { WALLET_ASSET_CATALOG } from './wallet-asset-catalog.js'; export type { GeoIpAdapter } from './geo-ip.js'; export { GEO_IP_ADAPTER } from './geo-ip.js'; diff --git a/packages/core/src/contracts/adapters/payment.ts b/packages/core/src/contracts/adapters/payment.ts index 6afa6f1e..ead0aa39 100644 --- a/packages/core/src/contracts/adapters/payment.ts +++ b/packages/core/src/contracts/adapters/payment.ts @@ -36,6 +36,50 @@ export type PaymentWebhookEvent = txHash?: string; }; +/** + * A balance sitting in a per-player custody container that is not yet in the pooled + * account withdrawals are paid from. Produced by `PaymentAdapter.listSweepableBalances` + * and handed back to `sweepToPool` unchanged. + */ +export type CustodyBalance = { + userId: string; + currency: string; + network: string; + amount: string; + /** Current network cost to move it, same units as `amount`. */ + estimatedFee: string; +}; + +/** + * Name of the single default `PaymentAdapter`/`PaymentWebhookVerifier` binding, wrapped + * as one `PaymentProviderRegistry` entry so an operator with one vendor changes nothing. + */ +export const DEFAULT_PAYMENT_PROVIDER = 'default'; + +/** One named vendor binding: the adapter it settles through and the verifier for its webhooks. */ +export type PaymentProvider = { + adapter: PaymentAdapter; + webhookVerifier: PaymentWebhookVerifier; +}; + +/** + * Looks up a named vendor's adapter/verifier pair. `wallet_asset.providerName` is the + * key; a webhook route resolves both the verifier AND the adapter from the SAME entry so + * a request can never be verified against one vendor's key and parsed by another's + * format (signature confusion). + * + * Core only looks a name up here - it never discovers or enumerates vendors itself. The + * operator composes this map in their own plugin because `Container.register` is + * last-wins: two overlays each binding the single `PAYMENT_ADAPTER`/`PAYMENT_WEBHOOK_VERIFIER` + * tokens would clobber each other, so running a fiat PSP and a crypto custodian at once + * needs an operator-owned map from provider name to pair, not a second core-discovered + * binding slot. + */ +export type PaymentProviderRegistry = { + get(providerName: string): PaymentProvider | null; + names(): readonly string[]; +}; + export type PaymentAdapter = { processDeposit( amount: string, @@ -80,6 +124,65 @@ export type PaymentAdapter = { rawBody: string, headers: Record, ): PaymentWebhookEvent | null; + + /** + * Whether this adapter can actually serve the given asset. The asset catalog is + * operator-editable at runtime, so an admin can name a (currency, network) pair the + * bound vendor has never heard of; the catalog's write path calls this first and + * rejects rather than letting the pair reach a player's deposit screen. Optional - + * an adapter that omits it is assumed to accept anything the operator configures. + */ + supportsAsset?(currency: string, network: string): boolean; + + /** + * Per-player balances the vendor holds that are not yet in the pooled account. + * Only meaningful for a custody vendor whose per-player deposit containers are + * distinct from the account withdrawals are paid out of - a synchronous PSP, which + * never holds a per-player balance, omits it. + */ + listSweepableBalances?(): Promise; + + /** + * Move one player's balance into the pooled account. Implemented alongside + * `listSweepableBalances`; the caller owns the policy (dust floor, fee thresholds) + * and this only performs the transfer it is handed. + * + * `idempotencyKey` exists because a thrown call cannot be distinguished from a lost + * response - the vendor must dedupe the retry on this key rather than double-move + * funds. `poolRef` is an opaque vendor-side identifier for the destination pool, + * recorded so the ledger can evidence that player funds landed in the player pool and + * not an operator account - a regulator asks this. + */ + sweepToPool?( + balance: CustodyBalance, + opts: { idempotencyKey: string }, + ): Promise<{ externalId: string; poolRef?: string }>; + + /** + * Balance currently in the pooled account for this asset. Consulted only when a sweep + * is blocked by the fee ceiling, to decide whether paying a high fee beats running the + * pool dry. Omit it and the ceiling is absolute. + */ + getPoolBalance?(currency: string, network: string): Promise; + + /** + * Vendor transactions in a window, normalized into the same events `parseWebhook` + * produces - reconciliation is that same normalization, polled instead of pushed. + * Implemented by a vendor whose ledger can be listed after the fact; a PSP that only + * pushes webhooks omits it. + */ + listTransactions?(range: { since: Date; until: Date }): Promise; + + /** + * Targeted status lookup for a single withdrawal, or null when the vendor has no + * record of it. Not redundant with `listTransactions`: a withdrawal stuck in + * `processing` for days falls outside any sane reconciliation window, so finalizing + * it needs a direct lookup by `externalId`. + */ + getWithdrawalStatus?(externalId: string): Promise<{ + status: 'processing' | 'completed' | 'failed'; + txHash?: string; + } | null>; }; export const PAYMENT_ADAPTER: Token = createToken('PAYMENT_ADAPTER'); @@ -101,3 +204,5 @@ export type PaymentWebhookVerifier = { export const PAYMENT_WEBHOOK_VERIFIER: Token = createToken( 'PAYMENT_WEBHOOK_VERIFIER', ); + +export const PAYMENT_PROVIDERS: Token = createToken('PAYMENT_PROVIDERS'); diff --git a/packages/core/src/contracts/adapters/rate-limit.ts b/packages/core/src/contracts/adapters/rate-limit.ts index 1de8e05a..c5ea75d6 100644 --- a/packages/core/src/contracts/adapters/rate-limit.ts +++ b/packages/core/src/contracts/adapters/rate-limit.ts @@ -21,6 +21,7 @@ export const RATE_LIMIT_KEYS = { EMAIL_VERIFICATION: 'email-verify', VERIFY_EMAIL: 'verify-email', WALLET_MUTATION: 'wallet-mutation', + WALLET_WEBHOOK: 'wallet-webhook', CHAT_ROOM_JOIN: 'chat-room-join', CHAT_SEND: 'chat-send', REPORT_ACCESS_DENIED: 'report-access-denied', diff --git a/packages/core/src/contracts/adapters/wallet-asset-catalog.ts b/packages/core/src/contracts/adapters/wallet-asset-catalog.ts new file mode 100644 index 00000000..fc79b22f --- /dev/null +++ b/packages/core/src/contracts/adapters/wallet-asset-catalog.ts @@ -0,0 +1,37 @@ +import { createToken } from './token.js'; + +/** + * One operator-configured (currency, network) pair the platform accepts deposits for or + * pays withdrawals out of. Rows are editable at runtime from the admin surface, so this + * is config rather than code: adding a currency is not a deploy. + */ +export type WalletAsset = { + currency: string; + network: string; + /** + * The bound payment vendor's own identifier for this asset (eg a custody vendor's + * `USDT_ERC20`). Opaque - the wallet module stores and returns it, never parses it, + * so a different vendor's identifier scheme needs no core change. + */ + providerAssetId: string; + minDeposit: string; + minWithdrawal: string; + withdrawalFee: string; + depositEnabled: boolean; + withdrawalEnabled: boolean; +}; + +/** + * Read side of the asset catalog, for an adapter that needs the operator's asset table + * without importing wallet internals. + * + * Unlike most ports here, the wallet module binds a DB-backed default implementation to + * this token itself (as with `CACHE`/`RATE_LIMITER`), so an operator gets a working + * catalog with no wiring; rebinding it is possible but not required. + */ +export type WalletAssetCatalog = { + list(): Promise; + get(currency: string, network: string): Promise; +}; + +export const WALLET_ASSET_CATALOG = createToken('WALLET_ASSET_CATALOG'); diff --git a/packages/core/src/contracts/schemas/__tests__/common-schemas.test.ts b/packages/core/src/contracts/schemas/__tests__/common-schemas.test.ts index 92b7611c..c82872af 100644 --- a/packages/core/src/contracts/schemas/__tests__/common-schemas.test.ts +++ b/packages/core/src/contracts/schemas/__tests__/common-schemas.test.ts @@ -18,8 +18,9 @@ describe('MoneyAmountSchema', () => { expect(MoneyAmountSchema.parse('0')).toBe('0'); }); - it('rejects more than 18 decimal places and a negative amount', () => { + it('rejects amounts outside numeric(38, 18)', () => { expect(MoneyAmountSchema.safeParse('0.0000000000000000001').success).toBe(false); + expect(MoneyAmountSchema.safeParse('100000000000000000000').success).toBe(false); expect(MoneyAmountSchema.safeParse('-1').success).toBe(false); }); }); diff --git a/packages/core/src/contracts/schemas/common.ts b/packages/core/src/contracts/schemas/common.ts index 12323461..22516765 100644 --- a/packages/core/src/contracts/schemas/common.ts +++ b/packages/core/src/contracts/schemas/common.ts @@ -23,13 +23,15 @@ export type ClientMeta = z.infer; // and the contract can never drift into truncating each other. export const MONEY_SCALE = 18; export const MONEY_PRECISION = 38; - -export const MoneyAmountSchema = z - .string() - .regex( - new RegExp(`^\\d+(\\.\\d{1,${MONEY_SCALE}})?$`), - `must be a non-negative decimal string with at most ${MONEY_SCALE} decimal places`, - ); +const MONEY_INTEGER_DIGITS = MONEY_PRECISION - MONEY_SCALE; + +export const MoneyAmountSchema = z.string().regex( + // `0*` first so the bound is on the VALUE, not the string. Postgres accepts + // `000000000000000000001` into numeric(38,18) - it is the number 1 - and a contract + // that counts characters would reject a deposit the database would have stored. + new RegExp(`^0*\\d{1,${MONEY_INTEGER_DIGITS}}(\\.\\d{1,${MONEY_SCALE}})?$`), + `must be a non-negative decimal string below 10^${MONEY_INTEGER_DIGITS} with at most ${MONEY_SCALE} decimal places`, +); export type MoneyAmount = z.infer; export const AUTH_GUARD_REASONS = [ @@ -47,7 +49,7 @@ export type AuthGuardReason = z.infer; export const SignedMoneyAmountSchema = z .string() .regex( - new RegExp(`^-?\\d+(\\.\\d{1,${MONEY_SCALE}})?$`), - `must be a decimal string (optionally negative) with at most ${MONEY_SCALE} decimal places`, + new RegExp(`^-?\\d{1,${MONEY_INTEGER_DIGITS}}(\\.\\d{1,${MONEY_SCALE}})?$`), + `must be a decimal string (optionally negative) with at most ${MONEY_INTEGER_DIGITS} integer and ${MONEY_SCALE} decimal places`, ); export type SignedMoneyAmount = z.infer; diff --git a/packages/core/src/contracts/schemas/events.ts b/packages/core/src/contracts/schemas/events.ts index dfefae80..c4a02697 100644 --- a/packages/core/src/contracts/schemas/events.ts +++ b/packages/core/src/contracts/schemas/events.ts @@ -149,7 +149,7 @@ export const domainEventSchemas = { .extend({ playerId: UuidSchema.nullable() }) .extend(authContextBase.shape), // A payments admin approved a pending withdrawal; it moves to `processing` and - // is sent to the PSP/Fireblocks rail. `adminId` is the acting reviewer. + // is sent to the PSP/custody rail. `adminId` is the acting reviewer. 'wallet.withdrawal.approved': walletTxnBase .extend({ adminId: UuidSchema }) .extend(authContextBase.shape), @@ -158,9 +158,22 @@ export const domainEventSchemas = { 'wallet.withdrawal.rejected': walletTxnBase .extend({ adminId: UuidSchema, reason: z.string() }) .extend(authContextBase.shape), - // An approved withdrawal failed at the PSP/Fireblocks rail; the held funds were + // An approved withdrawal failed at the PSP/custody rail; the held funds were // returned to the player balance and the transaction moved to `failed`. 'wallet.withdrawal.failed': walletTxnBase.extend({ adminId: UuidSchema }), + // A super admin credited or debited a balance directly, outside the deposit and + // withdrawal rails. Its own topic rather than a reuse of `wallet.deposit.completed`: + // a correction is not a deposit, and reporting it as one would overstate deposits and + // GGR. Subscribers that track balance movement must handle it or they see a balance + // change with no event behind it. + 'wallet.manual_adjustment.created': walletTxnBase + .extend({ + playerId: UuidSchema.nullable(), + adminId: UuidSchema, + direction: z.enum(['credit', 'debit']), + reason: z.string(), + }) + .extend(authContextBase.shape), 'gaming.round.started': z.object({ roundId: UuidSchema, @@ -548,6 +561,7 @@ export const domainEventVersions: Partial> = { 'wallet.withdrawal.approved': 2, 'wallet.withdrawal.rejected': 2, 'wallet.withdrawal.failed': 2, + 'wallet.manual_adjustment.created': 2, // v2: amount/previousAmount (decimal string) + minutes/previousMinutes polymorphic // pair (money limit vs session-time limit), never a JS number. 'rg.limit.set': 2, diff --git a/packages/core/src/contracts/schemas/platform-config.ts b/packages/core/src/contracts/schemas/platform-config.ts index 0dcbd399..b512666a 100644 --- a/packages/core/src/contracts/schemas/platform-config.ts +++ b/packages/core/src/contracts/schemas/platform-config.ts @@ -68,6 +68,53 @@ export const WalletConfigSchema = z * core code change. */ cryptoCurrencies: z.array(z.string().min(1)).optional(), + /** + * Custody sweep cron knobs. Static config, not a DB row - nothing here is edited + * during an incident. Absent means the sweep job no-ops. + */ + sweep: z + .object({ + cron: z.string().default('*/15 * * * *'), + /** + * Decimal string, compared exactly (see `moneyCompare`/`moneyScaleBy`). A + * balance must be worth at least this many times the fee to be worth sweeping. + */ + feeMultiple: MoneyAmountSchema.default('5'), + /** Balances considered per cycle; listSweepableBalances is unbounded. */ + batchSize: z.number().int().positive().default(200), + /** Vendor API parallelism. Tune to the vendor's rate limit. */ + concurrency: z.number().int().positive().default(4), + /** + * How long a sweep may sit in `unknown` before reconciliation reports it for a + * human. `unknown` means the vendor call threw and we cannot tell whether it + * was received, so the in-flight guard stays held and nobody releases it + * automatically. + */ + unknownAfterMinutes: z.number().int().positive().default(60), + /** + * How long a claimed run may sit unfinished before another cycle treats it as + * abandoned and takes over. Distinct from `unknownAfterMinutes`: this one is + * about a crashed worker, that one is about an ambiguous vendor call. + */ + staleRunAfterMinutes: z.number().int().positive().default(30), + }) + .strict() + .optional(), + /** + * Reconciliation cron knobs. Static config, not a DB row - nothing here is edited + * during an incident. Absent means the reconciliation job no-ops. + */ + reconciliation: z + .object({ + cron: z.string().default('0 * * * *'), + lookbackHours: z.number().int().positive().default(24), + stuckAfterMinutes: z.number().int().positive().default(60), + /** Run-claim takeover threshold, as in `sweep.staleRunAfterMinutes`. */ + staleRunAfterMinutes: z.number().int().positive().default(30), + alertThreshold: z.number().int().positive().default(10), + }) + .strict() + .optional(), }) .strict(); diff --git a/packages/core/src/contracts/schemas/wallet-tx.ts b/packages/core/src/contracts/schemas/wallet-tx.ts index adcc467a..4f391934 100644 --- a/packages/core/src/contracts/schemas/wallet-tx.ts +++ b/packages/core/src/contracts/schemas/wallet-tx.ts @@ -17,6 +17,8 @@ export const WALLET_TRANSACTION_TYPES = [ 'tip', 'gift', 'rain', + 'manual_credit', + 'manual_debit', ] as const; export const WALLET_TRANSACTION_STATUSES = [ @@ -31,15 +33,54 @@ export const WALLET_TRANSACTION_STATUSES = [ 'cancelled', ] as const; -// Settlement category, not a vendor: the concrete provider (Fireblocks, a PSP, ...) -// is carried separately in the transaction's providerName. `crypto` covers on-chain -// rails, `fiat` covers card/bank/PSP rails. +// Settlement category, not a vendor: the concrete provider (a custody/MPC vendor, a +// PSP, ...) is carried separately in the transaction's providerName. `crypto` covers +// on-chain rails, `fiat` covers card/bank/PSP rails. export const WALLET_RAILS = ['crypto', 'fiat'] as const; +// A vendor-side internal transfer of pooled/custody funds, never a player balance +// change. `unknown` is load-bearing: a thrown sweep attempt cannot tell "vendor never +// saw it" from "vendor accepted it and the response was lost", so the row parks in +// `unknown` and KEEPS holding the in-flight guard rather than risk a double transfer. +export const WALLET_CUSTODY_SWEEP_STATUSES = [ + 'pending', + 'processing', + 'completed', + 'failed', + 'unknown', +] as const; + +export const WALLET_JOB_RUN_STATUSES = ['running', 'completed', 'failed', 'abandoned'] as const; + +export const WALLET_RECONCILIATION_FINDING_KINDS = [ + 'missing_deposit', + 'unattributed_deposit', + 'amount_mismatch', + 'currency_mismatch', + 'status_mismatch', + 'unknown_at_provider', + 'unconfigured_asset', + 'stuck_sweep', +] as const; + +export const WALLET_RECONCILIATION_FINDING_STATUSES = ['open', 'resolved'] as const; + export const WalletTransactionTypeSchema = z.enum(WALLET_TRANSACTION_TYPES); export const WalletTransactionStatusSchema = z.enum(WALLET_TRANSACTION_STATUSES); export const WalletRailSchema = z.enum(WALLET_RAILS); +export const WalletCustodySweepStatusSchema = z.enum(WALLET_CUSTODY_SWEEP_STATUSES); +export const WalletJobRunStatusSchema = z.enum(WALLET_JOB_RUN_STATUSES); +export const WalletReconciliationFindingKindSchema = z.enum(WALLET_RECONCILIATION_FINDING_KINDS); +export const WalletReconciliationFindingStatusSchema = z.enum( + WALLET_RECONCILIATION_FINDING_STATUSES, +); export type WalletTransactionType = z.infer; export type WalletTransactionStatus = z.infer; export type WalletRail = z.infer; +export type WalletCustodySweepStatus = z.infer; +export type WalletJobRunStatus = z.infer; +export type WalletReconciliationFindingKind = z.infer; +export type WalletReconciliationFindingStatus = z.infer< + typeof WalletReconciliationFindingStatusSchema +>; diff --git a/packages/core/src/server/auth/permissions.ts b/packages/core/src/server/auth/permissions.ts index ead86c93..f92657be 100644 --- a/packages/core/src/server/auth/permissions.ts +++ b/packages/core/src/server/auth/permissions.ts @@ -21,6 +21,9 @@ export const statement = { tag: ['view', 'create', 'delete'] as const, 'chat-room': ['view', 'create', 'update', 'delete'] as const, 'auto-withdrawal-config': ['view', 'update'] as const, + 'wallet-asset': ['view', 'create', 'update', 'delete'] as const, + 'wallet-custody': ['view', 'run'] as const, + 'wallet-reconciliation': ['view', 'resolve', 'run'] as const, 'chat-command': ['view', 'update'] as const, 'chat-moderation': ['view', 'moderate'] as const, } as const; @@ -48,6 +51,9 @@ export const adminRole = ac.newRole({ tag: ['view', 'create', 'delete'], 'chat-room': ['view', 'create', 'update', 'delete'], 'auto-withdrawal-config': ['view', 'update'], + 'wallet-asset': ['view', 'create', 'update', 'delete'], + 'wallet-custody': ['view', 'run'], + 'wallet-reconciliation': ['view', 'resolve', 'run'], 'chat-command': ['view', 'update'], 'chat-moderation': ['view', 'moderate'], }); diff --git a/packages/core/src/server/db/__tests__/query-helpers.test.ts b/packages/core/src/server/db/__tests__/query-helpers.test.ts index 22e12a9d..a880e1b6 100644 --- a/packages/core/src/server/db/__tests__/query-helpers.test.ts +++ b/packages/core/src/server/db/__tests__/query-helpers.test.ts @@ -4,6 +4,8 @@ import { pageToOffset, moneyToNumber, moneyEquals, + moneyCompare, + moneyScaleBy, mapConcurrent, } from '../query-helpers.js'; @@ -65,6 +67,50 @@ describe('moneyEquals', () => { }); }); +describe('moneyCompare', () => { + it('orders two amounts written at different scales', () => { + expect(moneyCompare('9.00', '10.00')).toBe(-1); + expect(moneyCompare('10.00', '9.00')).toBe(1); + expect(moneyCompare('10', '10.00')).toBe(0); + }); + + it('separates two amounts that differ by one wei, where moneyToNumber cannot', () => { + expect(moneyCompare('1.000000000000000001', '1.000000000000000002')).toBe(-1); + expect(moneyCompare('1.000000000000000002', '1.000000000000000001')).toBe(1); + expect(moneyToNumber('1.000000000000000001')).toBe(moneyToNumber('1.000000000000000002')); + }); + + it('orders correctly at the margin where a float compare gives the wrong answer', () => { + // moneyToNumber demonstrably breaks here: both round to the same float, so a + // float-based compare reports them equal (or reverses order) instead of a { + it('multiplies exactly', () => { + expect(moneyScaleBy('10', '5')).toBe('50.000000000000000000'); + expect(moneyScaleBy('0.1', '3')).toBe('0.300000000000000000'); + }); + + it('scales a whole amount by a fee multiple', () => { + expect(moneyScaleBy('2.50', '5')).toBe('12.500000000000000000'); + }); + + it('is exact where a float multiplication would drift', () => { + // 0.1 * 3 famously drifts to 0.30000000000000004 in JS float arithmetic. + expect(0.1 * 3).not.toBe(0.3); + expect(moneyScaleBy('0.1', '3')).toBe('0.300000000000000000'); + }); + + it('truncates past MONEY_SCALE rather than rounding up', () => { + expect(moneyScaleBy('1.000000000000000001', '0.5')).toBe('0.500000000000000000'); + }); +}); + describe('mapConcurrent', () => { const identity = async (n: number) => n * 2; diff --git a/packages/core/src/server/db/index.ts b/packages/core/src/server/db/index.ts index db0bb1d8..e57e2b47 100644 --- a/packages/core/src/server/db/index.ts +++ b/packages/core/src/server/db/index.ts @@ -6,6 +6,10 @@ export { withAdvisoryXactLock, moneyToNumber, moneyEquals, + moneyCompare, + moneyScaleBy, + moneyAdd, + moneySubtract, mapConcurrent, } from './query-helpers.js'; diff --git a/packages/core/src/server/db/query-helpers.ts b/packages/core/src/server/db/query-helpers.ts index 1420b329..cd7dff62 100644 --- a/packages/core/src/server/db/query-helpers.ts +++ b/packages/core/src/server/db/query-helpers.ts @@ -35,6 +35,42 @@ function toMinorUnits(amount: string): bigint { return BigInt(whole + fraction.padEnd(MONEY_SCALE, '0').slice(0, MONEY_SCALE)); } +function fromMinorUnits(units: bigint): string { + const digits = units.toString().padStart(MONEY_SCALE + 1, '0'); + const whole = digits.slice(0, -MONEY_SCALE); + const fraction = digits.slice(-MONEY_SCALE); + return `${whole}.${fraction}`; +} + +// Exact three-way compare for two decimal-string money amounts. Never route this through +// moneyToNumber for the same reason moneyEquals doesn't: a float carries ~15 significant +// digits, so at MONEY_SCALE (18) two genuinely different amounts can compare equal, or +// worse, order backwards. +export function moneyCompare(a: string, b: string): -1 | 0 | 1 { + const diff = toMinorUnits(a) - toMinorUnits(b); + return diff < 0n ? -1 : diff > 0n ? 1 : 0; +} + +// Exact decimal multiplication of two money amounts (`amount * factor`), via the same +// bigint minor-units path as moneyEquals/moneyCompare - never JS float. Both operands are +// non-negative decimal strings; the result is truncated (not rounded) to MONEY_SCALE. +export function moneyScaleBy(amount: string, factor: string): string { + const product = (toMinorUnits(amount) * toMinorUnits(factor)) / 10n ** BigInt(MONEY_SCALE); + return fromMinorUnits(product); +} + +// Exact decimal addition and subtraction over the same bigint minor-units path. Used to +// derive one side of a balance change from the other: reading the balance separately and +// then updating it leaves a window for a concurrent writer, and an append-only audit row +// that records the wrong `before` cannot be corrected afterwards. +export function moneyAdd(a: string, b: string): string { + return fromMinorUnits(toMinorUnits(a) + toMinorUnits(b)); +} + +export function moneySubtract(a: string, b: string): string { + return fromMinorUnits(toMinorUnits(a) - toMinorUnits(b)); +} + // Run `fn` over `items` with at most `concurrency` promises in flight, results in input // order. Use this instead of `Promise.all(items.map(fn))` whenever `items` comes from a // query (unbounded) and `fn` touches the DB: an uncapped fan-out opens one pool connection diff --git a/packages/core/src/server/runtime/core-token-catalog.ts b/packages/core/src/server/runtime/core-token-catalog.ts index 9963ccb5..7a16dbfc 100644 --- a/packages/core/src/server/runtime/core-token-catalog.ts +++ b/packages/core/src/server/runtime/core-token-catalog.ts @@ -32,6 +32,7 @@ import { NOTIFICATION_DELIVERY_ADAPTER, OUTBOX, PAYMENT_ADAPTER, + PAYMENT_PROVIDERS, PAYMENT_WEBHOOK_VERIFIER, PLATFORM_CONFIG, PLAYER_ACTIVITY_TRACKER, @@ -49,6 +50,7 @@ import { SMS_ADAPTER, SOCIAL_COMMANDS, TAG_EVALUATION_COMMANDS, + WALLET_ASSET_CATALOG, WALLET_COMMANDS, WALLET_READER, } from '@openora/core/contracts'; @@ -93,6 +95,7 @@ const coreTokenCatalog = { NOTIFICATION_DELIVERY_ADAPTER, OUTBOX, PAYMENT_ADAPTER, + PAYMENT_PROVIDERS, PAYMENT_WEBHOOK_VERIFIER, PLATFORM_CONFIG, PLAYER_ACTIVITY_TRACKER, @@ -110,6 +113,7 @@ const coreTokenCatalog = { SMS_ADAPTER, SOCIAL_COMMANDS, TAG_EVALUATION_COMMANDS, + WALLET_ASSET_CATALOG, WALLET_COMMANDS, WALLET_READER, } satisfies TokenCatalog; diff --git a/packages/core/src/testing/mock.ts b/packages/core/src/testing/mock.ts index 0e983ce4..b7e1ba3b 100644 --- a/packages/core/src/testing/mock.ts +++ b/packages/core/src/testing/mock.ts @@ -7,7 +7,15 @@ import type { EventBus, OssContext, } from '@openora/core/server'; -import type { AuditWritePort, ClientMeta, IdentityReader } from '@openora/core/contracts'; +import { + DEFAULT_PAYMENT_PROVIDER, + type AuditWritePort, + type ClientMeta, + type IdentityReader, + type PaymentAdapter, + type PaymentProviderRegistry, + type PaymentWebhookVerifier, +} from '@openora/core/contracts'; // The one sanctioned home for test-double type assertions. A unit test standing in // for a collaborator is inherently partial, so the cast lives here - documented and @@ -103,6 +111,29 @@ export const makeAuditWriter = (): AuditWritePort & { record: Mock } => ({ recordInTransaction: vi.fn(async () => undefined), }); +/** + * PaymentProviderRegistry double wrapping a single adapter/verifier pair under + * DEFAULT_PAYMENT_PROVIDER - mirrors wallet/plugin.ts's default binding, so a test with + * one vendor changes nothing about how it builds a WalletService/router. Pass `names` to + * simulate an operator with more than one bound vendor (eg for providerName validation). + */ +export const makePaymentProviderRegistry = ( + options: { + adapter?: PaymentAdapter; + webhookVerifier?: PaymentWebhookVerifier; + names?: readonly string[]; + } = {}, +): PaymentProviderRegistry => { + const adapter = options.adapter ?? mock({}); + const webhookVerifier = + options.webhookVerifier ?? mock({ verify: vi.fn(() => false) }); + const names = options.names ?? [DEFAULT_PAYMENT_PROVIDER]; + return { + get: (name) => (name === DEFAULT_PAYMENT_PROVIDER ? { adapter, webhookVerifier } : null), + names: () => names, + }; +}; + export const makeIdentityReader = (): IdentityReader => mock({ getLastLoginAt: vi.fn().mockResolvedValue(null), diff --git a/packages/core/src/wallet/__tests__/settlement-network.test.ts b/packages/core/src/wallet/__tests__/settlement-network.test.ts new file mode 100644 index 00000000..81827b0f --- /dev/null +++ b/packages/core/src/wallet/__tests__/settlement-network.test.ts @@ -0,0 +1,87 @@ +import { describe, it, expect } from 'vitest'; +import { + resolveWithdrawalNetwork, + assertAboveMinimumWithdrawal, + AmbiguousNetworkError, + UnsupportedNetworkError, + WithdrawalDisabledError, + BelowMinimumWithdrawalError, +} from '../service/wallet.service.js'; + +const asset = (network: string, minWithdrawal = '0', withdrawalEnabled = true) => ({ + network, + minWithdrawal, + withdrawalEnabled, +}); + +describe('resolveWithdrawalNetwork', () => { + it('passes the caller through when the currency has no catalog rows', () => { + expect(resolveWithdrawalNetwork([], 'USD', undefined)).toBeNull(); + expect(resolveWithdrawalNetwork([], 'USD', 'sepa')).toBe('SEPA'); + }); + + it('implies the only payable network', () => { + expect(resolveWithdrawalNetwork([asset('ERC20')], 'USDT')).toBe('ERC20'); + }); + + it('demands a network when several are payable', () => { + expect(() => resolveWithdrawalNetwork([asset('ERC20'), asset('TRC20')], 'USDT')).toThrow( + AmbiguousNetworkError, + ); + }); + + it('implies the only network still payable when the others are disabled', () => { + const assets = [asset('ERC20'), asset('TRC20', '0', false)]; + + expect(resolveWithdrawalNetwork(assets, 'USDT')).toBe('ERC20'); + }); + + it('accepts an explicitly chosen payable network, normalizing case', () => { + const assets = [asset('ERC20'), asset('TRC20')]; + + expect(resolveWithdrawalNetwork(assets, 'USDT', 'trc20')).toBe('TRC20'); + }); + + it('rejects a network the currency does not settle on', () => { + expect(() => resolveWithdrawalNetwork([asset('ERC20')], 'USDT', 'BEP20')).toThrow( + UnsupportedNetworkError, + ); + }); + + it('rejects a network configured but disabled for withdrawal', () => { + const assets = [asset('ERC20'), asset('TRC20', '0', false)]; + + expect(() => resolveWithdrawalNetwork(assets, 'USDT', 'TRC20')).toThrow( + UnsupportedNetworkError, + ); + }); + + it('fails closed when the currency is configured but payable nowhere', () => { + const assets = [asset('ERC20', '0', false), asset('TRC20', '0', false)]; + + expect(() => resolveWithdrawalNetwork(assets, 'USDT')).toThrow(WithdrawalDisabledError); + expect(() => resolveWithdrawalNetwork(assets, 'USDT', 'ERC20')).toThrow( + WithdrawalDisabledError, + ); + }); +}); + +describe('assertAboveMinimumWithdrawal', () => { + const assets = [asset('ERC20', '20'), asset('BEP20', '1')]; + + it('enforces the floor of the chosen chain, not the currency', () => { + expect(() => assertAboveMinimumWithdrawal(assets, '5', 'USDT', 'ERC20')).toThrow( + BelowMinimumWithdrawalError, + ); + expect(() => assertAboveMinimumWithdrawal(assets, '5', 'USDT', 'BEP20')).not.toThrow(); + }); + + it('allows an amount exactly at the floor', () => { + expect(() => assertAboveMinimumWithdrawal(assets, '20', 'USDT', 'ERC20')).not.toThrow(); + }); + + it('is a no-op when the network has no catalog row', () => { + expect(() => assertAboveMinimumWithdrawal(assets, '0.01', 'USDT', 'TRC20')).not.toThrow(); + expect(() => assertAboveMinimumWithdrawal([], '0.01', 'USD', null)).not.toThrow(); + }); +}); diff --git a/packages/core/src/wallet/__tests__/wallet-asset.router.int.test.ts b/packages/core/src/wallet/__tests__/wallet-asset.router.int.test.ts new file mode 100644 index 00000000..dc13f2c6 --- /dev/null +++ b/packages/core/src/wallet/__tests__/wallet-asset.router.int.test.ts @@ -0,0 +1,391 @@ +import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; +import { findOneOrThrow } from '@openora/core/server'; +import { randomUUID } from 'node:crypto'; +import { call, ORPCError } from '@orpc/server'; +import type { AdminGuard } from '@openora/core/server'; +import type { PaymentAdapter } from '@openora/core/contracts'; +import { createTestDb, type TestDb } from '@openora/core/testing'; +import { migrate as migrateProfile } from '@openora/core/pam/migrate/profile'; +import { + mock, + makeEventBus, + testContext, + makeAuditWriter, + makeAdminGuard, + makeIdentityReader, + makePaymentProviderRegistry, +} from '../../testing/mock.js'; +import { migrate } from '../migrate.js'; +import { wallet, walletBalance, walletTransaction, walletAsset } from '../schema/index.js'; +import { createWalletRouter } from '../router/index.js'; +import { WalletService } from '../service/wallet.service.js'; + +const CTX = testContext(); +const CALLER_ID = '9a2f7c11-0000-4000-8000-0000000000cc'; + +const USDT_ERC20 = { + currency: 'USDT', + network: 'ERC20', + providerAssetId: 'USDT_ERC20', + minDeposit: '10', + minWithdrawal: '20', + withdrawalFee: '5', +} as const; + +let db: TestDb; + +beforeAll(async () => { + db = await createTestDb([migrate, migrateProfile]); +}); + +afterAll(async () => { + await db.drop(); +}); + +beforeEach(async () => { + await db.drizzle.db.delete(walletTransaction); + await db.drizzle.db.delete(walletBalance); + await db.drizzle.db.delete(wallet); + await db.drizzle.db.delete(walletAsset); +}); + +const adminGuard = () => makeAdminGuard({ caller: { userId: CALLER_ID, role: 'admin' } }); + +const denyingGuard = () => + makeAdminGuard({ + deny: [ + 'wallet-asset:view', + 'wallet-asset:create', + 'wallet-asset:update', + 'wallet-asset:delete', + ], + caller: { userId: CALLER_ID, role: 'support' }, + }); + +function routerWith( + guard: AdminGuard, + payment?: Partial, + providerNames?: readonly string[], +) { + const audit = makeAuditWriter(); + const paymentProviders = makePaymentProviderRegistry( + providerNames ? { names: providerNames } : {}, + ); + const service = new WalletService({ + drizzle: db.drizzle, + events: makeEventBus(), + payment: mock(payment ?? {}), + paymentProviders, + audit, + identityReader: makeIdentityReader(), + }); + const router = createWalletRouter(service, guard, audit, paymentProviders); + return { router, audit, service }; +} + +async function seedBalance(currency: string, amount: string) { + const row = findOneOrThrow( + await db.drizzle.db.insert(wallet).values({ userId: randomUUID(), currency }).returning(), + new Error('seedBalance: query returned no row'), + ); + await db.drizzle.db.insert(walletBalance).values({ walletId: row.id, currency, amount }); + return row; +} + +describe('wallet asset catalog routes', () => { + it('create: persists a row, normalizes casing, and audits', async () => { + const { router, audit } = routerWith(adminGuard()); + + const created = await call( + router.assets.create, + { ...USDT_ERC20, currency: 'usdt', network: 'erc20' }, + { context: CTX }, + ); + + expect(created).toMatchObject({ + currency: 'USDT', + network: 'ERC20', + providerAssetId: 'USDT_ERC20', + depositEnabled: true, + withdrawalEnabled: true, + }); + expect(audit.recordInTransaction).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ action: 'wallet.wallet_asset.created' }), + ); + }); + + it('create: rejects a duplicate (currency, network)', async () => { + const { router } = routerWith(adminGuard()); + await call(router.assets.create, { ...USDT_ERC20 }, { context: CTX }); + + await expect( + call(router.assets.create, { ...USDT_ERC20, providerAssetId: 'OTHER' }, { context: CTX }), + ).rejects.toThrow(ORPCError); + }); + + it('create: allows the same currency on a second network', async () => { + const { router } = routerWith(adminGuard()); + await call(router.assets.create, { ...USDT_ERC20 }, { context: CTX }); + + const bep20 = await call( + router.assets.create, + { ...USDT_ERC20, network: 'BEP20', providerAssetId: 'USDT_BSC' }, + { context: CTX }, + ); + + expect(bep20).toMatchObject({ currency: 'USDT', network: 'BEP20' }); + }); + + it('create: rejects a pair the bound adapter cannot serve', async () => { + const { router } = routerWith(adminGuard(), { supportsAsset: () => false }); + + await expect(call(router.assets.create, { ...USDT_ERC20 }, { context: CTX })).rejects.toThrow( + ORPCError, + ); + }); + + it('create: rejects a providerName not in the bound registry', async () => { + const { router } = routerWith(adminGuard(), undefined, ['vendor-a']); + + await expect( + call(router.assets.create, { ...USDT_ERC20, providerName: 'vendor-b' }, { context: CTX }), + ).rejects.toThrow(ORPCError); + }); + + it('create: accepts a providerName the registry knows', async () => { + const { router } = routerWith(adminGuard(), undefined, ['vendor-a']); + + const created = await call( + router.assets.create, + { ...USDT_ERC20, providerName: 'vendor-a' }, + { context: CTX }, + ); + + expect(created).toMatchObject({ providerName: 'vendor-a' }); + }); + + it('create: an omitted providerName falls back to the default binding (null column)', async () => { + const { router } = routerWith(adminGuard()); + + const created = await call(router.assets.create, { ...USDT_ERC20 }, { context: CTX }); + + expect(created.providerName).toBeNull(); + }); + + it('listAssets: is public and hides fully-disabled pairs and the vendor id', async () => { + const { router } = routerWith(adminGuard()); + await call(router.assets.create, { ...USDT_ERC20 }, { context: CTX }); + await call( + router.assets.create, + { + ...USDT_ERC20, + network: 'TRC20', + providerAssetId: 'USDT_TRX', + depositEnabled: false, + withdrawalEnabled: false, + }, + { context: CTX }, + ); + + const listed = await call(router.listAssets, {}, { context: CTX }); + + expect(listed).toHaveLength(1); + expect(listed[0]).toMatchObject({ currency: 'USDT', network: 'ERC20' }); + expect(listed[0]).not.toHaveProperty('providerAssetId'); + }); + + it('listAssets: hides providerName, sweepFeeCeiling and poolLiquidityFloor from the public catalog', async () => { + const { router } = routerWith(adminGuard(), undefined, ['vendor-a']); + await call( + router.assets.create, + { ...USDT_ERC20, providerName: 'vendor-a', sweepFeeCeiling: '2', poolLiquidityFloor: '100' }, + { context: CTX }, + ); + + const [listed] = await call(router.listAssets, {}, { context: CTX }); + + expect(listed).not.toHaveProperty('providerName'); + expect(listed).not.toHaveProperty('sweepFeeCeiling'); + expect(listed).not.toHaveProperty('poolLiquidityFloor'); + }); + + it('list: exposes providerName, sweepFeeCeiling and poolLiquidityFloor for an admin', async () => { + const { router } = routerWith(adminGuard(), undefined, ['vendor-a']); + await call( + router.assets.create, + { ...USDT_ERC20, providerName: 'vendor-a', sweepFeeCeiling: '2', poolLiquidityFloor: '100' }, + { context: CTX }, + ); + + const [listed] = await call(router.assets.list, {}, { context: CTX }); + + expect(listed).toMatchObject({ + providerName: 'vendor-a', + sweepFeeCeiling: '2.000000000000000000', + poolLiquidityFloor: '100.000000000000000000', + }); + }); + + it('listAssets: keeps a pair enabled on only one side', async () => { + const { router } = routerWith(adminGuard()); + await call(router.assets.create, { ...USDT_ERC20, withdrawalEnabled: false }, { context: CTX }); + + const listed = await call(router.listAssets, {}, { context: CTX }); + + expect(listed).toMatchObject([{ depositEnabled: true, withdrawalEnabled: false }]); + }); + + it('list: returns disabled rows and the vendor id for an admin', async () => { + const { router } = routerWith(adminGuard()); + await call( + router.assets.create, + { ...USDT_ERC20, depositEnabled: false, withdrawalEnabled: false }, + { context: CTX }, + ); + + const listed = await call(router.assets.list, {}, { context: CTX }); + + expect(listed).toMatchObject([{ providerAssetId: 'USDT_ERC20', depositEnabled: false }]); + }); + + it('update: applies a partial change without restating amounts', async () => { + const { router, audit } = routerWith(adminGuard()); + await call(router.assets.create, { ...USDT_ERC20 }, { context: CTX }); + + const updated = await call( + router.assets.update, + { currency: 'USDT', network: 'ERC20', withdrawalEnabled: false }, + { context: CTX }, + ); + + expect(updated).toMatchObject({ withdrawalEnabled: false, depositEnabled: true }); + expect(updated.minWithdrawal).toBe('20.000000000000000000'); + expect(audit.recordInTransaction).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ action: 'wallet.wallet_asset.updated' }), + ); + }); + + it('update: providerName is not an editable field - it stays whatever create set', async () => { + const { router } = routerWith(adminGuard(), undefined, ['vendor-a']); + await call(router.assets.create, { ...USDT_ERC20, providerName: 'vendor-a' }, { context: CTX }); + + const updated = await call( + router.assets.update, + // No `providerName` key exists on UpdateWalletAssetInputSchema - the type system + // (not a runtime check) is what makes this immutable. + { currency: 'USDT', network: 'ERC20', withdrawalEnabled: false }, + { context: CTX }, + ); + + expect(updated.providerName).toBe('vendor-a'); + }); + + it('update: 404s on a pair that does not exist', async () => { + const { router } = routerWith(adminGuard()); + + await expect( + call( + router.assets.update, + { currency: 'USDT', network: 'ERC20', withdrawalFee: '1' }, + { context: CTX }, + ), + ).rejects.toThrow(ORPCError); + }); + + it('delete: removes a pair no player holds', async () => { + const { router, audit } = routerWith(adminGuard()); + await call(router.assets.create, { ...USDT_ERC20 }, { context: CTX }); + + const deleted = await call( + router.assets.delete, + { currency: 'USDT', network: 'ERC20' }, + { context: CTX }, + ); + + expect(deleted).toBe(true); + expect(await call(router.assets.list, {}, { context: CTX })).toEqual([]); + expect(audit.recordInTransaction).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ action: 'wallet.wallet_asset.deleted' }), + ); + }); + + it('delete: is blocked while a player still holds that currency', async () => { + const { router } = routerWith(adminGuard()); + await call(router.assets.create, { ...USDT_ERC20 }, { context: CTX }); + await seedBalance('USDT', '5'); + + await expect( + call(router.assets.delete, { currency: 'USDT', network: 'ERC20' }, { context: CTX }), + ).rejects.toThrow(ORPCError); + expect(await call(router.assets.list, {}, { context: CTX })).toHaveLength(1); + }); + + it('delete: allows removal once the held balance is zero', async () => { + const { router } = routerWith(adminGuard()); + await call(router.assets.create, { ...USDT_ERC20 }, { context: CTX }); + await seedBalance('USDT', '0'); + + await expect( + call(router.assets.delete, { currency: 'USDT', network: 'ERC20' }, { context: CTX }), + ).resolves.toBe(true); + }); + + it('delete: is blocked while a pending/processing transaction exists for the pair - renaming providerName is a delete + create', async () => { + const { router } = routerWith(adminGuard()); + await call(router.assets.create, { ...USDT_ERC20 }, { context: CTX }); + const w = await seedBalance('USDT', '0'); + await db.drizzle.db.insert(walletTransaction).values({ + walletId: w.id, + type: 'withdrawal', + amount: '5', + currency: 'USDT', + network: 'ERC20', + status: 'processing', + rail: 'crypto', + }); + + await expect( + call(router.assets.delete, { currency: 'USDT', network: 'ERC20' }, { context: CTX }), + ).rejects.toThrow(ORPCError); + expect(await call(router.assets.list, {}, { context: CTX })).toHaveLength(1); + }); + + it('delete: allows removal once the in-flight transaction reaches a terminal state', async () => { + const { router } = routerWith(adminGuard()); + await call(router.assets.create, { ...USDT_ERC20 }, { context: CTX }); + const w = await seedBalance('USDT', '0'); + await db.drizzle.db.insert(walletTransaction).values({ + walletId: w.id, + type: 'withdrawal', + amount: '5', + currency: 'USDT', + network: 'ERC20', + status: 'completed', + rail: 'crypto', + }); + + await expect( + call(router.assets.delete, { currency: 'USDT', network: 'ERC20' }, { context: CTX }), + ).resolves.toBe(true); + }); + + it('delete: reports false for a pair that was never configured', async () => { + const { router } = routerWith(adminGuard()); + + await expect( + call(router.assets.delete, { currency: 'USDT', network: 'ERC20' }, { context: CTX }), + ).resolves.toBe(false); + }); + + it('admin routes reject a caller without the wallet-asset resource', async () => { + const { router } = routerWith(denyingGuard()); + + await expect(call(router.assets.list, {}, { context: CTX })).rejects.toThrow(ORPCError); + await expect(call(router.assets.create, { ...USDT_ERC20 }, { context: CTX })).rejects.toThrow( + ORPCError, + ); + }); +}); diff --git a/packages/core/src/wallet/__tests__/wallet-auto-rule.router.int.test.ts b/packages/core/src/wallet/__tests__/wallet-auto-rule.router.int.test.ts index af2b081e..168036eb 100644 --- a/packages/core/src/wallet/__tests__/wallet-auto-rule.router.int.test.ts +++ b/packages/core/src/wallet/__tests__/wallet-auto-rule.router.int.test.ts @@ -1,8 +1,8 @@ -import { describe, it, expect, vi, beforeAll, afterAll, beforeEach } from 'vitest'; +import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; import { eq } from 'drizzle-orm'; import { call, ORPCError } from '@orpc/server'; import type { AdminGuard } from '@openora/core/server'; -import type { PaymentAdapter, PaymentWebhookVerifier } from '@openora/core/contracts'; +import type { PaymentAdapter } from '@openora/core/contracts'; import { createTestDb, type TestDb } from '@openora/core/testing'; import { mock, @@ -11,6 +11,7 @@ import { makeAuditWriter, makeAdminGuard, makeIdentityReader, + makePaymentProviderRegistry, } from '../../testing/mock.js'; import { migrate } from '../migrate.js'; import { autoWithdrawalRule } from '../schema/index.js'; @@ -45,20 +46,16 @@ const autoRuleDenyingGuard = () => function routerWith(adminGuard: AdminGuard) { const audit = makeAuditWriter(); + const paymentProviders = makePaymentProviderRegistry(); const service = new WalletService({ drizzle: db.drizzle, events: makeEventBus(), payment: mock({}), + paymentProviders, audit, identityReader: makeIdentityReader(), }); - const router = createWalletRouter( - service, - adminGuard, - audit, - mock({}), - mock({ verify: vi.fn().mockReturnValue(false) }), - ); + const router = createWalletRouter(service, adminGuard, audit, paymentProviders); return { router, audit }; } diff --git a/packages/core/src/wallet/__tests__/wallet-auto-withdrawal-config.router.int.test.ts b/packages/core/src/wallet/__tests__/wallet-auto-withdrawal-config.router.int.test.ts index 9d1c46b7..f3420dbc 100644 --- a/packages/core/src/wallet/__tests__/wallet-auto-withdrawal-config.router.int.test.ts +++ b/packages/core/src/wallet/__tests__/wallet-auto-withdrawal-config.router.int.test.ts @@ -7,7 +7,6 @@ import type { AdminPlayerSummary, AdminUserDirectory, PaymentAdapter, - PaymentWebhookVerifier, PlatformConfig, PlayerTags, } from '@openora/core/contracts'; @@ -20,6 +19,7 @@ import { makeAuditWriter, makeAdminGuard, makeIdentityReader, + makePaymentProviderRegistry, NO_CLIENT_META, } from '../../testing/mock.js'; import { migrate } from '../migrate.js'; @@ -89,6 +89,7 @@ function routerWith(adminGuard: AdminGuard, platformConfig?: Partial({ getActiveTagKeys: vi.fn(async (ids: readonly string[]) => new Map(ids.map((id) => [id, []]))), }); + const paymentProviders = makePaymentProviderRegistry(); const service = new WalletService({ drizzle: db.drizzle, events: makeEventBus(), @@ -98,19 +99,14 @@ function routerWith(adminGuard: AdminGuard, platformConfig?: Partial(platformConfig) : undefined, riskTags, }); - const router = createWalletRouter( - service, - adminGuard, - audit, - mock({}), - mock({ verify: vi.fn().mockReturnValue(false) }), - ); + const router = createWalletRouter(service, adminGuard, audit, paymentProviders); return { router, audit, service }; } diff --git a/packages/core/src/wallet/__tests__/wallet-auto-withdrawal.int.test.ts b/packages/core/src/wallet/__tests__/wallet-auto-withdrawal.int.test.ts index 258e5f47..fd648537 100644 --- a/packages/core/src/wallet/__tests__/wallet-auto-withdrawal.int.test.ts +++ b/packages/core/src/wallet/__tests__/wallet-auto-withdrawal.int.test.ts @@ -2,17 +2,18 @@ import { describe, it, expect, vi, beforeAll, afterAll, beforeEach } from 'vites import { findOneOrThrow } from '@openora/core/server'; import { randomUUID } from 'node:crypto'; import { eq, sql } from 'drizzle-orm'; -import type { - AdminUserDirectory, - AuditWritePort, - AutoWithdrawalConfig, - KycStatus, - PaymentAdapter, - PlatformConfig, - AdminPlayerSummary, - PlayerTags, - TagEvaluationCommands, - TagKey, +import { + DEFAULT_PAYMENT_PROVIDER, + type AdminUserDirectory, + type AuditWritePort, + type AutoWithdrawalConfig, + type KycStatus, + type PaymentAdapter, + type PlatformConfig, + type AdminPlayerSummary, + type PlayerTags, + type TagEvaluationCommands, + type TagKey, } from '@openora/core/contracts'; import { createTestDb, type TestDb } from '@openora/core/testing'; import { migrate as migrateProfile } from '@openora/core/pam/migrate/profile'; @@ -22,6 +23,7 @@ import { makeIdentityReader, NO_CLIENT_META, makeAuditWriter, + makePaymentProviderRegistry, } from '../../testing/mock.js'; import { migrate } from '../migrate.js'; import { @@ -109,6 +111,7 @@ async function makeService({ drizzle: db.drizzle, events: events, payment: mock(psp), + paymentProviders: makePaymentProviderRegistry(), audit: mock(audit), identityReader: makeIdentityReader(), directory, @@ -186,7 +189,7 @@ describe('WalletService.withdraw auto-approval (real PG)', () => { status: 'completed', reviewedBy: null, reviewReason: 'auto-approved', - providerName: 'psp', + providerName: DEFAULT_PAYMENT_PROVIDER, }); expect(events.emit.mock.calls.map(([topic]) => topic)).toEqual([ 'wallet.withdrawal.requested', @@ -412,6 +415,7 @@ describe('WalletService.withdraw auto-approval (real PG)', () => { drizzle: db.drizzle, events: makeEventBus(), payment, + paymentProviders: makePaymentProviderRegistry(), audit: mock(makeAuditWriter()), identityReader: makeIdentityReader(), directory, @@ -724,7 +728,7 @@ describe('WalletService.withdraw auto-approval - crypto rail (real PG)', () => { expect(result.status).toBe('completed'); expect(await txById(result.transactionId)).toMatchObject({ rail: 'crypto', - providerName: 'fireblocks', + providerName: DEFAULT_PAYMENT_PROVIDER, reviewReason: 'auto-approved', }); }); diff --git a/packages/core/src/wallet/__tests__/wallet-custody-stubs.router.test.ts b/packages/core/src/wallet/__tests__/wallet-custody-stubs.router.test.ts new file mode 100644 index 00000000..f713471c --- /dev/null +++ b/packages/core/src/wallet/__tests__/wallet-custody-stubs.router.test.ts @@ -0,0 +1,123 @@ +import { describe, it, expect } from 'vitest'; +import { call, ORPCError } from '@orpc/server'; +import type { AdminGuard } from '@openora/core/server'; +import type { PaymentAdapter } from '@openora/core/contracts'; +import { + mock, + makeDrizzle, + makeEventBus, + testContext, + makeAuditWriter, + makeAdminGuard, + makeIdentityReader, + makePaymentProviderRegistry, +} from '../../testing/mock.js'; +import { createWalletRouter } from '../router/index.js'; +import { WalletService } from '../service/wallet.service.js'; + +const CTX = testContext(); +const CALLER_ID = '9a2f7c11-0000-4000-8000-0000000000dd'; + +// These stub routes only ever run adminGuard.assert() then throw NOT_IMPLEMENTED - no DB +// row is ever read or written, so a mocked DrizzleService is enough (no real Postgres). +function routerWith(guard: AdminGuard) { + const paymentProviders = makePaymentProviderRegistry(); + const service = new WalletService({ + drizzle: makeDrizzle(), + events: makeEventBus(), + payment: mock({}), + paymentProviders, + audit: makeAuditWriter(), + identityReader: makeIdentityReader(), + }); + return createWalletRouter(service, guard, makeAuditWriter(), paymentProviders); +} + +const authorizedGuard = () => makeAdminGuard({ caller: { userId: CALLER_ID, role: 'admin' } }); + +const deniedGuard = (deny: readonly string[]) => + makeAdminGuard({ deny, caller: { userId: CALLER_ID, role: 'support' } }); + +describe('wallet custody/reconciliation stub routes', () => { + describe('POST /wallet/custody/sweep/run', () => { + it('501s for an authorized caller', async () => { + const router = routerWith(authorizedGuard()); + + await expect(call(router.custody.sweep.run, {}, { context: CTX })).rejects.toMatchObject({ + code: 'NOT_IMPLEMENTED', + }); + }); + + it('403s for a caller missing wallet-custody:run', async () => { + const router = routerWith(deniedGuard(['wallet-custody:run'])); + + await expect(call(router.custody.sweep.run, {}, { context: CTX })).rejects.toBeInstanceOf( + ORPCError, + ); + await expect(call(router.custody.sweep.run, {}, { context: CTX })).rejects.toMatchObject({ + code: 'FORBIDDEN', + }); + }); + }); + + describe('GET /wallet/reconciliation', () => { + const input = { page: 1, limit: 20 }; + + it('501s for an authorized caller - NEVER a success shape, empty or otherwise', async () => { + const router = routerWith(authorizedGuard()); + + await expect(call(router.reconciliation.list, input, { context: CTX })).rejects.toMatchObject( + { code: 'NOT_IMPLEMENTED' }, + ); + }); + + it('403s for a caller missing wallet-reconciliation:view', async () => { + const router = routerWith(deniedGuard(['wallet-reconciliation:view'])); + + await expect(call(router.reconciliation.list, input, { context: CTX })).rejects.toMatchObject( + { code: 'FORBIDDEN' }, + ); + }); + }); + + describe('POST /wallet/reconciliation/{id}/resolve', () => { + const input = { + id: '63d3c264-3bf4-4d08-9b92-ea3eaf40a440', + resolution: { outcome: 'dismissed' as const, note: 'confirmed non-issue' }, + }; + + it('501s for an authorized caller', async () => { + const router = routerWith(authorizedGuard()); + + await expect( + call(router.reconciliation.resolve, input, { context: CTX }), + ).rejects.toMatchObject({ code: 'NOT_IMPLEMENTED' }); + }); + + it('403s for a caller missing wallet-reconciliation:resolve', async () => { + const router = routerWith(deniedGuard(['wallet-reconciliation:resolve'])); + + await expect( + call(router.reconciliation.resolve, input, { context: CTX }), + ).rejects.toMatchObject({ code: 'FORBIDDEN' }); + }); + }); + + describe('POST /wallet/reconciliation/run', () => { + it('501s for an authorized caller', async () => { + const router = routerWith(authorizedGuard()); + + await expect(call(router.reconciliation.run, {}, { context: CTX })).rejects.toMatchObject({ + code: 'NOT_IMPLEMENTED', + }); + }); + + it('403s for a caller missing wallet-reconciliation:run', async () => { + const router = routerWith(deniedGuard(['wallet-reconciliation:run'])); + + await expect(call(router.reconciliation.run, {}, { context: CTX })).rejects.toMatchObject({ + code: 'FORBIDDEN', + }); + }); + }); +}); diff --git a/packages/core/src/wallet/__tests__/wallet-kyc-gate.int.test.ts b/packages/core/src/wallet/__tests__/wallet-kyc-gate.int.test.ts index 26565437..d85d291b 100644 --- a/packages/core/src/wallet/__tests__/wallet-kyc-gate.int.test.ts +++ b/packages/core/src/wallet/__tests__/wallet-kyc-gate.int.test.ts @@ -17,6 +17,7 @@ import { makeIdentityReader, NO_CLIENT_META, makeAuditWriter, + makePaymentProviderRegistry, } from '../../testing/mock.js'; import { migrate } from '../migrate.js'; import { wallet, walletBalance, walletTransaction } from '../schema/index.js'; @@ -36,6 +37,7 @@ function makeService(kycStatus: KycStatus | null, gateWithdrawals: boolean) { drizzle: db.drizzle, events: makeEventBus(), payment: mock({ processWithdrawal: vi.fn() }), + paymentProviders: makePaymentProviderRegistry(), audit: makeAuditWriter(), identityReader: makeIdentityReader(), directory, diff --git a/packages/core/src/wallet/__tests__/wallet-webhook.router.int.test.ts b/packages/core/src/wallet/__tests__/wallet-webhook.router.int.test.ts index 1b6ed77a..37bdf66d 100644 --- a/packages/core/src/wallet/__tests__/wallet-webhook.router.int.test.ts +++ b/packages/core/src/wallet/__tests__/wallet-webhook.router.int.test.ts @@ -6,8 +6,11 @@ import { call, ORPCError } from '@orpc/server'; import type { AdminGuard } from '@openora/core/server'; import type { PaymentAdapter, + PaymentProviderRegistry, PaymentWebhookEvent, PaymentWebhookVerifier, + RateLimiterAdapter, + RateLimitKey, } from '@openora/core/contracts'; import { createTestDb, type TestDb } from '@openora/core/testing'; import { migrate as migrateProfile } from '@openora/core/pam/migrate/profile'; @@ -17,6 +20,7 @@ import { makeIdentityReader, testContext, makeAuditWriter, + makePaymentProviderRegistry, } from '../../testing/mock.js'; import { migrate } from '../migrate.js'; import { wallet, walletBalance, walletTransaction, walletDepositAddress } from '../schema/index.js'; @@ -43,11 +47,28 @@ beforeEach(async () => { await db.drizzle.db.delete(wallet); }); -function routerWith(payment: PaymentAdapter, verifier: PaymentWebhookVerifier) { +function routerWith( + payment: PaymentAdapter, + verifier: PaymentWebhookVerifier, + limiter?: RateLimiterAdapter, +) { + const paymentProviders = makePaymentProviderRegistry({ + adapter: payment, + webhookVerifier: verifier, + }); + return routerWithProviders(paymentProviders, payment, limiter); +} + +function routerWithProviders( + paymentProviders: PaymentProviderRegistry, + payment: PaymentAdapter, + limiter?: RateLimiterAdapter, +) { const service = new WalletService({ drizzle: db.drizzle, events: makeEventBus(), payment, + paymentProviders, audit: makeAuditWriter(), identityReader: makeIdentityReader(), }); @@ -55,11 +76,31 @@ function routerWith(payment: PaymentAdapter, verifier: PaymentWebhookVerifier) { service, mock({ assert: vi.fn() }), makeAuditWriter(), - payment, - verifier, + paymentProviders, + limiter, ); } +// A registry with two DISTINCTLY-behaving named providers, to test that a webhook is +// never verified with one vendor's key and parsed with another's format. +function twoProviderRegistry( + a: { payment: PaymentAdapter; verifier: PaymentWebhookVerifier }, + b: { payment: PaymentAdapter; verifier: PaymentWebhookVerifier }, +): PaymentProviderRegistry { + return { + get: (name) => { + if (name === 'vendor-a') { + return { adapter: a.payment, webhookVerifier: a.verifier }; + } + if (name === 'vendor-b') { + return { adapter: b.payment, webhookVerifier: b.verifier }; + } + return null; + }, + names: () => ['vendor-a', 'vendor-b'], + }; +} + const verifierReturning = (result: boolean) => mock({ verify: vi.fn().mockReturnValue(result) }); @@ -86,7 +127,7 @@ async function seedDepositAddress(currency = 'BTC') { userId: USER_ID, currency, address: DEPOSIT_ADDRESS, - providerName: 'fireblocks', + providerName: 'custody', }); } @@ -229,3 +270,139 @@ describe('wallet webhook route (M2M, no admin session)', () => { expect(await ledgerFor(w.id)).toHaveLength(0); }); }); + +describe('POST /wallet/webhook/{provider} (multi-provider routing)', () => { + it('resolves the verifier AND the adapter from the same named provider entry', async () => { + const w = await seedWallet(); + await seedDepositAddress(); + const a = { payment: paymentParsing(depositEvent), verifier: verifierReturning(true) }; + const b = { payment: paymentParsing(depositEvent), verifier: verifierReturning(true) }; + const router = routerWithProviders(twoProviderRegistry(a, b), a.payment); + + const result = await call( + router.webhookForProvider, + { provider: 'vendor-a' }, + ctx('{"event":"deposit"}'), + ); + + expect(result).toEqual({ ok: true }); + expect(a.verifier.verify).toHaveBeenCalledTimes(1); + expect(a.payment.parseWebhook).toHaveBeenCalledTimes(1); + expect(await ledgerFor(w.id)).toHaveLength(1); + }); + + it('never verifies with one provider and parses with another (signature confusion)', async () => { + const a = { payment: paymentParsing(depositEvent), verifier: verifierReturning(true) }; + const b = { payment: paymentParsing(depositEvent), verifier: verifierReturning(true) }; + const router = routerWithProviders(twoProviderRegistry(a, b), a.payment); + + await call(router.webhookForProvider, { provider: 'vendor-a' }, ctx('{"event":"deposit"}')); + + expect(a.verifier.verify).toHaveBeenCalledTimes(1); + expect(b.verifier.verify).not.toHaveBeenCalled(); + expect(b.payment.parseWebhook).not.toHaveBeenCalled(); + }); + + it('an unknown provider name fails the same shape as a bad signature, without calling any bound verifier', async () => { + const a = { payment: paymentParsing(depositEvent), verifier: verifierReturning(true) }; + const b = { payment: paymentParsing(depositEvent), verifier: verifierReturning(true) }; + const router = routerWithProviders(twoProviderRegistry(a, b), a.payment); + + await expect( + call( + router.webhookForProvider, + { provider: 'not-a-bound-vendor' }, + ctx('{"event":"deposit"}'), + ), + ).rejects.toMatchObject({ code: 'UNAUTHORIZED' }); + expect(a.verifier.verify).not.toHaveBeenCalled(); + expect(b.verifier.verify).not.toHaveBeenCalled(); + }); + + it('rejects a bad signature on the named-provider route the same way as the default route', async () => { + const a = { payment: paymentParsing(depositEvent), verifier: verifierReturning(false) }; + const b = { payment: paymentParsing(depositEvent), verifier: verifierReturning(true) }; + const router = routerWithProviders(twoProviderRegistry(a, b), a.payment); + + await expect( + call(router.webhookForProvider, { provider: 'vendor-a' }, ctx('{"event":"deposit"}')), + ).rejects.toMatchObject({ code: 'UNAUTHORIZED' }); + expect(a.payment.parseWebhook).not.toHaveBeenCalled(); + }); + + it('the unparameterised route keeps working, delegating to the default provider', async () => { + const w = await seedWallet(); + await seedDepositAddress(); + const router = routerWith(paymentParsing(depositEvent), verifierReturning(true)); + + const result = await call(router.webhook, {}, ctx('{"event":"deposit"}')); + + expect(result).toEqual({ ok: true }); + expect(await ledgerFor(w.id)).toHaveLength(1); + }); +}); + +describe('wallet webhook route - per-IP rate limit', () => { + function exhaustedLimiter(): RateLimiterAdapter { + return { + consume: vi.fn(async () => ({ allowed: false, retryAfterMs: 1000 })), + reset: vi.fn(async () => undefined), + }; + } + + it('rejects with 429 once the per-IP limiter denies the request', async () => { + const limiter = exhaustedLimiter(); + const router = routerWith(paymentParsing(null), verifierReturning(true), limiter); + + await expect( + call( + router.webhook, + {}, + { + context: testContext({ + request: { headers: {} }, + rawBody: '{}', + clientMeta: { ip: '1.2.3.4', userAgent: null }, + }), + }, + ), + ).rejects.toMatchObject({ code: 'TOO_MANY_REQUESTS' }); + expect(limiter.consume).toHaveBeenCalledTimes(1); + }); + + it('still rate-limits a request with no IP, under a shared bucket rather than skipping the limiter', async () => { + const limiter = exhaustedLimiter(); + const router = routerWith(paymentParsing(null), verifierReturning(true), limiter); + + await expect( + call( + router.webhook, + {}, + { + context: testContext({ + request: { headers: {} }, + rawBody: '{}', + clientMeta: { ip: null, userAgent: null }, + }), + }, + ), + ).rejects.toMatchObject({ code: 'TOO_MANY_REQUESTS' }); + expect(limiter.consume).toHaveBeenCalledTimes(1); + const [key] = (limiter.consume as ReturnType).mock.calls[0] as [string]; + expect(key).toBe('wallet-webhook:unknown'); + }); + + it('does not consume the limiter twice for the same request beyond the one throttle check', async () => { + const limiter: RateLimiterAdapter = { + consume: vi.fn(async () => ({ allowed: true, retryAfterMs: 0 })), + reset: vi.fn(async () => undefined), + }; + await seedWallet(); + await seedDepositAddress(); + const router = routerWith(paymentParsing(depositEvent), verifierReturning(true), limiter); + + await call(router.webhook, {}, ctx('{"event":"deposit"}')); + + expect(limiter.consume).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/core/src/wallet/__tests__/wallet.rate-limit.int.test.ts b/packages/core/src/wallet/__tests__/wallet.rate-limit.int.test.ts index 961cf70a..a7794cb2 100644 --- a/packages/core/src/wallet/__tests__/wallet.rate-limit.int.test.ts +++ b/packages/core/src/wallet/__tests__/wallet.rate-limit.int.test.ts @@ -5,7 +5,13 @@ import { findOneOrThrow, RedisRateLimiter } from '@openora/core/server'; import { createTestDb, createTestRedis, type TestDb, type TestRedis } from '@openora/core/testing'; import { migrate as migrateProfile } from '@openora/core/pam/migrate/profile'; import type { PaymentAdapter, AuditWritePort } from '@openora/core/contracts'; -import { mock, NO_CLIENT_META, makeEventBus, makeIdentityReader } from '../../testing/mock.js'; +import { + mock, + NO_CLIENT_META, + makeEventBus, + makeIdentityReader, + makePaymentProviderRegistry, +} from '../../testing/mock.js'; import { migrate } from '../migrate.js'; import { wallet, walletBalance, walletTransaction } from '../schema/index.js'; import { WalletService } from '../service/wallet.service.js'; @@ -24,6 +30,7 @@ const makeService = () => drizzle: db.drizzle, events, payment, + paymentProviders: makePaymentProviderRegistry(), audit, identityReader: makeIdentityReader(), limiter: new RedisRateLimiter(redis.client), diff --git a/packages/core/src/wallet/__tests__/wallet.router.int.test.ts b/packages/core/src/wallet/__tests__/wallet.router.int.test.ts index c942b62d..99fac8a0 100644 --- a/packages/core/src/wallet/__tests__/wallet.router.int.test.ts +++ b/packages/core/src/wallet/__tests__/wallet.router.int.test.ts @@ -1,9 +1,9 @@ -import { describe, it, expect, vi, beforeAll, afterAll, beforeEach } from 'vitest'; +import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; import { findOneOrThrow } from '@openora/core/server'; import { randomUUID } from 'node:crypto'; import { call, ORPCError } from '@orpc/server'; import type { AdminGuard } from '@openora/core/server'; -import type { PaymentAdapter, PaymentWebhookVerifier } from '@openora/core/contracts'; +import type { PaymentAdapter } from '@openora/core/contracts'; import { createTestDb, type TestDb } from '@openora/core/testing'; import { mock, @@ -12,6 +12,7 @@ import { makeAuditWriter, makeAdminGuard, makeIdentityReader, + makePaymentProviderRegistry, } from '../../testing/mock.js'; import { migrate } from '../migrate.js'; import { wallet, walletTransaction } from '../schema/index.js'; @@ -41,6 +42,7 @@ function realWalletService() { drizzle: db.drizzle, events: makeEventBus(), payment: mock({}), + paymentProviders: makePaymentProviderRegistry(), audit: makeAuditWriter(), identityReader: makeIdentityReader(), }); @@ -51,8 +53,7 @@ function routerWith(adminGuard: AdminGuard) { realWalletService(), adminGuard, makeAuditWriter(), - mock({}), - mock({ verify: vi.fn().mockReturnValue(false) }), + makePaymentProviderRegistry(), ); } @@ -61,6 +62,12 @@ const transactionDenyingGuard = () => const allowingGuard = () => makeAdminGuard({ caller: { userId: 'caller-1' } }); +const adjustmentDenyingGuard = () => + makeAdminGuard({ + deny: ['player:adjust-balance'], + caller: { userId: 'caller-1', role: 'admin' }, + }); + async function seedLedger(userId: string, amounts: string[]) { const row = findOneOrThrow( await db.drizzle.db.insert(wallet).values({ userId, currency: 'USD' }).returning(), @@ -121,3 +128,30 @@ describe('wallet router listPlayerTransactions authz', () => { expect(result.items[0]?.amount).toBe('10.000000000000000000'); }); }); + +describe('wallet router manualAdjustment authz', () => { + it('requires the player:adjust-balance grant and changes nothing on denial', async () => { + const userId = randomUUID(); + + await expect( + call( + routerWith(adjustmentDenyingGuard()).manualAdjustment, + { + userId, + direction: 'credit', + amount: '5', + currency: 'USD', + reason: 'compensation', + idempotencyKey: randomUUID(), + }, + { context: CTX }, + ), + // Asserted on the code, not just `ORPCError`: this input also fails + // PlayerNotFoundError, so a bare instanceof check passes no matter which + // permission the route asks for - and would not notice the guard changing. + ).rejects.toMatchObject({ code: 'FORBIDDEN' }); + + expect(await db.drizzle.db.select().from(wallet)).toHaveLength(0); + expect(await db.drizzle.db.select().from(walletTransaction)).toHaveLength(0); + }); +}); diff --git a/packages/core/src/wallet/__tests__/wallet.service.int.test.ts b/packages/core/src/wallet/__tests__/wallet.service.int.test.ts index 2cabfaf8..eacf1220 100644 --- a/packages/core/src/wallet/__tests__/wallet.service.int.test.ts +++ b/packages/core/src/wallet/__tests__/wallet.service.int.test.ts @@ -2,11 +2,12 @@ import { describe, it, expect, vi, beforeAll, afterAll, beforeEach } from 'vites import { findOneOrThrow } from '@openora/core/server'; import { randomUUID } from 'node:crypto'; import { and, eq, sql } from 'drizzle-orm'; -import type { - AdminUserDirectory, - PaymentAdapter, - AdminPlayerSummary, - TagEvaluationCommands, +import { + DEFAULT_PAYMENT_PROVIDER, + type AdminUserDirectory, + type PaymentAdapter, + type AdminPlayerSummary, + type TagEvaluationCommands, } from '@openora/core/contracts'; import { createTestDb, type TestDb } from '@openora/core/testing'; import { migrate as migrateProfile } from '@openora/core/pam/migrate/profile'; @@ -16,9 +17,16 @@ import { makeIdentityReader, NO_CLIENT_META, makeAuditWriter, + makePaymentProviderRegistry, } from '../../testing/mock.js'; import { migrate } from '../migrate.js'; -import { wallet, walletBalance, walletTransaction, walletDepositAddress } from '../schema/index.js'; +import { + wallet, + walletBalance, + walletTransaction, + walletDepositAddress, + walletAsset, +} from '../schema/index.js'; import { WalletService, type WalletServiceDeps, @@ -31,6 +39,9 @@ import { IdempotencyKeyReuseError, DepositAddressUnsupportedError, DestinationAddressRequiredError, + AmbiguousNetworkError, + BelowMinimumWithdrawalError, + WithdrawalDisabledError, } from '../service/wallet.service.js'; import { WithdrawalQueueItemSchema } from '../contract/index.js'; @@ -54,6 +65,7 @@ function makeService(overrides: Partial = {}) { drizzle: db.drizzle, events: events, payment: mock(psp), + paymentProviders: makePaymentProviderRegistry(), audit, identityReader: makeIdentityReader(), ...overrides, @@ -61,6 +73,12 @@ function makeService(overrides: Partial = {}) { return { svc, events, psp, audit }; } +function playerIdentityReader() { + const identityReader = makeIdentityReader(); + vi.mocked(identityReader.getPlayerIdByUserId).mockResolvedValue(randomUUID()); + return identityReader; +} + const queueService = () => makeService().svc; function makeDirectory(summaries: AdminPlayerSummary[]) { @@ -152,7 +170,7 @@ afterAll(async () => { beforeEach(async () => { await db.drizzle.db.execute( - sql`TRUNCATE ${walletTransaction}, ${walletDepositAddress}, ${wallet} RESTART IDENTITY CASCADE`, + sql`TRUNCATE ${walletTransaction}, ${walletDepositAddress}, ${walletAsset}, ${wallet} RESTART IDENTITY CASCADE`, ); }); @@ -192,7 +210,7 @@ describe('WalletService.deposit (real PG)', () => { const { svc } = makeService(); const w = await seedWallet({ currency: 'BTC' }); - await svc.deposit({ userId: w.userId, amount: '1', currency: 'BTC', provider: 'fireblocks' }); + await svc.deposit({ userId: w.userId, amount: '1', currency: 'BTC', provider: 'custody' }); const rows = await txRows(w.id); expect(rows[0]).toMatchObject({ type: 'deposit', rail: 'crypto' }); @@ -233,6 +251,110 @@ describe('WalletService.deposit (real PG)', () => { }); }); +describe('WalletService.manualAdjust (real PG)', () => { + it('credits, debits, writes an immutable ledger row and replays exactly once', async () => { + const { svc, audit, psp } = makeService({ identityReader: playerIdentityReader() }); + const player = await seedWallet({ balance: '10' }); + const adminId = randomUUID(); + const idempotencyKey = randomUUID(); + + const credit = await svc.manualAdjust({ + adminId, + userId: player.userId, + direction: 'credit', + amount: '5', + currency: 'USD', + reason: 'settlement correction', + idempotencyKey, + ...NO_CLIENT_META, + }); + const replay = await svc.manualAdjust({ + adminId, + userId: player.userId, + direction: 'credit', + amount: '5', + currency: 'USD', + reason: 'settlement correction', + idempotencyKey, + ...NO_CLIENT_META, + }); + const debit = await svc.manualAdjust({ + adminId, + userId: player.userId, + direction: 'debit', + amount: '3', + currency: 'USD', + reason: 'duplicate credit reversal', + idempotencyKey: randomUUID(), + ...NO_CLIENT_META, + }); + + expect(replay).toEqual(credit); + expect(await balanceOf(player.userId)).toBe(12); + expect(await txById(credit.transactionId)).toMatchObject({ + type: 'manual_credit', + status: 'completed', + reviewedBy: adminId, + reviewReason: 'settlement correction', + }); + expect(await txById(debit.transactionId)).toMatchObject({ type: 'manual_debit' }); + expect(audit.recordInTransaction).toHaveBeenCalledTimes(2); + expect(audit.recordInTransaction).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + action: 'wallet.manual_adjustment.created', + actorId: adminId, + resourceId: credit.transactionId, + }), + ); + expect(psp.processDeposit).not.toHaveBeenCalled(); + expect(psp.processWithdrawal).not.toHaveBeenCalled(); + }); + + it('rejects an overdraw without a ledger or audit entry', async () => { + const { svc, audit } = makeService({ identityReader: playerIdentityReader() }); + const player = await seedWallet({ balance: '10' }); + + await expect( + svc.manualAdjust({ + adminId: randomUUID(), + userId: player.userId, + direction: 'debit', + amount: '11', + currency: 'USD', + reason: 'correction', + idempotencyKey: randomUUID(), + ...NO_CLIENT_META, + }), + ).rejects.toBeInstanceOf(InsufficientBalanceError); + + expect(await balanceOf(player.userId)).toBe(10); + expect(await txRows(player.id)).toHaveLength(0); + expect(audit.recordInTransaction).not.toHaveBeenCalled(); + }); + + it('creates a selected-currency balance for a player without a wallet', async () => { + const { svc } = makeService({ identityReader: playerIdentityReader() }); + const userId = randomUUID(); + + await svc.manualAdjust({ + adminId: randomUUID(), + userId, + direction: 'credit', + amount: '7', + currency: 'EUR', + reason: 'goodwill', + idempotencyKey: randomUUID(), + ...NO_CLIENT_META, + }); + + expect(await svc.getBalances(userId)).toEqual({ + activeCurrency: 'EUR', + balances: [{ currency: 'EUR', balance: '7.000000000000000000' }], + }); + }); +}); + describe('WalletService.getBalances / setActiveCurrency (real PG)', () => { it('returns every held currency ordered by code alongside the active one', async () => { const { svc } = makeService(); @@ -317,6 +439,117 @@ describe('WalletService.withdraw (real PG)', () => { }); }); + it('pins the only payable network on the transaction', async () => { + const { svc } = makeService(); + const w = await seedWallet({ balance: '2', currency: 'BTC' }); + await db.drizzle.db.insert(walletAsset).values({ + currency: 'BTC', + network: 'BITCOIN', + providerAssetId: 'BTC', + minDeposit: '0', + minWithdrawal: '0', + withdrawalFee: '0', + }); + + const result = await svc.withdraw({ + userId: w.userId, + amount: '1', + currency: 'BTC', + destinationAddress: 'bc1qexample', + ...NO_CLIENT_META, + }); + + expect(await txById(result.transactionId)).toMatchObject({ network: 'BITCOIN' }); + }); + + it('demands a network when the currency is payable on several, holding no funds', async () => { + const { svc } = makeService(); + const w = await seedWallet({ balance: '100', currency: 'USDT' }); + await db.drizzle.db.insert(walletAsset).values( + ['ERC20', 'TRC20'].map((network) => ({ + currency: 'USDT', + network, + providerAssetId: `USDT_${network}`, + minDeposit: '0', + minWithdrawal: '0', + withdrawalFee: '0', + })), + ); + + await expect( + svc.withdraw({ + userId: w.userId, + amount: '10', + currency: 'USDT', + destinationAddress: '0xexample', + ...NO_CLIENT_META, + }), + ).rejects.toBeInstanceOf(AmbiguousNetworkError); + expect(await balanceOf(w.userId)).toBe(100); + }); + + it('enforces the minimum of the chosen chain, not the currency', async () => { + const { svc } = makeService(); + const w = await seedWallet({ balance: '100', currency: 'USDT' }); + await db.drizzle.db.insert(walletAsset).values([ + { + currency: 'USDT', + network: 'ERC20', + providerAssetId: 'USDT_ERC20', + minDeposit: '0', + minWithdrawal: '20', + withdrawalFee: '0', + }, + { + currency: 'USDT', + network: 'BEP20', + providerAssetId: 'USDT_BSC', + minDeposit: '0', + minWithdrawal: '1', + withdrawalFee: '0', + }, + ]); + const args = { + userId: w.userId, + amount: '5', + currency: 'USDT', + destinationAddress: '0xexample', + ...NO_CLIENT_META, + }; + + await expect(svc.withdraw({ ...args, network: 'ERC20' })).rejects.toBeInstanceOf( + BelowMinimumWithdrawalError, + ); + const ok = await svc.withdraw({ ...args, network: 'BEP20' }); + + expect(await txById(ok.transactionId)).toMatchObject({ network: 'BEP20' }); + }); + + it('fails closed when the currency is configured but payable nowhere', async () => { + const { svc } = makeService(); + const w = await seedWallet({ balance: '100', currency: 'USDT' }); + await db.drizzle.db.insert(walletAsset).values({ + currency: 'USDT', + network: 'ERC20', + providerAssetId: 'USDT_ERC20', + minDeposit: '0', + minWithdrawal: '0', + withdrawalFee: '0', + withdrawalEnabled: false, + }); + + await expect( + svc.withdraw({ + userId: w.userId, + amount: '10', + currency: 'USDT', + destinationAddress: '0xexample', + ...NO_CLIENT_META, + }), + ).rejects.toBeInstanceOf(WithdrawalDisabledError); + expect(await balanceOf(w.userId)).toBe(100); + }); + it('throws DestinationAddressRequiredError for a crypto withdrawal with no address', async () => { const { svc } = makeService(); const w = await seedWallet({ balance: '2', currency: 'BTC' }); @@ -648,7 +881,7 @@ describe('WalletService idempotency - withdraw (real PG)', () => { }); describe('WalletService.approveWithdrawal (real PG)', () => { - it('moves pending -> completed, records the provider, and emits approved then completed', async () => { + it('moves pending -> completed and emits approved then completed', async () => { const { svc, events, psp } = makeService(); const w = await seedWallet({ balance: '60' }); const adminId = randomUUID(); @@ -661,7 +894,7 @@ describe('WalletService.approveWithdrawal (real PG)', () => { expect(await txById(pending.id)).toMatchObject({ status: 'completed', reviewedBy: adminId, - providerName: 'psp', + providerName: DEFAULT_PAYMENT_PROVIDER, }); expect(await balanceOf(w.userId)).toBe(60); expect(emittedTopics(events)).toEqual([ @@ -670,14 +903,61 @@ describe('WalletService.approveWithdrawal (real PG)', () => { ]); }); - it('records the fireblocks provider for a crypto-rail withdrawal', async () => { + it('resolves providerName from the default binding when the pair has no catalog row', async () => { const { svc } = makeService(); const w = await seedWallet({ currency: 'BTC' }); const pending = await seedTx(w.id, { currency: 'BTC', rail: 'crypto', amount: '1' }); await svc.approveWithdrawal(randomUUID(), pending.id); - expect(await txById(pending.id)).toMatchObject({ providerName: 'fireblocks' }); + expect(await txById(pending.id)).toMatchObject({ providerName: DEFAULT_PAYMENT_PROVIDER }); + }); + + it("resolves providerName from the (currency, network) catalog row's own providerName", async () => { + const { svc } = makeService(); + await db.drizzle.db.insert(walletAsset).values({ + currency: 'USDT', + network: 'ERC20', + providerAssetId: 'USDT_ERC20', + minDeposit: '1', + minWithdrawal: '1', + withdrawalFee: '1', + providerName: 'vendor-a', + }); + const w = await seedWallet({ currency: 'USDT' }); + const pending = await seedTx(w.id, { + currency: 'USDT', + network: 'ERC20', + rail: 'crypto', + amount: '5', + }); + + await svc.approveWithdrawal(randomUUID(), pending.id); + + expect(await txById(pending.id)).toMatchObject({ providerName: 'vendor-a' }); + }); + + it('falls back to the default binding when the catalog row has a null providerName', async () => { + const { svc } = makeService(); + await db.drizzle.db.insert(walletAsset).values({ + currency: 'USDT', + network: 'TRC20', + providerAssetId: 'USDT_TRC20', + minDeposit: '1', + minWithdrawal: '1', + withdrawalFee: '1', + }); + const w = await seedWallet({ currency: 'USDT' }); + const pending = await seedTx(w.id, { + currency: 'USDT', + network: 'TRC20', + rail: 'crypto', + amount: '5', + }); + + await svc.approveWithdrawal(randomUUID(), pending.id); + + expect(await txById(pending.id)).toMatchObject({ providerName: DEFAULT_PAYMENT_PROVIDER }); }); it('marks failed, refunds the hold, emits failed, and rethrows when the PSP throws', async () => { @@ -1008,7 +1288,7 @@ describe('WalletService.getOrCreateDepositAddress (real PG)', () => { userId, currency: 'BTC', address: 'bc1qexisting', - providerName: 'fireblocks', + providerName: 'custody', }); expect(await svc.getOrCreateDepositAddress(userId, 'BTC')).toEqual({ @@ -1032,7 +1312,32 @@ describe('WalletService.getOrCreateDepositAddress (real PG)', () => { .select() .from(walletDepositAddress) .where(eq(walletDepositAddress.userId, userId)); - expect(stored).toMatchObject({ address: 'bc1qnew', providerName: 'fireblocks' }); + expect(stored).toMatchObject({ address: 'bc1qnew', providerName: DEFAULT_PAYMENT_PROVIDER }); + }); + + it("persists the (currency, network) catalog row's own providerName", async () => { + await db.drizzle.db.insert(walletAsset).values({ + currency: 'USDT', + network: 'ERC20', + providerAssetId: 'USDT_ERC20', + minDeposit: '1', + minWithdrawal: '1', + withdrawalFee: '1', + providerName: 'vendor-a', + }); + const userId = randomUUID(); + const issueDepositAddress = vi.fn(async () => ({ address: '0xnew' })); + const { svc } = makeService({ + payment: mock({ ...makePsp(), issueDepositAddress }), + }); + + await svc.getOrCreateDepositAddress(userId, 'USDT', 'ERC20'); + + const [stored] = await db.drizzle.db + .select() + .from(walletDepositAddress) + .where(eq(walletDepositAddress.userId, userId)); + expect(stored).toMatchObject({ address: '0xnew', providerName: 'vendor-a' }); }); it('issues one address when two calls race on the same user and currency', async () => { @@ -1068,7 +1373,7 @@ describe('WalletService.creditDepositByAddress (real PG)', () => { async function seedAddress(userId: string, address: string, currency = 'BTC', network?: string) { await db.drizzle.db .insert(walletDepositAddress) - .values({ userId, currency, address, network, providerName: 'fireblocks' }); + .values({ userId, currency, address, network, providerName: 'custody' }); } it('resolves the address, credits the wallet, and emits deposit.completed', async () => { diff --git a/packages/core/src/wallet/adapters/wallet-asset-catalog.service.ts b/packages/core/src/wallet/adapters/wallet-asset-catalog.service.ts new file mode 100644 index 00000000..95f6d70f --- /dev/null +++ b/packages/core/src/wallet/adapters/wallet-asset-catalog.service.ts @@ -0,0 +1,39 @@ +import { DrizzleService } from '@openora/core/server'; +import { type WalletAsset, type WalletAssetCatalog } from '@openora/core/contracts'; +import { and, asc, eq } from 'drizzle-orm'; +import { walletAsset } from '../schema/index.js'; + +const ASSET_COLUMNS = { + currency: walletAsset.currency, + network: walletAsset.network, + providerAssetId: walletAsset.providerAssetId, + minDeposit: walletAsset.minDeposit, + minWithdrawal: walletAsset.minWithdrawal, + withdrawalFee: walletAsset.withdrawalFee, + depositEnabled: walletAsset.depositEnabled, + withdrawalEnabled: walletAsset.withdrawalEnabled, +}; + +/** + * Default DB-backed catalog, bound by the wallet plugin. Reads every row, enabled or not: + * a payment adapter resolving an in-flight transaction still needs the asset it was + * created with. + */ +export class WalletAssetCatalogService implements WalletAssetCatalog { + constructor(private readonly drizzle: DrizzleService) {} + + list(): Promise { + return this.drizzle.db + .select(ASSET_COLUMNS) + .from(walletAsset) + .orderBy(asc(walletAsset.currency), asc(walletAsset.network)); + } + + async get(currency: string, network: string): Promise { + const [row] = await this.drizzle.db + .select(ASSET_COLUMNS) + .from(walletAsset) + .where(and(eq(walletAsset.currency, currency), eq(walletAsset.network, network))); + return row ?? null; + } +} diff --git a/packages/core/src/wallet/contract/index.ts b/packages/core/src/wallet/contract/index.ts index 7c389395..e12b0707 100644 --- a/packages/core/src/wallet/contract/index.ts +++ b/packages/core/src/wallet/contract/index.ts @@ -3,16 +3,31 @@ import * as z from 'zod'; import { KycStatusSchema, MoneyAmountSchema, + MONEY_PRECISION, + MONEY_SCALE, TagKeySchema, TimestampSchema, UuidSchema, WalletRailSchema, WalletTransactionStatusSchema, WalletTransactionTypeSchema, + WalletReconciliationFindingKindSchema, + WalletReconciliationFindingStatusSchema, } from '@openora/core/contracts'; import { PageQuerySchema, SortOrderSchema, paginated } from '@openora/core/contracts/kit'; -export { WalletRailSchema, WalletTransactionStatusSchema, WalletTransactionTypeSchema }; +export { + WalletRailSchema, + WalletTransactionStatusSchema, + WalletTransactionTypeSchema, + WalletReconciliationFindingKindSchema, + WalletReconciliationFindingStatusSchema, +}; + +// Stub routes not implemented in this PR - kept as one string so `grep 'Not implemented yet'` +// (matching the router's `notImplemented()` helper) finds every remaining stub; the count +// reaching zero is the definition of done for the custody/reconciliation feature set. +const NOT_IMPLEMENTED_YET = 'Not implemented yet'; // Deposit/withdraw amounts must be strictly positive; balances/thresholds may be zero. const PositiveMoneyAmountSchema = MoneyAmountSchema.refine((v) => Number(v) > 0, { @@ -27,6 +42,13 @@ const WalletCurrencyCodeSchema = z const WalletCurrencyInputSchema = WalletCurrencyCodeSchema.transform((c) => c.toUpperCase()); +// A currency does not identify a chain: USDT settles on ERC20, TRC20 and BEP20 with +// different addresses, fees and minimums. Free-form rather than an enum because each +// vendor spells chains its own way (ERC20 vs ETHEREUM vs eth-mainnet). +const WalletNetworkSchema = z.string().trim().min(1).max(32); + +const WalletNetworkInputSchema = WalletNetworkSchema.transform((n) => n.toUpperCase()); + export const WalletBalanceSchema = z.object({ balance: MoneyAmountSchema, currency: WalletCurrencyCodeSchema, @@ -46,10 +68,18 @@ export const WalletTransactionSchema = z.object({ type: WalletTransactionTypeSchema, amount: MoneyAmountSchema, currency: WalletCurrencyCodeSchema, + network: WalletNetworkSchema.nullable(), status: WalletTransactionStatusSchema, createdAt: TimestampSchema, }); +export const AdminWalletTransactionSchema = WalletTransactionSchema.extend({ + reviewedBy: UuidSchema.nullable(), + reviewedAt: TimestampSchema.nullable(), + reviewReason: z.string().nullable(), +}); +export type AdminWalletTransaction = z.infer; + export const DepositInputSchema = z.object({ amount: PositiveMoneyAmountSchema, currency: WalletCurrencyInputSchema, @@ -60,11 +90,28 @@ export const DepositInputSchema = z.object({ export const WithdrawInputSchema = z.object({ amount: PositiveMoneyAmountSchema, currency: WalletCurrencyInputSchema, + // Required for any currency the operator settles on more than one chain - a payout is + // rejected as ambiguous rather than guessing which chain the player meant. + network: WalletNetworkInputSchema.optional(), provider: z.string().optional(), idempotencyKey: UuidSchema.optional(), destinationAddress: z.string().optional(), }); +export const MANUAL_ADJUSTMENT_DIRECTIONS = ['credit', 'debit'] as const; +export const ManualAdjustmentDirectionSchema = z.enum(MANUAL_ADJUSTMENT_DIRECTIONS); +export type ManualAdjustmentDirection = z.infer; + +export const ManualWalletAdjustmentInputSchema = z.object({ + userId: UuidSchema, + direction: ManualAdjustmentDirectionSchema, + currency: WalletCurrencyInputSchema, + amount: PositiveMoneyAmountSchema, + reason: z.string().trim().min(1), + idempotencyKey: UuidSchema, +}); +export type ManualWalletAdjustmentInput = z.infer; + export const TransactionResultSchema = z.object({ transactionId: UuidSchema, status: WalletTransactionStatusSchema, @@ -107,6 +154,7 @@ export const WithdrawalQueueItemSchema = z.object({ username: z.string(), amount: MoneyAmountSchema, currency: WalletCurrencyCodeSchema, + network: WalletNetworkSchema.nullable(), rail: WalletRailSchema.nullable(), status: WalletTransactionStatusSchema, kycStatus: KycStatusSchema.nullable(), @@ -186,17 +234,148 @@ export const RejectWithdrawalInputSchema = z.object({ export const PaymentWebhookInputSchema = z.record(z.string(), z.unknown()); export const PaymentWebhookOutputSchema = z.object({ ok: z.literal(true) }); +// The vendor's raw JSON body plus the path-carried `provider` key (oRPC merges a route's +// path params into the same top-level input object as the body). `provider` is never +// attacker-trusted for routing to a signature key - the route resolves it against the +// bound PaymentProviderRegistry and 404/401s identically to a bad signature on a miss. +export const PaymentWebhookProviderInputSchema = z + .object({ provider: z.string().min(1) }) + .catchall(z.unknown()); + export const DepositAddressInputSchema = z.object({ currency: WalletCurrencyInputSchema, - network: z.string().min(1).optional(), + network: WalletNetworkInputSchema.optional(), }); export const DepositAddressSchema = z.object({ address: z.string(), currency: WalletCurrencyCodeSchema, - network: z.string().optional(), + network: WalletNetworkSchema.optional(), tag: z.string().optional(), }); +// Bounded to the column's integer-digit budget so an oversized value is a 4xx at the +// contract boundary instead of a DB overflow 500. +const WalletAssetAmountSchema = MoneyAmountSchema.refine( + (v) => (v.split('.').at(0) ?? '').length <= MONEY_PRECISION - MONEY_SCALE, + { message: `must have at most ${MONEY_PRECISION - MONEY_SCALE} integer digits` }, +); + +export const PublicWalletAssetSchema = z.object({ + currency: WalletCurrencyCodeSchema, + network: WalletNetworkSchema, + minDeposit: MoneyAmountSchema, + minWithdrawal: MoneyAmountSchema, + withdrawalFee: MoneyAmountSchema, + depositEnabled: z.boolean(), + withdrawalEnabled: z.boolean(), +}); +export type PublicWalletAsset = z.infer; + +// Admin-only, same treatment as providerAssetId: which vendor settles a pair and its +// sweep/pool policy are operational detail, never surfaced on the public catalog. +export const WalletAssetSchema = PublicWalletAssetSchema.extend({ + id: UuidSchema, + providerAssetId: z.string(), + // Null means the default single binding (PAYMENT_ADAPTER / PAYMENT_WEBHOOK_VERIFIER), + // never a vendor's name to parse - core treats this as an opaque operator-chosen key. + providerName: z.string().nullable(), + sweepFeeCeiling: MoneyAmountSchema.nullable(), + poolLiquidityFloor: MoneyAmountSchema.nullable(), + createdAt: TimestampSchema, + updatedAt: TimestampSchema, +}); +export type WalletAsset = z.infer; + +export const WalletAssetKeySchema = z.object({ + currency: WalletCurrencyInputSchema, + network: WalletNetworkInputSchema, +}); + +export const CreateWalletAssetInputSchema = z.object({ + currency: WalletCurrencyInputSchema, + network: WalletNetworkInputSchema, + providerAssetId: z.string().trim().min(1), + minDeposit: WalletAssetAmountSchema, + minWithdrawal: WalletAssetAmountSchema, + withdrawalFee: WalletAssetAmountSchema, + depositEnabled: z.boolean().default(true), + withdrawalEnabled: z.boolean().default(true), + // Validated against the bound PaymentProviderRegistry's names() at write time - an + // unvalidated typo would fall back to the default adapter, attempting eg a crypto + // payout through a PSP. Absent = the default single binding. + providerName: z.string().trim().min(1).optional(), + sweepFeeCeiling: WalletAssetAmountSchema.optional(), + poolLiquidityFloor: WalletAssetAmountSchema.optional(), +}); +export type CreateWalletAssetInput = z.infer; + +// The (currency, network) key is not mutable: renaming a pair is a delete plus a create, +// so an in-flight vendor reference can't be rewritten out from under a pending +// transaction. providerName is immutable the same way - it is deliberately absent here, +// not merely optional; changing it is only possible via delete + re-create, which the +// service blocks while a pending/processing transaction exists for the pair. +export const UpdateWalletAssetInputSchema = WalletAssetKeySchema.extend({ + providerAssetId: z.string().trim().min(1).optional(), + minDeposit: WalletAssetAmountSchema.optional(), + minWithdrawal: WalletAssetAmountSchema.optional(), + withdrawalFee: WalletAssetAmountSchema.optional(), + depositEnabled: z.boolean().optional(), + withdrawalEnabled: z.boolean().optional(), + sweepFeeCeiling: WalletAssetAmountSchema.optional(), + poolLiquidityFloor: WalletAssetAmountSchema.optional(), +}); +export type UpdateWalletAssetInput = z.infer; + +// Shared by both cron-style admin triggers - the caller gets back the id of the +// wallet_job_run row it claimed, nothing else, since the work itself runs async. +export const JobRunResultSchema = z.object({ runId: UuidSchema }); + +export const WalletReconciliationFindingSchema = z.object({ + id: UuidSchema, + runId: UuidSchema, + providerName: z.string(), + kind: WalletReconciliationFindingKindSchema, + currency: WalletCurrencyCodeSchema.nullable(), + network: WalletNetworkSchema.nullable(), + amount: MoneyAmountSchema.nullable(), + address: z.string().nullable(), + tag: z.string().nullable(), + txHash: z.string().nullable(), + externalId: z.string().nullable(), + transactionId: UuidSchema.nullable(), + detail: z.string().nullable(), + status: WalletReconciliationFindingStatusSchema, + resolvedBy: UuidSchema.nullable(), + resolvedAt: TimestampSchema.nullable(), + resolutionNote: z.string().nullable(), + createdAt: TimestampSchema, +}); +export type WalletReconciliationFinding = z.infer; + +export const ListReconciliationFindingsInputSchema = PageQuerySchema.extend({ + status: WalletReconciliationFindingStatusSchema.optional(), + kind: WalletReconciliationFindingKindSchema.optional(), + providerName: z.string().optional(), +}); +export type ListReconciliationFindingsInput = z.infer; + +// There is no third way to close a finding: crediting the player (a manual ledger entry +// made elsewhere, referenced here by its transactionId) or dismissing it as a non-issue +// (a mandatory note explaining why). Never a bare status flip with no evidence either way. +export const ReconciliationResolutionSchema = z.discriminatedUnion('outcome', [ + z.object({ outcome: z.literal('credited'), transactionId: UuidSchema }), + z.object({ outcome: z.literal('dismissed'), note: z.string().trim().min(1) }), +]); +export type ReconciliationResolution = z.infer; + +export const ResolveReconciliationFindingInputSchema = z.object({ + id: UuidSchema, + resolution: ReconciliationResolutionSchema, +}); +export type ResolveReconciliationFindingInput = z.infer< + typeof ResolveReconciliationFindingInputSchema +>; + export const walletContract = { getBalance: oc.route({ method: 'GET', path: '/wallet/balance' }).output(WalletBalanceSchema), @@ -217,6 +396,11 @@ export const walletContract = { .input(WithdrawInputSchema) .output(TransactionResultSchema), + manualAdjustment: oc + .route({ method: 'POST', path: '/wallet/manual-adjustments' }) + .input(ManualWalletAdjustmentInputSchema) + .output(TransactionResultSchema), + listTransactions: oc .route({ method: 'GET', path: '/wallet/transactions' }) .input( @@ -230,7 +414,7 @@ export const walletContract = { listPlayerTransactions: oc .route({ method: 'GET', path: '/wallet/transactions/{userId}' }) .input(ListPlayerTransactionsArgs) - .output(paginated(WalletTransactionSchema)), + .output(paginated(AdminWalletTransactionSchema)), withdrawals: { list: oc @@ -277,6 +461,32 @@ export const walletContract = { .output(WalletAutoWithdrawalConfigSchema), }, + // Unauthenticated by design - which assets exist is not secret. + listAssets: oc + .route({ method: 'GET', path: '/wallet/assets' }) + .output(z.array(PublicWalletAssetSchema)), + + assets: { + list: oc + .route({ method: 'GET', path: '/wallet/admin/assets' }) + .output(z.array(WalletAssetSchema)), + + create: oc + .route({ method: 'POST', path: '/wallet/admin/assets' }) + .input(CreateWalletAssetInputSchema) + .output(WalletAssetSchema), + + update: oc + .route({ method: 'PUT', path: '/wallet/admin/assets/{currency}/{network}' }) + .input(UpdateWalletAssetInputSchema) + .output(WalletAssetSchema), + + delete: oc + .route({ method: 'DELETE', path: '/wallet/admin/assets/{currency}/{network}' }) + .input(WalletAssetKeySchema) + .output(z.boolean()), + }, + deposits: { getAddress: oc .route({ method: 'POST', path: '/wallet/deposits/address' }) @@ -288,4 +498,47 @@ export const walletContract = { .route({ method: 'POST', path: '/wallet/webhook' }) .input(PaymentWebhookInputSchema) .output(PaymentWebhookOutputSchema), + + // Routes an inbound webhook to the named provider's adapter/verifier pair instead of + // the single default binding - see docs/adapters/payment.md "Multi-provider routing". + webhookForProvider: oc + .route({ method: 'POST', path: '/wallet/webhook/{provider}' }) + .input(PaymentWebhookProviderInputSchema) + .output(PaymentWebhookOutputSchema), + + custody: { + sweep: { + run: oc + .route({ + method: 'POST', + path: '/wallet/custody/sweep/run', + summary: NOT_IMPLEMENTED_YET, + }) + .output(JobRunResultSchema), + }, + }, + + reconciliation: { + list: oc + .route({ method: 'GET', path: '/wallet/reconciliation', summary: NOT_IMPLEMENTED_YET }) + .input(ListReconciliationFindingsInputSchema) + .output(paginated(WalletReconciliationFindingSchema)), + + resolve: oc + .route({ + method: 'POST', + path: '/wallet/reconciliation/{id}/resolve', + summary: NOT_IMPLEMENTED_YET, + }) + .input(ResolveReconciliationFindingInputSchema) + .output(WalletReconciliationFindingSchema), + + run: oc + .route({ + method: 'POST', + path: '/wallet/reconciliation/run', + summary: NOT_IMPLEMENTED_YET, + }) + .output(JobRunResultSchema), + }, }; diff --git a/packages/core/src/wallet/drizzle/migrations/0010_worthless_quasimodo.sql b/packages/core/src/wallet/drizzle/migrations/0010_worthless_quasimodo.sql new file mode 100644 index 00000000..c3245be5 --- /dev/null +++ b/packages/core/src/wallet/drizzle/migrations/0010_worthless_quasimodo.sql @@ -0,0 +1,84 @@ +CREATE TYPE "public"."wallet_custody_sweep_status" AS ENUM('pending', 'processing', 'completed', 'failed', 'unknown');--> statement-breakpoint +CREATE TYPE "public"."wallet_job_run_status" AS ENUM('running', 'completed', 'failed', 'abandoned');--> statement-breakpoint +CREATE TYPE "public"."wallet_reconciliation_finding_kind" AS ENUM('missing_deposit', 'unattributed_deposit', 'amount_mismatch', 'currency_mismatch', 'status_mismatch', 'unknown_at_provider', 'unconfigured_asset', 'stuck_sweep');--> statement-breakpoint +CREATE TYPE "public"."wallet_reconciliation_finding_status" AS ENUM('open', 'resolved');--> statement-breakpoint +ALTER TYPE "public"."wallet_transaction_type" ADD VALUE 'manual_credit';--> statement-breakpoint +ALTER TYPE "public"."wallet_transaction_type" ADD VALUE 'manual_debit';--> statement-breakpoint +CREATE TABLE "wallet_asset" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "currency" text NOT NULL, + "network" text NOT NULL, + "provider_asset_id" text NOT NULL, + "min_deposit" numeric(38, 18) NOT NULL, + "min_withdrawal" numeric(38, 18) NOT NULL, + "withdrawal_fee" numeric(38, 18) NOT NULL, + "deposit_enabled" boolean DEFAULT true NOT NULL, + "withdrawal_enabled" boolean DEFAULT true NOT NULL, + "provider_name" text, + "sweep_fee_ceiling" numeric(38, 18), + "pool_liquidity_floor" numeric(38, 18), + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "wallet_custody_sweep" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "user_id" uuid NOT NULL, + "provider_name" text NOT NULL, + "currency" text NOT NULL, + "network" text NOT NULL, + "amount" numeric(38, 18) NOT NULL, + "estimated_fee" numeric(38, 18) NOT NULL, + "external_id" text, + "pool_ref" text, + "tx_hash" text, + "status" "wallet_custody_sweep_status" DEFAULT 'pending' NOT NULL, + "run_id" uuid, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "wallet_job_run" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "job_name" text NOT NULL, + "run_id" uuid NOT NULL, + "started_at" timestamp with time zone DEFAULT now() NOT NULL, + "finished_at" timestamp with time zone, + "status" "wallet_job_run_status" DEFAULT 'running' NOT NULL, + "summary" jsonb, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "wallet_reconciliation_finding" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "run_id" uuid NOT NULL, + "provider_name" text NOT NULL, + "kind" "wallet_reconciliation_finding_kind" NOT NULL, + "currency" text, + "network" text, + "amount" numeric(38, 18), + "address" text, + "tag" text, + "tx_hash" text, + "external_id" text, + "transaction_id" uuid, + "detail" text, + "status" "wallet_reconciliation_finding_status" DEFAULT 'open' NOT NULL, + "resolved_by" uuid, + "resolved_at" timestamp with time zone, + "resolution_note" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "wallet_transaction" ADD COLUMN "network" text;--> statement-breakpoint +CREATE UNIQUE INDEX "wallet_asset_currency_network_idx" ON "wallet_asset" USING btree ("currency","network");--> statement-breakpoint +CREATE UNIQUE INDEX "wallet_custody_sweep_user_id_currency_network_idx" ON "wallet_custody_sweep" USING btree ("user_id","currency","network") WHERE status IN ('pending','processing','unknown');--> statement-breakpoint +CREATE UNIQUE INDEX "wallet_custody_sweep_external_id_idx" ON "wallet_custody_sweep" USING btree ("external_id") WHERE "wallet_custody_sweep"."external_id" IS NOT NULL;--> statement-breakpoint +CREATE INDEX "wallet_custody_sweep_status_created_at_idx" ON "wallet_custody_sweep" USING btree ("status","created_at");--> statement-breakpoint +CREATE INDEX "wallet_custody_sweep_provider_name_created_at_idx" ON "wallet_custody_sweep" USING btree ("provider_name","created_at");--> statement-breakpoint +CREATE UNIQUE INDEX "wallet_job_run_job_name_idx" ON "wallet_job_run" USING btree ("job_name") WHERE "wallet_job_run"."finished_at" IS NULL;--> statement-breakpoint +CREATE INDEX "wallet_job_run_job_name_started_at_idx" ON "wallet_job_run" USING btree ("job_name","started_at");--> statement-breakpoint +CREATE UNIQUE INDEX "wallet_reconciliation_finding_kind_external_id_idx" ON "wallet_reconciliation_finding" USING btree ("kind","external_id") WHERE "wallet_reconciliation_finding"."external_id" IS NOT NULL;--> statement-breakpoint +CREATE INDEX "wallet_reconciliation_finding_status_created_at_idx" ON "wallet_reconciliation_finding" USING btree ("status","created_at");--> statement-breakpoint +CREATE INDEX "wallet_reconciliation_finding_run_id_idx" ON "wallet_reconciliation_finding" USING btree ("run_id");--> statement-breakpoint +CREATE INDEX "wallet_transaction_currency_network_idx" ON "wallet_transaction" USING btree ("currency","network"); \ No newline at end of file diff --git a/packages/core/src/wallet/drizzle/migrations/meta/0010_snapshot.json b/packages/core/src/wallet/drizzle/migrations/meta/0010_snapshot.json new file mode 100644 index 00000000..a3513d4d --- /dev/null +++ b/packages/core/src/wallet/drizzle/migrations/meta/0010_snapshot.json @@ -0,0 +1,1565 @@ +{ + "id": "2d4930dd-4e83-4bdf-898d-4faa12878e94", + "prevId": "1d88a54e-042f-4628-81c7-0959aa0cacc5", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.auto_withdrawal_rule": { + "name": "auto_withdrawal_rule", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "threshold": { + "name": "threshold", + "type": "numeric(38, 18)", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "auto_withdrawal_rule_user_id_unique": { + "name": "auto_withdrawal_rule_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wallet": { + "name": "wallet", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'USD'" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "wallet_user_id_unique": { + "name": "wallet_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wallet_asset": { + "name": "wallet_asset", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "network": { + "name": "network", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_asset_id": { + "name": "provider_asset_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "min_deposit": { + "name": "min_deposit", + "type": "numeric(38, 18)", + "primaryKey": false, + "notNull": true + }, + "min_withdrawal": { + "name": "min_withdrawal", + "type": "numeric(38, 18)", + "primaryKey": false, + "notNull": true + }, + "withdrawal_fee": { + "name": "withdrawal_fee", + "type": "numeric(38, 18)", + "primaryKey": false, + "notNull": true + }, + "deposit_enabled": { + "name": "deposit_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "withdrawal_enabled": { + "name": "withdrawal_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "provider_name": { + "name": "provider_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sweep_fee_ceiling": { + "name": "sweep_fee_ceiling", + "type": "numeric(38, 18)", + "primaryKey": false, + "notNull": false + }, + "pool_liquidity_floor": { + "name": "pool_liquidity_floor", + "type": "numeric(38, 18)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "wallet_asset_currency_network_idx": { + "name": "wallet_asset_currency_network_idx", + "columns": [ + { + "expression": "currency", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "network", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wallet_auto_withdrawal_config": { + "name": "wallet_auto_withdrawal_config", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "singleton_key": { + "name": "singleton_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'global'" + }, + "fiat_threshold": { + "name": "fiat_threshold", + "type": "numeric(38, 18)", + "primaryKey": false, + "notNull": true + }, + "crypto_threshold": { + "name": "crypto_threshold", + "type": "numeric(38, 18)", + "primaryKey": false, + "notNull": true + }, + "exclude_risk_flags": { + "name": "exclude_risk_flags", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "ARRAY['high_risk','bonus_abuser','kyc_rejected','withdrawal_review','multi_account']::text[]" + }, + "updated_by": { + "name": "updated_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "wallet_auto_withdrawal_config_singletonKey_unique": { + "name": "wallet_auto_withdrawal_config_singletonKey_unique", + "nullsNotDistinct": false, + "columns": ["singleton_key"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wallet_balance": { + "name": "wallet_balance", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "wallet_id": { + "name": "wallet_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "amount": { + "name": "amount", + "type": "numeric(38, 18)", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "wallet_balance_wallet_id_currency_idx": { + "name": "wallet_balance_wallet_id_currency_idx", + "columns": [ + { + "expression": "wallet_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "currency", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "wallet_balance_wallet_id_wallet_id_fk": { + "name": "wallet_balance_wallet_id_wallet_id_fk", + "tableFrom": "wallet_balance", + "tableTo": "wallet", + "columnsFrom": ["wallet_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wallet_custody_sweep": { + "name": "wallet_custody_sweep", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "provider_name": { + "name": "provider_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "network": { + "name": "network", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "amount": { + "name": "amount", + "type": "numeric(38, 18)", + "primaryKey": false, + "notNull": true + }, + "estimated_fee": { + "name": "estimated_fee", + "type": "numeric(38, 18)", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pool_ref": { + "name": "pool_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tx_hash": { + "name": "tx_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "wallet_custody_sweep_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "wallet_custody_sweep_user_id_currency_network_idx": { + "name": "wallet_custody_sweep_user_id_currency_network_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "currency", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "network", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "status IN ('pending','processing','unknown')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "wallet_custody_sweep_external_id_idx": { + "name": "wallet_custody_sweep_external_id_idx", + "columns": [ + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"wallet_custody_sweep\".\"external_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "wallet_custody_sweep_status_created_at_idx": { + "name": "wallet_custody_sweep_status_created_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "wallet_custody_sweep_provider_name_created_at_idx": { + "name": "wallet_custody_sweep_provider_name_created_at_idx", + "columns": [ + { + "expression": "provider_name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wallet_deposit_address": { + "name": "wallet_deposit_address", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "network": { + "name": "network", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "address": { + "name": "address", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tag": { + "name": "tag", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_name": { + "name": "provider_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "wallet_deposit_address_user_id_currency_network_idx": { + "name": "wallet_deposit_address_user_id_currency_network_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "currency", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "network", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"wallet_deposit_address\".\"network\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "wallet_deposit_address_user_id_currency_idx": { + "name": "wallet_deposit_address_user_id_currency_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "currency", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"wallet_deposit_address\".\"network\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "wallet_deposit_address_address_tag_idx": { + "name": "wallet_deposit_address_address_tag_idx", + "columns": [ + { + "expression": "address", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tag", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"wallet_deposit_address\".\"tag\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "wallet_deposit_address_address_network_currency_idx": { + "name": "wallet_deposit_address_address_network_currency_idx", + "columns": [ + { + "expression": "address", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "network", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "currency", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"wallet_deposit_address\".\"tag\" IS NULL AND \"wallet_deposit_address\".\"network\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "wallet_deposit_address_address_idx": { + "name": "wallet_deposit_address_address_idx", + "columns": [ + { + "expression": "address", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wallet_job_run": { + "name": "wallet_job_run", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "job_name": { + "name": "job_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "wallet_job_run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "summary": { + "name": "summary", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "wallet_job_run_job_name_idx": { + "name": "wallet_job_run_job_name_idx", + "columns": [ + { + "expression": "job_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"wallet_job_run\".\"finished_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "wallet_job_run_job_name_started_at_idx": { + "name": "wallet_job_run_job_name_started_at_idx", + "columns": [ + { + "expression": "job_name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wallet_provider_vault": { + "name": "wallet_provider_vault", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "provider_name": { + "name": "provider_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "vault_account_id": { + "name": "vault_account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "wallet_provider_vault_user_id_provider_name_idx": { + "name": "wallet_provider_vault_user_id_provider_name_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "wallet_provider_vault_provider_name_vault_account_id_idx": { + "name": "wallet_provider_vault_provider_name_vault_account_id_idx", + "columns": [ + { + "expression": "provider_name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "vault_account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wallet_reconciliation_finding": { + "name": "wallet_reconciliation_finding", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "provider_name": { + "name": "provider_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "wallet_reconciliation_finding_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "network": { + "name": "network", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "amount": { + "name": "amount", + "type": "numeric(38, 18)", + "primaryKey": false, + "notNull": false + }, + "address": { + "name": "address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag": { + "name": "tag", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tx_hash": { + "name": "tx_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "transaction_id": { + "name": "transaction_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "detail": { + "name": "detail", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "wallet_reconciliation_finding_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "resolved_by": { + "name": "resolved_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "resolved_at": { + "name": "resolved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "resolution_note": { + "name": "resolution_note", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "wallet_reconciliation_finding_kind_external_id_idx": { + "name": "wallet_reconciliation_finding_kind_external_id_idx", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"wallet_reconciliation_finding\".\"external_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "wallet_reconciliation_finding_status_created_at_idx": { + "name": "wallet_reconciliation_finding_status_created_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "wallet_reconciliation_finding_run_id_idx": { + "name": "wallet_reconciliation_finding_run_id_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wallet_transaction": { + "name": "wallet_transaction", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "wallet_id": { + "name": "wallet_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "wallet_transaction_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "amount": { + "name": "amount", + "type": "numeric(38, 18)", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "network": { + "name": "network", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "wallet_transaction_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "rail": { + "name": "rail", + "type": "wallet_rail", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "reviewed_by": { + "name": "reviewed_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "reviewed_at": { + "name": "reviewed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "review_reason": { + "name": "review_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_name": { + "name": "provider_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_ref_id": { + "name": "provider_ref_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "destination_address": { + "name": "destination_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tx_hash": { + "name": "tx_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "wallet_transaction_wallet_id_idx": { + "name": "wallet_transaction_wallet_id_idx", + "columns": [ + { + "expression": "wallet_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "wallet_transaction_created_at_idx": { + "name": "wallet_transaction_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "wallet_transaction_type_idx": { + "name": "wallet_transaction_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "wallet_transaction_status_idx": { + "name": "wallet_transaction_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "wallet_transaction_rail_idx": { + "name": "wallet_transaction_rail_idx", + "columns": [ + { + "expression": "rail", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "wallet_transaction_currency_idx": { + "name": "wallet_transaction_currency_idx", + "columns": [ + { + "expression": "currency", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "wallet_transaction_currency_network_idx": { + "name": "wallet_transaction_currency_network_idx", + "columns": [ + { + "expression": "currency", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "network", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "wallet_transaction_tx_hash_idx": { + "name": "wallet_transaction_tx_hash_idx", + "columns": [ + { + "expression": "tx_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "wallet_transaction_status_type_created_at_idx": { + "name": "wallet_transaction_status_type_created_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "wallet_transaction_wallet_id_type_status_idx": { + "name": "wallet_transaction_wallet_id_type_status_idx", + "columns": [ + { + "expression": "wallet_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "wallet_transaction_provider_ref_id_idx": { + "name": "wallet_transaction_provider_ref_id_idx", + "columns": [ + { + "expression": "provider_ref_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"wallet_transaction\".\"provider_ref_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "wallet_transaction_wallet_id_idempotency_key_idx": { + "name": "wallet_transaction_wallet_id_idempotency_key_idx", + "columns": [ + { + "expression": "wallet_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"wallet_transaction\".\"idempotency_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "wallet_transaction_wallet_id_wallet_id_fk": { + "name": "wallet_transaction_wallet_id_wallet_id_fk", + "tableFrom": "wallet_transaction", + "tableTo": "wallet", + "columnsFrom": ["wallet_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.wallet_custody_sweep_status": { + "name": "wallet_custody_sweep_status", + "schema": "public", + "values": ["pending", "processing", "completed", "failed", "unknown"] + }, + "public.wallet_job_run_status": { + "name": "wallet_job_run_status", + "schema": "public", + "values": ["running", "completed", "failed", "abandoned"] + }, + "public.wallet_rail": { + "name": "wallet_rail", + "schema": "public", + "values": ["crypto", "fiat"] + }, + "public.wallet_reconciliation_finding_kind": { + "name": "wallet_reconciliation_finding_kind", + "schema": "public", + "values": [ + "missing_deposit", + "unattributed_deposit", + "amount_mismatch", + "currency_mismatch", + "status_mismatch", + "unknown_at_provider", + "unconfigured_asset", + "stuck_sweep" + ] + }, + "public.wallet_reconciliation_finding_status": { + "name": "wallet_reconciliation_finding_status", + "schema": "public", + "values": ["open", "resolved"] + }, + "public.wallet_transaction_status": { + "name": "wallet_transaction_status", + "schema": "public", + "values": ["pending", "processing", "completed", "failed", "rejected", "on_hold", "cancelled"] + }, + "public.wallet_transaction_type": { + "name": "wallet_transaction_type", + "schema": "public", + "values": [ + "deposit", + "withdrawal", + "bet", + "win", + "loss", + "bonus", + "tip", + "gift", + "rain", + "manual_credit", + "manual_debit" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/packages/core/src/wallet/drizzle/migrations/meta/_journal.json b/packages/core/src/wallet/drizzle/migrations/meta/_journal.json index c8e9d861..9b33b61c 100644 --- a/packages/core/src/wallet/drizzle/migrations/meta/_journal.json +++ b/packages/core/src/wallet/drizzle/migrations/meta/_journal.json @@ -71,6 +71,13 @@ "when": 1787059873921, "tag": "0009_smooth_sue_storm", "breakpoints": true + }, + { + "idx": 10, + "version": "7", + "when": 1787270367938, + "tag": "0010_worthless_quasimodo", + "breakpoints": true } ] } diff --git a/packages/core/src/wallet/plugin.ts b/packages/core/src/wallet/plugin.ts index e3e22ce7..9fad2922 100644 --- a/packages/core/src/wallet/plugin.ts +++ b/packages/core/src/wallet/plugin.ts @@ -7,8 +7,11 @@ import { ADMIN_WALLET_REPORTING, PAYMENT_ADAPTER, PAYMENT_WEBHOOK_VERIFIER, + PAYMENT_PROVIDERS, + DEFAULT_PAYMENT_PROVIDER, WALLET_COMMANDS, WALLET_READER, + WALLET_ASSET_CATALOG, PLATFORM_CONFIG, RATE_LIMITER, PLAYER_TAGS, @@ -19,6 +22,7 @@ import { import { WalletService } from './service/wallet.service.js'; import { WalletCommandsService } from './service/wallet-commands.service.js'; import { WalletReaderService } from './adapters/wallet-reader.service.js'; +import { WalletAssetCatalogService } from './adapters/wallet-asset-catalog.service.js'; import { DrizzleAdminWalletReporting } from './admin-reporting.js'; import { createWalletRouter } from './router/index.js'; import { MockPaymentAdapter } from './adapters/mock/mock-payment-adapter.js'; @@ -41,6 +45,19 @@ export default { .parse(process.env.PAYMENT_WEBHOOK_SECRET || undefined); return new HmacPaymentWebhookVerifier(webhookSecret); }); + // Wraps the single PAYMENT_ADAPTER/PAYMENT_WEBHOOK_VERIFIER tokens as the 'default' + // registry entry, so a one-vendor operator changes nothing. Core only looks a + // providerName up here - it never discovers vendors itself, because Container.register + // is last-wins: two overlays each rebinding the single tokens would clobber each + // other, so running more than one vendor at once needs an operator-composed map + // (rebind PAYMENT_PROVIDERS entirely) rather than a second core-discovered slot. + ctx.provide(PAYMENT_PROVIDERS, (c) => ({ + get: (name: string) => + name === DEFAULT_PAYMENT_PROVIDER + ? { adapter: c.get(PAYMENT_ADAPTER), webhookVerifier: c.get(PAYMENT_WEBHOOK_VERIFIER) } + : null, + names: () => [DEFAULT_PAYMENT_PROVIDER], + })); // Other modules debit through this port within their own transaction (never importing wallet tables). See ADR-0016. ctx.provide( WALLET_COMMANDS, @@ -53,12 +70,16 @@ export default { // 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))); + // Operator-editable currency/network config, readable by a payment adapter without + // importing wallet tables. Overlay-rebindable, but bound here so it always works. + ctx.provide(WALLET_ASSET_CATALOG, (c) => new WalletAssetCatalogService(c.get(DRIZZLE))); ctx.routers.add('wallet', (c) => createWalletRouter( new WalletService({ drizzle: c.get(DRIZZLE), events: c.get(EVENT_BUS), payment: c.get(PAYMENT_ADAPTER), + paymentProviders: c.get(PAYMENT_PROVIDERS), identityReader: c.get(IDENTITY_READER), directory: c.get(ADMIN_USER_DIRECTORY), platformConfig: c.has(PLATFORM_CONFIG) ? c.get(PLATFORM_CONFIG) : undefined, @@ -71,8 +92,8 @@ export default { }), c.get(ADMIN_GUARD), c.get(AUDIT_WRITER), - c.get(PAYMENT_ADAPTER), - c.get(PAYMENT_WEBHOOK_VERIFIER), + c.get(PAYMENT_PROVIDERS), + c.get(RATE_LIMITER), ), ); }, diff --git a/packages/core/src/wallet/router/index.ts b/packages/core/src/wallet/router/index.ts index 591132f4..9ebc3ccd 100644 --- a/packages/core/src/wallet/router/index.ts +++ b/packages/core/src/wallet/router/index.ts @@ -1,9 +1,20 @@ import { implement, ORPCError } from '@orpc/server'; -import { getUserId, mapErrors, type AdminGuard, type OssContext } from '@openora/core/server'; -import type { - AuditWritePort, - PaymentAdapter, - PaymentWebhookVerifier, +import { + getUserId, + mapErrors, + assertRateLimit, + type AdminGuard, + type OssContext, +} from '@openora/core/server'; +import { + DEFAULT_PAYMENT_PROVIDER, + RATE_LIMIT_KEYS, + makeRateLimitKey, + type AuditWritePort, + type PaymentProviderRegistry, + type PaymentWebhookEvent, + type RateLimiterAdapter, + type RateLimitKey, } from '@openora/core/contracts'; import { walletContract } from '../contract/index.js'; import { @@ -18,17 +29,81 @@ import { DepositAddressUnsupportedError, DestinationAddressRequiredError, AutoWithdrawalConfigNotFoundError, + WalletAssetNotFoundError, + WalletAssetAlreadyExistsError, + WalletAssetUnsupportedError, + WalletAssetUnknownProviderError, + WalletAssetInUseError, + WalletAssetHasInFlightTransactionsError, + AmbiguousNetworkError, + UnsupportedNetworkError, + WithdrawalDisabledError, + BelowMinimumWithdrawalError, + PlayerNotFoundError, } from '../service/wallet.service.js'; +// One helper so `grep notImplemented` finds every remaining stub; the count reaching +// zero is the definition of done for the custody sweep / reconciliation feature set. +const notImplemented = (): never => { + throw new ORPCError('NOT_IMPLEMENTED'); +}; + +// Unauthenticated route: it costs a signature verification (and, for a custody vendor, +// a DB lookup) per request with nothing else gating it. Keyed on client IP, not a +// vendor/account identity, since an attacker chooses neither. A request with no IP +// signal (proxy misconfiguration) still shares one bucket rather than skipping the +// limiter outright - "no signal" must never mean "no limit". +const WALLET_WEBHOOK_RATE_LIMIT = { limit: 120, windowMs: 60 * 1000 }; + +async function dispatchWebhook( + wallet: WalletService, + paymentProviders: PaymentProviderRegistry, + providerName: string, + rawBody: string | undefined, + headers: Record, +): Promise<{ ok: true }> { + const provider = paymentProviders.get(providerName); + // A missing provider fails the exact same shape as a bad signature - the path segment + // must never let an attacker enumerate which vendors are bound. + if ( + rawBody === undefined || + !provider || + !(await provider.webhookVerifier.verify(rawBody, headers)) + ) { + throw new ORPCError('UNAUTHORIZED', { message: 'Invalid payment webhook signature' }); + } + // Always the SAME provider's adapter that verified the signature - never verify with + // one vendor's key and parse with another's format (signature confusion). + const event: PaymentWebhookEvent | null | undefined = provider.adapter.parseWebhook?.( + rawBody, + headers, + ); + if (event) { + if (event.kind === 'deposit') { + await wallet.creditDepositByAddress(event); + } else { + await wallet.reconcileWithdrawalStatus(event); + } + } + return { ok: true as const }; +} + export function createWalletRouter( wallet: WalletService, adminGuard: AdminGuard, audit: AuditWritePort, - paymentAdapter: PaymentAdapter, - webhookVerifier: PaymentWebhookVerifier, + paymentProviders: PaymentProviderRegistry, + limiter?: RateLimiterAdapter, ) { const os = implement(walletContract).$context(); + const throttleWebhook = (context: OssContext) => + assertRateLimit( + limiter, + makeRateLimitKey(RATE_LIMIT_KEYS.WALLET_WEBHOOK, context.clientMeta.ip ?? 'unknown'), + WALLET_WEBHOOK_RATE_LIMIT, + ); + return os.router({ getBalance: os.getBalance.handler(({ context }) => wallet.getBalance(getUserId(context))), @@ -56,14 +131,26 @@ export function createWalletRouter( return mapErrors( { NOT_FOUND: WalletNotFoundError, - BAD_REQUEST: [InsufficientBalanceError, CurrencyMismatchError], - CONFLICT: [KycRequiredError, IdempotencyKeyReuseError, DestinationAddressRequiredError], + BAD_REQUEST: [ + InsufficientBalanceError, + CurrencyMismatchError, + AmbiguousNetworkError, + UnsupportedNetworkError, + BelowMinimumWithdrawalError, + ], + CONFLICT: [ + KycRequiredError, + IdempotencyKeyReuseError, + DestinationAddressRequiredError, + WithdrawalDisabledError, + ], }, () => wallet.withdraw({ userId: getUserId(context), amount: input.amount, currency: input.currency, + network: input.network, idempotencyKey: input.idempotencyKey, destinationAddress: input.destinationAddress, ...context.clientMeta, @@ -71,6 +158,22 @@ export function createWalletRouter( ); }), + manualAdjustment: os.manualAdjustment.handler(async ({ input, context }) => { + const { + userId: adminId, + ip, + userAgent, + } = await adminGuard.assert(context, 'player', 'adjust-balance'); + return mapErrors( + { + NOT_FOUND: PlayerNotFoundError, + BAD_REQUEST: InsufficientBalanceError, + CONFLICT: IdempotencyKeyReuseError, + }, + () => wallet.manualAdjust({ ...input, adminId, ip, userAgent }), + ); + }), + listTransactions: os.listTransactions.handler(({ context, input }) => wallet.getTransactions({ userId: getUserId(context), @@ -89,6 +192,7 @@ export function createWalletRouter( limit: input.limit, sortBy: input.sortBy, sortOrder: input.sortOrder, + includeInternal: true, }); }), @@ -202,6 +306,60 @@ export function createWalletRouter( }), }, + listAssets: os.listAssets.handler(() => wallet.listEnabledWalletAssets()), + + assets: { + list: os.assets.list.handler(async ({ context }) => { + await adminGuard.assert(context, 'wallet-asset', 'view'); + return wallet.listWalletAssets(); + }), + + create: os.assets.create.handler(async ({ input, context }) => { + const { + userId: adminId, + ip, + userAgent, + } = await adminGuard.assert(context, 'wallet-asset', 'create'); + return mapErrors( + { + CONFLICT: [ + WalletAssetAlreadyExistsError, + WalletAssetUnsupportedError, + WalletAssetUnknownProviderError, + ], + }, + () => wallet.createWalletAsset(adminId, input, { ip, userAgent }), + ); + }), + + update: os.assets.update.handler(async ({ input, context }) => { + const { + userId: adminId, + ip, + userAgent, + } = await adminGuard.assert(context, 'wallet-asset', 'update'); + return mapErrors( + { + NOT_FOUND: WalletAssetNotFoundError, + CONFLICT: WalletAssetUnsupportedError, + }, + () => wallet.updateWalletAsset(adminId, input, { ip, userAgent }), + ); + }), + + delete: os.assets.delete.handler(async ({ input, context }) => { + const { + userId: adminId, + ip, + userAgent, + } = await adminGuard.assert(context, 'wallet-asset', 'delete'); + return mapErrors( + { CONFLICT: [WalletAssetInUseError, WalletAssetHasInFlightTransactionsError] }, + () => wallet.deleteWalletAsset(adminId, input.currency, input.network, { ip, userAgent }), + ); + }), + }, + deposits: { getAddress: os.deposits.getAddress.handler(({ input, context }) => mapErrors({ CONFLICT: DepositAddressUnsupportedError }, () => @@ -211,22 +369,51 @@ export function createWalletRouter( }, webhook: os.webhook.handler(async ({ context }) => { - const rawBody = context.rawBody; - if ( - rawBody === undefined || - !(await webhookVerifier.verify(rawBody, context.request.headers)) - ) { - throw new ORPCError('UNAUTHORIZED', { message: 'Invalid payment webhook signature' }); - } - const event = paymentAdapter.parseWebhook?.(rawBody, context.request.headers); - if (event) { - if (event.kind === 'deposit') { - await wallet.creditDepositByAddress(event); - } else { - await wallet.reconcileWithdrawalStatus(event); - } - } - return { ok: true as const }; + await throttleWebhook(context); + return dispatchWebhook( + wallet, + paymentProviders, + DEFAULT_PAYMENT_PROVIDER, + context.rawBody, + context.request.headers, + ); + }), + + webhookForProvider: os.webhookForProvider.handler(async ({ input, context }) => { + await throttleWebhook(context); + return dispatchWebhook( + wallet, + paymentProviders, + input.provider, + context.rawBody, + context.request.headers, + ); }), + + custody: { + sweep: { + run: os.custody.sweep.run.handler(async ({ context }) => { + await adminGuard.assert(context, 'wallet-custody', 'run'); + return notImplemented(); + }), + }, + }, + + reconciliation: { + list: os.reconciliation.list.handler(async ({ context }) => { + await adminGuard.assert(context, 'wallet-reconciliation', 'view'); + return notImplemented(); + }), + + resolve: os.reconciliation.resolve.handler(async ({ context }) => { + await adminGuard.assert(context, 'wallet-reconciliation', 'resolve'); + return notImplemented(); + }), + + run: os.reconciliation.run.handler(async ({ context }) => { + await adminGuard.assert(context, 'wallet-reconciliation', 'run'); + return notImplemented(); + }), + }, }); } diff --git a/packages/core/src/wallet/schema/index.ts b/packages/core/src/wallet/schema/index.ts index a0004ac8..e5cebd41 100644 --- a/packages/core/src/wallet/schema/index.ts +++ b/packages/core/src/wallet/schema/index.ts @@ -8,13 +8,23 @@ import { pgEnum, index, uniqueIndex, + boolean, + jsonb, } from 'drizzle-orm/pg-core'; import { WALLET_RAILS, WALLET_TRANSACTION_STATUSES, WALLET_TRANSACTION_TYPES, + WALLET_CUSTODY_SWEEP_STATUSES, + WALLET_JOB_RUN_STATUSES, + WALLET_RECONCILIATION_FINDING_KINDS, + WALLET_RECONCILIATION_FINDING_STATUSES, type WalletRail, type WalletTransactionStatus, + type WalletCustodySweepStatus, + type WalletJobRunStatus, + type WalletReconciliationFindingKind, + type WalletReconciliationFindingStatus, type TagKey, MONEY_PRECISION, MONEY_SCALE, @@ -71,6 +81,10 @@ export const walletTransaction = pgTable( type: walletTransactionTypeEnum().notNull(), amount: decimal({ precision: MONEY_PRECISION, scale: MONEY_SCALE }).notNull(), currency: text().notNull(), + // The chain this moved on. A currency does not identify one, so reconciliation and + // reporting are per (currency, network). Null on a fiat rail and on any internal + // transaction type (bet/win/bonus) that never touches a chain. + network: text(), status: walletTransactionStatusEnum() .$type() .notNull() @@ -103,6 +117,7 @@ export const walletTransaction = pgTable( index('wallet_transaction_status_idx').on(t.status), index('wallet_transaction_rail_idx').on(t.rail), index('wallet_transaction_currency_idx').on(t.currency), + index('wallet_transaction_currency_network_idx').on(t.currency, t.network), index('wallet_transaction_tx_hash_idx').on(t.txHash), index('wallet_transaction_status_type_created_at_idx').on(t.status, t.type, t.createdAt), index('wallet_transaction_wallet_id_type_status_idx').on(t.walletId, t.type, t.status), @@ -196,8 +211,179 @@ export const walletAutoWithdrawalConfig = pgTable('wallet_auto_withdrawal_config createdAt: timestamp({ withTimezone: true }).notNull().defaultNow(), }); +// Chain-level behaviour (how the vendor hands out addresses) is deliberately NOT here - +// that belongs to the bound payment adapter, which owns its own vendor vocabulary. +export const walletAsset = pgTable( + 'wallet_asset', + { + id: uuid().primaryKey().defaultRandom(), + currency: text().notNull(), + network: text().notNull(), + providerAssetId: text().notNull(), + minDeposit: decimal({ precision: MONEY_PRECISION, scale: MONEY_SCALE }).notNull(), + minWithdrawal: decimal({ precision: MONEY_PRECISION, scale: MONEY_SCALE }).notNull(), + withdrawalFee: decimal({ precision: MONEY_PRECISION, scale: MONEY_SCALE }).notNull(), + // Independent: an asset can take deposits before it can pay out. + depositEnabled: boolean().notNull().default(true), + withdrawalEnabled: boolean().notNull().default(true), + // Which bound payment vendor settles this (currency, network) pair. Core treats + // this as an opaque operator-chosen key, never a vendor's product name - null + // means the default single binding (the only adapter bound to PAYMENT_ADAPTER). + providerName: text(), + // The maximum network fee tolerated when sweeping this asset from a per-player + // custody container into the pool. Null means no ceiling. Per-asset, not global: + // fee units differ per chain, so one global number cannot compare a fee + // denominated in one chain's native coin against another's. + sweepFeeCeiling: decimal({ precision: MONEY_PRECISION, scale: MONEY_SCALE }), + // Below this pooled balance, the sweep fee ceiling is ignored - running the pool + // dry to save a fee costs more than the fee. Null means never override the ceiling. + poolLiquidityFloor: decimal({ precision: MONEY_PRECISION, scale: MONEY_SCALE }), + createdAt: timestamp({ withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp({ withTimezone: true }) + .notNull() + .defaultNow() + .$onUpdateFn(() => new Date()), + }, + (t) => [uniqueIndex('wallet_asset_currency_network_idx').on(t.currency, t.network)], +); + +export const walletCustodySweepStatusEnum = pgEnum( + 'wallet_custody_sweep_status', + WALLET_CUSTODY_SWEEP_STATUSES, +); + +// An internal transfer of vendor-side funds between containers (per-player custody -> +// pooled account). Must NEVER touch a player balance - the player was already credited +// at deposit time, so this table only tracks the vendor-side movement. +export const walletCustodySweep = pgTable( + 'wallet_custody_sweep', + { + id: uuid().primaryKey().defaultRandom(), + // Cross-module (identity), bare uuid - whose custody container this swept. + userId: uuid().notNull(), + providerName: text().notNull(), + currency: text().notNull(), + network: text().notNull(), + amount: decimal({ precision: MONEY_PRECISION, scale: MONEY_SCALE }).notNull(), + estimatedFee: decimal({ precision: MONEY_PRECISION, scale: MONEY_SCALE }).notNull(), + externalId: text(), + // Opaque vendor-side id of the pool the funds landed in. Player funds must stay + // separate from operator funds, and a regulator asks which account received them - + // without this the ledger can evidence that a sweep happened but not where it went. + poolRef: text(), + txHash: text(), + // `unknown`: a thrown sweepToPool cannot distinguish "vendor never saw it" from + // "vendor accepted it and the response was lost". The row moves to `unknown` and + // KEEPS holding the in-flight guard below - releasing it would risk a double + // transfer of real funds. + status: walletCustodySweepStatusEnum() + .$type() + .notNull() + .default('pending'), + runId: uuid(), + createdAt: timestamp({ withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp({ withTimezone: true }) + .notNull() + .defaultNow() + .$onUpdateFn(() => new Date()), + }, + (t) => [ + // The durable in-flight guard: at most one pending/processing/unknown sweep per + // (user, currency, network) at a time. + uniqueIndex('wallet_custody_sweep_user_id_currency_network_idx') + .on(t.userId, t.currency, t.network) + .where(sql`status IN ('pending','processing','unknown')`), + uniqueIndex('wallet_custody_sweep_external_id_idx') + .on(t.externalId) + .where(sql`${t.externalId} IS NOT NULL`), + index('wallet_custody_sweep_status_created_at_idx').on(t.status, t.createdAt), + index('wallet_custody_sweep_provider_name_created_at_idx').on(t.providerName, t.createdAt), + ], +); + +export const walletJobRunStatusEnum = pgEnum('wallet_job_run_status', WALLET_JOB_RUN_STATUSES); + +// Claim-based concurrency guard plus run history for the wallet cron jobs (sweep, +// reconciliation). Claiming a run IS the insert; a unique-index conflict means another +// cycle already owns it. Not a session-level advisory lock: a lock held by a pooled +// connection that gets returned to the pool is a deadlock waiting to happen. A partial +// unique index is the same guarantee, enforced by the database, with no connection +// affinity. +export const walletJobRun = pgTable( + 'wallet_job_run', + { + id: uuid().primaryKey().defaultRandom(), + jobName: text().notNull(), + runId: uuid().notNull(), + startedAt: timestamp({ withTimezone: true }).notNull().defaultNow(), + finishedAt: timestamp({ withTimezone: true }), + status: walletJobRunStatusEnum().$type().notNull().default('running'), + summary: jsonb().$type>(), + createdAt: timestamp({ withTimezone: true }).notNull().defaultNow(), + }, + (t) => [ + // At most one live run per job. + uniqueIndex('wallet_job_run_job_name_idx') + .on(t.jobName) + .where(sql`${t.finishedAt} IS NULL`), + index('wallet_job_run_job_name_started_at_idx').on(t.jobName, t.startedAt), + ], +); + +export const walletReconciliationFindingKindEnum = pgEnum( + 'wallet_reconciliation_finding_kind', + WALLET_RECONCILIATION_FINDING_KINDS, +); + +export const walletReconciliationFindingStatusEnum = pgEnum( + 'wallet_reconciliation_finding_status', + WALLET_RECONCILIATION_FINDING_STATUSES, +); + +// A finding is a report, never a credit instruction - nothing automated may credit a +// player or move funds off the back of a row in this table. +export const walletReconciliationFinding = pgTable( + 'wallet_reconciliation_finding', + { + id: uuid().primaryKey().defaultRandom(), + runId: uuid().notNull(), + providerName: text().notNull(), + kind: walletReconciliationFindingKindEnum().$type().notNull(), + currency: text(), + network: text(), + amount: decimal({ precision: MONEY_PRECISION, scale: MONEY_SCALE }), + address: text(), + tag: text(), + txHash: text(), + externalId: text(), + transactionId: uuid(), + detail: text(), + status: walletReconciliationFindingStatusEnum() + .$type() + .notNull() + .default('open'), + // Cross-module (identity admin user), bare uuid. + resolvedBy: uuid(), + resolvedAt: timestamp({ withTimezone: true }), + resolutionNote: text(), + createdAt: timestamp({ withTimezone: true }).notNull().defaultNow(), + }, + (t) => [ + // Re-running reconciliation over an overlapping window must not duplicate findings. + uniqueIndex('wallet_reconciliation_finding_kind_external_id_idx') + .on(t.kind, t.externalId) + .where(sql`${t.externalId} IS NOT NULL`), + index('wallet_reconciliation_finding_status_created_at_idx').on(t.status, t.createdAt), + index('wallet_reconciliation_finding_run_id_idx').on(t.runId), + ], +); + export type Wallet = typeof wallet.$inferSelect; export type WalletTransaction = typeof walletTransaction.$inferSelect; export type AutoWithdrawalRule = typeof autoWithdrawalRule.$inferSelect; export type WalletDepositAddress = typeof walletDepositAddress.$inferSelect; export type WalletAutoWithdrawalConfig = typeof walletAutoWithdrawalConfig.$inferSelect; +export type WalletAssetRow = typeof walletAsset.$inferSelect; +export type WalletCustodySweep = typeof walletCustodySweep.$inferSelect; +export type WalletJobRun = typeof walletJobRun.$inferSelect; +export type WalletReconciliationFinding = typeof walletReconciliationFinding.$inferSelect; diff --git a/packages/core/src/wallet/service/wallet.service.ts b/packages/core/src/wallet/service/wallet.service.ts index 1914e909..1f7a727e 100644 --- a/packages/core/src/wallet/service/wallet.service.ts +++ b/packages/core/src/wallet/service/wallet.service.ts @@ -3,6 +3,7 @@ import { type DrizzleDb, type DrizzleTx, makeNotFoundError, + serializeRow, makeConflictError, createDomainError, DrizzleService, @@ -13,10 +14,15 @@ import { createLogger, moneyToNumber, moneyEquals, + moneyAdd, + moneyCompare, + moneySubtract, } from '@openora/core/server'; import { normalizeKycStatus, + DEFAULT_PAYMENT_PROVIDER, type PaymentAdapter, + type PaymentProviderRegistry, type PaymentWebhookEvent, type AdminUserDirectory, type PlatformConfig, @@ -42,11 +48,13 @@ import { autoWithdrawalRule, walletAutoWithdrawalConfig, walletDepositAddress, + walletAsset, type Wallet, type WalletDepositAddress, type WalletTransaction, type AutoWithdrawalRule as AutoWithdrawalRuleRow, type WalletAutoWithdrawalConfig as WalletAutoWithdrawalConfigRow, + type WalletAssetRow, } from '../schema/index.js'; import type { TransactionResult, @@ -55,6 +63,11 @@ import type { AutoWithdrawalRule, WalletAutoWithdrawalConfig, WalletTransactionSortBy, + WalletAsset, + PublicWalletAsset, + CreateWalletAssetInput, + UpdateWalletAssetInput, + ManualAdjustmentDirection, } from '../contract/index.js'; const logger = createLogger('wallet'); @@ -62,6 +75,7 @@ const logger = createLogger('wallet'); export const WalletNotFoundError = makeNotFoundError('Wallet'); export const WithdrawalNotFoundError = makeNotFoundError('Withdrawal'); export const AutoWithdrawalConfigNotFoundError = makeNotFoundError('AutoWithdrawalConfig'); +export const PlayerNotFoundError = makeNotFoundError('Player'); export const InsufficientBalanceError = createDomainError<[available: string, requested: string]>( 'InsufficientBalanceError', @@ -93,6 +107,55 @@ export const DestinationAddressRequiredError = makeConflictError( 'A destination address is required for a crypto-rail withdrawal', ); +export const WalletAssetNotFoundError = makeNotFoundError('WalletAsset'); + +export const WalletAssetAlreadyExistsError = makeConflictError( + 'WalletAssetAlreadyExistsError', + 'An asset is already configured for this currency and network', +); + +export const WalletAssetUnsupportedError = makeConflictError( + 'WalletAssetUnsupportedError', + 'The bound payment adapter cannot serve this currency and network', +); + +export const WalletAssetInUseError = makeConflictError( + 'WalletAssetInUseError', + 'Players still hold a balance in this currency', +); + +export const WalletAssetUnknownProviderError = makeConflictError( + 'WalletAssetUnknownProviderError', + 'providerName is not a registered payment provider', +); + +export const WalletAssetHasInFlightTransactionsError = makeConflictError( + 'WalletAssetHasInFlightTransactionsError', + 'A pending or processing transaction exists for this currency and network', +); + +export const AmbiguousNetworkError = createDomainError( + 'AmbiguousNetworkError', + (currency, networks) => + `Currency ${currency} settles on several networks (${networks}) - a network is required`, +); + +export const UnsupportedNetworkError = createDomainError( + 'UnsupportedNetworkError', + (currency, network) => `Currency ${currency} is not settled on network ${network}`, +); + +export const WithdrawalDisabledError = createDomainError( + 'WithdrawalDisabledError', + (currency) => `Withdrawals are disabled for ${currency} on every configured network`, +); + +export const BelowMinimumWithdrawalError = createDomainError( + 'BelowMinimumWithdrawalError', + (amount, minimum, currency, network) => + `Withdrawal of ${amount} ${currency} on ${network} is below the ${minimum} ${currency} minimum`, +); + const KYC_PASS_STATUSES: ReadonlySet = new Set(['approved', 'manually_overridden']); export const CurrencyMismatchError = createDomainError( @@ -107,9 +170,9 @@ export const AmbiguousDepositAddressError = createDomainError( `Deposit address ${address} on ${network ?? 'an unknown network'} is issued to more than one user`, ); -// 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. -// Overridable per-operator via `platformConfig.wallet.cryptoCurrencies` - see `railFor`. +// Crypto currencies settle on the crypto rail through a custody/MPC vendor; everything +// else on the fiat rail (a PSP). The concrete provider is recorded per transaction, not +// here. Overridable per-operator via `platformConfig.wallet.cryptoCurrencies` - see `railFor`. const DEFAULT_CRYPTO_CURRENCIES = new Set(['BTC', 'ETH', 'USDT', 'USDC']); // Mirrors the `wallet.currency` column default: what a player without a wallet row @@ -121,6 +184,69 @@ const DEFAULT_WALLET_CURRENCY = 'USD'; // RATE_LIMITER to change the backend, not this policy. const WALLET_MUTATION_RATE_LIMIT = { limit: 30, windowMs: 60 * 1000 }; +/** The catalog fields a payout decision needs - a structural subset of a wallet_asset row. */ +type WithdrawalAsset = { + network: string; + minWithdrawal: string; + withdrawalEnabled: boolean; +}; + +/** + * Pins the chain a payout settles on. With one payable network the choice is implied; with + * several an explicit network is mandatory, because picking one silently would send a + * player's USDT over a chain their receiving wallet may not support. + * + * `assets` is every catalog row for the currency, enabled or not, so the two empty cases + * stay distinguishable: no rows at all means the operator never configured this currency + * (a fiat PSP), and the caller's choice passes through unchecked; rows that are all + * withdrawal-disabled is a deliberate operator decision and fails closed. + */ +export function resolveWithdrawalNetwork( + assets: readonly WithdrawalAsset[], + currency: string, + network?: string, +): string | null { + if (assets.length === 0) { + return network?.toUpperCase() ?? null; + } + const payable = assets.filter((asset) => asset.withdrawalEnabled); + const [only, ...rest] = payable; + if (!only) { + throw new WithdrawalDisabledError(currency); + } + if (network === undefined) { + if (rest.length > 0) { + throw new AmbiguousNetworkError(currency, payable.map((asset) => asset.network).join(', ')); + } + return only.network.toUpperCase(); + } + const wanted = network.toUpperCase(); + if (!payable.some((asset) => asset.network.toUpperCase() === wanted)) { + throw new UnsupportedNetworkError(currency, wanted); + } + return wanted; +} + +/** + * Rejects a payout worth less than the operator's per-network floor. The floor is per chain, + * not per currency: moving USDT costs cents on BEP20 and dollars on ERC20, so one + * currency-wide minimum is either too high for the cheap chain or below the fee on the + * expensive one. + */ +export function assertAboveMinimumWithdrawal( + assets: readonly WithdrawalAsset[], + amount: string, + currency: string, + network: string | null, +): void { + const asset = assets.find( + (candidate) => candidate.withdrawalEnabled && candidate.network.toUpperCase() === network, + ); + if (asset && moneyCompare(amount, asset.minWithdrawal) < 0) { + throw new BelowMinimumWithdrawalError(amount, asset.minWithdrawal, currency, asset.network); + } +} + export function railFor(currency: string, cryptoCurrencies?: readonly string[]): WalletRail { const set = cryptoCurrencies ? new Set(cryptoCurrencies.map((c) => c.toUpperCase())) @@ -190,6 +316,7 @@ export async function readWalletBalance( // prefix won't parse - hash namespace + key into a stable pseudo-uuid instead. const DEPOSIT_IDEMPOTENCY_NAMESPACE = 'deposit'; const WITHDRAW_IDEMPOTENCY_NAMESPACE = 'withdraw'; +const MANUAL_ADJUSTMENT_IDEMPOTENCY_NAMESPACE = 'manual-adjustment'; function namespacedIdempotencyKey(namespace: string, rawKey: string): string { const hex = createHash('sha256').update(`${namespace}:${rawKey}`).digest('hex'); @@ -274,16 +401,28 @@ function toAutoWithdrawalConfigDto(row: WalletAutoWithdrawalConfigRow): WalletAu }; } -// The concrete settlement provider recorded per transaction: the crypto rail settles -// through Fireblocks, the fiat rail through a PSP. -function providerNameFor(rail: WalletRail | null): string { - return rail === 'crypto' ? 'fireblocks' : 'psp'; +const PUBLIC_ASSET_COLUMNS = { + currency: walletAsset.currency, + network: walletAsset.network, + minDeposit: walletAsset.minDeposit, + minWithdrawal: walletAsset.minWithdrawal, + withdrawalFee: walletAsset.withdrawalFee, + depositEnabled: walletAsset.depositEnabled, + withdrawalEnabled: walletAsset.withdrawalEnabled, +}; + +function toWalletAssetDto(row: WalletAssetRow): WalletAsset { + return serializeRow(row, { dateFields: ['createdAt', 'updatedAt'], decimalFields: [] }); } export type WalletServiceDeps = { drizzle: DrizzleService; events: EventBus; payment: PaymentAdapter; + // Required: always bound (wallet/plugin.ts wraps the default single PAYMENT_ADAPTER/ + // PAYMENT_WEBHOOK_VERIFIER tokens under DEFAULT_PAYMENT_PROVIDER), and the asset + // catalog's providerName write path needs it to fail closed on an unregistered name. + paymentProviders: PaymentProviderRegistry; identityReader: IdentityReader; directory?: AdminUserDirectory; platformConfig?: PlatformConfig; @@ -311,6 +450,7 @@ export class WalletService { private readonly drizzle: DrizzleService; private readonly events: EventBus; private readonly payment: PaymentAdapter; + private readonly paymentProviders: PaymentProviderRegistry; private readonly identityReader: IdentityReader; private readonly directory?: AdminUserDirectory; private readonly platformConfig?: PlatformConfig; @@ -323,6 +463,7 @@ export class WalletService { drizzle, events, payment, + paymentProviders, directory, identityReader, platformConfig, @@ -334,6 +475,7 @@ export class WalletService { this.drizzle = drizzle; this.events = events; this.payment = payment; + this.paymentProviders = paymentProviders; this.directory = directory; this.identityReader = identityReader; this.platformConfig = platformConfig; @@ -343,10 +485,39 @@ export class WalletService { this.audit = audit; } + // Every catalog row for the currency, enabled or not - resolveWithdrawalNetwork needs + // both to tell "never configured" from "deliberately disabled". + private assetsForCurrency(currency: string) { + return this.drizzle.db + .select({ + network: walletAsset.network, + minWithdrawal: walletAsset.minWithdrawal, + withdrawalEnabled: walletAsset.withdrawalEnabled, + }) + .from(walletAsset) + .where(eq(walletAsset.currency, currency.toUpperCase())); + } + private resolveRail(currency: string): WalletRail { return railFor(currency, this.platformConfig?.wallet?.cryptoCurrencies); } + // The concrete vendor a transaction/deposit-address settles through: the catalog row + // for the exact (currency, network) pair names the bound provider. Reconciliation + // scopes its diff by this column, so a null network (an unconfigured pair - no + // catalog row could ever match it) and a configured-but-null providerName column both + // resolve to the single default binding rather than leaving the column null. + private async providerNameFor(currency: string, network: string | null): Promise { + if (network === null) { + return DEFAULT_PAYMENT_PROVIDER; + } + const [row] = await this.drizzle.db + .select({ providerName: walletAsset.providerName }) + .from(walletAsset) + .where(and(eq(walletAsset.currency, currency), eq(walletAsset.network, network))); + return row?.providerName ?? DEFAULT_PAYMENT_PROVIDER; + } + private rateLimit(userId: User['id']) { return this.limiter ? assertRateLimit(this.limiter, `wallet-mutation:${userId}`, WALLET_MUTATION_RATE_LIMIT) @@ -502,6 +673,155 @@ export class WalletService { return { transactionId, status: 'completed' }; } + async manualAdjust({ + adminId, + userId, + direction, + amount, + currency, + reason, + idempotencyKey, + ip, + userAgent, + }: { + adminId: User['id']; + userId: User['id']; + direction: ManualAdjustmentDirection; + amount: string; + currency: string; + reason: string; + idempotencyKey: string; + } & ClientMeta): Promise { + const playerId = await this.identityReader.getPlayerIdByUserId(userId); + if (!playerId) { + throw new PlayerNotFoundError(userId); + } + + const { result, emitted } = await this.drizzle.db.transaction(async (txn) => { + let walletRecord = ( + await txn.select().from(wallet).where(eq(wallet.userId, userId)).for('update') + ).at(0); + if (!walletRecord) { + if (direction === 'debit') { + throw new InsufficientBalanceError('0', amount); + } + const [created] = await txn + .insert(wallet) + .values({ userId, currency }) + .onConflictDoNothing() + .returning(); + walletRecord = + created ?? + findOneOrThrow( + await txn.select().from(wallet).where(eq(wallet.userId, userId)).for('update'), + new WalletNotFoundError(userId), + ); + } + + const existing = await this.findByIdempotencyKey( + txn, + walletRecord.id, + namespacedIdempotencyKey(MANUAL_ADJUSTMENT_IDEMPOTENCY_NAMESPACE, idempotencyKey), + ); + if (existing) { + this.assertManualAdjustmentReplayMatches(existing, { + adminId, + direction, + amount, + currency, + reason, + }); + return { result: { transactionId: existing.id, status: existing.status }, emitted: false }; + } + + const { row, replayed } = await this.insertIdempotentTransaction(txn, { + namespace: MANUAL_ADJUSTMENT_IDEMPOTENCY_NAMESPACE, + walletId: walletRecord.id, + rawIdempotencyKey: idempotencyKey, + amount, + currency, + values: { + walletId: walletRecord.id, + type: direction === 'credit' ? 'manual_credit' : 'manual_debit', + amount, + currency, + status: 'completed', + reviewedBy: adminId, + reviewedAt: new Date(), + reviewReason: reason, + }, + }); + if (replayed) { + this.assertManualAdjustmentReplayMatches(row, { + adminId, + direction, + amount, + currency, + reason, + }); + return { result: { transactionId: row.id, status: row.status }, emitted: false }; + } + + const balances = + direction === 'credit' + ? await creditWalletBalance(txn, walletRecord.id, currency, amount) + : await debitWalletBalance(txn, walletRecord.id, currency, amount); + const [balance] = balances; + if (!balance) { + throw new InsufficientBalanceError( + await readWalletBalance(txn, walletRecord.id, currency), + amount, + ); + } + // Derived from the row the update returned, never read separately beforehand. The + // wallet row is locked here but the deposit credit path does not take that lock, so + // a deposit can commit between a pre-read and this update - and `before` is going + // into an append-only audit record that cannot be corrected later. + const balanceBefore = + direction === 'credit' + ? moneySubtract(balance.amount, amount) + : moneyAdd(balance.amount, amount); + await this.audit.recordInTransaction(txn, { + actorId: adminId, + actorType: 'admin', + action: 'wallet.manual_adjustment.created', + resourceType: 'wallet_transaction', + resourceId: row.id, + before: { balance: balanceBefore, currency }, + after: { + balance: balance.amount, + currency, + transactionId: row.id, + direction, + amount, + reason, + }, + ip, + userAgent, + }); + return { result: { transactionId: row.id, status: row.status }, emitted: true }; + }); + + // Every other balance-mutating path on this service emits; without this one an admin + // correction moves a real balance while responsible-gaming monitoring, analytics and + // any operator subscriber see nothing at all. + if (emitted) { + this.events.emit('wallet.manual_adjustment.created', { + userId, + playerId, + adminId, + amount, + currency, + transactionId: result.transactionId, + direction, + reason, + ip, + userAgent, + }); + } + return result; + } + // Pre-PSP replay check for deposit; also returns the resolved wallet so the transaction // below can skip re-selecting it. Throws IdempotencyKeyReuseError on an amount/currency mismatch. private async findDepositReplay({ @@ -546,6 +866,32 @@ export class WalletService { } } + private assertManualAdjustmentReplayMatches( + existing: WalletTransaction, + { + adminId, + direction, + amount, + currency, + reason, + }: { + adminId: User['id']; + direction: ManualAdjustmentDirection; + amount: string; + currency: string; + reason: string; + }, + ): void { + if ( + existing.type !== (direction === 'credit' ? 'manual_credit' : 'manual_debit') || + existing.reviewedBy !== adminId || + existing.reviewReason !== reason + ) { + throw new IdempotencyKeyReuseError(); + } + this.assertReplayMatches(existing, amount, currency); + } + // Two concurrent requests with the same key can both pass the pre-insert check and race // on the unique index; onConflictDoNothing makes the loser's insert a no-op so it re-reads // the winner's committed row and returns it as a replay instead of aborting the transaction. @@ -611,6 +957,7 @@ export class WalletService { userId, amount, currency, + network, idempotencyKey, destinationAddress, ip, @@ -619,6 +966,7 @@ export class WalletService { userId: User['id']; amount: string; currency: string; + network?: string; idempotencyKey?: string; destinationAddress?: string; } & ClientMeta): Promise { @@ -627,6 +975,9 @@ export class WalletService { if (this.resolveRail(currency) === 'crypto' && !destinationAddress) { throw new DestinationAddressRequiredError(); } + const assets = await this.assetsForCurrency(currency); + const settlementNetwork = resolveWithdrawalNetwork(assets, currency, network); + assertAboveMinimumWithdrawal(assets, amount, currency, settlementNetwork); const { transactionId, status, replayed, walletId, rail } = await this.drizzle.db.transaction( async (txn) => { @@ -667,6 +1018,7 @@ export class WalletService { currency, status: 'pending', rail: this.resolveRail(currency), + network: settlementNetwork, destinationAddress: destinationAddress ?? null, }, }); @@ -818,6 +1170,7 @@ export class WalletService { username: summary?.username ?? '', amount: r.tx.amount, currency: r.tx.currency, + network: r.tx.network, rail: r.tx.rail ?? null, status: r.tx.status, kycStatus: summary?.kycStatus ?? null, @@ -920,6 +1273,8 @@ export class WalletService { rail: tx.rail, adminId, destinationAddress: tx.destinationAddress, + // Pinned at request time; without it an adapter cannot tell ERC20 USDT from TRC20. + network: tx.network, }); } catch (err) { // Payout did not happen - mark failed and return the held funds in one transaction. @@ -935,7 +1290,10 @@ export class WalletService { if (result.status !== 'completed') { await this.drizzle.db .update(walletTransaction) - .set({ providerName: providerNameFor(tx.rail), providerRefId: result.externalId }) + .set({ + providerName: await this.providerNameFor(tx.currency, tx.network), + providerRefId: result.externalId, + }) .where(eq(walletTransaction.id, tx.id)); return { transactionId: tx.id, status: 'processing' }; } @@ -944,7 +1302,7 @@ export class WalletService { .update(walletTransaction) .set({ status: 'completed', - providerName: providerNameFor(tx.rail), + providerName: await this.providerNameFor(tx.currency, tx.network), providerRefId: result.externalId, }) .where(eq(walletTransaction.id, tx.id)); @@ -1289,6 +1647,171 @@ export class WalletService { }); } + async listWalletAssets(): Promise { + const rows = await this.drizzle.db + .select() + .from(walletAsset) + .orderBy(asc(walletAsset.currency), asc(walletAsset.network)); + return rows.map(toWalletAssetDto); + } + + listEnabledWalletAssets(): Promise { + return this.drizzle.db + .select(PUBLIC_ASSET_COLUMNS) + .from(walletAsset) + .where(or(eq(walletAsset.depositEnabled, true), eq(walletAsset.withdrawalEnabled, true))) + .orderBy(asc(walletAsset.currency), asc(walletAsset.network)); + } + + async getWalletAsset(currency: string, network: string): Promise { + const [row] = await this.drizzle.db + .select() + .from(walletAsset) + .where(and(eq(walletAsset.currency, currency), eq(walletAsset.network, network))); + return row ? toWalletAssetDto(row) : null; + } + + // The catalog is operator-editable, so an admin can name a pair the bound vendor has + // never heard of. Reject at write time rather than at a player's deposit request. + private assertAdapterSupports(currency: string, network: string) { + if (this.payment.supportsAsset && !this.payment.supportsAsset(currency, network)) { + throw new WalletAssetUnsupportedError(); + } + } + + // Same discipline as assertAdapterSupports, for the provider key itself: an + // unvalidated typo silently falls back to the default adapter, which means eg a + // crypto payout attempted through a PSP. Undefined (the default binding) always passes. + private assertProviderNameValid(providerName: string | undefined) { + if (providerName !== undefined && !this.paymentProviders.names().includes(providerName)) { + throw new WalletAssetUnknownProviderError(); + } + } + + async createWalletAsset( + adminId: User['id'], + input: CreateWalletAssetInput, + meta?: ClientMeta, + ): Promise { + this.assertAdapterSupports(input.currency, input.network); + this.assertProviderNameValid(input.providerName); + return this.drizzle.db.transaction(async (txn) => { + const rows = await txn.insert(walletAsset).values(input).onConflictDoNothing().returning(); + // Empty => the (currency, network) unique index rejected it. + const asset = toWalletAssetDto(findOneOrThrow(rows, new WalletAssetAlreadyExistsError())); + await this.audit.recordInTransaction(txn, { + actorId: adminId, + actorType: 'admin', + action: 'wallet.wallet_asset.created', + resourceType: 'wallet_asset', + resourceId: asset.id, + before: null, + after: asset, + ...meta, + }); + return asset; + }); + } + + // Deliberately touches only the catalog row: a withdrawal already pending or processing + // in this currency keeps its own terms and is never cancelled by disabling the pair. + async updateWalletAsset( + adminId: User['id'], + { currency, network, ...changes }: UpdateWalletAssetInput, + meta?: ClientMeta, + ): Promise { + if (changes.providerAssetId !== undefined) { + this.assertAdapterSupports(currency, network); + } + return this.drizzle.db.transaction(async (txn) => { + const [before] = await txn + .select() + .from(walletAsset) + .where(and(eq(walletAsset.currency, currency), eq(walletAsset.network, network))); + if (!before) { + throw new WalletAssetNotFoundError(`${currency}/${network}`); + } + const rows = await txn + .update(walletAsset) + .set(changes) + .where(and(eq(walletAsset.currency, currency), eq(walletAsset.network, network))) + .returning(); + const asset = toWalletAssetDto( + findOneOrThrow(rows, new WalletAssetNotFoundError(`${currency}/${network}`)), + ); + await this.audit.recordInTransaction(txn, { + actorId: adminId, + actorType: 'admin', + action: 'wallet.wallet_asset.updated', + resourceType: 'wallet_asset', + resourceId: asset.id, + before: toWalletAssetDto(before), + after: asset, + ...meta, + }); + return asset; + }); + } + + async deleteWalletAsset( + adminId: User['id'], + currency: string, + network: string, + meta?: ClientMeta, + ): Promise { + return this.drizzle.db.transaction(async (txn) => { + const [before] = await txn + .select() + .from(walletAsset) + .where(and(eq(walletAsset.currency, currency), eq(walletAsset.network, network))); + if (!before) { + return false; + } + // wallet_balance is keyed by currency only (no network column), so this guard is + // necessarily currency-wide: removing one network of a currency players still hold + // is blocked even if their balance arrived over another network. Fails safe. + const [held] = await txn + .select({ n: count() }) + .from(walletBalance) + .where(and(eq(walletBalance.currency, currency), sql`${walletBalance.amount} > 0`)); + if ((held?.n ?? 0) > 0) { + throw new WalletAssetInUseError(); + } + // Renaming a pair is a delete plus a create (the (currency, network) key AND + // providerName are immutable), so this delete is the only way providerName ever + // effectively changes. Block it while a pending/processing transaction exists for + // this exact (currency, network) pair - otherwise the vendor reference an in-flight + // payout is settling through gets rewritten out from under it. + const [inFlight] = await txn + .select({ n: count() }) + .from(walletTransaction) + .where( + and( + eq(walletTransaction.currency, currency), + eq(walletTransaction.network, network), + inArray(walletTransaction.status, ['pending', 'processing']), + ), + ); + if ((inFlight?.n ?? 0) > 0) { + throw new WalletAssetHasInFlightTransactionsError(); + } + await txn + .delete(walletAsset) + .where(and(eq(walletAsset.currency, currency), eq(walletAsset.network, network))); + await this.audit.recordInTransaction(txn, { + actorId: adminId, + actorType: 'admin', + action: 'wallet.wallet_asset.deleted', + resourceType: 'wallet_asset', + resourceId: before.id, + before: toWalletAssetDto(before), + after: null, + ...meta, + }); + return true; + }); + } + private async autoApprovalKycStatus(userId: User['id']): Promise { // No directory bound => cannot verify KYC => fail closed. if (!this.directory) { @@ -1493,7 +2016,11 @@ export class WalletService { limit, sortBy, sortOrder, - }: PaginationOptions<{ userId: User['id'] }, WalletTransactionSortBy>) { + includeInternal = false, + }: PaginationOptions< + { userId: User['id']; includeInternal?: boolean }, + WalletTransactionSortBy + >) { const db = this.drizzle.db; const [walletRecord] = await db.select().from(wallet).where(eq(wallet.userId, userId)); @@ -1528,8 +2055,12 @@ export class WalletService { type: tx.type, amount: tx.amount, currency: tx.currency, + network: tx.network, status: tx.status, createdAt: tx.createdAt.toISOString(), + reviewedBy: includeInternal ? tx.reviewedBy : null, + reviewedAt: includeInternal ? (tx.reviewedAt?.toISOString() ?? null) : null, + reviewReason: includeInternal ? tx.reviewReason : null, })), total: Number(n), page, @@ -1559,7 +2090,7 @@ export class WalletService { network: network ?? null, address: issued.address, tag: issued.tag ?? null, - providerName: providerNameFor(this.resolveRail(currency)), + providerName: await this.providerNameFor(currency, network ?? null), }) .onConflictDoNothing() .returning(); @@ -1617,6 +2148,9 @@ export class WalletService { currency: event.currency, status: 'completed', rail: this.resolveRail(event.currency), + // Prefer the chain the vendor reported; fall back to the one the address was + // issued on, which is the only network an address-only webhook can imply. + network: event.network ?? depositAddress.network, providerName: depositAddress.providerName, providerRefId: event.externalId, destinationAddress: event.address, diff --git a/packages/testing/src/__tests__/wallet-manual-adjustment.e2e.test.ts b/packages/testing/src/__tests__/wallet-manual-adjustment.e2e.test.ts new file mode 100644 index 00000000..7207ec7f --- /dev/null +++ b/packages/testing/src/__tests__/wallet-manual-adjustment.e2e.test.ts @@ -0,0 +1,118 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { randomUUID } from 'node:crypto'; +import { loadExtensions } from '@openora/core/server'; +import { + asAdmin, + bootTestApp, + registerAndMaterializePlayer, + seedMinimal, + setupTestDb, + type TestApp, + type TestDb, +} from '../index.js'; + +let db: TestDb; +let testApp: TestApp; + +function object(value: unknown): Record { + if (!isRecord(value)) { + throw new Error('expected JSON object'); + } + return value; +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object'; +} + +// Uses the shared helper rather than posting to /identity/register directly: the +// registration contract owns which fields are mandatory, and a hand-rolled body here +// silently rots the moment one is added. +async function registerPlayer() { + const { client, userId } = await registerAndMaterializePlayer(testApp, { + email: `manual-adjustment-${randomUUID()}@e2e.test`, + }); + return { player: client, userId }; +} + +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['NODE_ENV'] ??= 'test'; + db = await setupTestDb(); + testApp = await bootTestApp({ plugins: await loadExtensions(), databaseUrl: db.url }); + await seedMinimal(testApp.container, { playerCount: 0 }); +}, 60_000); + +afterAll(async () => { + await testApp?.close(); + await db?.dispose(); +}); + +describe('manual wallet adjustment', () => { + it('allows Super Admin credit, writes private admin history and audit, and rejects a player', async () => { + const { player, userId } = await registerPlayer(); + const admin = await asAdmin(testApp.app); + const idempotencyKey = randomUUID(); + + const denied = await player.post('/wallet/manual-adjustments', { + userId, + direction: 'credit', + amount: '10', + currency: 'USD', + reason: 'attempted self-credit', + idempotencyKey: randomUUID(), + }); + expect(denied.status).toBe(403); + + const adjusted = await admin.post('/wallet/manual-adjustments', { + userId, + direction: 'credit', + amount: '10', + currency: 'USD', + reason: 'support compensation', + idempotencyKey, + }); + expect(adjusted.status).toBe(200); + const adjustmentRaw: unknown = await adjusted.json(); + const adjustmentBody = object(adjustmentRaw); + const transactionId = adjustmentBody['transactionId']; + if (typeof transactionId !== 'string') { + throw new Error('adjustment has no transactionId'); + } + + const balanceRaw: unknown = await (await player.get('/wallet/balance')).json(); + const balance = object(balanceRaw); + expect(balance['balance']).toBe('10.000000000000000000'); + + const historyRaw: unknown = await ( + await admin.get(`/wallet/transactions/${userId}?page=1&limit=20`) + ).json(); + const history = object(historyRaw); + const items = history['items']; + if (!Array.isArray(items)) { + throw new Error('admin history has no items'); + } + expect(items).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: transactionId, + type: 'manual_credit', + reviewReason: 'support compensation', + }), + ]), + ); + + const auditRaw: unknown = await ( + await admin.get( + `/audit/logs?resourceId=${transactionId}&action=wallet.manual_adjustment.created`, + ) + ).json(); + const audit = object(auditRaw); + expect(audit['items']).toEqual( + expect.arrayContaining([ + expect.objectContaining({ action: 'wallet.manual_adjustment.created' }), + ]), + ); + }); +}); diff --git a/tools/lint/verify-oss-hygiene.ts b/tools/lint/verify-oss-hygiene.ts index e62d7e66..d26188f6 100644 --- a/tools/lint/verify-oss-hygiene.ts +++ b/tools/lint/verify-oss-hygiene.ts @@ -1,17 +1,13 @@ #!/usr/bin/env node /** * OSS hygiene guard. openora is public; a downstream client's internal Jira - * project key must never leak into a file name or file content in this repo. - * Runs in `pnpm verify`. + * project key, and a downstream client's or vendor's identity, must never leak + * into a file name or file content in this repo. Runs in `pnpm verify`. * Operates on `git ls-files` (tracked files only), so gitignored paths and * build output are excluded by construction. * - * Add a second client key by appending to CLIENT_TICKET_PATTERNS below. - * - * Client and vendor NAMES are checked by a sibling pattern list added alongside - * the change that removes the last hardcoded vendor identifier from core. That - * rule cannot pass until then, so it ships with the commit that makes it true - * rather than landing here as a permanently-red check. + * Add a second client key by appending to CLIENT_TICKET_PATTERNS below, or a + * second client/vendor name by appending to CLIENT_VENDOR_NAME_PATTERNS. */ import { execSync } from 'node:child_process'; import { readFileSync } from 'node:fs'; @@ -37,6 +33,27 @@ const CLIENT_TICKET_PATTERNS = [ }, ] as const; +// A client's or vendor's name identifies who this platform was built for or against - +// a business relationship this public repo cannot gate on, and a worse leak than a +// ticket id. Content-only: unlike CLIENT_TICKET_PATTERNS this has no path check, +// because an adapter file is conventionally named after the vendor it binds, which is +// the exact pattern OSS consumers are meant to follow. +const CLIENT_VENDOR_NAME_PATTERNS = [ + { key: 'Betfeel', content: /betfeel/gi }, + { key: 'Fireblocks', content: /fireblocks/gi }, +] as const; + +// Naming a vendor as an illustrative example of what an operator might bind is not the +// same as hardcoding behaviour for one - exempt exactly those spots, and only from +// CLIENT_VENDOR_NAME_PATTERNS. Ticket ids are still checked in these files, and a vendor +// name used anywhere else, including a real binding in a core module, still fails. +const VENDOR_EXAMPLE_EXEMPT_FILES = new Set([ + 'packages/core/src/contracts/adapters/kyc.ts', + 'packages/core/src/contracts/schemas/platform-config.ts', +]); +const isVendorExampleExempt = (file: string) => + VENDOR_EXAMPLE_EXEMPT_FILES.has(file) || file.startsWith('docs/adapters/'); + const scannedExtensions = new Set(['.ts', '.tsx', '.md', '.json']); const extname = (file: string) => file.slice(file.lastIndexOf('.')); @@ -50,6 +67,10 @@ const explainTicket = (token: string, where: string) => `client ticket id "${token}" ${where}. Name the file, or describe the behaviour, ` + 'for what it verifies; ticket ids belong in the commit message and the PR description, not in a public repo.'; +const explainVendorName = (token: string, where: string) => + `client/vendor name "${token}" ${where}. Use a generic operator/vendor reference instead; ` + + 'a name that identifies who this was built for or against does not belong in a public repo.'; + const pathFailures = trackedFiles.flatMap((file) => { const match = CLIENT_TICKET_PATTERNS.map((p) => file.match(p.path)).find(Boolean); return match ? [` ${file}: ${explainTicket(match[0].toLowerCase(), 'in filename')}`] : []; @@ -64,13 +85,21 @@ const contentFailures = trackedFiles } catch { return []; } + const vendorExempt = isVendorExampleExempt(file); return text.split('\n').flatMap((line, i) => { const ticketHits = CLIENT_TICKET_PATTERNS.flatMap((p) => [...line.matchAll(p.content)].map( (m) => ` ${file}:${i + 1}: ${explainTicket(m[0], `on line ${i + 1}`)}`, ), ); - return ticketHits; + const vendorHits = vendorExempt + ? [] + : CLIENT_VENDOR_NAME_PATTERNS.flatMap((p) => + [...line.matchAll(p.content)].map( + (m) => ` ${file}:${i + 1}: ${explainVendorName(m[0], `on line ${i + 1}`)}`, + ), + ); + return [...ticketHits, ...vendorHits]; }); }); @@ -78,11 +107,11 @@ const failures = [...pathFailures, ...contentFailures]; if (failures.length > 0) { console.error( - `[FAIL] oss-hygiene: ${failures.length} client ticket reference(s):\n${failures.join('\n')}`, + `[FAIL] oss-hygiene: ${failures.length} client ticket/name reference(s):\n${failures.join('\n')}`, ); process.exit(1); } console.log( - `[PASS] oss-hygiene: no client ticket references across ${trackedFiles.length} tracked files.`, + `[PASS] oss-hygiene: no client ticket or client/vendor name references across ${trackedFiles.length} tracked files.`, );