Skip to content

Refuse partial fills in the B2B forwarder and keeper - #1438

Merged
ebma merged 6 commits into
stagingfrom
fix/monerium-partial-fill-guard
Oct 21, 2026
Merged

ebma merged 6 commits into
stagingfrom
fix/monerium-partial-fill-guard

Conversation

@ebma

@ebma ebma commented Oct 20, 2026 •

Copy link
Copy Markdown
Member

Why

The live thin-liquidity test on the sandbox turned up a gap between the keeper's quote and the forwarder's settlement. When a Uniswap pool runs out of liquidity along the path, QuoterV2 and SwapRouter02 both stop early: the swap takes only part of amountIn. QuoterV2 still returns a number, and VortexForwarder._swap only checked for overspend (consumed > amountIn).

_settle and the keeper's projection value the fill against the whole amountIn, so a partial fill looks like a bad price. When the missing share is within the keeper's current subsidy tier (at most about 1%), the keeper sends the swap, the vault pays the "shortfall" for EURe that never left the clone, and that EURe stays on the clone outside the deposit's accounting (the execution books the full chunk). A larger missing share defers, as it did in the test. The permissionless path pays no subsidy, so its floor check already rejects partial fills.

Sepolia evidence (pool at 0.3% of its liquidity, QuoterV2 0xEd1f…2FB3): 1 and 3 EURe filled normally; 5 EURe returned 3.555 USDC with the price after the hop at MAX_SQRT_RATIO - 1, the swap limit, after about 3.2 EURe and about 17.4M gas. The keeper quoted exactly that partial fill (3,655 bps shortfall) and deferred.

What changes

  • Contract (VortexForwarder._swap): the route must take exactly amountIn; anything else reverts InputNotConsumed (replaces the Overspend check, which only covered one direction).
  • Keeper (chain.ts quoteRouteOutput): a quote whose hop ends at the swap-limit price (isPartialFill) throws, so the route counts as unquotable and the chunk waits as no_route. This also protects clones already deployed from the old implementation, such as the sandbox's.
  • Depth monitor: it quotes through the same function. A size a route cannot take whole counts as unbounded impact at that size, so a route that cannot fill a perSwapCap-sized swap gives the existing cap warning, and one that cannot fill minSwapAmount gives DEPTH BELOW FLOOR (error on mainnet, warning on the sandbox), with partial fill in the detail line instead of an impact of thousands of bps. Any other quote failure still skips the route, and the check aborts when no route quotes.

Deployment

Already-deployed clones keep their implementation; the keeper guard covers them. The contract check applies to factories deployed from this source onward.

Docs

Architecture doc ("Floor on the net" and "Routes") and security spec invariant 7 describe the exact-input rule and the keeper guard; security spec Monitoring item 2 describes how the depth check classifies a partial fill.

Tests

  • test_swap_partialFill_revertsInsteadOfSubsidizing: a keeper swap where the router takes 990 of 1,000 EURe with a fill the vault would otherwise top up reverts, and the vault and clone balances are unchanged. Fails on the old check (the swap succeeds and the vault pays).
  • chain.test.ts: isPartialFill flags a hop at either swap limit (including the Sepolia value above) and passes normal fills.
  • Forge: 89 unit and invariant tests pass; the 4 mainnet fork tests pass against real pools (real full fills take exactly amountIn).
  • monitoring.test.ts: a perSwapCap-sized partial fill warns on mainnet and on the sandbox with no error line; a minSwapAmount-sized one logs DEPTH BELOW FLOOR. Both fail against the monitor before this fix.
  • API: the full suite passes after merging staging (2395); the monerium-b2b suite passes with the monitor fix (353). Lint, verify, api typecheck and wire-contract are clean.

ebma added 3 commits October 20, 2026 10:53
_swap only checked that the router took no more than amountIn. A pool
that runs out of liquidity takes less, and _settle valued that partial
fill as a fill of the whole chunk, so a keeper swap could draw a vault
subsidy for EURe that never left the clone and leave that EURe outside
the deposit's accounting. The Sepolia sandbox pool at 0.3% liquidity
showed it: a 5 EURe quote stopped at the swap limit after about 3 EURe.
QuoterV2 prices a partial fill without complaint: a hop that runs out
of liquidity ends at the swap-limit price having taken only part of its
input. The keeper took that output as a price and would send the swap
once the shortfall fit its subsidy tier. Clones deployed before the
contract check settle such a fill, so the keeper refuses the quote
itself; the route is skipped and the chunk waits as no_route.
@netlify

netlify Bot commented Oct 20, 2026 •

Copy link
Copy Markdown

✅ Deploy Preview for vortexfi canceled.

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

@netlify

netlify Bot commented Oct 20, 2026 •

Copy link
Copy Markdown

✅ Deploy Preview for vrtx-dashboard canceled.

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

@netlify

netlify Bot commented Oct 20, 2026 •

Copy link
Copy Markdown

✅ Deploy Preview for vortex-sandbox ready!

Name Link
🔨 Latest commit d11ff1d
🔍 Latest deploy log https://app.netlify.com/projects/vortex-sandbox/deploys/6ad8a888001c7f838ae12d65
😎 Deploy Preview https://deploy-preview-1438--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.

ebma added 3 commits October 21, 2026 13:18
Since quoteRouteOutput throws on a partial fill, the depth check dropped a route's healthy minSwapAmount quote whenever its perSwapCap quote ran out of liquidity, and a pool thin at cap on every route logged an error-level abort with no mainnet/sandbox split. A size the route cannot take whole now counts as unbounded impact, so it reaches the existing cap warning or DEPTH BELOW FLOOR verdict.
@ebma
ebma merged commit ccedf6b into staging Oct 21, 2026
6 checks passed
@ebma
ebma deleted the fix/monerium-partial-fill-guard branch October 21, 2026 12:00
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