Skip to content

[simplex]: refuse vault batches on undelegated wallets, honour maxRedeem, bound bid permits, check Binance deposits - #1411

Merged
seunlanlege merged 5 commits into
mainfrom
fix/simplex-vault-permit-binance-guards
Oct 20, 2026
Merged

seunlanlege merged 5 commits into
mainfrom
fix/simplex-vault-permit-binance-guards

Conversation

@seunlanlege

@seunlanlege seunlanlege commented Oct 16, 2026 •

Copy link
Copy Markdown
Member

This PR makes four fixes from the 2026-10-15 simplex audit (c14, c53, c12, c63).

Vault batches on an undelegated wallet (src/funding/vault/VaultFundingPlanner.ts, src/core/boot.ts)

  • Why: the native fallback sends executeBatch to the solver EOA. With no 7702 code that tx succeeds and does nothing, so a sweep recorded movements that never happened and burned gas every interval.
  • Fix: submitBatch refuses the native send when isDelegated is false, with the error The wallet is not 7702-delegated on <chain> (or its delegation could not be read); a vault batch sent to it would do nothing.
    • The sweep rejects before onTx.
    • The sweep timer logs the error, and POST /api/vault/sweep returns it.
    • Sponsored ops are unaffected.

maxRedeem (VaultFundingPlanner.redeemChain, TokenSender.send, the ERC-4626 ABI)

  • Why: the shutdown redeem asked for the whole share balance, so one liquidity-capped vault reverted the entire per-chain batch.
  • Fix: it now redeems min(balanceOf, maxRedeem), skips a vault whose cap is zero, and logs what is left behind. An operator vault-share send above maxRedeem is refused before anything is submitted.

Bid permit deadline (src/services/paymaster/*, ContractInteractionService.prepareBidUserOp)

  • Why: every op signed a Permit2 permit valid for 1 h, and SimplexPaymaster reports the permit deadline as the op's validUntil. A bid that is valid for ~330 s and selected late therefore still passed validation, then reverted FillExpired at the solver's expense.
  • Fix: PaymasterOptions.permitDeadline is an optional absolute deadline in unix seconds, passed through buildPaymasterAndData into buildPermit2Mode.
    • A bid signs the destination head block's timestamp plus bidValiditySeconds + BID_DISCOVERY_PAD_SECONDS (30 s) + BID_PERMIT_SKEW_SECONDS (120 s). prepareBidUserOp reads the head once with getBlock and reuses its number for bidValidUntilBlock.
    • The deadline is anchored to the chain because the EntryPoint judges it on the chain's clock; the host clock plays no part. The skew covers the head's age at signing, inclusion latency and the buffer a bundler applies to an op whose validUntil is close to now.
    • Any op that passes no permitDeadline (sends, sweeps, delegation) signs the host clock plus one hour, as before. The EIP-2612 bootstrap mode is unchanged (its deadline is maxUint256).
  • What it guarantees: an op more than about 120 s past the bid window is rejected at validation. The bid's on-chain validUntil is block-based, so an op that lands after it but before the permit deadline still reverts FillExpired at the solver's cost; closing that needs SolverAccount to return a block-number validUntil, which is a contract change. When blocks are slow, whichever limit comes first applies.

Binance deposit preflight (src/services/rebalancers/binance.ts)

  • Fix: before a CEX deposit, two checks run, both before any on-chain transfer:
    • Binance's contractAddress for the coin and network must match the token the transfer sends (case-insensitive, 0x optional);
    • a deposit address that carries a tag or memo is refused.
  • Fails closed: if Binance lists no contract, the deposit is refused.
  • Not fixed here: withdrawal preconditions.

Validation

  • Touched suites (funding/vault, rebalancers/binance.preflight, services/SimplexPaymaster, services/bid-budget, token-sender): 5 files, 111 passed.
  • The permit deadline cases:
    • SimplexPaymaster.test.ts: the signed deadline is the absolute value passed in, and the host clock plus one hour when none is passed;
    • bid-budget.test.ts: a bid's deadline is the head timestamp plus the window, even with the host 400 s behind the chain.
  • pnpm --filter="@hyperbridge/simplex": lint exit 0 (warnings only); test:data 146 passed; test:unit 261 passed; test:filler 70 passed, 5 skipped.
  • npx tsc --noEmit -p .: 14 errors, the ones already on main; none in files this PR changes.
  • The c12 premise was checked in evm/src/utils/SimplexPaymaster.sol: mode 0x02 packs the permit deadline as validUntil.

Not covered: no mainnet run.

@Wizdave97 Wizdave97 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: approve with minor comments.

All four fixes match their descriptions:

  • Mode 0x02 packs the permit deadline as validUntil with validAfter = 0, and SolverAccount.validateUserOp returns no time range of its own.
  • Each bid signs a fresh permit, so no permit is reused.
  • Sends, sweeps and delegation keep the 1 h deadline, and the EIP-2612 bootstrap path is untouched.
  • isDelegated isn't cached.
  • An undelegated operator can still exit through a single-call vault-share send, i.e. a redeem sent from the EOA.
  • A vault-share "send" is a redeem, so maxRedeem is the right bound.
  • Binance's contract addresses match the SDK config for ETH, BSC, MATIC, ARBITRUM and BASE USDC.
  1. The permit deadline uses the host's wall clock, not chain time (paymaster/provider/simplex.ts:320; the window is set at ContractInteractionService.ts:876-878).

    • Suppose simplex runs on an operator desktop whose clock is D seconds behind the chain and the bundler. Each bid is then admissible for about 450 − D − bundler buffer seconds.
    • At around 6–7 minutes of skew (with the default 300 s validity), every sponsored bid fails validation, while the solver still pays Hyperbridge's bid deposit and fee. Before this PR, up to about 1 h of skew was tolerated.
    • Suggestion: anchor the deadline to the destination head block's timestamp. bidValidUntilBlock already reads the head, so it could use getBlock and pass an absolute deadline. At minimum, warn at boot when the host clock and the chain head differ by more than about 30 s.
  2. The fix is narrower than "rejected at validation, at no cost" (ContractInteractionService.ts:871-875, the PR body and the changelog).

    • The permit expires about 120 s after the block window ends, and later still when blocks run faster than measured.
    • An op included in that gap still passes validation, reverts with FillExpired, and the solver pays the gas. The realistic remaining cases are the window closing between simulation and inclusion, and a placer submitting late on purpose.
    • So this shrinks the griefing window from about 1 h to about 2 min; it doesn't close it. Please reword it as "ops more than ~120 s past the bid window are rejected at validation".
    • A complete fix would have SolverAccount return the fill's validUntil as a block number, since EntryPoint v0.9 accepts block-number validity ranges. That's a contract change for a follow-up.
  3. The capped-redeem log promises a later pass that never runs. VaultFundingPlanner.ts:639 logs "the rest is left for a later pass", but redeemAll only runs at shutdown or on POST /api/vault/redeem. Say instead that the operator has to redeem the rest.

Notes:

  • Unsponsored bids (#1418's assertUnsponsoredBidFunded path) carry no Permit2 deadline, so this bound covers sponsored bids only.
  • #1412 adds the same isDelegated refusal for native multi-call TokenSender batches. Together the two PRs cover both native executeBatch paths; a shared helper would keep them consistent.
  • #1422's RETRACTED_BID_MARGIN_SECONDS = 60 should reuse BID_PERMIT_SKEW_SECONDS.
  • The conflicts with #1423 in bid-budget.test.ts are mechanical.
  • The Files: / Tests: list at the end of the changelog note repeats the diff and can go.

seunlanlege added a commit that referenced this pull request Oct 19, 2026
- Anchor a bid's Permit2 deadline to the destination head's timestamp, not
  the host clock. `permitDeadlineSeconds` becomes an absolute
  `permitDeadline`; `prepareBidUserOp` reads the head once with `getBlock`
  and reuses its number for `bidValidUntilBlock`. Other ops keep the host
  clock plus one hour. Test: a host 400s behind the chain still yields
  head + window.
- Reword the guarantee: ops well past the bid window are rejected at
  validation; one landing in the tail still reverts `FillExpired` at the
  solver's cost. A full fix needs a block-number `validUntil` from
  `SolverAccount` (noted as a contract follow-up).
- The capped-redeem warning says the operator has to redeem the rest:
  `redeemAll` runs only at shutdown or on POST /api/vault/redeem.
- Drop the Files/Tests lists from the changelog note and revise it.
seunlanlege added a commit that referenced this pull request Oct 19, 2026
Fold the capped-redeem warning into the maxRedeem section so the note describes the final behaviour once.
seunlanlege added a commit that referenced this pull request Oct 19, 2026
- Anchor a bid's Permit2 deadline to the destination head's timestamp, not
  the host clock. `permitDeadlineSeconds` becomes an absolute
  `permitDeadline`; `prepareBidUserOp` reads the head once with `getBlock`
  and reuses its number for `bidValidUntilBlock`. Other ops keep the host
  clock plus one hour. Test: a host 400s behind the chain still yields
  head + window.
- Reword the guarantee: ops well past the bid window are rejected at
  validation; one landing in the tail still reverts `FillExpired` at the
  solver's cost. A full fix needs a block-number `validUntil` from
  `SolverAccount` (noted as a contract follow-up).
- The capped-redeem warning says the operator has to redeem the rest:
  `redeemAll` runs only at shutdown or on POST /api/vault/redeem.
- Drop the Files/Tests lists from the changelog note and revise it.
seunlanlege added a commit that referenced this pull request Oct 19, 2026
Fold the capped-redeem warning into the maxRedeem section so the note describes the final behaviour once.
@seunlanlege
seunlanlege force-pushed the fix/simplex-vault-permit-binance-guards branch from f5e099b to 86927f6 Compare October 19, 2026 15:08

@Wizdave97 Wizdave97 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved. The permit deadline now comes from the destination chain's head block timestamp, using a block read that already happens, so there's no extra RPC call. The capped-redeem log is fixed.

Non-blocking follow-ups:

  • Flow doc. paymaster-selection-for-a-sponsored-userop.md:12 still describes deadline = now + permitDeadlineSeconds, an option that no longer exists.
  • PR description. It still says a late bid is rejected "at no cost" and still names permitDeadlineSeconds.
  • Wrong revert name. The comment at ContractInteractionService.ts:878 says a late bid "reverted Expired". It reverts FillExpired.
  • Changelog. Lines 43-45 describe this PR's first version (host clock plus a window) rather than main. State the rule directly instead.

seunlanlege added a commit that referenced this pull request Oct 20, 2026
…eem, bound bid permits, check Binance deposits
- Anchor a bid's Permit2 deadline to the destination head's timestamp, not
  the host clock. `permitDeadlineSeconds` becomes an absolute
  `permitDeadline`; `prepareBidUserOp` reads the head once with `getBlock`
  and reuses its number for `bidValidUntilBlock`. Other ops keep the host
  clock plus one hour. Test: a host 400s behind the chain still yields
  head + window.
- Reword the guarantee: ops well past the bid window are rejected at
  validation; one landing in the tail still reverts `FillExpired` at the
  solver's cost. A full fix needs a block-number `validUntil` from
  `SolverAccount` (noted as a contract follow-up).
- The capped-redeem warning says the operator has to redeem the rest:
  `redeemAll` runs only at shutdown or on POST /api/vault/redeem.
- Drop the Files/Tests lists from the changelog note and revise it.
Fold the capped-redeem warning into the maxRedeem section so the note describes the final behaviour once.
@seunlanlege
seunlanlege force-pushed the fix/simplex-vault-permit-binance-guards branch from 18ac538 to 9a72d5c Compare October 20, 2026 12:12
@seunlanlege
seunlanlege merged commit 1c540c3 into main Oct 20, 2026
1 of 2 checks passed
@seunlanlege
seunlanlege deleted the fix/simplex-vault-permit-binance-guards branch October 20, 2026 12:23
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.

2 participants