Skip to content

feat: add same-chain swap rebalancing bridge#8618

Open
nambrot-agent wants to merge 4 commits into
mainfrom
codex/swap-rebalancing-bridge-pr
Open

feat: add same-chain swap rebalancing bridge#8618
nambrot-agent wants to merge 4 commits into
mainfrom
codex/swap-rebalancing-bridge-pr

Conversation

@nambrot-agent

@nambrot-agent nambrot-agent commented Apr 16, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds SwapRebalancingBridge, a same-chain rebalance bridge that uses MovableCollateralRouter.rebalance(...) only as a source-collateral release primitive, then executes exact-input swap calls and settles an enrolled destination router with exact nominal output.

Intended usage

  • Owner whitelists:
    • authorized rebalancer EOAs
    • external swap targets
    • ERC20 allowanceTargets
  • Rebalancer calls executeRebalance(sourceRouter, destinationRouter, amountIn, minAmountOut, deadline, swapCalls).
  • Bridge verifies:
    • destination router is enrolled on source router via routers(...) or crossCollateralRouters(...)
    • source and destination routers share localDomain
  • Bridge stores pending state and calls sourceRouter.rebalance(localDomain, amountIn, this).
  • Source router calls back into quoteTransferRemote(...) and transferRemote(...).
  • Bridge pulls amountIn from the source router, executes exact-input swap calls, then:
    • if actualOut < requiredOut, pulls the shortfall from the rebalancer in outputToken
    • transfers exactly requiredOut to the destination router
    • refunds any surplus to the rebalancer

Accepted tradeoffs

  • Same-chain only.
  • Single in-flight rebalance per bridge.
  • Exact-input swaps only.
  • Intended for router-style venues where the venue owns the hop path.
  • Not a fully generic arbitrary multi-step token executor where the bridge itself must manage intermediate-token balances.
  • Nominal accounting assumes standard TokenRouter scaleNumerator/scaleDenominator math.
  • Routers with custom inbound/outbound math are out of scope for v1.
  • Callback recipient is intentionally ignored; the enrolled destinationRouter passed by the rebalancer is authoritative.

Ergonomics

  • Single-call entrypoint for rebalancers.
  • Owner-managed allowlists constrain arbitrary calldata to known venues/spenders.
  • Helper views:
    • pendingRebalance()
    • isEnrolledDestination(...)
    • requiredOut(...)
  • Fork coverage includes real Uniswap on Ethereum and real Aerodrome on Base.

Trust assumptions

  • Owner curates safe target / allowanceTarget allowlists.
  • Authorized rebalancers submit economically sane swap calldata.
  • Rebalancers can fund output-token shortfalls.
  • Source router is configured so rebalance(localDomain, amountIn, bridge) is allowed to execute.
  • Source and destination routers expose honest token and scale parameters.

Security / accounting properties

  • LPs receive exact nominal requiredOut or the call reverts.
  • Rebalancer bears downside in outputToken.
  • Rebalancer receives upside in outputToken.
  • Bridge does not keep execution alpha.
  • Output-token approvals to swap venues were removed.
  • Input-token approval is narrowed to pending.amountIn.
  • Unspent input reverts when inputToken != outputToken.

Tests

Local:

  • forge test --match-path test/token/SwapRebalancingBridge.t.sol
  • forge test --match-path test/token/SwapRebalancingBridge.fork.t.sol

Coverage includes:

  • auth / enrollment / deadline
  • ignored callback recipient
  • exact nominal settlement
  • shortfall top-up
  • surplus refund
  • minAmountOut revert
  • whitelist failures
  • approval cleanup
  • zero-scale guard
  • fork happy / deficit / surplus / revert paths on Ethereum Uniswap
  • fork happy path on Base Aerodrome

Open with Devin

@github-project-automation github-project-automation Bot moved this to In Review in Hyperlane Tasks Apr 16, 2026
@nambrot-agent nambrot-agent changed the title Add same-chain swap rebalancing bridge feat: add same-chain swap rebalancing bridge Apr 16, 2026
@coderabbitai

coderabbitai Bot commented Apr 16, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

Adds SwapRebalancingBridge: interface and contract enabling same-chain exact-input swap rebalances with owner-managed rebalancer/target allowlists, single in-flight pending state, swap-call execution with temporary allowances, required-output computation, and comprehensive unit and fork tests plus a changeset.

Changes

Swap Rebalancing Bridge Feature

Layer / File(s) Summary
Interface and data contracts
solidity/contracts/token/interfaces/ISwapRebalancingBridge.sol
SwapCall and PendingRebalance structs and ISwapRebalancingBridge declare execute, auth setters, pending state, enrollment, requiredOut, and quoteTransferRemote.
Bridge implementation and logic
solidity/contracts/token/SwapRebalancingBridge.sol
Implements SwapRebalancingBridge: imports, state (authorizedRebalancers, whitelists, pending), owner setters, executeRebalance validation/storage, quoteTransferRemote, transferRemote callback (pulls input, runs swapCalls with whitelisted targets/allowances, enforces minOut/input-spent, handles shortfall/payout/refund), enrollment and _requiredOut math, swap-call storage/execution, and pending cleanup.
Test support contracts
solidity/contracts/test/TestSwapTarget.sol, solidity/test/token/SwapRebalancingBridge.t.sol
TestSwapTarget simulates swapExactInput behavior; MockRebalanceRouter simulates router quoting, enrollment, and rebalance callback flows used by unit tests.
Unit test suite
solidity/test/token/SwapRebalancingBridge.t.sol
Foundry unit tests cover authorization, enrollment/domain/deadline/scale validation, quoteTransferRemote response, transferRemote exact payout, shortfall pull, surplus refund, unapproved target/allowance reverts, input-spent checks, approval clearing, cross-collateral path, and concurrent pending rebalance reverts.
Fork tests
solidity/test/token/SwapRebalancingBridge.fork.t.sol
Fork tests with MockForkRouter and chain suites: Ethereum (Uniswap V3 USDC→USDT) and Base (Aerodrome), verifying exact destination funding, rebalancer shortfall top-up, surplus refunds, and min-out enforcement.
Release metadata
.changeset/swap-rebalancing-bridge.md
Minor version bump for @hyperlane-xyz/core documenting the new SwapRebalancingBridge and its test coverage.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title 'feat: add same-chain swap rebalancing bridge' accurately and concisely summarizes the main change—introducing a new SwapRebalancingBridge contract for same-chain rebalancing operations.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description check ✅ Passed The PR description is comprehensive and well-structured, covering all key template sections with detailed implementation details and testing coverage.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@codecov

codecov Bot commented Apr 16, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 77.51938% with 29 lines in your changes missing coverage. Please review.
✅ Project coverage is 79.28%. Comparing base (3554887) to head (b77bec1).
⚠️ Report is 84 commits behind head on main.

Additional details and impacted files
@@            Coverage Diff             @@
##             main    #8618      +/-   ##
==========================================
- Coverage   79.33%   79.28%   -0.06%     
==========================================
  Files         143      144       +1     
  Lines        4278     4407     +129     
  Branches      436      465      +29     
==========================================
+ Hits         3394     3494     +100     
- Misses        855      884      +29     
  Partials       29       29              
Flag Coverage Δ
solidity 80.48% <77.51%> (-0.11%) ⬇️

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.46% <ø> (ø)
token 87.08% <77.51%> (-0.92%) ⬇️
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.

@devin-ai-integration devin-ai-integration 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.

Devin Review found 2 potential issues.

View 4 additional findings in Devin Review.

Open in Devin Review

token: pending.inputToken,
amount: pending.amountIn
});
quotes[2] = Quote({token: pending.inputToken, amount: 0});

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.

🟡 quoteTransferRemote returns duplicate token addresses violating ITokenBridge interface contract

quoteTransferRemote returns quotes[1] and quotes[2] both with token: pending.inputToken, violating the ITokenBridge interface documentation at solidity/contracts/interfaces/ITokenBridge.sol:18 which states: "There should not be duplicate token addresses in the returned quotes."

The current caller (MovableCollateralRouter.rebalance at solidity/contracts/token/libs/MovableCollateralRouter.sol:143) uses Quotes.extract() which sums amounts for duplicate tokens, so the duplicate with amount: 0 on quotes[2] doesn't cause a runtime issue today. However, other callers that process quotes positionally or build a map (overwriting one entry with the other) would misinterpret the fee structure. The likely intended token for quotes[2] is pending.outputToken (the destination token being credited), not pending.inputToken.

Suggested change
quotes[2] = Quote({token: pending.inputToken, amount: 0});
quotes[2] = Quote({token: pending.outputToken, amount: 0});
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +438 to +441
function _clearPending() internal {
delete pending;
delete pendingSwapCalls;
}

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.

🚩 No recovery mechanism for stuck pending rebalance state

If source.rebalance() at line 221 returns successfully without calling transferRemote(), the pending state is committed to storage and RebalanceAlreadyPending (line 161-162) permanently blocks all future rebalances. There is no owner escape hatch or timeout-based cleanup, despite pending.deadline being stored. The real MovableCollateralRouter.rebalance (solidity/contracts/token/libs/MovableCollateralRouter.sol:156) always calls bridge.transferRemote(...) unconditionally—if it reverts, the whole transaction reverts and pending is never committed. So this only manifests with a buggy or non-standard source router implementation. Given the trust model (owner sets authorized rebalancers, routers must be enrolled contracts), the risk is low, but adding an onlyOwner clearPending() function would be a cheap safety net.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

@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.

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

349-381: 💤 Low value

Add a brief comment documenting rounding behavior.

The double Math.Rounding.Down usage means the requiredOut might be slightly less than a mathematically perfect conversion. A quick comment noting which party this favors (initiator, since less output is required) would help future readers understand the design choice. Not a showstopper by any means, just good swamp hygiene.

+    // Note: Double rounding down minimally favors the initiator (slightly lower requiredOut).
     function _requiredOut(
         IMovableCollateralRouterLike sourceRouter,

As per coding guidelines: "Document precision loss and which actors benefit/suffer from it in Solidity."

🤖 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/SwapRebalancingBridge.sol` around lines 349 - 381,
Add a short inline NatSpec or code comment inside the _requiredOut function
explaining that both Math.mulDiv calls use Math.Rounding.Down, which causes
potential precision loss resulting in requiredOut being rounded down (i.e.,
possibly slightly less than exact conversion) and therefore favors the
initiator/sender (they need to supply less output than exact math); reference
the two Math.Rounding.Down usages and note this is an intentional design choice
about who benefits from rounding.
solidity/contracts/token/interfaces/ISwapRebalancingBridge.sol (1)

12-23: 💤 Low value

Consider packing localDomain with an adjacent address field for gas savings.

The PendingRebalance struct has a uint32 localDomain sitting alone in its own storage slot. Moving it next to one of the address fields (e.g., outputToken) would pack them together and save a storage slot. Not the end of the swamp, but every bit of gas counts when you're storing this in state.

 struct PendingRebalance {
     address initiator;
     address sourceRouter;
     address destinationRouter;
     address inputToken;
-    address outputToken;
-    uint32 localDomain;
+    address outputToken;   // 20 bytes
+    uint32 localDomain;    // 4 bytes - packs with outputToken in same slot
     uint256 amountIn;
     uint256 minAmountOut;
     uint256 requiredOut;
     uint256 deadline;
 }

Note: Adjacent placement in struct definition enables packing. Verify Solidity version behavior, but this should work in 0.8.x.

🤖 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/interfaces/ISwapRebalancingBridge.sol` around lines
12 - 23, The PendingRebalance struct currently has uint32 localDomain isolated
in its own slot; to enable storage packing and save gas, reorder the struct so
localDomain is declared directly adjacent to an address-sized field (for example
move localDomain next to outputToken or inputToken), ensuring the resulting
field order keeps addresses aligned before smaller uints; update any code that
constructs or reads PendingRebalance to match the new field order (references to
PendingRebalance, localDomain, outputToken, inputToken, and any constructors or
assignment sites).
🤖 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.

Nitpick comments:
In `@solidity/contracts/token/interfaces/ISwapRebalancingBridge.sol`:
- Around line 12-23: The PendingRebalance struct currently has uint32
localDomain isolated in its own slot; to enable storage packing and save gas,
reorder the struct so localDomain is declared directly adjacent to an
address-sized field (for example move localDomain next to outputToken or
inputToken), ensuring the resulting field order keeps addresses aligned before
smaller uints; update any code that constructs or reads PendingRebalance to
match the new field order (references to PendingRebalance, localDomain,
outputToken, inputToken, and any constructors or assignment sites).

In `@solidity/contracts/token/SwapRebalancingBridge.sol`:
- Around line 349-381: Add a short inline NatSpec or code comment inside the
_requiredOut function explaining that both Math.mulDiv calls use
Math.Rounding.Down, which causes potential precision loss resulting in
requiredOut being rounded down (i.e., possibly slightly less than exact
conversion) and therefore favors the initiator/sender (they need to supply less
output than exact math); reference the two Math.Rounding.Down usages and note
this is an intentional design choice about who benefits from rounding.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 1723460a-de35-4dd9-8444-594b399f5e9f

📥 Commits

Reviewing files that changed from the base of the PR and between 3554887 and e59c102.

📒 Files selected for processing (6)
  • .changeset/swap-rebalancing-bridge.md
  • solidity/contracts/test/TestSwapTarget.sol
  • solidity/contracts/token/SwapRebalancingBridge.sol
  • solidity/contracts/token/interfaces/ISwapRebalancingBridge.sol
  • solidity/test/token/SwapRebalancingBridge.fork.t.sol
  • solidity/test/token/SwapRebalancingBridge.t.sol

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