Skip to content

fix(rebalancer): wait for inventory rebalance tx confirmation#3503

Merged
pxrl merged 1 commit into
masterfrom
droplet/T90K0AL22-C073ELHSDQ8-1781819511-797189
Jun 18, 2026
Merged

fix(rebalancer): wait for inventory rebalance tx confirmation#3503
pxrl merged 1 commit into
masterfrom
droplet/T90K0AL22-C073ELHSDQ8-1781819511-797189

Conversation

@droplet-rl

Copy link
Copy Markdown
Contributor

Problem

The inventory rebalancer submits a cross-chain transfer and then immediately moves on without waiting for the transaction to be mined. The flagged Slack run ("Executed Inventory rebalances 📒") reported rebalances to BNB / zkSync / Base as done while the txs were still only submitted.

Trace:
InventoryClient.rebalanceInventoryIfNeededsendTokenCrossChainAdapterManager.sendTokenCrossChainBaseChainAdapter.sendTokenToTargetChainsubmitTransaction.

In sendTokenToTargetChain, the AugmentedTransaction was built without ensureConfirmation. So TransactionClient._submit skips its entire if (txn.ensureConfirmation) block — the one that awaits txnResponse.wait() and handles TRANSACTION_REPLACED — and returns the pending tx. The caller then just reads { hash } and returns.

Fix

Set ensureConfirmation: true on the rebalance transaction. This routes it through the shared confirmation loop in TransactionClient._submit, which:

  • awaits the receipt before returning (no more fire-and-forget), and
  • is robust to RPC errors like transaction replacement: on TRANSACTION_REPLACED it resubmits under a fresh nonce instead of throwing; on CALL_EXCEPTION it surfaces the revert; otherwise it retries the wait up to maxTries.

This is the same idiom already used by the refiller, the standalone src/rebalancer/adapters/*, the dataworker, and the finalizers — this inventory path was the one place that omitted it.

Scope

In production sendTokenToTargetChain is reached only via the inventory rebalancer (AdapterManager.sendTokenCrossChainInventoryClient), so this does not change behavior for other bots.

Testing

  • tsc --noEmit, eslint, prettier: clean
  • test/generic-adapters/AdapterManager.SendTokensCrossChain.ts — 6 passing
  • test/generic-adapters/zkSync.ts — 21 passing

Note / possible follow-up

The shared TRANSACTION_REPLACED handler resubmits on any replacement. In the ethers reason: "repriced" case (a same-nonce speed-up of the same logical tx) the replacement was already mined, so a resubmit could double-send. That's pre-existing behavior in the shared handler (affects every ensureConfirmation caller), so I left it out of this targeted fix — happy to tighten it to inspect error.cancelled / error.receipt as a separate change if wanted.

🤖 Generated with Claude Code

InventoryClient.rebalanceInventoryIfNeeded submits cross-chain rebalances via
BaseChainAdapter.sendTokenToTargetChain, which built its AugmentedTransaction
without ensureConfirmation. As a result TransactionClient._submit skipped its
receipt-wait + replacement-handling block and returned the pending tx, so the
bot read { hash } and moved on before the rebalance was mined.

Set ensureConfirmation: true so the rebalance routes through the shared
confirmation loop, which awaits the receipt and is robust to RPC errors like
TRANSACTION_REPLACED (resubmits under a fresh nonce instead of throwing) — the
same idiom already used by the refiller, standalone rebalancer adapters,
dataworker, and finalizers.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@pxrl
pxrl merged commit 78bec94 into master Jun 18, 2026
2 of 5 checks passed
@pxrl
pxrl deleted the droplet/T90K0AL22-C073ELHSDQ8-1781819511-797189 branch June 18, 2026 22:01
@droplet-rl

Copy link
Copy Markdown
Contributor Author

Thanks for the review and approval, @pxrl 🙏

I fetched this review's inline comments (/pulls/3503/reviews/4528773188/comments) and the PR-wide review-comment endpoint — both return empty, and the review body is blank, so I read this as a clean approve with no requested changes. Nothing to address on my end; leaving the branch as-is.

One open item from the PR description still stands if you want it as a follow-up: the shared TRANSACTION_REPLACED handler resubmits on any replacement, including the ethers reason: "repriced" (same-nonce speed-up) case where the tx was already mined — a potential double-send. It's pre-existing behavior affecting every ensureConfirmation caller, so I kept it out of this targeted fix, but happy to tighten it (inspect error.cancelled / error.receipt) as a separate change if useful. Otherwise this is good to merge.

@droplet-rl

Copy link
Copy Markdown
Contributor Author

🔎 View trace

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 4be95a023e

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

// Block until the rebalance is mined rather than returning a pending tx. TransactionClient's
// confirmation loop awaits the receipt and is robust to RPC errors like transaction replacement
// (TRANSACTION_REPLACED), resubmitting under a fresh nonce instead of throwing.
ensureConfirmation: true,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Do not resubmit repriced bridge transactions

Because this line now opts inventory bridge deposits into TransactionClient._submit's confirmation path, any same-nonce speed-up/reprice of the transaction can double-send funds: ethers reports a mined same-calldata replacement as TRANSACTION_REPLACED with reason: "repriced"/cancelled: false, but src/clients/TransactionClient.ts:162-167 unconditionally resubmits under a fresh nonce. In that scenario the original logical bridge transfer has already mined, and the recursive submit sends the same rebalance amount again; this should either treat non-cancelled replacements as success using the provided receipt, or avoid enabling confirmation until that distinction is handled.

Useful? React with 👍 / 👎.

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.

Confirmed — this is a real P1. ethers classifies a repriced replacement (cancelled === false) as a gas speed-up of the same logical transaction, which has already mined under a new hash, so the unconditional resubmit here double-sends the transfer. It affects every ensureConfirmation caller, not just the inventory path this PR added.

This PR merged ~2 minutes before the review landed, so the fix is in a follow-up: #3504. There the TRANSACTION_REPLACED branch returns the mined replacement when cancelled === false and only resubmits on genuine cancellations/replacements (cancelled === true), with a regression test covering both paths.

@droplet-rl

Copy link
Copy Markdown
Contributor Author

Thanks Codex — the P1 is valid and I've actioned it.

This PR had already merged (~22:01Z) about 2 minutes before the review landed (~22:03Z), so I couldn't amend it in place. The fix is in a follow-up PR: #3504.

Summary of the issue and fix:

  • ensureConfirmation's TRANSACTION_REPLACED handler in TransactionClient._submit resubmitted under a fresh nonce on any replacement. ethers reports a same-nonce gas speed-up of the same logical tx as TRANSACTION_REPLACED with cancelled === false; in that case the transfer already mined under a new hash, so resubmitting double-sends (e.g. a duplicated inventory rebalance). This affected every ensureConfirmation caller, not just the inventory path fix(rebalancer): wait for inventory rebalance tx confirmation #3503 added.
  • fix(TransactionClient): don't resubmit repriced (already-mined) replacements #3504 returns the mined replacement when cancelled === false and only resubmits on genuine cancellations/replacements (cancelled === true). Added a regression test alongside the existing resubmit test; tsc/eslint/prettier clean and test/TransactionClient.ts passes (11 tests).

I've replied on the inline thread as well and left #3504 for human review (not self-merging).

@droplet-rl

Copy link
Copy Markdown
Contributor Author

🔎 View trace

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.

2 participants