Skip to content

Rebalancer runs fill evaluation in simulate mode, which still decrements token balances before inventory rebalance #3623

Description

@jiapy97

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:

  1. Calls tokenClient.decrementLocalBalance(destinationChainId, outputToken, outputAmount)
  2. Enqueues fill txs and setFillStatus(Filled) (same optimistic booking as the fill-path liveness issue)
  3. 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

  1. evaluateFill always decrements local balance (and sets fill status) before enqueue, independent of simulate.
  2. executeFills(..., simulate=true) returns without sending and without rolling back balances or fillStatus.
  3. 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:

  1. 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).
  2. Or in runRebalancer: after simulate fills, force tokenClient.clearTokenData() + tokenClient.update() and clear poisoned fillStatus before rebalanceInventoryIfNeeded.
  3. 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!

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions