Skip to content

Fix four B2B keeper gaps found in the live sandbox tests - #1429

Open
ebma wants to merge 64 commits into
stagingfrom
fix/monerium-keeper-gaps
Open

ebma wants to merge 64 commits into
stagingfrom
fix/monerium-keeper-gaps

Conversation

@ebma

@ebma ebma commented Oct 9, 2026 •

Copy link
Copy Markdown
Member

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.

  1. Sepolia did not quote. The keeper quoted and projected swaps only on mainnet; QuoterV2 was pinned to chain id 1. Off mainnet it took the first enabled route unquoted, so none of the projection defers ran on the sandbox.
  2. No poke after a failed swap. When a cycle sent no swap (a failed attempt or the retry backoff), the batch marker stayed unarmed. The RECOVERY_DELAY clock started a cycle late, so refunds landed about twice as late as promised.
  3. Fee increases were never applied. applyFeePolicy() is permissionless and the contract expects the keeper to finalize a timelocked increase, but no keeper code called it.
  4. Sub-minimum deposits stalled the account. A payment below the minimum swap never converts. poke() refuses to arm below MIN_SWAP_FLOOR, so recover() 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

  • Quoting. chain.ts pins QuoterV2 per chain: mainnet 0x61fFE014bA17989E743c5F6cB21bF9697530B21e (unchanged) and Sepolia 0xEd1f6473345F45b75F8179591dd5bA1888cf2FB3, 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.
  • Poke. runConversionExecutor pokes an unarmed marker after a failed attempt and on a backoff skip, at most once per cycle, under the send lock.
  • Fee apply. When the clone's pending fee policy is due, the keeper sends 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.
  • Sub-minimum deposits. The keeper passes over a deposit below the minimum, so younger deposits still convert, and sets the new waiting reason below_minimum on it (cleared, with a fresh since, once a lowered minimum makes it swappable).
  • Refunds below the floor. Once such a payment is marked recovering on a clone below MIN_SWAP_FLOOR with no USDC, REFUND NEEDS OPERATOR prints the exact EURe top-up to send to the clone: MIN_SWAP_FLOOR minus 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 normal recover() and the refund pipeline refund the payer's own EURe after RECOVERY_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. Outside MONERIUM_B2B_AUTO_RECOVERY=auto the 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.
  • New alerts. REFUND OVERDUE warns for a deposit marked for recovery longer than RECOVERY_DELAY plus one hour with no recover sent (error past TRIGGER_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 backoff. A planned recover now backs off only on failed recovers, so failing swaps no longer delay an eligible refund by up to an hour.
  • Partner record. refund.refundedAt is the deposit's refunded transition, so a refund closed by operations reports its close instead of null or the failure time.
  • Keeper candidates. Accounts are picked from provider-attributed settling deposits plus accounts with a pending execution: an unattributed top-up row no longer makes its account a candidate forever, and a pending execution whose deposit left the settling statuses is still resolved.

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_minimum is a new DepositWaitingReason value; 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. refundedAt keeps 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_minimum clears with a fresh since; 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:

  • Code: the below_minimum clear 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.
  • Tests: one fee-apply test that two sharper tests already cover; a byte-identical helper shared at file scope; the two quoter tests folded into the parametrized depth-check cases, which now assert both quote sizes on both chains.
  • Docs: repetition trimmed in the ADR-0005 amendment, the architecture doc and the security spec (which also removes a contradiction about hand refunds outside auto mode); 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).

ebma added 21 commits October 9, 2026 19:20
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.
@netlify

netlify Bot commented Oct 9, 2026 •

Copy link
Copy Markdown

✅ Deploy Preview for vrtx-dashboard canceled.

Name Link
🔨 Latest commit fe900e2
🔍 Latest deploy log https://app.netlify.com/projects/vrtx-dashboard/deploys/6acd06dc2cd87e18773e8fd4

@netlify

netlify Bot commented Oct 9, 2026 •

Copy link
Copy Markdown

✅ Deploy Preview for vortex-sandbox ready!

Name Link
🔨 Latest commit fe900e2
🔍 Latest deploy log https://app.netlify.com/projects/vortex-sandbox/deploys/6acd06dc27beef984d5ea24d
😎 Deploy Preview https://deploy-preview-1429--vortex-sandbox.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
🤖 Make changes Run an agent on this branch

To edit notification comments on pull requests, go to your Netlify project configuration.

@netlify

netlify Bot commented Oct 9, 2026 •

Copy link
Copy Markdown

✅ Deploy Preview for vortexfi canceled.

Name Link
🔨 Latest commit fe900e2
🔍 Latest deploy log https://app.netlify.com/projects/vortexfi/deploys/6acd06dcbadc954a7a2d56fe

ebma added 2 commits October 10, 2026 10:54
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.
ebma added 30 commits October 10, 2026 11:19
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.
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