fix(control-plane): remember reversed payout events so a redelivery never re-debits and a reversal credits the recorded amount - #9624
Conversation
…ever re-debits and a reversal credits the recorded amount The fake settlement backend tracked settled events with a single Set<string>, so reversePayout deleted the only proof an event was ever processed: a webhook redelivered after a refund/dispute re-decremented the pool, and a reversal credited back the caller-supplied amount instead of what was actually taken. Track every ever-recorded event in a Map keyed by payoutEventKey with the recorded amount and a reversed flag; recordPayoutEligibleEvent is a lifetime no-op for any ever-recorded key (including reversed ones), reversePayout credits back the recorded amount and stays idempotent, and the public settledEventKeys ReadonlySet keeps its documented recorded-and-not-reversed semantics as a derived view. JSDoc on both interface methods now states the ever-recorded and recorded-amount rules. Closes JSONbored#9612
|
Warning ⏸️ LoopOver review result - manual review recommendedReview updated: 2026-07-29 01:18:49 UTC
Review summary Nits — 3 non-blocking
Decision drivers
Context & advisory signals — never blocks the verdict
Review context
Contributor next steps
Signal definitions
🧪 Chat with LoopOverAsk LoopOver a question about this PR directly in a comment — grounded only in the same cached, public-safe facts shown above, never a new claim.
Full command reference: https://loopover.ai/docs/loopover-commands 🧪 Experimental — new and may change. 🟩 Safe / merged · 🟦 Advisory · 🟨 Held for review · 🟥 Blocked / closed 💰 Earn for open-source contributions like this. Gittensor lets GitHub contributors earn for the work they already do — register to start earning →. Checked by LoopOver, a quiet PR intelligence layer for OSS maintainers.
|
|
Superagent didn't find any vulnerabilities or security issues in this PR. |
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #9624 +/- ##
==========================================
- Coverage 90.02% 89.20% -0.83%
==========================================
Files 888 888
Lines 111983 112006 +23
Branches 26570 26571 +1
==========================================
- Hits 100810 99912 -898
- Misses 9843 11006 +1163
+ Partials 1330 1088 -242
Flags with carried forward coverage won't be shown. Click here to find out more.
|
Closes #9612
Summary
fix(control-plane): remember reversed payout events so a redelivery never re-debits and a reversal credits the recorded amountThe in-memory
FakeSettlementBackendDrivertracked settled payout events with a singleSet<string>keyed bypayoutEventKey. That representation broke both idempotency clauses its own interface documents:reversePayoutdeleted the key from the set — the only record the event was ever processed — so a redeliveredpull_requestwebhook after a refund/dispute hit!settledEventKeys.has(key)and decremented the pool a second time (fundPool 500 → record → 400 → reverse → 500 → record again → 400instead of staying500).amountis deliberately not part of the event key, soreversePayout({ ...event, amount: 1_000_000 }, "refund")after recordingamount: 100credited 1,000,000 back — the fake had no memory of what it actually took.Root cause
Presence/absence of a key in one
Setconflated "currently settled" with "ever recorded", and stored nothing about the decremented amount.Fix
settledEventKeys: Set<string>internally with a module-localMap<string, SettledEventRecord>({ amount, reversed }), closed over bycreateFakeSettlementBackendDriverand exposed throughgetaccessors — same fake shape ascreateFakeTenantProvisioningDriver. Entries are never deleted, so an ever-recorded event stays known.recordPayoutEligibleEventis now a no-op (no balance/state change) for any key it has ever recorded, including one since reversed; it still pushes ontocallson every invocation.reversePayoutcredits back the recorded amount (record.amount), ignoring the reversal call'sevent.amount; still a no-op for unrecorded and already-reversed keys; still pushes ontocallson every invocation.settledEventKeys: ReadonlySet<string>property remains with unchanged semantics — keys currently recorded-and-not-reversed — derived from the new internal state.addToPoolBalance) so the?? 0default has a single, fully branch-covered home.fundPool,getPoolBalance,payoutEventKey, thecallslog shape (amount: event.amountas passed), and both exported type surfaces are unchanged.recordPayoutEligibleEvent(ever-recorded rule) andreversePayout(recorded-amount rule), which the code now honours.Diff:
control-plane/src/settlement-backend-driver.tsandcontrol-plane/test/settlement-backend-driver.test.tsonly. Every pre-existing test passes unmodified. No settlement policy, nopayoutEventKeychange, no ledger abstraction.Tests (RED → GREEN)
New named regression tests, run against the unfixed source first:
After the fix (plus a fourth test covering record on a never-funded pool):
getPoolBalance("pool-1") === 500anddriver.calls.map(c => c.step)is exactly["fundPool", "recordPayoutEligibleEvent", "reversePayout", "recordPayoutEligibleEvent"].reversePayout({ ...event, amount: 1_000_000 }, "refund")after recordingamount: 100restores the balance to exactly its pre-record value.settledEventKeys.settledEventKeyspresence/absence tests pass unmodified.Coverage
Both arms of every changed conditional are exercised: record on a never-seen key, a currently-settled key, and a previously-reversed key; reverse on an unrecorded key, a settled key, and an already-reversed key;
addToPoolBalanceon an absent and a present pool entry. CI's own coverage recipe (node --experimental-strip-types scripts/control-plane-coverage.ts, c8 over the built dist remapped tocontrol-plane/src/**):control-plane/src/settlement-backend-driver.ts: LF 156 / LH 156, BRF 18 / BRH 18 — zero uncovered lines or branches in the file.Validation commands (repo root)