Skip to content

feat: add atomic local rebalancing bridge#8894

Open
xeno097 wants to merge 54 commits into
audit-q3-2026from
xeno+yorhodes/atomic-local-rebalancing-bridge
Open

feat: add atomic local rebalancing bridge#8894
xeno097 wants to merge 54 commits into
audit-q3-2026from
xeno+yorhodes/atomic-local-rebalancing-bridge

Conversation

@xeno097

@xeno097 xeno097 commented Jun 12, 2026

Copy link
Copy Markdown
Contributor

Replaces #8806

This PR supersedes #8806, which was opened from a fork I don't own (so I can't push the follow-up changes there). It targets the same base branch and contains the same feature, with the #8806 review feedback resolved, the #8863 multi-recipient design folded in, and additional security hardening applied from review (see "Review feedback & hardening" below).

Summary

  • add AtomicLocalRebalancingBridge for same-chain local rebalances, exposing a single rebalance(...) entrypoint defined by the new IRebalancingBridge interface
  • bind the source router as an immutable constructor argument (one bridge per source route) so the caller cannot supply an arbitrary/forged router; the sourceRouter call argument is a checked echo of that immutable
  • the destination is a per-call bytes32 argument, validated against the source's rebalance targets via IRebalanceTargets.isRebalanceTarget(localDomain, destinationRecipient) — the bridge funds the validated argument, not the source's callback recipient
  • reuse MovableCollateralRouter allowed rebalancers for caller authorization
  • execute arbitrary CallLib calls (ABI-decoded from the trailing bytes) after the same-chain rebalance callback; calls must produce the required output-token balance on the wrapper
  • after the calls, enforce the source-drain and output-produced invariants, then transfer the required output amount directly from the wrapper to the destination router
  • fund the destination only from output the calls produce, then sweep all remaining token and native balances to the rebalancer so the bridge retains nothing between calls
  • CrossCollateralRouter implements IRebalanceTargets, supporting multiple local rebalance recipients per domain beyond the enrolled remote router
  • remove standing bridge token approvals from movable collateral routes

Entrypoint

function rebalance(
    uint32 domain,
    uint256 amount,
    ITokenBridge sourceRouter,        // checked echo of the immutable allowedSourceRouter
    bytes32 destinationRecipient,     // EVM-address-encoded; validated via isRebalanceTarget
    bytes calldata data               // ABI-encoded CallLib.Call[]
) external payable;

The signature mirrors the canonical MovableCollateralRouter.rebalance(uint32,uint256,ITokenBridge) and carries the destination + calls as additional arguments. data is opaque to the interface so future flavors can decode it differently.

Rebalance targets (IRebalanceTargets)

  • CrossCollateralRouter implements isRebalanceTarget(uint32 domain, bytes32 target).
  • The enrolled remote router for a domain is always a valid target; additional targets are opt-in via addRebalanceTarget / removeRebalanceTarget (owner-only, events emitted), surfaced by rebalanceTargets(domain).
  • Cross-collateral routers (enrolled via enrollCrossCollateralRouters) are not automatically valid targets and must be added explicitly.
  • The zero address is never a valid target, even on a domain with no enrolled router.
  • A bridge source must implement IRebalanceTargets; the constructor verifies the source is a contract.

Standing approval model

  • addBridge now only allowlists the bridge; it no longer creates max token allowance.
  • HypERC20Collateral no longer approves bridges during bridge enrollment.
  • MovableCollateralRouter.rebalance grants only the exact collateral-token allowance quoted by the selected bridge, and revokes any unconsumed allowance after transferRemote.
  • approveTokenForBridge(address,address) is retained for upgrade/governance compatibility but now revokes legacy approval instead of setting max approval (the selector is overloaded by impl version).
  • SDK behavior is version-gated:
    • On legacy routers (contract version <= MAX_LEGACY_BRIDGE_APPROVAL_VERSION), allowedRebalancingBridges[].approvedTokens still emits approveTokenForBridge grants, since those impls grant max. On newer routers the field is ignored (allowances are granted per rebalance).
    • During in-place upgrade from a legacy version, the SDK revokes stale standing allowances for every bridge that remains allowlisted — matched by bridge address across all domains (so a bridge moved between domains is still cleaned up), covering both the collateral token and any configured approvedTokens. A bridge being removed in the same run has its collateral cleared on-chain by _removeBridge, but its extra approvedTokens are not discoverable from config and must be revoked while the bridge is still configured.
    • The SDK exports MAX_LEGACY_BRIDGE_APPROVAL_VERSION and bridgeApprovalGrantsMaxAllowance(version); the governance transaction reader uses them to read the target router's on-chain version and describe approveTokenForBridge as a grant or a revoke accordingly (instead of always labelling it a clear).

Atomic bridge call model

Rebalancer calls perform approvals/swaps/top-ups and must leave at least the required output-token balance on AtomicLocalRebalancingBridge, produced by the calls (balances already on the wrapper cannot satisfy the requirement). The wrapper pays the destination router directly, so a call bundle cannot satisfy the local balance requirement by invoking the destination router's outbound bridge path with an attacker-controlled remote recipient. The whole flow (including the calls window) is nonReentrant; a single transient callback flag authorizes exactly one transferRemote escrow and is consumed before the token pull (CEI), so the post-invariant native refund cannot re-enter to escrow again.

sequenceDiagram
    participant R as Rebalancer
    participant B as AtomicLocalRebalancingBridge
    participant S as Source router
    participant C as Call targets and DEX
    participant D as Destination router

    R->>B: rebalance(localDomain, amountIn, sourceRouter, destinationRecipient, data)
    B->>B: require sourceRouter equals allowedSourceRouter, caller is an allowed rebalancer
    B->>S: isRebalanceTarget(localDomain, destinationRecipient), else InvalidRecipient
    B->>B: set callback-active flag, snapshot balances
    B->>S: rebalance(localDomain, amountIn, bridge)
    S->>B: transferRemote(localDomain, recipient, amountIn)
    B->>B: consume callback flag, reject a second callback
    B->>S: transferFrom(sourceRouter, bridge, amountIn)
    B->>B: assert callback consumed, else MissingCallback
    B->>C: multicall(decoded calls) produces output token on the bridge
    B->>B: enforce source-drain and output-produced invariants
    B->>D: transfer(destinationRouter, requiredOutputAmount)
    B->>R: sweep all remaining token and native balances
    S->>S: revoke any unconsumed allowance
Loading

Review feedback & hardening

Addresses the #8806/#8894 review feedback (CodeRabbit, Nam, security review):

  • Caller-supplied source router could pull from any authorized route (Critical) — the source is an immutable constructor arg; the sourceRouter argument is a checked echo, so the arbitrary calls cannot puppet privileges on another route.
  • ReentrancyReentrancyGuardTransient (nonReentrant) plus a single transient callback flag consumed before escrow (CEI); the legitimate transferRemote callback is authorized once, a second callback reverts, and a missing callback reverts MissingCallback.
  • transferRemote inside calls inflating the destination — the destination is funded only by output the calls produce; routing output through the destination drains the wrapper and reverts InsufficientOutputTokenProduced.
  • Leftover-balance handling — funding is measured against pre-escrow snapshots, so pre-existing balances can never count toward funding the destination. After the invariants pass, all remaining token and native balances are swept to the rebalancer, leaving nothing on the bridge between calls.
  • Native handling — unspent msg.value is refunded via Address.sendValue after the callback flag is consumed, so it cannot re-enter to escrow.
  • Leftover bridge allowancerebalance revokes any unconsumed allowance.
  • Destination is authoritative & validated — the bridge ignores the source's callback recipient and funds the per-call destinationRecipient, validated via isRebalanceTarget; the zero address is rejected.
  • Par invariant — output is converted from input by decimals only (requiredOutputAmount); a non-par destination reverts or demands absurd top-ups. Par is a configuration-only invariant.

Decisions

  • approveTokenForBridge naming — kept (the selector is relied on by upgrade/governance tooling). Rather than rename, the human-review hazard is mitigated: the governance transaction reader now reads the target's version and labels the call a grant or a revoke correctly.
  • isRebalanceTarget(domain, bytes32(0)) — rejected at the read; addRebalanceTarget is intentionally left unguarded (the read is the authoritative oracle, the bridge fails closed regardless).

EIP-170

The Solidity optimizer runs was lowered from 10,000 to 9,990 (Foundry + Hardhat). At 10,000 runs the optimizer crossed a discrete cost-model threshold that inflated CrossCollateralRouter to 24,607 bytes (31 over the limit); output is byte-identical across 9,900–9,990, bringing CCR to 24,457 bytes with a negligible runtime gas trade-off.

Upgrade note

Not an old-major-incompatible upgrade. Existing routes can upgrade in place because the approveTokenForBridge(address,address) selector remains available; calling it after upgrade clears legacy standing allowances (collateral token and any approvedTokens). The atomic bridge assumes route approvals are not left available outside the active rebalance call.

Changesets

  • @hyperlane-xyz/core: major — bridge approval behavior changes and standing allowances are removed; IRebalanceTargets/IRebalancingBridge added; optimizer runs lowered.
  • @hyperlane-xyz/sdk: minor — legacy approvedTokens grants are version-gated, stale-allowance revoke is broadened, and version helpers are exported.

Tests

  • forge test --match-contract AtomicLocalRebalancingBridge
  • forge test --match-contract CrossCollateralRouter
  • forge test --match-path test/token/MovableCollateralRouter.t.sol
  • forge test --match-path test/token/HypERC20MovableCollateral.t.sol
  • forge test --match-test testFuzz_localRebalance_doesNotDecreaseRouterTokenSum --fuzz-runs 1000
  • fork tests (pinned blocks, archive RPC): test/token/AtomicLocalRebalancingBridge.fork.t.sol
  • SDK: pnpm -C typescript/sdk test:hardhat (rebalancing-bridge grant/revoke gating, version spoofing)
  • CLI_E2E_TEST=warp-deploy-2 pnpm -C typescript/cli test:ethereum:e2e

Nam's Office Computer and others added 30 commits April 16, 2026 18:23
The source router was a caller-supplied argument to localRebalance, gated
only by that router's own isAllowedRebalancer. An unauthorized caller could
pass a forged router that allowlisted them, reach the arbitrary-calls window
in the bridge's context, and puppet the bridge's standing rebalancer
privilege on co-enrolled routes.

Bind the source router as an immutable constructor argument and drop it from
localRebalance, so auth binds to the real route and one bridge serves one
source. Tests construct their own source-bound bridge where needed.
The reentrancy check reused _CALLBACK_RECIPIENT_SLOT, which is cleared before
the arbitrary calls run, leaving the calls window unguarded against a nested
localRebalance. Add a dedicated _REENTRANCY_GUARD_SLOT set at entry and cleared
at the end of localRebalance, so the guard spans the whole body, and leave the
recipient slot to do only the source-to-destination handoff.
- Use forceApprove for the rebalancer's output-token allowance so the mainnet
  USDT fork test no longer reverts on USDT's non-bool-returning approve.
- Re-pin the Base fork block to one where the USDC/USDT Aerodrome pool quotes
  ~1:1, so the swap plus top-up funds the destination (the old block had an
  imbalanced pool, correctly tripping InsufficientOutput).
- Default RPC_URL_MAINNET to an archive-capable endpoint so the mainnet fork
  tests can instantiate the historical fork from .env.default.
localRebalance checked output funding and refunded using absolute balances, so
a token donation sitting on the wrapper could satisfy the destination while the
escrowed input was refunded to the rebalancer, letting an authorized rebalancer
extract source collateral funded by a third-party donation.

Snapshot the wrapper balances before the calls and require the calls to produce
at least requiredDelta of new output; refund only the per-call delta so
donations are never counted toward funding nor swept. For a shared input/output
token the entry snapshot is reused so the escrow legitimately counts as funding.

Covered by donation-funding revert, shared-token escrow funding with donation
preserved, and surplus-refund-with-donation-kept tests.
localRebalance is payable so calls can spend native (e.g. DEX fees), but any
unspent msg.value was stranded and could be spent by a later caller via the
native-balance sentinel. Refund this call's unspent native to the rebalancer,
excluding pre-existing/force-fed native, reverting if the send fails. The
refund runs while the reentrancy guard is still set so the recipient hook
cannot re-enter.
rebalance granted an exact per-call allowance but never revoked it, so a bridge
that consumed less than its quoted collateral left a residual standing allowance
- the opposite of the per-call approval goal. Revoke to zero after transferRemote
returns. All in-repo bridges consume the full quoted collateral synchronously,
so this is defense-in-depth and also clears any legacy max allowance carried by
an upgraded router on its first rebalance.
quoteTransferRemote returned two entries for the input token, violating the
ITokenFee no-duplicate-token expectation. The router extracts by summing per
token so behavior is unchanged; return a single collateral entry plus the
zero native entry.
The test asserted a standing max allowance from the removed _addBridge override.
Expect no standing allowance before rebalance and assert none remains after,
matching the exact per-call approval plus post-transfer revoke.
The deploy no longer emits bridge approval txs, so the per-bridge allowance is
zero. Update the e2e assertions to expect 0 and drop the now-unused MAX_UINT256
constant; the approvedTokens config entries are kept to exercise that they are
ignored. Verified via CLI_E2E_TEST=warp-deploy-2 (14 passing).
Rename the changeset to match the contract name and describe the final
behavior: the bridge binds its source router at construction, guards the whole
local-rebalance flow against reentrancy, funds the destination only from output
produced by the calls, refunds unspent input/output/native, and rebalance
revokes any unconsumed allowance. Rephrased in present-tense declarative.
The native refund excluded pre-existing native, but calls could still spend it
(e.g. via the native-balance sentinel) without reverting. Require the wrapper's
balance after the calls to be at least its pre-existing native, so calls can
spend at most this call's msg.value and donated native stays untouched.
Replace the hand-rolled transient reentrancy slot with the repo's
ReentrancyGuardTransient nonReentrant modifier (already used by QuotedCalls).
transferRemote stays unguarded so the legitimate rebalance callback still
re-enters; only nested localRebalance is blocked, now via
ReentrancyGuardReentrantCall.
Use the repo-wide Address.sendValue convention (as in TokenBridgeOft and
QuotedCalls) for the native refund instead of a hand-rolled call/return-check,
and drop the now-unused NativeRefundFailed error.
The single callback slot held the source router and was then overwritten with
the resolved destination recipient, which was hard to follow. Use a dedicated
slot for each. Since the source slot now stays set for the whole callback
window, reject a second callback explicitly so a source cannot pull collateral
twice, and revert with MissingCallback when the source never calls back.

@paulbalaji paulbalaji left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Review — atomic local rebalancing bridge

Reviewed at c19e83a via a parallel red-team (value-accounting, arbitrary-call/reentrancy/privilege-escalation, approval-model/backwards-compat, SDK/TS, test-adequacy). Approving — the design is sound and I could not construct a fund-drain, under-collateralization, value-stranding, or caller-extraction path under the stated trust model (immutable source router, owner-configured recipient, balance-stable ERC20s). A few things I'd like addressed as part of rollout / follow-up (inline), none of which block the contract being correct as written.

What holds up well

  • Immutable sourceRouter genuinely bounds blast radius; localRebalance consults only it for auth and the pull.
  • Privilege escalation through the arbitrary calls window is blocked on every vector traced: re-entering source.rebalance (slots cleared → NoActiveRebalance), pulling via another bridge (InvalidInputDelta), transferFrom the source (allowance already revoked before calls run), and inflating the destination via its own bridge path (InsufficientOutput). The caller is already a source rebalancer, so gains no new authority.
  • Delta-based accounting correctly excludes pre-existing input/output/native donations from funding and refunds, handles the shared input==output branch, and the reentrancy guard + transient state-machine (2nd-callback reject / MissingCallback) hold.
  • Native: pre-existing balance can't be spent (InvalidNativeDelta); only msg.value accrual is refunded.
  • Exact-allowance model: collateralFees provably matches what every in-tree bridge (base TokenRouter, OFT, Everclear) pulls; the post-transfer revoke is robust across revert / native / no-op paths and is strictly safer than the prior standing-max-approval.
  • SDK removals are complete (no dangling refs), no type casts introduced, and old configs still parse + apply.

Please address (see inline)

  1. Legacy standing allowances on already-deployed routes (most important). The whole model assumes a route holds no bridge allowance outside the active rebalance call, but routes upgraded in place keep their old type(uint256).max approvals until governance clears them per-pair. The contract is correct either way, but this is the load-bearing security premise and is enforced only by the upgrade note. Recommend the SDK emit revocation txs for existing positive router→bridge allowances during apply/upgrade (even when the bridge stays allowlisted), plus a runbook step. One reviewer would treat this as blocking; at minimum it should ship alongside rollout, not as docs.
  2. decimals() DoS for tokens reporting decimals() >= 78 or not implementing it (liveness only, config-dependent).
  3. Fuzz invariant is weaker than the PR text claims — single shared 6-decimal token, catch {} swallows the (many) reverting inputs with no success counter, and CI runs at the repo default of 50 runs, not 1000.
  4. Minor: no event on localRebalance; quote returns 2 entries (not "single"); arbitrary calls can move non-input/output token donations off the bridge; changeset tense; stale e2e test name.

Pre-existing, not introduced here (FYI)

allowedRebalancingBridges[].approvedTokens is now fully vestigial (no longer consumed on deploy), but it's still schema-accepted, the reader returns only { bridge }, and transformConfigToCheck doesn't strip it — so a config that sets approvedTokens can drift on warp check. types.ts / EvmWarpRouteReader.ts / configUtils.ts are untouched by this PR, so this is a pre-existing latent issue, not a regression. Worth a follow-up to deprecate/strip the field now that it does nothing.

Comment thread solidity/contracts/token/libs/MovableCollateralRouter.sol
Comment thread typescript/sdk/src/token/EvmWarpModule.ts Outdated
Comment thread solidity/contracts/token/AtomicLocalRebalancingBridge.sol
Comment thread solidity/contracts/token/AtomicLocalRebalancingBridge.sol
Comment thread solidity/contracts/token/AtomicLocalRebalancingBridge.sol Outdated
Comment thread solidity/contracts/token/AtomicLocalRebalancingBridge.sol Outdated
Comment thread solidity/test/token/AtomicLocalRebalancingBridge.t.sol
Comment thread .changeset/atomic-local-rebalancing-bridge.md
Comment thread typescript/cli/src/tests/ethereum/warp/warp-deploy-2.e2e-test.ts Outdated
xeno097 added 8 commits June 12, 2026 16:20
…e approveTokenForBridge

- Document the configuration-only par invariant on the bridge and _requiredDelta:
  input and output must be economically at par since conversion is decimals-only.
- Add testFuzz_localRebalance_crossDecimalPreservesValue asserting decimal-
  normalized value across routers does not decrease for distinct input/output
  decimals and a fuzzed swap price.
- Mark approveTokenForBridge @Custom:deprecated and note it revokes despite the
  name; the selector is retained for upgrade/governance compatibility.
Routes upgraded in place still carried the pre-atomic-rebalancing
type(uint256).max allowance for bridges that remain allowlisted. Add
EvmWarpModule.createRevokeStaleBridgeAllowancesTxs, wired into update() after the
upgrade tx, which revokes those allowances. Gated on (1) the router being on a
legacy contract version (<= 11.3.1, the last grant-max approveTokenForBridge) and
(2) an in-place upgrade being generated this run, so the revoke tx only ever runs
against the new revoke-semantics impl. Hardhat tests drive the gate through the
real update() flow via the version-spoof pattern.
Native collateral is unsupported (decimal conversion needs an ERC20). Reject a
native source at construction (the source is immutable) and guard
quoteTransferRemote against a caller whose collateral token is address(0), which
would otherwise produce a duplicate zero-address quote entry.
localRebalance moved value (escrow, swap, destination funding, refund) but
emitted no event of its own; only the source router's CollateralMoved fired.
Emit LocalRebalanceExecuted(destinationRouter, amountIn, requiredDelta) on
success so the resolved destination and amounts are observable.
Output donations (output funding floor) and native donations (InvalidNativeDelta)
were already protected, but the wrapper's pre-call input balance had no floor, so
a call could swap the escrow plus an input donation and the rebalancer would
pocket the surplus output. Require the input balance after the calls to be at
least the pre-call input snapshot, matching the output/native guards.
Bound the swap output at or above requiredDelta so the cross-decimal fuzz always
succeeds and asserts every run (no swallowed reverts), add an upper-bound check
that router value increases by at most the up-rounding of requiredDelta, and bump
both rebalance fuzz tests to 1000 runs via per-test forge-config.
The test no longer creates token approvals (it asserts the allowance is 0), so
rename it to 'should set the allowed bridges without creating token approvals'.
If a source router leaks a standing approval to the bridge, a call could pull
extra collateral from the source beyond amountIn. Assert the source-drain guard
reverts InvalidInputDelta when a call drains the source via such a leaked
approval.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

♻️ Duplicate comments (1)
solidity/contracts/token/AtomicLocalRebalancingBridge.sol (1)

252-255: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Guard decimals() before exponentiating.

Line 254 still does 10 ** decimals(). Tokens returning >= 78 overflow here, and tokens that don’t implement decimals() still brick every localRebalance on that route with a raw revert instead of a bridge-specific error. That’s a config-shaped liveness failure, not just a docs quirk.

Possible hardening
+    error InvalidDecimals();
+
     /// `@dev` Reverts if `token` does not implement `IERC20Metadata.decimals()`.
     function _decimalScale(address token) internal view returns (uint256) {
-        return 10 ** uint256(IERC20Metadata(token).decimals());
+        try IERC20Metadata(token).decimals() returns (uint8 d) {
+            if (d > 77) revert InvalidDecimals();
+            return 10 ** uint256(d);
+        } catch {
+            revert InvalidToken();
+        }
     }
As per coding guidelines, "Validate all parameters within safe bounds in Solidity function implementations."
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@solidity/contracts/token/AtomicLocalRebalancingBridge.sol` around lines 252 -
255, The _decimalScale function currently directly calls
IERC20Metadata(token).decimals() and computes 10 ** decimals(), which can
overflow for decimals >= 78 and will raw-revert if the token doesn't implement
decimals(); update _decimalScale to first call IERC20Metadata(token).decimals()
in a try/catch (or a low-level staticcall) and on failure revert with a
bridge-specific error (e.g. UnsupportedToken or MissingDecimals for the token),
then validate the returned decimals value is within safe bounds
(require(decimals <= 77) or similar) before computing 10 ** uint256(decimals) to
avoid overflow; reference symbols: _decimalScale and IERC20Metadata.decimals().

Source: Coding guidelines

🧹 Nitpick comments (1)
solidity/contracts/token/AtomicLocalRebalancingBridge.sol (1)

49-54: 📐 Maintainability & Code Quality | ⚡ Quick win

Include the initiating rebalancer in LocalRebalanceExecuted.

Right now this only indexes destinationRouter, so off-chain consumers still can’t tell who kicked the rebalance without replaying the whole tx. Adding an indexed caller keeps the event properly queryable and lines up with the repo’s event rules.

As per coding guidelines, "Index action creator and operated-upon users/IDs in Solidity events."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@solidity/contracts/token/AtomicLocalRebalancingBridge.sol` around lines 49 -
54, The LocalRebalanceExecuted event must include the initiating rebalancer as
an indexed address so off-chain consumers can query who triggered the action;
update the event declaration in AtomicLocalRebalancingBridge.sol
(LocalRebalanceExecuted) to add an address indexed rebalancer (or caller)
parameter, and update every emit LocalRebalanceExecuted(...) call to pass the
rebalancer (use the function's rebalancer param or msg.sender where appropriate)
so the emitted event matches the new signature.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Duplicate comments:
In `@solidity/contracts/token/AtomicLocalRebalancingBridge.sol`:
- Around line 252-255: The _decimalScale function currently directly calls
IERC20Metadata(token).decimals() and computes 10 ** decimals(), which can
overflow for decimals >= 78 and will raw-revert if the token doesn't implement
decimals(); update _decimalScale to first call IERC20Metadata(token).decimals()
in a try/catch (or a low-level staticcall) and on failure revert with a
bridge-specific error (e.g. UnsupportedToken or MissingDecimals for the token),
then validate the returned decimals value is within safe bounds
(require(decimals <= 77) or similar) before computing 10 ** uint256(decimals) to
avoid overflow; reference symbols: _decimalScale and IERC20Metadata.decimals().

---

Nitpick comments:
In `@solidity/contracts/token/AtomicLocalRebalancingBridge.sol`:
- Around line 49-54: The LocalRebalanceExecuted event must include the
initiating rebalancer as an indexed address so off-chain consumers can query who
triggered the action; update the event declaration in
AtomicLocalRebalancingBridge.sol (LocalRebalanceExecuted) to add an address
indexed rebalancer (or caller) parameter, and update every emit
LocalRebalanceExecuted(...) call to pass the rebalancer (use the function's
rebalancer param or msg.sender where appropriate) so the emitted event matches
the new signature.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 741bbcb6-9081-4e96-9bfd-ed140cd3eae1

📥 Commits

Reviewing files that changed from the base of the PR and between c19e83a and 673ec09.

📒 Files selected for processing (7)
  • .changeset/atomic-local-rebalancing-bridge.md
  • solidity/contracts/token/AtomicLocalRebalancingBridge.sol
  • solidity/contracts/token/libs/MovableCollateralRouter.sol
  • solidity/test/token/AtomicLocalRebalancingBridge.t.sol
  • typescript/cli/src/tests/ethereum/warp/warp-deploy-2.e2e-test.ts
  • typescript/sdk/src/token/EvmWarpModule.hardhat-test.ts
  • typescript/sdk/src/token/EvmWarpModule.ts
🚧 Files skipped from review as they are similar to previous changes (2)
  • .changeset/atomic-local-rebalancing-bridge.md
  • solidity/contracts/token/libs/MovableCollateralRouter.sol

Reconcile the multi-recipient design (#8863) onto the hardened atomic
local rebalancing bridge:

- Add IRebalanceTargets and CrossCollateralRouter rebalance-target
  enrollment (addRebalanceTarget/removeRebalanceTarget/isRebalanceTarget),
  storage-appended. Cross-collateral routers must be added explicitly.
- Reshape the bridge entry to the canonical-mirroring
  rebalance(uint32,uint256,ITokenBridge,bytes32,bytes): the source arg is
  a checked echo of the immutable source, the destination recipient is
  supplied per call and validated via isRebalanceTarget, and the trailing
  bytes carry the abi-encoded calls. The source must be a
  CrossCollateralRouter, asserted at construction.
- Keep the immutable source, nonReentrant guard, consumed-after-escrow
  callback flag, and the donation/native/input/output invariants.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@solidity/contracts/token/AtomicLocalRebalancingBridge.sol`:
- Around line 140-142: The conversion of destinationRecipient to address using
TypeCasts.bytes32ToAddress() does not validate that the value is canonically
encoded (upper 96 bits must be zero). Add a canonicality check before the
conversion to verify that destinationRecipient has all zeros in its upper 96
bits, ensuring it represents a valid canonical address encoding. This validation
should occur before the TypeCasts.bytes32ToAddress() call to enforce proper
parameter bounds as per your coding guidelines.

In `@solidity/contracts/token/CrossCollateralRouter.sol`:
- Around line 189-196: The isRebalanceTarget function has a parameter validation
issue: when routers(_domain) is unset (defaults to bytes32(0)), the function
incorrectly returns true for a _target of bytes32(0), authorizing an invalid
zero target implicitly. Add a validation check at the beginning of the
isRebalanceTarget function to ensure _target is not bytes32(0), and return false
for invalid zero targets. This prevents false-positive authorization results and
aligns with the requirement to validate all parameters within safe bounds in
Solidity function implementations.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 2a25e007-e8d9-48c0-a946-ab454c0e72ab

📥 Commits

Reviewing files that changed from the base of the PR and between 673ec09 and c2eb688.

📒 Files selected for processing (7)
  • .changeset/atomic-local-rebalancing-bridge.md
  • solidity/contracts/token/AtomicLocalRebalancingBridge.sol
  • solidity/contracts/token/CrossCollateralRouter.sol
  • solidity/contracts/token/interfaces/IRebalanceTargets.sol
  • solidity/test/token/AtomicLocalRebalancingBridge.fork.t.sol
  • solidity/test/token/AtomicLocalRebalancingBridge.t.sol
  • solidity/test/token/CrossCollateralRouter.t.sol
✅ Files skipped from review due to trivial changes (1)
  • .changeset/atomic-local-rebalancing-bridge.md
🚧 Files skipped from review as they are similar to previous changes (1)
  • solidity/test/token/AtomicLocalRebalancingBridge.fork.t.sol

Comment thread solidity/contracts/token/AtomicLocalRebalancingBridge.sol Outdated
Comment thread solidity/contracts/token/CrossCollateralRouter.sol
Comment thread solidity/contracts/token/libs/MovableCollateralRouter.sol
Comment thread solidity/contracts/token/AtomicLocalRebalancingBridge.sol Outdated
Comment thread solidity/contracts/token/AtomicLocalRebalancingBridge.sol Outdated
Comment thread solidity/contracts/token/AtomicLocalRebalancingBridge.sol Outdated
Comment thread solidity/contracts/token/AtomicLocalRebalancingBridge.sol
) {
revert InvalidInputDelta();
}
// Calls must not spend the wrapper's pre-call input donation.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

What is pre-call input donation? Is it just the pulling of the funds from the source router? Donation seems misleading then?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

yup changed comment wording here

}
// Calls must not spend the wrapper's pre-call input donation.
if (
IERC20(inputToken).balanceOf(address(this)) < selfBefore.inputToken

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I'm not sure I understand this invariant, why is it wrong if this contract ends up with inputToken?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

This check is more about avoiding the rebalancer to use already existing funds on the bridge to complete a rebalance. Rebalance might be called with an amountIn that is less than the required amount to complete a swap so the already existing funds on the contract would be used, but that wouldn't be a proper rebalance, no (source router would have more tokens than the destination because less was pulled)?

Comment thread solidity/contracts/token/AtomicLocalRebalancingBridge.sol Outdated
Comment thread solidity/contracts/token/AtomicLocalRebalancingBridge.sol Outdated
Comment thread solidity/contracts/token/AtomicLocalRebalancingBridge.sol Outdated
xeno097 added 4 commits June 16, 2026 16:49
- add IRebalancingBridge interface defining the rebalance entrypoint signature
- rename for clarity (drop "delta"/"input"/"output"): sourceToken/
  destinationToken, amount, requiredOutputAmount, allowedSourceRouter immutable
  + plain sourceRouter param, InvalidSourceTokenBalance, _refundTokenBalance
- narrow the constructor to an is-contract check; IRebalanceTargets support is a
  documented precondition surfaced at rebalance
- remove the pre-existing-native floor (native can only accrue via exotic
  force-send); refund unspent native in full
- keep the source-token floor and destination receipt check; tidy comments
Re-add getAllowedBridgesApprovalTxs so warp apply grants the standing
approvedTokens allowances that pre-atomic-rebalancing routers still need.
Gated on the on-chain contract version being legacy (<= MAX_LEGACY_BRIDGE_
APPROVAL_VERSION), mirroring createRevokeStaleBridgeAllowancesTxs: newer
impls grant per rebalance and the same selector revokes, so grants are
suppressed there.
…voke

Export MAX_LEGACY_BRIDGE_APPROVAL_VERSION and bridgeApprovalGrantsMaxAllowance
so callers can tell whether approveTokenForBridge grants or revokes for a given
contract version. The governance transaction reader now reads the target router's
version and labels the call a grant or a revoke accordingly, instead of always
describing it as a clear.

Broaden createRevokeStaleBridgeAllowancesTxs to also revoke any configured
approvedTokens (not just the collateral token) for bridges that remain allowlisted
after an in-place upgrade, so no stale legacy max approval survives.
isRebalanceTarget returned true for bytes32(0) on a domain with no enrolled
router, since routers(domain) is itself zero. Guard the read so the zero
address is never a valid rebalance target. The setter (addRebalanceTarget) is
intentionally left unguarded.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (1)
solidity/test/token/AtomicLocalRebalancingBridge.t.sol (1)

708-742: 💤 Low value

Consider adding a test for pre-existing native balance on the bridge.

The test_rebalance_refundsUnspentNative test handles the happy path where msg.value gets returned. But if that native balance check I mentioned in the implementation gets added, you'd want a test here to make sure pre-existing ETH on the bridge doesn't get swept up by the rebalancer. Just somethin' to keep in mind, yeah?

function test_rebalance_preservesPreExistingNativeBalance() public {
    swapTarget.setOutputAmount(100e6);
    // Pre-fund bridge with some ETH
    vm.deal(address(bridge), 0.5 ether);
    vm.deal(rebalancer, 1 ether);

    vm.prank(rebalancer);
    bridge.rebalance{value: 1 ether}(
        LOCAL_DOMAIN,
        100e6,
        ITokenBridge(address(sourceRouter)),
        _toBytes32(address(destinationRouter)),
        abi.encode(_rebalancerCalls(100e6))
    );

    // Rebalancer gets back only what they sent
    assertEq(rebalancer.balance, 1 ether);
    // Pre-existing ETH stays on bridge
    assertEq(address(bridge).balance, 0.5 ether);
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@solidity/test/token/AtomicLocalRebalancingBridge.t.sol` around lines 708 -
742, Add a new test function called
test_rebalance_preservesPreExistingNativeBalance after the existing
test_rebalance_revertsWhenNativeRefundFails function to verify that pre-existing
native balance on the bridge contract is preserved during a rebalance operation.
The test should pre-fund the bridge with ETH, then execute a rebalance with
additional ETH from the rebalancer, and assert that only the rebalancer's sent
ETH is returned while the pre-existing bridge balance remains unchanged. This
ensures the implementation correctly distinguishes between msg.value and
pre-existing contract balance when processing refunds.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@solidity/contracts/token/AtomicLocalRebalancingBridge.sol`:
- Line 75: The `InvalidNativeDelta` error definition in the contract is declared
but never thrown, and the native balance refund code at lines 218-221 sends the
entire contract balance without validating against pre-existing balance. Either
remove the unused `InvalidNativeDelta` error if native balance protection is not
required, or implement proper native balance snapshotting by capturing the
contract's balance before the call that receives native tokens, then validating
or refunding only the difference between pre-existing and new balance while
throwing `InvalidNativeDelta` if validation fails. This ensures the refund logic
aligns with the PR's stated intention of preventing pre-existing balance
spending.

---

Nitpick comments:
In `@solidity/test/token/AtomicLocalRebalancingBridge.t.sol`:
- Around line 708-742: Add a new test function called
test_rebalance_preservesPreExistingNativeBalance after the existing
test_rebalance_revertsWhenNativeRefundFails function to verify that pre-existing
native balance on the bridge contract is preserved during a rebalance operation.
The test should pre-fund the bridge with ETH, then execute a rebalance with
additional ETH from the rebalancer, and assert that only the rebalancer's sent
ETH is returned while the pre-existing bridge balance remains unchanged. This
ensures the implementation correctly distinguishes between msg.value and
pre-existing contract balance when processing refunds.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 3fa5e268-ca6f-4488-b58f-a03c31d559f6

📥 Commits

Reviewing files that changed from the base of the PR and between 23f6a7e and 2c4db35.

📒 Files selected for processing (10)
  • .changeset/atomic-local-rebalancing-bridge.md
  • solidity/contracts/token/AtomicLocalRebalancingBridge.sol
  • solidity/contracts/token/CrossCollateralRouter.sol
  • solidity/contracts/token/interfaces/IRebalancingBridge.sol
  • solidity/test/token/AtomicLocalRebalancingBridge.t.sol
  • solidity/test/token/CrossCollateralRouter.t.sol
  • typescript/infra/src/tx/govern-transaction-reader.ts
  • typescript/sdk/src/index.ts
  • typescript/sdk/src/token/EvmWarpModule.hardhat-test.ts
  • typescript/sdk/src/token/EvmWarpModule.ts
🚧 Files skipped from review as they are similar to previous changes (4)
  • solidity/contracts/token/CrossCollateralRouter.sol
  • solidity/test/token/CrossCollateralRouter.t.sol
  • .changeset/atomic-local-rebalancing-bridge.md
  • typescript/sdk/src/token/EvmWarpModule.hardhat-test.ts

Comment thread solidity/contracts/token/AtomicLocalRebalancingBridge.sol
xeno097 added 6 commits June 17, 2026 12:06
Convert the four post-call invariant checks (previously a doubled
InvalidSourceTokenBalance and a doubled InsufficientOutput) to require
statements with distinct ALRB-prefixed messages, matching the MCR/CCR
convention and disambiguating which invariant failed. The messages are
named string constants on the contract, mirrored as bytes constants in the
tests. Also group the function's comments and drop the "wrapper" naming.
Refund the bridge's entire token balances (not just the amount accrued during
the call) to the rebalancer, mirroring the existing full native-balance refund,
so no tokens are left stranded on the wrapper between calls. The funding
invariants stay snapshot-based, so pre-existing balances still cannot fund the
destination; only the post-invariant refund now sweeps everything. Tests updated
accordingly (and the "donation" wording dropped for "pre-existing balance").
Make the post-call section read as two verify-settle cycles: the three checks
that verify the rebalancer's calls (one of which gates the destination transfer)
run first, then the destination funding and balance sweeps run as one
uninterrupted settlement block, then the destination-gain check verifies the
settlement. Comments label each phase precisely rather than implying a single
linear checks/effects split. Behavior unchanged.
createRevokeStaleBridgeAllowancesTxs matched remaining bridges per domain, so a
bridge moved between domains fell out of every intersection and kept its stale
legacy allowance. Match by bridge address across all domains instead (ERC20
allowances are router+bridge and domain-agnostic), revoking the collateral token
and any configured approvedTokens. Removed bridges are still left to _removeBridge
and added bridges still never held a legacy allowance. Adds a moved-bridge test.
The post-settlement destination-gain assertion could only fail if safeTransfer
under-delivered to the destination router — i.e. a non-standard (fee-on-transfer/
rebasing) destination token, which the contract already documents as unsupported.
The arbitrary calls cannot reduce the destination router's balance (the wrapper
has no authority over it), so under the standard-ERC20-at-par precondition the
check is always true and no test ever exercised it. Removed it along with the
now-unused destination-router snapshot and ERR_DESTINATION_UNDERFUNDED; source-drain
and output-produced carry the safety weight.
createRevokeStaleBridgeAllowancesTxs only emitted revokes when an in-place
upgrade was scheduled AND the router was still on a legacy impl. If a prior run
upgraded the impl but its revoke txs never executed, a rerun saw the new version
(or no scheduled upgrade) and emitted nothing, stranding the stale allowance.
Now it skips only the unsafe case (legacy impl not being upgraded, where
approveTokenForBridge would grant); otherwise it revokes against the new impl —
whether upgrading in place or already upgraded — gated by the existing non-zero
on-chain allowance check. Updates the already-upgraded test to the retry case and
adds a no-stale-allowance negative.
sourceToken: sourceBalanceBefore,
// Shared source/destination token reuses the source snapshot;
// otherwise exclude any pre-existing destination-token balance.
destinationToken: destinationToken == sourceToken

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I just noticed this now but when would source and destinationToken be the same?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Couldn't we use this bridge to replenish collateral between 2 different warp routes that have the same collateral token (i know it is not the main use case for this contract)?

Comment thread solidity/contracts/token/AtomicLocalRebalancingBridge.sol Outdated
Comment thread solidity/contracts/token/AtomicLocalRebalancingBridge.sol Outdated
xeno097 added 3 commits June 17, 2026 19:41
Revert the full-balance sweep: the rebalancer is refunded only the balances
accrued during the call, leaving any balance the wrapper held before escrow
untouched (per Nam's review). This restores the "pre-existing untouched" property
end-to-end. Native now gets the same treatment as the tokens: snapshot the
pre-existing native (excluding msg.value), reject calls that spend it
(ERR_PREEXISTING_NATIVE_SPENT), and refund only the unspent remainder. The native
snapshot lives on SelfBalanceSnapshot to avoid stack-too-deep. Restores the
NativeSink helper and the pre-existing-native tests.
Break the dense rebalance body into named internal helpers, following the repo's
conventions (thin orchestrator + _validate*/_verbNoun helpers, explicit params in
the 4-6 range, reusing the existing SelfBalanceSnapshot rather than a context
struct): _validateAndParseSourceRouter, _validateAndParseDestinationAddress,
_snapshotSelfTokenBalances, _validatePostCallBalances, _refundAccruedBalances. The
destination funding transfer stays inline (the one core mutation). Rename
_pullSourceCollateral -> _pullSourceRouterCollateral (per review). Document the
operating invariants in a contract-level @dev block and clarify the shared
source/destination-token case. No behavior change.
Convert the four post-call invariant require-strings to custom errors so the whole
contract reverts uniformly with custom errors (input validation already did),
resolving the mixed-style inconsistency: SourceRouterOverdrawn,
PreexistingSourceTokenSpent, PreexistingNativeBalanceSpent,
InsufficientOutputTokenProduced. Tests assert the error selectors instead of the
dropped string mirrors.
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.

4 participants