Skip to content

fix(finalizer): protect pending swap orders' source balance from Binance finalizer withdrawals#3559

Closed
droplet-rl wants to merge 1 commit into
masterfrom
droplet/C0AFB0BP3SB-1783490172-528569
Closed

fix(finalizer): protect pending swap orders' source balance from Binance finalizer withdrawals#3559
droplet-rl wants to merge 1 commit into
masterfrom
droplet/C0AFB0BP3SB-1783490172-528569

Conversation

@droplet-rl

Copy link
Copy Markdown
Contributor

Problem

The Binance finalizer deducts pending rebalance orders' destination-token amounts (getPendingRebalances) from the shared exchange account's withdrawable balance, but nothing protects the source-token side of an order in PENDING_DEPOSIT. When a swap order's deposit is credited while the finalizer has withdrawal backlog in the same coin, the finalizer withdraws the just-credited funds to service that backlog. The order then fails its balance check every cycle ("Available balance for input token ... is less than amount to transfer"), never places its spot order, and starves until REBALANCER_PENDING_ORDER_TTL (default 1h) elapses and BaseAdapter._redisCleanupPendingOrders prunes it.

Observed 7 times over 2026-07-01 → 2026-07-08 on USDT→USDC (HyperEVM) swap-rebalancer orders. Fully traced example (2026-07-08, cloid 0xe42d273cb1ce29c53aad23e9241606cd):

  • 04:56:09 — 82,385.72 USDT-BNB transferred to the Binance deposit address (tx), order created in pending-deposit
  • ~04:58 — Binance credits the deposit
  • 04:59:27 — finalizer withdraws exactly 82,385.724629 USDT back to BSC against a pre-existing 158k amountToFinalize backlog (pendingRebalanceDeduction was 0 for USDT — only the destination-side USDC was protected)
  • 05:56:12 — order pruned without finalization

Change

  • Add RebalancerAdapter.getPendingDepositSourceAmounts(account) — source-token amounts of orders in PENDING_DEPOSIT, keyed by source chain/token — implemented generically in BaseAdapter from the existing Redis status set (all adapters extend BaseAdapter).
  • getPendingBinanceRebalanceDeductions now also deducts these source-token amounts. The source- and destination-side maps are netted to positive per-coin amounts independently before being summed, so negative on-chain adjustments in getPendingRebalances (e.g. intermediate bridge legs) cannot cancel the source-side protection.
  • Docs: updated the Binance Finalizer section of src/rebalancer/README.md.

The deduction is deliberately conservative: for same-coin (no-swap) routes the order is briefly protected on both source and destination sides while in PENDING_DEPOSIT; over-deduction only delays finalizer sweeps, which self-resolves when the order progresses or is pruned.

Not in scope (follow-up)

The prune events also revealed a second issue: the 30-minute SWAP deposit tag expires while the finalizer's deposit-history lookback is much longer, so every swap deposit — including successfully swapped ones — is later re-counted into the finalizer's amountToFinalize ledger (this is where the 158k/248k backlog that consumed the traced order's deposit came from). That accounting fix is intentionally left to a separate PR.

Testing

  • yarn tsc --noEmit clean
  • hardhat test test/BinanceFinalizer.ts — 9 passing (2 new)
  • hardhat test test/RebalancerClient.cumulativeRebalancing.ts — 19 passing
  • eslint + prettier clean on touched files

🤖 Generated with Claude Code

…nce finalizer withdrawals

The Binance finalizer deducts pending orders' destination-token amounts
(getPendingRebalances) from the shared exchange account's withdrawable
balance, but nothing protects the source-token side. When a swap order's
deposit credits while the finalizer has withdrawal backlog in the same
coin, the finalizer withdraws the just-credited funds, the order can
never place its spot order, and it starves until
REBALANCER_PENDING_ORDER_TTL prunes it. Observed 7 times over
2026-07-01..08 on USDT->USDC (HyperEVM) orders, e.g. cloid
0xe42d273cb1ce29c53aad23e9241606cd, whose 82,385.72 USDT deposit was
withdrawn back to BSC within 3 minutes of crediting.

Add RebalancerAdapter.getPendingDepositSourceAmounts(), implemented
generically in BaseAdapter from the PENDING_DEPOSIT status set, and
deduct these source-token amounts in the Binance finalizer. The source-
and destination-side maps are netted to positive amounts independently
before being summed, so negative on-chain adjustments (intermediate
bridge legs) cannot cancel the source-side protection.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@droplet-rl

Copy link
Copy Markdown
Contributor Author

Closing in favor of #3560 per discussion in Slack: rather than deducting pending orders' source-token amounts from the finalizer's withdrawable balance (treating the symptom), #3560 stops the phantom amountToFinalize debt from forming in the first place by tying the SWAP deposit tag's lifecycle to order state — persistent while the order lives or after it consumes the deposit, deleted on TTL prune so the finalizer can reclaim abandoned funds.

@droplet-rl droplet-rl closed this Jul 8, 2026
nicholaspai added a commit that referenced this pull request Jul 8, 2026
#3560)

## Problem

`_depositToBinance` tags each swap deposit `SWAP` in Redis so the
Binance finalizer excludes it from its `amountToFinalize` ledger — but
the tag expired on a **30-minute wall clock**, while the finalizer's
deposit-history lookback is far longer. Once the tag lapsed,
`getBinanceDepositType` returned `UNKNOWN` and the deposit was
re-counted as a bridge deposit awaiting withdrawal — **even when its
swap had completed and the funds were long gone**. This phantom debt was
then paid out of the *next* order's just-credited deposit, starving that
order until `REBALANCER_PENDING_ORDER_TTL` pruned it.

Observed 7 times over 2026-07-01 → 07-08 on USDT→USDC (HyperEVM) routes.
Traced example (cloid `0xe42d273cb1ce29c53aad23e9241606cd`, 2026-07-08):
its 82,385.72 USDT deposit
([tx](https://bscscan.com/tx/0x2a845bbb98e1347aa594e8fdde705d74d42d4ae22ed9884fdfe1d014603a60ff))
was credited at ~04:58 and withdrawn back to BSC by the finalizer at
04:59:27 against a 158k phantom backlog created by the *previous,
successfully completed* order (`0x32e9b903`, swap filled 04:16,
finalized 04:21, tag expired ~04:31). The starved order was pruned at
05:56:12 — the PagerDuty alert that kicked this off.

## Change

Tag lifecycle now follows **order state** instead of wall clock (the
withdrawal-side SWAP tag already worked this way — set with the Redis
default 1-week TTL):

- **Deposit** — `setBinanceDepositType(..., SWAP)` without explicit TTL
→ Redis default (1 week), which outlives the finalizer lookback. A
`cloid → {chainId, transactionHash}` mapping is stored alongside the
order state so later transitions can find the tag.
- **Order abandoned in `PENDING_DEPOSIT`** (TTL prune) — new
`BaseAdapter._onExpiredOrderPruned` hook (no-op by default, wrapped so
it can't break the shared read path); the Binance adapter deletes the
deposit's tag, handing the unconsumed funds to the finalizer for reclaim
on its next run.
- **Order progressed past `PENDING_DEPOSIT`** — the deposit was consumed
by the spot order; the tag stays, so the finalizer's ledger never
re-counts consumed deposits.

Behavior change: stuck-funds recovery starts when the order dies
(`REBALANCER_PENDING_ORDER_TTL`, default 1h) rather than 30 minutes
after deposit. This also removes the race where the finalizer could
reclaim a *live* order's deposit that was legitimately waiting out
Binance's deposit-unlock confirmations (RW00441).

Not touched: `scripts/swapOnBinance.ts` (interactive operator flow
manages its own tag TTL); orders created before this change have no txn
mapping — the prune hook logs and skips, falling back to today's
behavior.

Replaces #3559, which protected the balance from the phantom debt but
didn't stop the phantom debt from forming.

## Testing

- `yarn tsc --noEmit` clean
- `hardhat test test/BinanceAdapter.helpers.ts
test/BinanceAdapter.withdrawals.ts test/BinanceUtils.ts
test/BinanceFinalizer.ts` — 48 passing
- eslint + prettier clean on touched files

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: droplet-rl <284132418+droplet-rl@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: nicholaspai <npai.nyc@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant