Repository navigation
Fix the refund retry cap and a reverse swap skipped on a lagging read - #1410
Merged
Merged
Conversation
Below the attempt cap, retryOrFail stored the failure in the recovery's error, and the orchestrator reads an error on a recovering deposit as the operator's retry and resets the attempts. So every keeper cycle started over at attempt 1: a redeem Monerium keeps refusing was retried about every 20 s indefinitely, REFUND FAILED was never logged and the deposit never reached recovery_failed. Only the terminal failure now sets error; the warn log still carries each attempt's reason.
Refund steps retry on later keeper cycles, without backoff, and the recovery row keeps only the error that parked it.
✅ Deploy Preview for vrtx-dashboard canceled.
|
✅ Deploy Preview for vortex-sandbox ready!
To edit notification comments on pull requests, go to your Netlify project configuration. |
✅ Deploy Preview for vortexfi canceled.
|
The refund's first step read the refund wallet's USDC balance right after the recover receipt and took zero as "a swap already landed". A node behind the recover's block answers zero for both balances, so the swap was skipped, the float topped up the whole gap, and the recovered USDC stayed on the refund wallet with no step that moves it. Zero USDC now counts as a landed swap only when the swap's EURe output shows; otherwise the step reads again next cycle. Found live in the sandbox rehearsal: a half-converted EUR 60 refund skipped the swap of 27.9 USDC and the float paid 25 EURe instead.
retryOrFail already records why only fail() writes the error; the orchestrator keeps its original one-line comment.
This was referenced Oct 8, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this fixes
Two bugs in the refund path (
recovery.ts), both found in a local end-to-end rehearsal on Sepolia.1. A refused refund step retried forever
A refund step that keeps failing (for example a SEPA redeem order Monerium refuses) was retried about every 20 seconds forever. The deposit never reached
recovery_failed,REFUND FAILEDwas never logged, and the refund queue stayed blocked.Root cause, in
recovery.ts:retryOrFailwrote the failure reason intomonerium_recoveries.error.runRecoveryOrchestratorreads anerroron a deposit that is stillrecoveringas the operator's retry (the operator moved a parked refund back torecovering) and resetsattemptsto 0.Seen live: a refund payout refused by Monerium (HTTP 400) was logged as "attempt 1" on every cycle. With the fix applied, the next refused payout parked as
recovery_failedafter exactly 5 attempts.2. A lagging balance read skipped the reverse swap
The refund's first step reads the refund wallet's USDC right after the
recoverreceipt and took a zero balance as "a swap already landed" (the lost-hash case). A node behind therecover's block answers zero for both balances, so the swap was skipped, the float topped up the whole gap, and the recovered USDC stayed on the refund wallet with no step that ever moves it.Seen live: a half-converted EUR 60 refund (35 EURe + 27.9 USDC recovered) skipped the swap and the float paid 25 EURe instead of a few cents; the payer's refund amount stays correct, but the float overpays and the USDC is stranded.
Fix: zero USDC counts as a landed swap only when the swap's EURe output shows (EURe above the recovered amount); otherwise the step reads again on the next cycle.
Changes
Retry cap:
retryOrFailbelow the cap now writes onlyattemptsandphase; the warn log still carries each attempt's reason.fail()setserror, so the orchestrator's reset fires only after a terminal failure that the operator moved back torecovering, which then gets five fresh attempts as before.Reverse swap:
Moved: with USDC recovered but neither the USDC nor a swap output visible, return without changing the phase; the next cycle reads again.Behaviour change: a refund step that fails five cycles in a row now parks as
recovery_failed(about 100 s at the 20 s cycle), the documented outcome, instead of retrying indefinitely. No partner-visible field or webhook payload changes:errorandattemptsare not part of any snapshot.Tests
recovery.test.tsfeeds a lagging read (both balances zero) right afterrecover: the step stays inMovedwith no calls, and once the read catches up it sends the reverse swap of the full USDC. It fails on the old code.recovery.test.tsdrives the realrunRecoveryOrchestratorthrough five refused redeem orders. It expectsattempts1 to 4 witherrorempty, thenrecovery_failedwith "after 5 attempts", no further Monerium call while parked, and five fresh attempts after an operator retry. It fails on the old code at the first cycle.recovery.test.tsandmonitoring.test.ts: 47 pass.tscand Biome clean.Cleanup pass
fail()writeserror) is stated once, inretryOrFail(5cd656a, comment only).Movedbranch with no new state; nothing else in the diff is dead, duplicated or orphaned; no dependency or wire-contract change.recoverexecution fixture inrecovery.test.tsinto a helper. It would save about 15 lines but rewrite two tests this PR does not otherwise touch.tsc,bun run verifyandwire-contract:checkclean.