Skip to content

fix(core): price compliance totals through the exchange-rate pivot - #221

Merged
zaxovaiko merged 2 commits into
devfrom
feat/BF-0/usd-reference
Sep 24, 2026
Merged

zaxovaiko merged 2 commits into
devfrom
feat/BF-0/usd-reference

Conversation

@zaxovaiko

Copy link
Copy Markdown
Member

Summary

Compliance totals on a platform with no base currency are now priced through the exchange-rate pivot instead of summing raw amounts across currencies: the re-KYC cumulative deposit check, the lifetime deposit behind tag rules and first-deposit checks, and the large_amount withdrawal queue tag. BF-0

Why

  • The re-KYC check summed only deposits in player.currency. On a platform where players deposit in several currencies, a player who never deposits in that currency reads as 0 forever, so re-KYC never fires.
  • getLifetimeDeposit summed every currency together (1 BTC + 20000 DOGE = 20001), so a threshold rule compared a meaningless number.
  • The large_amount tag compared a raw amount in any currency against one flat threshold.

A missing rate never produces a guessed total:

  • sumInPivot returns null when any currency cannot be priced.
  • Re-KYC: logs and reports the error, writes nothing (no watermark from a guess), and re-evaluates on the next deposit.
  • WalletReader.getLifetimeDeposit now returns string | null (priced into the pivot). high_roller skips on null, basic_kyc_needed still assigns, first-deposit checks read null as not-first.
  • large_amount flags an unpriced withdrawal rather than letting it pass.

Alternatives considered

  • A per-transaction stored pivot value: exact historical valuation, but a ledger schema change and backfill. Totals here re-value history at today's rate, a known ceiling.
  • A large sentinel for unpriced totals: rejected, it poisoned the re-KYC watermark and assigned sticky tags off a missing rate.

Risks

  • WalletReader.getLifetimeDeposit return type widened to string | null - a breaking change for any external implementer or caller (minor bump, documented in the changeset).
  • WalletReaderService constructor takes a deps object with a required pivotCurrency.
  • Re-KYC and tag decisions now depend on rate availability; an outage skips re-KYC evaluation with an error report instead of deciding on partial data.

…ency 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.
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<string | null>.
  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.
@zaxovaiko
zaxovaiko merged commit edbe6d0 into dev Sep 24, 2026
2 checks passed
@zaxovaiko
zaxovaiko deleted the feat/BF-0/usd-reference branch September 24, 2026 22:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant