Skip to content

feat(core): DelayedFlowRouter — amount-sensitive rate-limit + timelock ISM#8671

Draft
yorhodes wants to merge 4 commits intomainfrom
feat/delayed-flow-router
Draft

feat(core): DelayedFlowRouter — amount-sensitive rate-limit + timelock ISM#8671
yorhodes wants to merge 4 commits intomainfrom
feat/delayed-flow-router

Conversation

@yorhodes
Copy link
Copy Markdown
Member

Summary

Adds DelayedFlowRouter, a hook + ISM that pairs with a warp route on both chains and slows cross-chain withdrawals proportionally when net flow exceeds a configurable fraction of the paired pool — without rejecting them. Designed to bound the blast radius of a bridge / ISM compromise (e.g. LayerZero rsETH) while leaving normal small-amount flow instant.

  • Capacity is live: maxCapacity() = pool × thresholdBps / BPS reads the paired warp router's balance (native) / balanceOf (collateral) / totalSupply (synthetic) at call time. No snapshot.
  • Refill derives from capacity: subclasses override only maxCapacity(); the refill rate follows automatically.
  • Delay committed at preverify: destination _handle consumes the bucket and writes an immutable readyAt[id]. verify is a pure read — no re-evaluation at verify time (would only ever lengthen delays and complicate the state machine).
  • Deposits credit 1:1: balanced two-way flow (rebalancing, fee traffic) preserves net-zero bucket change and instant UX.
  • Pair with PausableIsm via StaticAggregationIsm (pausable first) so watchers can kill delivery during the delay window with a clean Pausable: paused revert.

Lifecycle

sequenceDiagram
  autonumber
  actor U as User
  participant SR as Synthetic Router<br/>(origin)
  participant OD as origin<br/>DelayedFlowRouter
  participant OM as Origin Mailbox
  participant DM as Dest Mailbox
  participant DD as destination<br/>DelayedFlowRouter
  participant CR as Collateral Router<br/>(destination)

  Note over U,CR: maxCapacity() = pool × thresholdBps / BPS (read live)

  U->>SR: transferRemote(dest, to, amount)
  SR->>SR: burn synthetic (totalSupply ↓)
  SR->>OM: dispatch(warp)
  OM-->>OD: postDispatch(warp)
  Note over OD: sender == warpRouter<br/>nonce >= nextDispatchNonce<br/>_credit(amount)
  OD->>OM: dispatch(preverify = id, amount)

  Note over OM,DM: cross-chain delivery — preverify & warp<br/>arrive independently

  DM-->>DD: handle(preverify)
  Note over DD: maxCapacity() read NOW<br/>(pre-withdrawal pool)<br/>_consume → deficit<br/>wait = min(deficit, maxDelay)
  Note over DD: commit readyAt[id]

  DM->>CR: process(warp)
  CR->>DD: verify(warp)
  alt block.timestamp < readyAt[id]
    DD-->>CR: revert MessageNotReadyUntil
  else ready
    DD-->>CR: true
    CR->>U: release(amount)
  end
Loading

See docs/delayed-flow-router.md for the full design.

Refactors

  • TimelockRouter: postDispatch / _handle are now overridable end-to-end, with leaf helpers _TimelockRouter_dispatchPreverify(id, destination, payload) and _TimelockRouter_commitReadyAt(id, wait) exposed for subclasses. No more virtual-callback weaving (_encodePreverify, _wait removed).
  • RateLimited: maxCapacity() made virtual; refill rate derived from it automatically; _credit and _consume primitives added; token-bucket math switched to Math.mulDiv for precision; single-maxCapacity()-read-per-path factoring via a private _levelAt(cap) helper.

Security notes

  • Sender binding: postDispatch requires message.sender == warpRouter so a third party can't grief our bucket by dispatching arbitrary messages through the Mailbox.
  • Recipient binding: verify requires message.recipient == warpRouter so we don't attest to messages not destined for our paired route.
  • Replay: nextDispatchNonce (uint32, monotonic) + _isLatestDispatched prevent same-message double-credit / re-preverify.
  • CEI: all paths check-then-effect-then-interact; external reads in maxCapacity() are STATICCALL-only (IERC20.balanceOf/totalSupply are view) and can't reenter.

Test plan

  • forge test --match-contract DelayedFlowRouter (13 tests): capacity source derivation (HypNative / HypERC20 / HypERC20Collateral), under-threshold passes immediately, over-threshold scales proportionally, oversize clips at maxDelay, deposit credits bucket, preverify replay rejected, sender/recipient binding, PausableIsm composition.
  • forge test --match-contract TimelockRouter|RateLimited (26 tests): existing suites still pass after refactor.
  • Gas snapshot (pnpm -C solidity gas).
  • End-to-end on a testnet warp route with threshold 10%, maxDelay 1 day.

Follow-ups (out of scope for v1)

  • DelayedFlowRouterFactory that deploys both legs, cross-enrolls, and wraps in StaticAggregationIsm([Pausable, Delayed]).
  • SDK/CLI defaults (thresholdBps, maxDelay).
  • HypXERC20 variants (different capacity notion).
  • Owner-settable parameters behind a timelock.

Add `DelayedFlowRouter`, a hook + ISM that pairs with a warp route and
slows cross-chain withdrawals proportionally when net flow exceeds a
configurable fraction of the paired pool. Capacity is read live from
`warpRouter.balance` / `balanceOf` / `totalSupply`; deposits credit the
bucket 1:1 so balanced two-way flow preserves instant UX for rebalancers.
Compose with `PausableIsm` via `StaticAggregationIsm` (pausable first) to
let watchers kill delivery during the delay window.

Refactor `TimelockRouter` for extension: `postDispatch` and `_handle` are
now overridable end-to-end via leaf helpers `_TimelockRouter_dispatchPreverify`
and `_TimelockRouter_commitReadyAt`. Make `RateLimited.maxCapacity()`
virtual so subclasses may back it dynamically (with refill rate derived
automatically), and add `_credit` / `_consume` primitives. Token-bucket
math uses `Math.mulDiv` for precision.
Rename `nextDispatchNonce` → `lastCreditedNonce` with strict `>` check —
semantics are now "the highest Mailbox nonce we've credited." Add fuzz
tests for wait invariants (zero iff amount ≤ level, clamped at maxDelay)
and round-trip net-flow (deposit after withdrawal returns the bucket to
its prior level, modulo cap clamping). Add a regression test for the
layered-defense behavior: a message with a fresh nonce but no matching
Mailbox dispatch is rejected by `_isLatestDispatched`.
…ario

Add `testFuzz_sequence_invariants` exercising 8 alternating
withdrawal/deposit ops with bucket bounds and per-message wait clamp
checks. Add `test_rebalancing_restoresInstantUX` showing that a
rebalancer-deposit after a drain restores instant UX for the next
same-sized withdrawal. Add `test_postDispatch_revertsIfNotLatestDispatched`
to cover the layered-defense path where a fresh nonce doesn't bypass
`_isLatestDispatched`. Extract `_simulateWithdrawal` / `_deposit`
helpers and reuse them across tests.
`calculateCurrentLevel` no longer reverts on zero capacity — it returns
0 so dynamic-capacity subclasses can use it as a pass-through.
`testRateLimited_revertsIfMaxNotSet` and
`testCalculateCurrentLevel_revertsWhenCapacityIsZero` are renamed to
`returnsZero…` and assert the new behavior.
@codecov
Copy link
Copy Markdown

codecov Bot commented Apr 28, 2026

Codecov Report

❌ Patch coverage is 90.80460% with 8 lines in your changes missing coverage. Please review.
✅ Project coverage is 79.56%. Comparing base (60dbe23) to head (d0ef511).
⚠️ Report is 7 commits behind head on main.

Additional details and impacted files
@@            Coverage Diff             @@
##             main    #8671      +/-   ##
==========================================
+ Coverage   79.33%   79.56%   +0.22%     
==========================================
  Files         143      144       +1     
  Lines        4278     4330      +52     
  Branches      436      444       +8     
==========================================
+ Hits         3394     3445      +51     
+ Misses        855      854       -1     
- Partials       29       31       +2     
Flag Coverage Δ
solidity 80.83% <90.80%> (+0.25%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

Components Coverage Δ
core 87.80% <ø> (ø)
hooks 78.11% <ø> (ø)
isms 81.64% <87.75%> (+0.18%) ⬆️
token 88.00% <ø> (ø)
middlewares 87.76% <ø> (ø)
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: In Review

Development

Successfully merging this pull request may close these issues.

1 participant