From 8f1c6fc19a2dc982e41dc145b7141b9be1112275 Mon Sep 17 00:00:00 2001 From: Volodymyr Zakhovaiko Date: Thu, 24 Sep 2026 23:56:03 +0200 Subject: [PATCH 1/2] fix(core): price compliance totals through the fx pivot, not raw currency sums Re-KYC's cumulative-deposit trigger only ever summed deposits already in the player's own currency field, which reads $0 forever on a platform with no matching base-currency wallet rail. The high_roller tag's lifetime-deposit total and the withdrawal queue's large_amount tag both summed or compared raw wallet_transaction.amount across whatever currencies a player used. All three now price through EXCHANGE_RATE_READER before comparing. A new sumInPivot helper (@openora/core/server) returns a large sentinel instead of a partial total when a currency can't be priced, so a missing rate never silently undercounts a compliance total - it forces the threshold to read as crossed instead. --- .changeset/pivot-priced-compliance-totals.md | 13 ++++++++ packages/core/src/compliance/plugin.ts | 1 + .../src/compliance/service/kyc.service.ts | 24 +++++++++++--- packages/core/src/server/db/index.ts | 2 ++ packages/core/src/server/db/query-helpers.ts | 32 ++++++++++++++++++- .../wallet/adapters/wallet-reader.service.ts | 22 ++++++++++--- packages/core/src/wallet/plugin.ts | 5 +++ .../core/src/wallet/service/wallet.service.ts | 11 +++++-- 8 files changed, 97 insertions(+), 13 deletions(-) create mode 100644 .changeset/pivot-priced-compliance-totals.md diff --git a/.changeset/pivot-priced-compliance-totals.md b/.changeset/pivot-priced-compliance-totals.md new file mode 100644 index 00000000..9bc0b344 --- /dev/null +++ b/.changeset/pivot-priced-compliance-totals.md @@ -0,0 +1,13 @@ +--- +'@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 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:** a compliance total must never silently undercount because one currency had no quote. Where a per-transaction amount can't be priced (the `large_amount` queue tag), the transaction is flagged anyway rather than dropped from consideration. Where a running total can't be fully priced (re-KYC's cumulative sum, `getLifetimeDeposit`), the new `sumInPivot` helper (`@openora/core/server`) returns a large sentinel instead of a partial number, so the threshold comparison downstream always reads as crossed instead of quietly passing. This trades a possible extra manual review/re-verification for never missing one - flagged here for anyone relying on the previous behaviour. + +`WalletReaderService`'s constructor gains two new optional trailing parameters (`exchangeRateReader`, `pivotCurrency`) and `KycVerificationDeps` gains an optional `exchangeRateReader` - both already wired from `EXCHANGE_RATE_READER` in their plugins, no consumer wiring required unless a service is constructed directly outside the plugin. diff --git a/packages/core/src/compliance/plugin.ts b/packages/core/src/compliance/plugin.ts index e33ddfe4..8c3bbfc4 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 2fd39aab..09ae94a3 100644 --- a/packages/core/src/compliance/service/kyc.service.ts +++ b/packages/core/src/compliance/service/kyc.service.ts @@ -7,6 +7,7 @@ import { makeNotFoundError, mapConcurrent, serializeRow, + sumInPivot, withAdvisoryXactLock, type EventBus, } from '@openora/core/server'; @@ -14,6 +15,7 @@ import { normalizeKycStatus, type KycAdapter, type AuditWritePort, + type ExchangeRateReader, type KycCheckResult, type KycDocument, type KycRiskSignals, @@ -179,6 +181,7 @@ export type KycVerificationDeps = { identityReader: IdentityReader; platformConfig?: PlatformConfig; reKycTrigger?: ReKycTrigger; + exchangeRateReader?: ExchangeRateReader; }; export class KycVerificationService { @@ -190,6 +193,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 +204,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() { @@ -606,6 +611,11 @@ export class KycVerificationService { * 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 +626,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 +638,12 @@ export class KycVerificationService { eq(wallet.userId, userId), eq(walletTransaction.type, 'deposit'), eq(walletTransaction.status, 'completed'), - eq(walletTransaction.currency, current.currency), ), - ); + ) + .groupBy(walletTransaction.currency); + const deposited = { + total: await sumInPivot(depositsByCurrency, current.currency, this.exchangeRateReader), + }; const [lastFire] = await this.drizzle.db .select({ triggerDeposits: kycVerification.triggerDeposits }) diff --git a/packages/core/src/server/db/index.ts b/packages/core/src/server/db/index.ts index 298d22e2..a40c48b7 100644 --- a/packages/core/src/server/db/index.ts +++ b/packages/core/src/server/db/index.ts @@ -22,6 +22,8 @@ export { moneyFloorToScale, moneyCeilToScale, mapConcurrent, + sumInPivot, + PIVOT_SUM_UNPRICED_SENTINEL, } 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 b544917f..441a9398 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,36 @@ 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/large_amount threshold) has to +// price every currency into one before it can be summed or compared. A row this can't price +// (no quote) is never dropped from the total - dropping it would silently undercount a +// compliance check - so it forces PIVOT_SUM_UNPRICED_SENTINEL, a value no configured threshold +// sits above, and the caller's ">= threshold" always fires instead of passing quietly. +// ponytail: a sentinel rather than a typed { total, unpriced } result - fine while nothing +// displays this total verbatim to an admin; add the typed flag if that changes. +export const PIVOT_SUM_UNPRICED_SENTINEL = '999999999999999999'; + +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 PIVOT_SUM_UNPRICED_SENTINEL; + } + 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/adapters/wallet-reader.service.ts b/packages/core/src/wallet/adapters/wallet-reader.service.ts index 46930bc8..96793d14 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'; @@ -39,6 +40,12 @@ export class WalletReaderService implements WalletReader { constructor( private readonly drizzle: DrizzleService, private readonly defaultCurrency?: string, + // Prices getLifetimeDeposit's per-currency sum into pivotCurrency - 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. Both optional so + // an fx-less install still resolves (sumInPivot then falls back to its unpriced sentinel). + private readonly exchangeRateReader?: ExchangeRateReader, + private readonly pivotCurrency: string = 'USD', ) {} getBalances(userId: string): Promise { @@ -46,8 +53,8 @@ export class WalletReaderService implements WalletReader { } async getLifetimeDeposit(userId: string): Promise { - const [row] = await this.drizzle.db - .select({ total: sum(walletTransaction.amount) }) + 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 +63,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 84a3c77f..2d349e29 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, @@ -246,6 +247,10 @@ export default { new WalletReaderService( c.get(DRIZZLE), c.has(PLATFORM_CONFIG) ? c.get(PLATFORM_CONFIG).wallet?.defaultCurrency : undefined, + c.has(EXCHANGE_RATE_READER) ? c.get(EXCHANGE_RATE_READER) : undefined, + resolveExchangeRatePivot( + c.has(PLATFORM_CONFIG) ? c.get(PLATFORM_CONFIG).exchangeRate : undefined, + ), ), ); ctx.provide(ADMIN_WALLET_REPORTING, (c) => new DrizzleAdminWalletReporting(c.get(DRIZZLE))); diff --git a/packages/core/src/wallet/service/wallet.service.ts b/packages/core/src/wallet/service/wallet.service.ts index 622c33c5..be1068ba 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)) { From a36aa322be0add0be7df062da35f639f3b976a13 Mon Sep 17 00:00:00 2001 From: Volodymyr Zakhovaiko Date: Fri, 25 Sep 2026 00:14:55 +0200 Subject: [PATCH 2/2] fix(core): never fabricate a missing compliance total, and add coverage Review fix on the pivot-pricing change: PIVOT_SUM_UNPRICED_SENTINEL poisoned the re-KYC watermark (a fired evaluation wrote triggerDeposits as the sentinel, so no later deposit could ever cross a fresh band) and made tag rules assign off a missing rate rather than a real value. sumInPivot now returns string | null instead of a sentinel - null means at least one currency could not be priced, never a guessed number standing in for a real one. - KycVerificationService.handleDeposit: on null, logs (logger.error + reportError, naming the userId and the unpriced currencies) and returns without writing a kyc_verification row or touching the watermark. The next deposit re-evaluates once rates are back. - WalletReader.getLifetimeDeposit port changed to Promise. Every caller now handles null explicitly, in the direction that never loses the compliance signal: high_roller skips and warns (a sticky tag should not be assigned or removed off a guess); basic_kyc_needed still assigns (null only happens once a deposit exists); bonus eligibility's isFirstDeposit paths (offerFacts and the isFirstDeposit-less fallback) treat null as not-first. - The large_amount queue tag has no running total to lose, so an unpriced withdrawal is flagged rather than skipped - never a silent pass. - WalletReaderService takes a deps object; pivotCurrency is required and wallet/plugin.ts wires it from resolveExchangeRatePivot instead of a hardcoded default. Tests: handleDeposit crossing a threshold summed across currencies, and writing no row (then firing normally once rates return) when a currency is unpriced; getLifetimeDeposit pricing into the pivot vs returning null; large_amount on a small-face/high-value coin, a large-face/low-value coin, and an unpriced one. --- .changeset/pivot-priced-compliance-totals.md | 10 ++- .../__tests__/kyc.service.int.test.ts | 80 +++++++++++++++++- .../src/compliance/service/kyc.service.ts | 41 ++++++++- .../src/contracts/adapters/wallet-reader.ts | 9 +- .../pam/tag/service/tag-evaluation.service.ts | 16 +++- .../src/promo/bonus/service/offer.service.ts | 13 ++- packages/core/src/server/db/index.ts | 1 - packages/core/src/server/db/query-helpers.ts | 19 ++--- .../wallet-reader.service.int.test.ts | 84 ++++++++++++++++++- .../__tests__/wallet.service.int.test.ts | 45 ++++++++++ .../wallet/adapters/wallet-reader.service.ts | 38 ++++++--- packages/core/src/wallet/plugin.ts | 14 ++-- 12 files changed, 322 insertions(+), 48 deletions(-) diff --git a/.changeset/pivot-priced-compliance-totals.md b/.changeset/pivot-priced-compliance-totals.md index 9bc0b344..51a1bc7e 100644 --- a/.changeset/pivot-priced-compliance-totals.md +++ b/.changeset/pivot-priced-compliance-totals.md @@ -6,8 +6,12 @@ Re-KYC's cumulative-deposit trigger, the `high_roller` tag's lifetime-deposit to 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 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). +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:** a compliance total must never silently undercount because one currency had no quote. Where a per-transaction amount can't be priced (the `large_amount` queue tag), the transaction is flagged anyway rather than dropped from consideration. Where a running total can't be fully priced (re-KYC's cumulative sum, `getLifetimeDeposit`), the new `sumInPivot` helper (`@openora/core/server`) returns a large sentinel instead of a partial number, so the threshold comparison downstream always reads as crossed instead of quietly passing. This trades a possible extra manual review/re-verification for never missing one - flagged here for anyone relying on the previous behaviour. +**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: -`WalletReaderService`'s constructor gains two new optional trailing parameters (`exchangeRateReader`, `pivotCurrency`) and `KycVerificationDeps` gains an optional `exchangeRateReader` - both already wired from `EXCHANGE_RATE_READER` in their plugins, no consumer wiring required unless a service is constructed directly outside the plugin. +- `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 59188fab..5139682f 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/service/kyc.service.ts b/packages/core/src/compliance/service/kyc.service.ts index 09ae94a3..81222296 100644 --- a/packages/core/src/compliance/service/kyc.service.ts +++ b/packages/core/src/compliance/service/kyc.service.ts @@ -6,6 +6,7 @@ import { findOneOrThrow, makeNotFoundError, mapConcurrent, + reportError, serializeRow, sumInPivot, withAdvisoryXactLock, @@ -606,6 +607,22 @@ 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 @@ -641,9 +658,26 @@ export class KycVerificationService { ), ) .groupBy(walletTransaction.currency); - const deposited = { - total: await sumInPivot(depositsByCurrency, current.currency, this.exchangeRateReader), - }; + 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 }) @@ -658,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 84872f4d..5be6d8a5 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 730cd3a5..f665f5cf 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 94811f5e..f24e42c5 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 a40c48b7..0789de3e 100644 --- a/packages/core/src/server/db/index.ts +++ b/packages/core/src/server/db/index.ts @@ -23,7 +23,6 @@ export { moneyCeilToScale, mapConcurrent, sumInPivot, - PIVOT_SUM_UNPRICED_SENTINEL, } 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 441a9398..702612b0 100644 --- a/packages/core/src/server/db/query-helpers.ts +++ b/packages/core/src/server/db/query-helpers.ts @@ -134,20 +134,17 @@ function fromUnitsAtScale(units: bigint, scale: number): string { } // 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/large_amount threshold) has to -// price every currency into one before it can be summed or compared. A row this can't price -// (no quote) is never dropped from the total - dropping it would silently undercount a -// compliance check - so it forces PIVOT_SUM_UNPRICED_SENTINEL, a value no configured threshold -// sits above, and the caller's ">= threshold" always fires instead of passing quietly. -// ponytail: a sentinel rather than a typed { total, unpriced } result - fine while nothing -// displays this total verbatim to an admin; add the typed flag if that changes. -export const PIVOT_SUM_UNPRICED_SENTINEL = '999999999999999999'; - +// 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 { +): Promise { let total = '0'; for (const row of rows) { if (row.currency.toUpperCase() === pivotCurrency.toUpperCase()) { @@ -156,7 +153,7 @@ export async function sumInPivot( } const converted = rates ? await rates.convert(row.total, row.currency, pivotCurrency) : null; if (converted === null) { - return PIVOT_SUM_UNPRICED_SENTINEL; + return null; } total = moneyAdd(total, converted); } 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 45c8000a..6c51a162 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 4a63d49c..7986c53c 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 96793d14..9565b7ef 100644 --- a/packages/core/src/wallet/adapters/wallet-reader.service.ts +++ b/packages/core/src/wallet/adapters/wallet-reader.service.ts @@ -36,23 +36,39 @@ 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, - // Prices getLifetimeDeposit's per-currency sum into pivotCurrency - 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. Both optional so - // an fx-less install still resolves (sumInPivot then falls back to its unpriced sentinel). - private readonly exchangeRateReader?: ExchangeRateReader, - private readonly pivotCurrency: string = 'USD', - ) {} + 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 { + /** 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) diff --git a/packages/core/src/wallet/plugin.ts b/packages/core/src/wallet/plugin.ts index 2d349e29..f054000c 100644 --- a/packages/core/src/wallet/plugin.ts +++ b/packages/core/src/wallet/plugin.ts @@ -244,14 +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, - c.has(EXCHANGE_RATE_READER) ? c.get(EXCHANGE_RATE_READER) : undefined, - resolveExchangeRatePivot( + 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