diff --git a/.changeset/pivot-priced-compliance-totals.md b/.changeset/pivot-priced-compliance-totals.md new file mode 100644 index 000000000..51a1bc7e2 --- /dev/null +++ b/.changeset/pivot-priced-compliance-totals.md @@ -0,0 +1,17 @@ +--- +'@openora/core': minor +--- + +Re-KYC's cumulative-deposit trigger, the `high_roller` tag's lifetime-deposit total, and the withdrawal queue's `large_amount` tag are now priced in a single reference currency before they are compared or summed, instead of adding or comparing raw amounts across whatever currencies a player happens to hold. + +A platform with no base currency lets a player deposit and withdraw in several coins at once. `KycVerificationService.handleDeposit` used to sum only the deposits already in the player's own currency field - on a crypto-only platform with no matching wallet currency, that read $0 forever and re-KYC never fired. `WalletReader.getLifetimeDeposit` (the `high_roller` rule's input) and the withdrawal queue's `large_amount` heuristic both summed or compared raw `wallet_transaction.amount` regardless of currency - `1 BTC + 20000 DOGE` read as `20001`. + +All three now convert through the already-existing `EXCHANGE_RATE_READER` port: re-KYC sums every currency into the player's own currency field, `getLifetimeDeposit` sums into `exchangeRate.pivot` (`USD` by default, and now always the configured pivot rather than a hardcoded default), and the queue tag prices the withdrawal into the same pivot before comparing it to `LARGE_WITHDRAWAL_THRESHOLD` (previously a raw, currency-blind compare - the queue tag's own doc comment used to call this "a display hint, not a decision"; it is a decision now). + +**Missing-rate behaviour, and a breaking port change:** a compliance total must never silently undercount because one currency had no quote, and it must never fabricate a value either - a made-up total sums, compares and gets written down exactly like a real one, and nothing downstream can tell the difference. So: + +- `WalletReader.getLifetimeDeposit(userId): Promise` - the return type changed from `string` to `string | null`. `null` means at least one currency could not be priced. Every core caller handles it explicitly and in the direction that never quietly loses the compliance signal: `high_roller` skips the evaluation and logs a warning (never assigns or removes off a guess - the tag is sticky); `basic_kyc_needed` (only asks "any deposit on file") still assigns, since `null` can only happen when a deposit exists; bonus eligibility's `isFirstDeposit` fallback treats `null` as "not the first deposit". A custom `WalletReader` implementation (or a mock) built against the old `Promise` signature needs updating. +- The withdrawal queue's per-transaction `large_amount` check has no running total to lose, so an unpriced withdrawal is flagged rather than skipped - never a silent pass. +- `KycVerificationService.handleDeposit` never writes a `kyc_verification` row from an unpriced total: on an unpriced currency it logs an error (`logger.error` + `reportError`, naming the userId and the unpriced currencies) and returns without evaluating the threshold or moving the re-KYC watermark. The next deposit re-evaluates once rates are available again. + +`WalletReaderService`'s constructor now takes a single deps object (`{ drizzle, defaultCurrency?, exchangeRateReader?, pivotCurrency }`) instead of positional params - `pivotCurrency` is required and should be wired from `resolveExchangeRatePivot(platformConfig.exchangeRate)`, not hardcoded. `KycVerificationDeps` gains an optional `exchangeRateReader`. Both are already wired this way in their plugins. diff --git a/packages/core/src/compliance/__tests__/kyc.service.int.test.ts b/packages/core/src/compliance/__tests__/kyc.service.int.test.ts index 59188fab8..5139682f8 100644 --- a/packages/core/src/compliance/__tests__/kyc.service.int.test.ts +++ b/packages/core/src/compliance/__tests__/kyc.service.int.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeAll, afterAll, beforeEach } from 'vites import { randomUUID } from 'node:crypto'; import { desc, eq, sql } from 'drizzle-orm'; import type { + ExchangeRateReader, KycAdapter, KycStatusWriter, KycVendorStatus, @@ -21,7 +22,13 @@ let db: TestDb; type AdapterResult = { referenceId: string; status: KycVendorStatus; verificationUrl?: string }; -function makeService(options: { adapter?: Partial; config?: PlatformConfig } = {}) { +function makeService( + options: { + adapter?: Partial; + config?: PlatformConfig; + exchangeRateReader?: ExchangeRateReader; + } = {}, +) { const events = makeEventBus(); const audit = makeAuditWriter(); const adapterResult: AdapterResult = { @@ -42,6 +49,7 @@ function makeService(options: { adapter?: Partial; config?: Platf statusWriter, identityReader: makeIdentityReader(), ...(options.config ? { platformConfig: options.config } : {}), + ...(options.exchangeRateReader ? { exchangeRateReader: options.exchangeRateReader } : {}), }); return { svc, events, audit, kycAdapter, statusWriter, adapterResult }; } @@ -88,6 +96,24 @@ async function verificationsOf(userId: string) { .orderBy(desc(kycVerification.createdAt)); } +// A fake pivot-rate reader for the fx-aware handleDeposit tests: `prices` gives the USD value +// of one whole unit of a currency, `unpriced` names currencies that answer null (no quote). +function makeRates(prices: Record, unpriced: string[] = []): ExchangeRateReader { + return mock({ + getRate: vi.fn(async () => null), + convert: vi.fn(async (amount: string, from: string, to: string) => { + if (from === to) { + return amount; + } + if (unpriced.includes(from)) { + return null; + } + const price = prices[from]; + return price === undefined ? null : String(Number(amount) * Number(price)); + }), + }); +} + beforeAll(async () => { db = await createTestDb([migrate, migrateProfile, migrateWallet]); }); @@ -551,6 +577,58 @@ describe('KycVerificationService.handleDeposit - threshold re-KYC (real PG)', () ); expect(await verificationsOf(userId)).toHaveLength(2); }); + + it('fires on the deposit that crosses the threshold once summed across currencies', async () => { + const rates = makeRates({ BTC: '6000', ETH: '5000' }); + const { svc, statusWriter } = makeService({ + config: mock({ kyc: { reverifyThresholds: { USD: '10000' } } }), + exchangeRateReader: rates, + }); + const { userId } = await seedPlayer(); + + await seedDeposit(userId, '1', 'BTC'); + await svc.handleDeposit(userId); + expect(statusWriter.setStatus).not.toHaveBeenCalled(); + + await seedDeposit(userId, '1', 'ETH'); + await svc.handleDeposit(userId); + + expect(statusWriter.setStatus).toHaveBeenCalledWith( + userId, + 'resubmission_requested', + expect.objectContaining({ source: 'reverify' }), + expect.anything(), + ); + const [row] = await verificationsOf(userId); + expect(Number(row?.triggerDeposits)).toBe(11000); + }); + + it('writes no row when a deposit currency cannot be priced, then fires once rates return', async () => { + const config = mock({ kyc: { reverifyThresholds: { USD: '10000' } } }); + const { userId } = await seedPlayer(); + await seedDeposit(userId, '1', 'DOGE'); + + const unpriced = makeService({ config, exchangeRateReader: makeRates({}, ['DOGE']) }); + await unpriced.svc.handleDeposit(userId); + + expect(unpriced.statusWriter.setStatus).not.toHaveBeenCalled(); + expect(await verificationsOf(userId)).toHaveLength(0); + + await seedDeposit(userId, '1', 'BTC'); + const priced = makeService({ + config, + exchangeRateReader: makeRates({ DOGE: '1', BTC: '20000' }), + }); + await priced.svc.handleDeposit(userId); + + expect(priced.statusWriter.setStatus).toHaveBeenCalledWith( + userId, + 'resubmission_requested', + expect.objectContaining({ source: 'reverify' }), + expect.anything(), + ); + expect(await verificationsOf(userId)).toHaveLength(1); + }); }); describe('KycVerificationService.applyExemption (real PG)', () => { diff --git a/packages/core/src/compliance/plugin.ts b/packages/core/src/compliance/plugin.ts index e33ddfe4b..8c3bbfc41 100644 --- a/packages/core/src/compliance/plugin.ts +++ b/packages/core/src/compliance/plugin.ts @@ -298,6 +298,7 @@ export default { statusWriter: c.get(KYC_STATUS_WRITER), identityReader: c.get(IDENTITY_READER), platformConfig, + exchangeRateReader: c.has(EXCHANGE_RATE_READER) ? c.get(EXCHANGE_RATE_READER) : undefined, }); kycRef = kyc; const compliance = makeComplianceService(c); diff --git a/packages/core/src/compliance/service/kyc.service.ts b/packages/core/src/compliance/service/kyc.service.ts index 2fd39aab2..812222966 100644 --- a/packages/core/src/compliance/service/kyc.service.ts +++ b/packages/core/src/compliance/service/kyc.service.ts @@ -6,7 +6,9 @@ import { findOneOrThrow, makeNotFoundError, mapConcurrent, + reportError, serializeRow, + sumInPivot, withAdvisoryXactLock, type EventBus, } from '@openora/core/server'; @@ -14,6 +16,7 @@ import { normalizeKycStatus, type KycAdapter, type AuditWritePort, + type ExchangeRateReader, type KycCheckResult, type KycDocument, type KycRiskSignals, @@ -179,6 +182,7 @@ export type KycVerificationDeps = { identityReader: IdentityReader; platformConfig?: PlatformConfig; reKycTrigger?: ReKycTrigger; + exchangeRateReader?: ExchangeRateReader; }; export class KycVerificationService { @@ -190,6 +194,7 @@ export class KycVerificationService { private readonly identityReader: IdentityReader; private readonly platformConfig?: PlatformConfig; private readonly reKycTrigger: ReKycTrigger; + private readonly exchangeRateReader?: ExchangeRateReader; constructor(deps: KycVerificationDeps) { this.drizzle = deps.drizzle; @@ -200,6 +205,7 @@ export class KycVerificationService { this.identityReader = deps.identityReader; this.platformConfig = deps.platformConfig; this.reKycTrigger = deps.reKycTrigger ?? new CumulativeDepositReKycTrigger(); + this.exchangeRateReader = deps.exchangeRateReader; } private get provider() { @@ -601,11 +607,32 @@ export class KycVerificationService { }; } + // Names which of `rows`' currencies sumInPivot could not price, for the error log/report + // when a re-KYC evaluation has to be skipped - sumInPivot itself only says "some row failed". + private async unpricedCurrencies( + rows: readonly { currency: string; total: string }[], + pivotCurrency: string, + ): Promise { + const others = rows.filter((row) => row.currency.toUpperCase() !== pivotCurrency.toUpperCase()); + const checked = await mapConcurrent(others, 5, async (row) => ({ + currency: row.currency, + priced: this.exchangeRateReader + ? (await this.exchangeRateReader.convert(row.total, row.currency, pivotCurrency)) !== null + : false, + })); + return checked.filter((row) => !row.priced).map((row) => row.currency); + } + /** * Deposit-event hook: flips a currently-approved player to `resubmission_requested` * once cumulative deposits cross a fresh per-currency threshold band. Idempotent twice * over: skips unless presently approved, and the watermark stops a re-approved * high-roller re-firing on every later deposit. + * + * A player can deposit in several currencies (no platform base currency), so every + * completed deposit is priced into the player's reference currency before it is summed - + * filtering to that one currency instead (as this used to) reads $0 forever for a player + * who never deposits in it. */ async handleDeposit(userId: User['id']) { const [current] = await this.drizzle.db @@ -616,8 +643,11 @@ export class KycVerificationService { return; } - const [deposited] = await this.drizzle.db - .select({ total: sql`coalesce(sum(${walletTransaction.amount}), 0)` }) + const depositsByCurrency = await this.drizzle.db + .select({ + currency: walletTransaction.currency, + total: sql`coalesce(sum(${walletTransaction.amount}), 0)`, + }) .from(walletTransaction) .innerJoin(wallet, eq(wallet.id, walletTransaction.walletId)) .where( @@ -625,9 +655,29 @@ export class KycVerificationService { eq(wallet.userId, userId), eq(walletTransaction.type, 'deposit'), eq(walletTransaction.status, 'completed'), - eq(walletTransaction.currency, current.currency), ), + ) + .groupBy(walletTransaction.currency); + const totalDeposits = await sumInPivot( + depositsByCurrency, + current.currency, + this.exchangeRateReader, + ); + // Never write a watermark from a guess: a fabricated total would sum, compare and get + // stored exactly like a real one, and no later deposit could tell the difference. Skip + // this evaluation and let the next deposit re-check once every currency prices again. + if (totalDeposits === null) { + const unpriced = await this.unpricedCurrencies(depositsByCurrency, current.currency); + const err = new Error( + `handleDeposit: no rate for ${unpriced.join(', ')} into ${current.currency}`, + ); + logger.error( + { err, userId, unpriced, pivotCurrency: current.currency }, + 'handleDeposit: could not price a deposit currency, skipping re-KYC evaluation', ); + reportError(err, { userId, extra: { unpriced, pivotCurrency: current.currency } }); + return; + } const [lastFire] = await this.drizzle.db .select({ triggerDeposits: kycVerification.triggerDeposits }) @@ -642,7 +692,6 @@ export class KycVerificationService { .orderBy(desc(kycVerification.createdAt)) .limit(1); - const totalDeposits = deposited?.total ?? '0'; const snapshot = { totalDeposits, currency: current.currency, diff --git a/packages/core/src/contracts/adapters/wallet-reader.ts b/packages/core/src/contracts/adapters/wallet-reader.ts index 84872f4de..5be6d8a56 100644 --- a/packages/core/src/contracts/adapters/wallet-reader.ts +++ b/packages/core/src/contracts/adapters/wallet-reader.ts @@ -29,8 +29,13 @@ export type WalletBalancesReading = { }; export type WalletReader = { - /** Sum of all completed deposits for a player, as a decimal string (same as wallet_transaction.amount). Used for high_roller evaluation. */ - getLifetimeDeposit(userId: string): Promise; + /** + * Sum of all completed deposits for a player, priced into the implementation's reference + * currency (a decimal string, not necessarily the same unit as wallet_transaction.amount - + * a player can deposit in several currencies). Null when at least one currency's amount + * could not be priced; never a partial or fabricated total. Used for high_roller evaluation. + */ + getLifetimeDeposit(userId: string): Promise; /** Always answers: a player with no wallet row yet gets an empty `balances` array and the platform's default wallet currency, never a throw. */ getBalances(userId: string): Promise; /** Count of completed withdrawals for a player within the last windowDays days. Used for high_risk evaluation. */ diff --git a/packages/core/src/pam/tag/service/tag-evaluation.service.ts b/packages/core/src/pam/tag/service/tag-evaluation.service.ts index 730cd3a5e..f665f5cf8 100644 --- a/packages/core/src/pam/tag/service/tag-evaluation.service.ts +++ b/packages/core/src/pam/tag/service/tag-evaluation.service.ts @@ -10,7 +10,7 @@ import { type TagRule, type User, } from '@openora/core/contracts'; -import { moneyToNumber, mapConcurrent, type DrizzleTx } from '@openora/core/server'; +import { moneyToNumber, mapConcurrent, createLogger, type DrizzleTx } from '@openora/core/server'; import { TagService, TagAlreadyInUseError, TagAssignmentNotFoundError } from './tag.service.js'; import { TagRuleService, TagRuleNotFoundError } from './tag-rule.service.js'; import { @@ -23,6 +23,7 @@ import { const EVAL_CHUNK_SIZE = 100; const MULTI_ACCOUNT_REASON = 'identity signal matched another player account'; const BONUS_ABUSER_REASON = 'multi-account risk rule matched'; +const logger = createLogger('pam-tag-evaluation'); function isPendingKycStatus(status: KycStatus): boolean { return status === 'pending' || status === 'resubmission_requested'; @@ -151,7 +152,14 @@ export class TagEvaluationService { if (highRoller && highRoller.threshold !== null) { const lifetimeDeposit = await this.walletReader.getLifetimeDeposit(userId); - if (moneyToNumber(lifetimeDeposit) >= moneyToNumber(highRoller.threshold)) { + if (lifetimeDeposit === null) { + // Never tag (or untag - a sticky high_roller stays sticky) off a missing rate: a + // fabricated total can't be told apart from a real one once it's compared and assigned. + logger.warn( + { userId }, + 'onDepositCompleted: could not price lifetime deposits, skipping high_roller evaluation', + ); + } else if (moneyToNumber(lifetimeDeposit) >= moneyToNumber(highRoller.threshold)) { await this.tryAssignTag({ userId, tagKey: 'high_roller', @@ -420,7 +428,9 @@ export class TagEvaluationService { return; } const lifetimeDeposit = await this.walletReader.getLifetimeDeposit(userId); - if (moneyToNumber(lifetimeDeposit) > 0) { + // null still means there was at least one deposit (just one this pricing pass couldn't + // total) - this rule only asks "any deposit on file", so an unpriced amount still assigns. + if (lifetimeDeposit === null || moneyToNumber(lifetimeDeposit) > 0) { await this.tryAssignTag({ userId, tagKey: 'basic_kyc_needed', diff --git a/packages/core/src/promo/bonus/service/offer.service.ts b/packages/core/src/promo/bonus/service/offer.service.ts index 94811f5eb..f24e42c58 100644 --- a/packages/core/src/promo/bonus/service/offer.service.ts +++ b/packages/core/src/promo/bonus/service/offer.service.ts @@ -162,7 +162,16 @@ export class OfferService { return context; } const lifetime = await this.wallet.getLifetimeDeposit(userId); - return { ...context, isFirstDeposit: moneyCompare(lifetime, '0') === 0 }; + // null means at least one currency couldn't be priced, which only happens once a deposit + // exists - so unpriced reads the same as "not the first deposit". + return { ...context, isFirstDeposit: lifetime !== null && moneyCompare(lifetime, '0') === 0 }; + } + + // Fallback for a WalletReader implementation without isFirstDeposit; null (an unpriced + // currency) reads as "not the first deposit", same direction as offerFacts above. + private async isFirstDepositByLifetimeTotal(userId: Uuid, depositAmount: string) { + const lifetime = await this.wallet.getLifetimeDeposit(userId); + return lifetime !== null && moneyCompare(lifetime, depositAmount) === 0; } /** The offers open to this player right now, with what their deposits have put toward each. */ @@ -242,7 +251,7 @@ export class OfferService { // `created_at` against every other completed deposit, which does not move once written. const isFirstDeposit = this.wallet.isFirstDeposit ? await this.wallet.isFirstDeposit(deposit.userId, deposit.transactionId) - : moneyCompare(await this.wallet.getLifetimeDeposit(deposit.userId), deposit.amount) === 0; + : await this.isFirstDepositByLifetimeTotal(deposit.userId, deposit.amount); // Re-evaluated here rather than trusted from opt-in time: this job can run after a // self-exclusion or ban that landed between the opt-in and this deposit settling, and the // forfeit sweep that reacted to that exclusion has no way to know a grant would appear later. diff --git a/packages/core/src/server/db/index.ts b/packages/core/src/server/db/index.ts index 298d22e2d..0789de3ee 100644 --- a/packages/core/src/server/db/index.ts +++ b/packages/core/src/server/db/index.ts @@ -22,6 +22,7 @@ export { moneyFloorToScale, moneyCeilToScale, mapConcurrent, + sumInPivot, } from './query-helpers.js'; // Transactional outbox - writer binds atomically with the state change; relay publishes to MESSAGE_BROKER. See ADR-0016. diff --git a/packages/core/src/server/db/query-helpers.ts b/packages/core/src/server/db/query-helpers.ts index b544917f8..702612b03 100644 --- a/packages/core/src/server/db/query-helpers.ts +++ b/packages/core/src/server/db/query-helpers.ts @@ -1,4 +1,4 @@ -import { MONEY_SCALE, MoneyAmountSchema } from '@openora/core/contracts'; +import { MONEY_SCALE, MoneyAmountSchema, type ExchangeRateReader } from '@openora/core/contracts'; import { sql } from 'drizzle-orm'; import type { DrizzleTx } from './drizzle.js'; @@ -133,6 +133,33 @@ function fromUnitsAtScale(units: bigint, scale: number): string { return scale === 0 ? whole : `${whole}.${digits.slice(digits.length - scale)}`; } +// A no-base-currency platform holds each row in whatever coin it was made in, so a +// compliance total (re-KYC cumulative deposits, a high_roller threshold) has to price every +// currency into one before it can be summed. A row this can't price (no quote) is never +// silently dropped from the total - that would undercount a compliance check - so the whole +// sum reads back as null and the caller decides what "we don't know" means for it. Never +// guess a value to fill the gap: a fabricated total sums, compares and gets written down like +// a real one, and nothing downstream can tell the difference. +export async function sumInPivot( + rows: readonly { currency: string; total: string }[], + pivotCurrency: string, + rates: ExchangeRateReader | undefined, +): Promise { + let total = '0'; + for (const row of rows) { + if (row.currency.toUpperCase() === pivotCurrency.toUpperCase()) { + total = moneyAdd(total, row.total); + continue; + } + const converted = rates ? await rates.convert(row.total, row.currency, pivotCurrency) : null; + if (converted === null) { + return null; + } + total = moneyAdd(total, converted); + } + return total; +} + // Escape LIKE wildcards so a caller-supplied value matches literally and a // stray % _ or \ can't widen the match. Backslash is the default PG escape char. export function escapeLike(value: string): string { diff --git a/packages/core/src/wallet/__tests__/wallet-reader.service.int.test.ts b/packages/core/src/wallet/__tests__/wallet-reader.service.int.test.ts index 45c8000ac..6c51a1621 100644 --- a/packages/core/src/wallet/__tests__/wallet-reader.service.int.test.ts +++ b/packages/core/src/wallet/__tests__/wallet-reader.service.int.test.ts @@ -1,12 +1,31 @@ -import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; +import { describe, it, expect, vi, beforeAll, afterAll, beforeEach } from 'vitest'; import { findOneOrThrow } from '@openora/core/server'; import { randomUUID } from 'node:crypto'; import { eq, sql } from 'drizzle-orm'; +import type { ExchangeRateReader } from '@openora/core/contracts'; import { createTestDb, type TestDb } from '@openora/core/testing'; import { migrate } from '../migrate.js'; import { wallet, walletBalance, walletTransaction } from '../schema/index.js'; import { WalletReaderService } from '../adapters/wallet-reader.service.js'; +// A fake pivot-rate reader: `prices` gives the USD value of one whole unit of a currency, +// `unpriced` names currencies that answer null (no quote). +function makeRates(prices: Record, unpriced: string[] = []): ExchangeRateReader { + return { + getRate: vi.fn(async () => null), + convert: vi.fn(async (amount: string, from: string, to: string) => { + if (from === to) { + return amount; + } + if (unpriced.includes(from)) { + return null; + } + const price = prices[from]; + return price === undefined ? null : String(Number(amount) * Number(price)); + }), + }; +} + let db: TestDb; let svc: WalletReaderService; @@ -22,7 +41,7 @@ async function seedWallet() { beforeAll(async () => { db = await createTestDb([migrate]); - svc = new WalletReaderService(db.drizzle); + svc = new WalletReaderService({ drizzle: db.drizzle, pivotCurrency: 'USD' }); }); afterAll(async () => { @@ -165,7 +184,11 @@ describe('WalletReaderService.getBalance (real PG)', () => { }); it('reports the configured default currency for a user with no wallet row', async () => { - const configured = new WalletReaderService(db.drizzle, 'USDT'); + const configured = new WalletReaderService({ + drizzle: db.drizzle, + defaultCurrency: 'USDT', + pivotCurrency: 'USD', + }); const userId = randomUUID(); expect(await configured.getBalance(userId)).toEqual({ balance: '0', currency: 'USDT' }); @@ -177,7 +200,11 @@ describe('WalletReaderService.getBalance (real PG)', () => { it('keeps an existing wallet on its own active currency when a default is configured', async () => { const w = await seedWallet(); - const configured = new WalletReaderService(db.drizzle, 'USDT'); + const configured = new WalletReaderService({ + drizzle: db.drizzle, + defaultCurrency: 'USDT', + pivotCurrency: 'USD', + }); expect((await configured.getBalance(w.userId)).currency).toBe('USD'); }); @@ -206,3 +233,52 @@ describe('WalletReaderService.getBalance (real PG)', () => { expect(result.currency).toBe('EUR'); }); }); + +async function seedDeposit(walletId: string, amount: string, currency: string) { + await db.drizzle.db.insert(walletTransaction).values({ + walletId, + type: 'deposit', + amount, + currency, + status: 'completed', + direction: 'credit', + rail: currency === 'USD' ? 'fiat' : 'crypto', + }); +} + +describe('WalletReaderService.getLifetimeDeposit (real PG)', () => { + it('sums deposits held in a single currency with no fx module wired', async () => { + const w = await seedWallet(); + await seedDeposit(w.id, '10', 'USD'); + await seedDeposit(w.id, '5', 'USD'); + + expect(Number(await svc.getLifetimeDeposit(w.userId))).toBe(15); + }); + + it('prices every currency into the pivot instead of summing raw amounts', async () => { + const w = await seedWallet(); + await seedDeposit(w.id, '1', 'BTC'); + await seedDeposit(w.id, '20000', 'DOGE'); + const priced = new WalletReaderService({ + drizzle: db.drizzle, + pivotCurrency: 'USD', + exchangeRateReader: makeRates({ BTC: '60000', DOGE: '0.1' }), + }); + + // Raw would read 20001 (1 BTC + 20000 DOGE) - priced reads 60000 + 2000. + expect(Number(await priced.getLifetimeDeposit(w.userId))).toBe(62000); + }); + + it('returns null, not a partial total, when a currency cannot be priced', async () => { + const w = await seedWallet(); + await seedDeposit(w.id, '1', 'BTC'); + await seedDeposit(w.id, '1', 'DOGE'); + const partiallyPriced = new WalletReaderService({ + drizzle: db.drizzle, + pivotCurrency: 'USD', + exchangeRateReader: makeRates({ BTC: '60000' }, ['DOGE']), + }); + + expect(await partiallyPriced.getLifetimeDeposit(w.userId)).toBeNull(); + }); +}); 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 4a63d49c5..7986c53c3 100644 --- a/packages/core/src/wallet/__tests__/wallet.service.int.test.ts +++ b/packages/core/src/wallet/__tests__/wallet.service.int.test.ts @@ -6,6 +6,7 @@ import { DEFAULT_PAYMENT_PROVIDER, PaymentRejectedError, type AdminUserDirectory, + type ExchangeRateReader, type PaymentAdapter, type AdminPlayerSummary, type TagEvaluationCommands, @@ -86,6 +87,24 @@ function playerIdentityReader() { const queueService = () => makeService().svc; +// A fake pivot-rate reader: `prices` gives the USD value of one whole unit of a currency, +// `unpriced` names currencies that answer null (no quote). +function makeRates(prices: Record, unpriced: string[] = []): ExchangeRateReader { + return { + getRate: vi.fn(async () => null), + convert: vi.fn(async (amount: string, from: string, to: string) => { + if (from === to) { + return amount; + } + if (unpriced.includes(from)) { + return null; + } + const price = prices[from]; + return price === undefined ? null : String(Number(amount) * Number(price)); + }), + }; +} + function makeDirectory(summaries: AdminPlayerSummary[]) { return mock({ lookupPlayers: vi.fn(async (ids: string[]) => summaries.filter((s) => ids.includes(s.userId))), @@ -1555,6 +1574,32 @@ describe('WalletService.listWithdrawals (real PG)', () => { expect(byId.get(small.id)).not.toContain('large_amount'); }); + it('prices the withdrawal into the pivot before tagging large_amount, not the raw face amount', async () => { + const w = await seedWallet(); + // 0.2 BTC at $30000/BTC = $6000 - a small face amount, well over the $5000 pivot threshold. + const smallFaceHighValue = await seedTx(w.id, { amount: '0.2', currency: 'BTC' }); + // 20000 DOGE at $0.1/DOGE = $2000 - a large face amount, under the threshold. + const largeFaceLowValue = await seedTx(w.id, { amount: '20000', currency: 'DOGE' }); + const { svc } = makeService({ rates: makeRates({ BTC: '30000', DOGE: '0.1' }) }); + + const { items } = await svc.listWithdrawals({ page: 1, limit: 20 }); + + const byId = new Map(items.map((i) => [i.transactionId, i.riskTags])); + expect(byId.get(smallFaceHighValue.id)).toContain('large_amount'); + expect(byId.get(largeFaceLowValue.id)).not.toContain('large_amount'); + }); + + it('flags large_amount for a withdrawal in a currency with no rate, instead of dropping it', async () => { + const w = await seedWallet(); + const unpriceable = await seedTx(w.id, { amount: '1', currency: 'ZZZ' }); + const { svc } = makeService({ rates: makeRates({}, ['ZZZ']) }); + + const { items } = await svc.listWithdrawals({ page: 1, limit: 20 }); + + const byId = new Map(items.map((i) => [i.transactionId, i.riskTags])); + expect(byId.get(unpriceable.id)).toContain('large_amount'); + }); + it('tags high_frequency once the wallet has three withdrawals in the trailing 24h', async () => { const quiet = await seedWallet(); const busy = await seedWallet(); diff --git a/packages/core/src/wallet/adapters/wallet-reader.service.ts b/packages/core/src/wallet/adapters/wallet-reader.service.ts index 46930bc81..9565b7ef4 100644 --- a/packages/core/src/wallet/adapters/wallet-reader.service.ts +++ b/packages/core/src/wallet/adapters/wallet-reader.service.ts @@ -1,8 +1,9 @@ -import { DrizzleService } from '@openora/core/server'; +import { DrizzleService, sumInPivot } from '@openora/core/server'; import { type WalletReader, type WalletBalancesReading, type WalletProviderTransaction, + type ExchangeRateReader, } from '@openora/core/contracts'; import { and, count, eq, gt, inArray, lt, sum } from 'drizzle-orm'; import { wallet, walletTransaction } from '../schema/index.js'; @@ -35,19 +36,41 @@ function toProviderTransaction( }; } +export type WalletReaderServiceDeps = { + drizzle: DrizzleService; + defaultCurrency?: string; + // Prices getLifetimeDeposit's per-currency sum into pivotCurrency (the platform's + // configured exchange-rate pivot, e.g. resolveExchangeRatePivot(platformConfig.exchangeRate)) + // - a platform with no base currency can hold a player's deposits in several coins at once, + // so a raw SUM across them (1 BTC + 20000 DOGE = 20001) is never a valid comparison. + // exchangeRateReader stays optional so an fx-less install still resolves (getLifetimeDeposit + // then answers null for any player holding a non-pivot-currency deposit). + exchangeRateReader?: ExchangeRateReader; + pivotCurrency: string; +}; + export class WalletReaderService implements WalletReader { - constructor( - private readonly drizzle: DrizzleService, - private readonly defaultCurrency?: string, - ) {} + private readonly drizzle: DrizzleService; + private readonly defaultCurrency: string | undefined; + private readonly exchangeRateReader: ExchangeRateReader | undefined; + private readonly pivotCurrency: string; + + constructor(deps: WalletReaderServiceDeps) { + this.drizzle = deps.drizzle; + this.defaultCurrency = deps.defaultCurrency; + this.exchangeRateReader = deps.exchangeRateReader; + this.pivotCurrency = deps.pivotCurrency; + } getBalances(userId: string): Promise { return readWalletBalances(this.drizzle.db, userId, this.defaultCurrency); } - async getLifetimeDeposit(userId: string): Promise { - const [row] = await this.drizzle.db - .select({ total: sum(walletTransaction.amount) }) + /** Sum of a player's completed deposits, priced into pivotCurrency. Null when at least one + * currency's amount could not be priced - never a partial or fabricated total. */ + async getLifetimeDeposit(userId: string): Promise { + const rows = await this.drizzle.db + .select({ currency: walletTransaction.currency, total: sum(walletTransaction.amount) }) .from(walletTransaction) .innerJoin(wallet, eq(walletTransaction.walletId, wallet.id)) .where( @@ -56,8 +79,13 @@ export class WalletReaderService implements WalletReader { eq(walletTransaction.type, 'deposit'), eq(walletTransaction.status, 'completed'), ), - ); - return row?.total ?? '0'; + ) + .groupBy(walletTransaction.currency); + return sumInPivot( + rows.map((row) => ({ currency: row.currency, total: row.total ?? '0' })), + this.pivotCurrency, + this.exchangeRateReader, + ); } async getWithdrawalCountInWindow(userId: string, windowDays: number): Promise { diff --git a/packages/core/src/wallet/plugin.ts b/packages/core/src/wallet/plugin.ts index 84a3c77f5..f054000c6 100644 --- a/packages/core/src/wallet/plugin.ts +++ b/packages/core/src/wallet/plugin.ts @@ -22,6 +22,7 @@ import { PLAY_ELIGIBILITY, RG_LIMITS, EXCHANGE_RATE_READER, + resolveExchangeRatePivot, SWAP_ADAPTER, SWAP_WEBHOOK_VERIFIER, AUDIT_WRITER, @@ -243,10 +244,16 @@ export default { ctx.provide( WALLET_READER, (c) => - new WalletReaderService( - c.get(DRIZZLE), - c.has(PLATFORM_CONFIG) ? c.get(PLATFORM_CONFIG).wallet?.defaultCurrency : undefined, - ), + new WalletReaderService({ + drizzle: c.get(DRIZZLE), + defaultCurrency: c.has(PLATFORM_CONFIG) + ? c.get(PLATFORM_CONFIG).wallet?.defaultCurrency + : undefined, + exchangeRateReader: c.has(EXCHANGE_RATE_READER) ? c.get(EXCHANGE_RATE_READER) : undefined, + pivotCurrency: resolveExchangeRatePivot( + c.has(PLATFORM_CONFIG) ? c.get(PLATFORM_CONFIG).exchangeRate : undefined, + ), + }), ); ctx.provide(ADMIN_WALLET_REPORTING, (c) => new DrizzleAdminWalletReporting(c.get(DRIZZLE))); // Operator-editable currency/network config, readable by a payment adapter without diff --git a/packages/core/src/wallet/service/wallet.service.ts b/packages/core/src/wallet/service/wallet.service.ts index 622c33c5f..be1068ba3 100644 --- a/packages/core/src/wallet/service/wallet.service.ts +++ b/packages/core/src/wallet/service/wallet.service.ts @@ -12,7 +12,7 @@ import { withAdvisoryXactLock, assertRateLimit, createLogger, - moneyToNumber, + mapConcurrent, moneyEquals, moneyAdd, moneyCompare, @@ -1609,11 +1609,16 @@ export class WalletService { // One batched velocity query for the page (no N+1); shares the window + threshold + query the auto-approval evaluator uses. const pageWalletIds = [...new Set(pageRows.map((r) => r.tx.walletId))]; const frequentWalletIds = await this.frequentWithdrawalWalletIds(db, pageWalletIds); + const pivotCurrency = resolveExchangeRatePivot(this.platformConfig?.exchangeRate); - const items: WithdrawalQueueItem[] = pageRows.map((r) => { + const items: WithdrawalQueueItem[] = await mapConcurrent(pageRows, 5, async (r) => { const summary = byUserId.get(r.userId); const riskTags: string[] = []; - if (moneyToNumber(r.tx.amount) >= moneyToNumber(LARGE_WITHDRAWAL_THRESHOLD)) { + // The queue tag is priced in the fx pivot, same as auto-approval - a raw compare let a + // 5000 DOGE withdrawal read as "large" next to a 5000 BTC one that dwarfs it. No rate + // for the coin flags it anyway: never let a compliance total quietly undercount. + const pivotAmount = await this.toPivotAmount(r.tx.amount, r.tx.currency, pivotCurrency); + if (pivotAmount === null || moneyCompare(pivotAmount, LARGE_WITHDRAWAL_THRESHOLD) >= 0) { riskTags.push('large_amount'); } if (frequentWalletIds.has(r.tx.walletId)) {