Skip to content

fix(wallet): check address-credited deposits against the player's RG deposit limit - #160

Open
zaxovaiko wants to merge 1 commit into
devfrom
fix/rg-limit-on-address-deposits
Open

fix(wallet): check address-credited deposits against the player's RG deposit limit#160
zaxovaiko wants to merge 1 commit into
devfrom
fix/rg-limit-on-address-deposits

Conversation

@zaxovaiko

Copy link
Copy Markdown
Member

Summary

A deposit credited by address (the custody webhook and poller path) is now checked against the player's responsible-gambling deposit limit. The credit still happens, and an over-limit deposit files a rg_limit_breach reconciliation finding with the limit, the period and what was already used, plus the usual wallet.reconciliation_finding.recorded audit row.

Why

The PSP path refuses an over-limit deposit before charging. The address path credited any amount with no check at all, so a player with a daily limit could deposit any amount on chain and nothing recorded that the limit had been passed. An operator had no list of deposits to return the excess on.

Alternatives considered

  • Refusing the credit. The funds are already on chain when the webhook lands, so refusing would leave the player's money outside the ledger.
  • Holding the deposit in a pending state until an operator releases it. Needs a new state and a release flow; the finding gives operators the same list without either.

Risks

  • Adds rg_limit_breach to WALLET_RECONCILIATION_FINDING_KINDS; the wallet migration 0020 adds it to the finding kind enum.
  • The limit check runs before the credit transaction and never blocks it. If the check itself fails, the error is logged and no finding is filed, so an outage of the limits read means a missed finding rather than a stuck deposit.
  • A replayed webhook for an already-credited deposit does not file a second finding.

…deposit limit

The PSP path refuses an over-limit deposit before charging; the custody webhook and poller path credited any amount with no check. The funds are already on chain there, so the credit still happens, but an over-limit deposit now files an rg_limit_breach reconciliation finding with the limit, period and prior usage so an operator can return the excess. A failing limit check is logged and never blocks the credit.

Signed-off-by: Volodymyr Zakhovaiko <zaxovaiko@gmail.com>

@jakubfilinger-b jakubfilinger-b left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The gap is real and the shape of the answer is right: the funds are on chain, so refusing is not on the table, and a finding plus an audit row is the honest thing to leave behind. Crediting first and reporting second, with the check outside the credit transaction so a gate failure can never roll back money that already arrived, is the correct trade — and the three tests cover exactly the three outcomes that matter.

One blocking item and two smaller ones. The blocking one is the read placement: the sibling PSP path runs the same check inside its transaction and this one does not, and the difference is load-bearing under concurrency. Detail on that thread.

A correction for the description and changeset while you're in there: they both say "the custody webhook and poller path", but creditDepositByAddress has exactly one caller — the webhook route in wallet/router/index.ts — and reconciliation-finding.service.ts says as much in its own doc comment ("hits an unattributable deposit in real time, not on a poll"). I checked the other creditWalletBalance call sites: the PSP deposit is already gated, and the rest are manualAdjust, swap legs and withdrawal refunds, none of which are player deposits. So the coverage is right — it's just the sentence that claims more than it does, and this one lands in an operator-facing changeset.

// The funds are already on chain, so the player's deposit limit cannot refuse this credit
// the way it refuses a PSP charge. It is still asked, with this deposit as the attempted
// move, and a refusal becomes a finding so a human can act on the excess.
const rgDecision = await this.rgDecisionForLandedDeposit(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment explains why this sits outside the credit transaction and I agree with the reasoning — a gate failure must not roll back on-chain funds. But it also reads used on this.drizzle.db before the transaction opens, and the sibling path does the opposite: assertWithinRgDepositLimit is handed txn and called from inside the deposit transaction at :961.

That asymmetry has a concrete consequence. Two deposits to the same player landing close together both compute used from the same pre-credit snapshot, so each is judged on its own against the limit. A player with a daily limit of 1 who lands 0.6 and 0.6 within the window has both credited, both judged allowed, and no finding filed — even though the day closes at 1.2 against a limit of 1. That is precisely the row an operator needs to return the excess, and it is the case a determined player would produce deliberately.

It also makes the numbers in the finding itself unreliable: used is the value from before this credit and before any concurrent one, so the (X already used) in detail can already be wrong by the time the row is written.

You don't need the check inside the transaction to fix it — you need it after the credit commits, reading post-credit state. Move the call below the transaction (still guarded by !replayed) and ask the gate about the state as it now stands. A gate failure there still cannot roll anything back, which is the property the comment is protecting, and the answer stops depending on how many deposits raced.

txHash: event.txHash,
externalId: event.externalId,
transactionId,
detail: `credited over the player's ${rgDecision.period} ${rgDecision.limitType} limit of ${rgDecision.limit} (${rgDecision.used} already used) - the funds were already on chain`,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The changeset promises the finding carries "the limit, the period and what was already used", but all three exist only inside this English sentence. wallet_reconciliation_finding has no metadata column, so limit, period, limitType and used are unreachable to anything but a human reading detail — an operator UI cannot sort or filter by them, and the excess-return workflow this row exists to trigger has to regex the string to learn how much the excess actually was. The test pins the prose (toContain('daily deposit limit of 1')), which will also make the wording load-bearing the first time someone rephrases it.

The table already carries amount (the deposit) and currency; what is missing is the limit side. Either add a metadata jsonb column in the same migration that adds the enum value and put the decision in it, or if that is too much for this PR, say so in the changeset rather than implying the fields are queryable.

amount: string,
currency: string,
): Promise<RgLimitDecision | null> {
if (!this.rgLimits) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

An unbound RG_LIMITS returns null here and the deposit is credited with no check and no trace — same as before this PR, which is fair, but it is now the one path where a licence-facing gate can be absent and nothing anywhere says so. assertWithinRgDepositLimit at :799 has the identical early return, so an operator who never binds the port has both deposit routes silently ungated.

Not this PR's job to make the port required, but a one-time warning at plugin bind time ("wallet loaded without RG_LIMITS - deposit limits are not enforced") would turn a silent condition into a visible one, and it is the kind of thing that gets asked about in an audit. Worth a follow-up issue if not here.

Separately: the try/catch logs userId and currency but not the externalId or txHash, so the log line cannot be tied back to the deposit it failed on. Those are already in scope at the call site — worth threading through.

expect(emittedTopics(events)).toEqual(['wallet.deposit.completed']);
expect(rgLimits.checkDeposit).toHaveBeenCalledWith(expect.anything(), w.userId, '2', 'BTC');
const findings = await findingsFor(externalId);
expect(findings).toHaveLength(1);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good set — over-limit, under-limit and gate-down are the three that matter, and asserting the balance in all three is what makes the "credit always happens" claim real rather than stated.

Worth one more, given the (kind, externalId) partial unique index and onConflictDoNothing in recordReconciliationFinding: a second over-limit deposit for the same player with a different externalId should file a second finding. The dedup key makes replay safe, which is the behaviour the changeset promises, but nothing currently pins that it does not also swallow a genuinely new breach.

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.

2 participants