Summary
The Rebalancer personality (runRebalancer in src/relayer/index.ts) does:
await rebalancer.checkForUnfilledDepositsAndFill(true); // simulate only — no on-chain fills
// ...
await inventoryClient.rebalanceInventoryIfNeeded(); // real L1→L2 capital moves
await inventoryClient.withdrawExcessBalances();
checkForUnfilledDepositsAndFill(simulate=true) still runs the full evaluateFill path, which:
- Calls
tokenClient.decrementLocalBalance(destinationChainId, outputToken, outputAmount)
- Enqueues fill txs and
setFillStatus(Filled) (same optimistic booking as the fill-path liveness issue)
- Then
executeFills(chainId, simulate=true) drops the queue without restoring balances or clearing fill status
So for every deposit that would have been filled, destination inventory is booked as spent without any transfer. Immediately afterward, rebalanceInventoryIfNeeded() reads those poisoned local balances and may:
- Over-rebalance L1→L2 toward destinations that only look short
- Mis-rank repayment / allocation percentages
- Compound with rebalance-path optimistic
trackCrossChainTransfer if those rebalances are dry-run or fail
|
|
| Type |
Reliability — dry-run fill path mutates inventory used for real capital moves |
| Severity |
High (operator capital misrouting / inventory integrity) |
| Not claimed |
Direct theft of user deposits from SpokePool; remote “drain the bridge” exploit |
Environment
| Item |
Value |
| Repo |
across-protocol/relayer |
| Commit checked |
9bb9d79888e2371a0e616563aed1361e69fba264 |
| Files |
src/relayer/index.ts (runRebalancer); src/relayer/Relayer.ts (evaluateFill / fillRelay / executeFills) |
Root cause
evaluateFill always decrements local balance (and sets fill status) before enqueue, independent of simulate.
executeFills(..., simulate=true) returns without sending and without rolling back balances or fillStatus.
runRebalancer intentionally uses simulate=true “to consider fills” before rebalancing, but does not refresh tokenClient from chain (nor clear fill status) between the two steps.
Net: simulate-only fill accounting poisons the ledger that drives real rebalance transactions.
Expected vs actual
|
Expected |
Actual |
checkForUnfilledDepositsAndFill(true) |
Side-effect free on inventory ledgers (and ideally on fillStatus) |
Decrements destination local balance; may set Filled |
| Between simulate fills and rebalance |
Fresh / honest balances (or explicit “pending fill” not treated as spent) |
Rebalance sees fake shortfalls |
| After simulate-only loop |
On-chain: no fill; local: unchanged |
On-chain: still unfilled; local: balance reduced |
Proof of concept (Hardhat / Level-2)
cd /path/to/across-relayer # pin 9bb9d79888e2371a0e616563aed1361e69fba264
# install deps per README
RELAYER_TEST=true npx hardhat test test/Level2_AC_simulate_poisons_inventory.ts
Expected:
✔ AC-016: simulate=true decrements destination balance without fill (rebalancer poison)
1 passing
Assertions (test intent):
- Destination local balance decreases by
deposit.outputAmount after a simulate-only fill loop
- No on-chain fill; deposit remains unfilled
fillStatus becomes Filled (related optimistic-status issue)
Impact
| Who |
Impact |
| Operators running the Rebalancer personality |
Real bridge rebalance txs can be sized from incorrect destination shortfalls; capital misrouting; inventory strategy drift |
| End users |
No direct SpokePool drain claimed; possible indirect worse operator behavior (other relayers may still fill user deposits) |
| Frequency |
Every rebalancer run with unfilled, profitable-looking deposits |
Not claimed: an unauthenticated attacker withdrawing user escrow, or a standalone on-chain double-spend of user funds via this bug alone.
An adversary who can create many unfilled, profitable-looking deposits may amplify inventory poisoning on rebalancer bots; that still targets operator ledgers/decisions, not a direct user-fund drain primitive.
Suggested fix
Prefer (1) so dry-run is side-effect free:
- In
evaluateFill / fill booking: do not decrementLocalBalance or setFillStatus(Filled) when simulate === true (thread simulate into booking, or book only after successful non-simulate submit/confirm).
- Or in
runRebalancer: after simulate fills, force tokenClient.clearTokenData() + tokenClient.update() and clear poisoned fillStatus before rebalanceInventoryIfNeeded.
- Add a regression test: simulate-only fill loop must not change local balances used by inventory rebalance.
Related / out of scope
- Fill-path optimistic
Filled + balance debit without rollback on sim/submit failure (same booking primitives; distinct entrypoint: normal fill vs rebalancer simulate-then-rebalance).
- Rebalance
trackCrossChainTransfer before send / dry-run mutation (inventory send path; can compound with this issue).
- forceOriginRepayment empty eligible set (different inventory function).
- Not an iosiro-style speedUp double-spend of user fills.
Thanks!
Summary
The Rebalancer personality (
runRebalancerinsrc/relayer/index.ts) does:checkForUnfilledDepositsAndFill(simulate=true)still runs the full evaluateFill path, which:tokenClient.decrementLocalBalance(destinationChainId, outputToken, outputAmount)setFillStatus(Filled)(same optimistic booking as the fill-path liveness issue)executeFills(chainId, simulate=true)drops the queue without restoring balances or clearing fill statusSo for every deposit that would have been filled, destination inventory is booked as spent without any transfer. Immediately afterward,
rebalanceInventoryIfNeeded()reads those poisoned local balances and may:trackCrossChainTransferif those rebalances are dry-run or failEnvironment
across-protocol/relayer9bb9d79888e2371a0e616563aed1361e69fba264src/relayer/index.ts(runRebalancer);src/relayer/Relayer.ts(evaluateFill/fillRelay/executeFills)Root cause
evaluateFillalways decrements local balance (and sets fill status) before enqueue, independent ofsimulate.executeFills(..., simulate=true)returns without sending and without rolling back balances orfillStatus.runRebalancerintentionally usessimulate=true“to consider fills” before rebalancing, but does not refreshtokenClientfrom chain (nor clear fill status) between the two steps.Net: simulate-only fill accounting poisons the ledger that drives real rebalance transactions.
Expected vs actual
checkForUnfilledDepositsAndFill(true)FilledProof of concept (Hardhat / Level-2)
Expected:
Assertions (test intent):
deposit.outputAmountafter a simulate-only fill loopfillStatusbecomesFilled(related optimistic-status issue)Impact
Not claimed: an unauthenticated attacker withdrawing user escrow, or a standalone on-chain double-spend of user funds via this bug alone.
An adversary who can create many unfilled, profitable-looking deposits may amplify inventory poisoning on rebalancer bots; that still targets operator ledgers/decisions, not a direct user-fund drain primitive.
Suggested fix
Prefer (1) so dry-run is side-effect free:
evaluateFill/ fill booking: do notdecrementLocalBalanceorsetFillStatus(Filled)whensimulate === true(threadsimulateinto booking, or book only after successful non-simulate submit/confirm).runRebalancer: after simulate fills, forcetokenClient.clearTokenData()+tokenClient.update()and clear poisonedfillStatusbeforerebalanceInventoryIfNeeded.Related / out of scope
Filled+ balance debit without rollback on sim/submit failure (same booking primitives; distinct entrypoint: normal fill vs rebalancer simulate-then-rebalance).trackCrossChainTransferbefore send / dry-run mutation (inventory send path; can compound with this issue).Thanks!