Repository navigation
Conversation
The keeper only quoted on Ethereum mainnet, so on the Sepolia sandbox no projection defer ran and below-floor or over-cap swaps reverted on chain and backed off instead of waiting with a partner-visible reason. Pin the QuoterV2 per chain id and keep the unquoted first-route fallback only for chains without one.
The marker starts the RECOVERY_DELAY clock. A failed attempt or a retry backoff skip sent nothing, so a deposit whose every attempt reverted kept batchOpenedAt at zero until the deadline job marked it recovering, and its refund landed a whole extra delay late.
A skip that waits on another execution leaves the poke to that execution's own sequence, so an implicit-nonce poke never races it. A backoff reached by the top-of-cycle pending check now falls through to the slot check, which pokes, instead of returning a cycle early.
applyFeePolicy is permissionless and the contract expects the keeper to finalize an announced increase once its 24 h timelock elapsed, but no keeper code called it, so increases stayed pending forever.
A timed-out apply left the increase looking pending, so every later cycle re-sent it, stacked keeper nonces and held the worker loop for another receipt timeout. The test also pins that the apply lands before the swap reads the fee policy.
Without it the fixture fell into the apply path and logged a spurious getBlock failure on every run.
A payment below MIN_SWAP_FLOOR never arms the forwarder's batch marker, so recover() cannot move it and the oldest-first planner held every later deposit of the account until the refund deadline. The planner now passes over it with a below_minimum waiting reason, and the stranded balance monitor flags a marked refund the contract cannot recover so the operator refunds it by hand.
A recovering deposit whose funds left the clone through an unrecorded path (permissionless swap/forwardAll) would have been flagged for a manual refund, paying the client twice. The alert now also names the memo that keeps a later keeper recover exactly-once.
The check compared only the stuck deposits' EURe with the clone balance, so EURe of a younger settling deposit or left by an earlier manual refund could stand in for funds that already left on the permissionless path, prompting a double refund.
✅ 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. |
This was referenced Oct 9, 2026
✅ Deploy Preview for vortexfi canceled.
|
This was referenced Oct 9, 2026
A hand refund of a payment stuck below MIN_SWAP_FLOOR left its EURe on the clone, bypassed the one-refund-per-client gate, and left no recovery record. Topping the clone up to one wei above the floor lets poke arm the marker, so the keeper's normal recover and refund pipeline refund the payer's own EURe after RECOVERY_DELAY; the top-up stays on the clone below the floor and the odd wei keeps it unattributed. The alert now names the clone's balance in decimal EURe, the exact top-up (decimal and raw) and the token, and the funds-left warning no longer claims that all funds left a partly swapped deposit.
A recover that is mined but whose execution row is still Pending has already taken its EURe, yet the ledger still counted it, so a sibling stuck deposit got "reconcile, do not refund" instead of the operator alert. The keeper confirms the row on its next cycle; waiting one monitoring pass gives the right verdict.
The reference listed the waiting reasons as a closed enum, so a strict client validating against it would reject a reason added later, as below_minimum just was, and its webhook endpoint would keep failing deliveries. Tell clients to treat an unknown reason as a generic wait.
The architecture doc still called a chunk-swapped remainder recoverable as usual because its USDC keeps the marker armed, yet every younger forward re-times that marker, and a younger forward that leaves a clone below the floor switches it off. Spell out both outcomes so the top-up and REFUND OVERDUE cases read as the known cost of no longer blocking younger payments.
The audit checklist pinned none of the new security-relevant keeper and monitor behaviours, so an auditor had no pointer from the invariants to the tests that hold them: the below_minimum pass-over and clear, the recover backoff exemption, the fee apply re-send guard, the top-up remedy and the overdue refund alert.
The architecture doc limited REFUND OVERDUE to clones at or above the floor, but the monitor also raises it on a sub-floor clone that still holds USDC, as the security spec already says.
The prescribed top-up was MIN_SWAP_FLOOR - balance + 1 wei, which is off a whole cent only while the clone balance is a whole number of cents. Once an earlier top-up's odd wei sits on the clone, the next top-up equals the recovered deposit's amount exactly, so the mint watcher could bind the operator's transfer to a pending order of that amount and record the client's real mint as unattributed. Add one more wei whenever the amount would land on a whole cent.
REFUND NEEDS OPERATOR fires in every MONERIUM_B2B_AUTO_RECOVERY mode, but only auto runs the refund pipeline after the keeper's recover. In alert or off the payer's EURe then waits on the client's refund wallet while the alert told the operator never to refund by hand. Name the mode and the manual redeem there instead.
A refund the orchestrator parked in recovery_failed keeps a recovery row whose last write is the failure. When an operator closes it as refunded through the admin route, refund.refundedAt reported that failure time. Use the recovery row's timestamp only once the row is redeemed, otherwise the deposit's refunded transition.
Under the sub-floor refund remedy every clone that had such a refund keeps its top-up as an unattr: Minted row. The candidate query did not exclude those rows, so the account ran the executor on every cycle for nothing and had due fee applies sent while idle. The executor already ignores unattr: rows; the mint watcher's touched accounts still give the top-up its one run.
The last fee apply is remembered in one process's memory, but the keeper is built to run in several processes on one database, and each of them is as blind to it as a restarted one. State that limit where the restart case was documented.
A parked refund closed by hand in auto mode was dated by the PATCH until the next orchestrator cycle closed its recovery row, and by that close afterwards, so the webhook and the deposits endpoint could report different refundedAt values. Every later write to a refunded row is silent, and on the automatic path the deposit turns refunded just before the row is redeemed, so the deposit's transition is one stable time for both paths.
The sub-floor refund check skips an account while a recover is pending, and REFUND OVERDUE treats a pending recover as progress. A recover row the keeper cannot resolve (failing receipt lookups, an unresolvable hashless row) therefore silenced every refund alert of the account with no time limit, and no other monitor sees such a row before a recovery row exists. Warn once it is pending past the refund monitor's linger margin and error after four times that.
The audit checklist item predates the whole-cent guard and the alert/off wording, so it contradicted Monitoring invariant 3 and left out the tests that pin both.
Biome's lint fix prefers a plain literal where nothing is interpolated.
Updating each loaded deposit keeps the instance in step with its row by itself, so the bulk update and the separate set loop that mirrored it into memory collapse into one write per deposit, in the same transaction.
The pass already skips an account while any of its recovers is pending, so the marked-deposit filter's pending check can never match. What remains is the confirmed-recover filter the ledger sum uses, so both now derive from one list, and recoverOf takes the one status each caller asks for.
The resend bound, the public-pool skip and the once-per-reason logging are already documented on the constant, the skip map and the inline check, so the docstring keeps only the decisions: why the keeper applies, why it remembers the last apply, and why a failure never stops the cycle.
Both resend guards block its second cycle, so it fails only when both are gone. The private-transport test pins the remembered apply and the public-pool test pins the nonce gate, each on its own: disabling either guard fails one of them.
Both describes defined a byte-identical activeAccountWithDeposit, so one file-scope copy serves both and a fixture change cannot drift between them.
The depth-check cases already run once per quoted chain, so each now also asserts that both quote sizes went to that chain's pinned QuoterV2, which makes the two standalone quoter tests redundant.
The paragraph gave the one-cycle candidacy of an unattr: row twice, once in a parenthetical and again as the reason the orphan inflow's poke is not retried. One sentence now carries the rule and its consequence.
Each sub-floor refund text restated a point it had just made: the ADR amendment its no-automatic-refund rule, the whole-cent rule and the timing; the architecture bullet the auto-only pipeline and the leftover top-up; the spec its hand-refund rule, in a form that contradicted the alert and off modes. The sandbox minimum now points to the production paragraph instead of repeating it. No decision or mode scope changes.
Keeper invariant 2 already states that keeper writes go through the private transport and that the public nonce counts cannot see them, so the fee-apply invariant cites it instead of repeating it.
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.
Why
The live sandbox edge-case tests of the B2B onramp turned up four keeper gaps. This PR fixes all four and replaces #1425, #1426, #1427 and #1428. A follow-up review of the combined branch found no blockers; its findings are addressed here too. Commits keep one concern each.
RECOVERY_DELAYclock started a cycle late, so refunds landed about twice as late as promised.applyFeePolicy()is permissionless and the contract expects the keeper to finalize a timelocked increase, but no keeper code called it.poke()refuses to arm belowMIN_SWAP_FLOOR, sorecover()never becomes eligible for it on its own. The keeper also kept serving that deposit first, blocking the account's later deposits, and nobody was told.What changes
chain.tspins QuoterV2 per chain: mainnet0x61fFE014bA17989E743c5F6cB21bF9697530B21e(unchanged) and Sepolia0xEd1f6473345F45b75F8179591dd5bA1888cf2FB3, Uniswap's official Sepolia QuoterV2. Pricing and the executable-depth monitor use it. Off mainnet, DEPTH BELOW FLOOR logs at warn with a re-centre hint, so a drifting sandbox pool is not an incident.runConversionExecutorpokes an unarmed marker after a failed attempt and on a backoff skip, at most once per cycle, under the send lock.applyFeePolicy()before pricing, at most once per cycle, as a best-effort step: a failed read or a revert only skips it. It skips the apply while any keeper transaction (of any account) is unmined in the public pool, and while its own last apply is younger than 15 minutes with no receipt, so a private-relay apply is not re-sent. Each skip reason is logged once.below_minimumon it (cleared, with a freshsince, once a lowered minimum makes it swappable).MIN_SWAP_FLOORwith no USDC, REFUND NEEDS OPERATOR prints the exact EURe top-up to send to the clone:MIN_SWAP_FLOORminus the clone balance plus 1 wei, one wei more if that is a whole number of cents, so the mint watcher can never match it to an order. The keeper's existing poke then arms the marker, and its normalrecover()and the refund pipeline refund the payer's own EURe afterRECOVERY_DELAY, with the full refund record. The top-up stays on the clone below the floor as one unattributed row, and the marker switches off by itself. OutsideMONERIUM_B2B_AUTO_RECOVERY=autothe alert points to the manual redeem from the refund wallet. ADR-0005 gets an amendment, and the partner docs state the EUR 1 minimum in the production section.RECOVERY_DELAYplus one hour with no recover sent (error pastTRIGGER_DELAY), for example when younger forwards keep re-timing the marker. RECOVER STUCK PENDING warns for a recover execution pending over an hour (error after four).refund.refundedAtis the deposit's refunded transition, so a refund closed by operations reports its close instead of null or the failure time.Note: the depth monitor now also runs on the sandbox. The internal B2B runbook (§2.7, §3) is updated separately for the top-up procedure and the new alert lines.
Compatibility
Additive:
below_minimumis a newDepositWaitingReasonvalue; the shared endpoint types, OpenAPI and wire-contract snapshot are updated, and the docs now say new reasons may be added. No existing value, route or payload shape changes.refundedAtkeeps its type; for automatic refunds it still marks the refund's completion, and refunds closed before this change shift by a few seconds in the read API (no webhook is re-sent).Docs
The architecture doc, security spec (including new audit checklist lines) and ADR-0005 cover the per-chain quoter, when the keeper arms the marker, the fee-apply step and its guard, the recover backoff, the pass-over and its ordering cost, the top-up refund procedure and the new alerts. API docs pages 07 and 14 and the OpenAPI descriptions are updated.
Tests
Each fix ships tests that fail without it, including: Sepolia quotes and a projection defer fires; failed attempts and backoff skips poke an unarmed marker; a due increase is applied once and not re-sent while a private apply is unmined; failing swaps do not delay an eligible recover;
below_minimumclears with a freshsince; the top-up amount stays off a whole cent across repeated top-ups; REFUND OVERDUE and RECOVER STUCK PENDING fire only when they should; a late payer fill does not re-date a refunded deposit. The full apps/api suite passes: 2368 pass, 54 skip, 0 fail (after the cleanup below removed three redundant tests). Typecheck, verify, wire-contract, the API docs check and the api build are clean.Cleanup
A vortex-cleanup pass over this PR's own additions removed 70 net lines with no behaviour change, each removal checked against the code and, for tests, by breaking the guarded behaviour and seeing a remaining test fail:
below_minimumclear now updates the loaded deposits directly instead of a bulk update plus an in-memory sync; the monitor's sub-floor branch drops a filter clause that could never match after its pending-recover skip and computes the unrecovered set once; the fee-apply docstring no longer repeats its constants' comments.automode); the partner page's sandbox minimum note now points to the production paragraph.Kept on purpose: the unattributed top-up row in the "top-up landed" monitor test (keeps the fixture faithful to the real state after a top-up), the second half of the sequenced-poke test (it pins the one-cycle poke bound), and the inline fixtures in the manager-events tests (a helper for two call sites would not pay off).