Skip to content

fix: Prevent Hedera same-nonce spam retries for successful txs - #108

Open
HelloKashif wants to merge 5 commits into
mainfrom
kashif/hedera-nonce-spam-fix
Open

fix: Prevent Hedera same-nonce spam retries for successful txs#108
HelloKashif wants to merge 5 commits into
mainfrom
kashif/hedera-nonce-spam-fix

Conversation

@HelloKashif

@HelloKashif HelloKashif commented Jul 2, 2026

Copy link
Copy Markdown

Problem

On Hedera, after a successful eth_sendRawTransaction, TXM runs validateOnChainSequence. That reads the mined nonce via SequenceAt (eth_getTransactionCount with latest). If the count is still equal to the tx nonce, TXM treats the send as underpriced and immediately gas-bumps with the same nonce — up to 3 times, no sleep between attempts.

Where this happens in code:

On Hedera, the mined nonce can lag several seconds behind RPC acceptance. The immediate check is too eager.

This matches what we see on Hedera mainnet: bursts of same-nonce OffRamp txs (WRONG_NONCE) a second or two apart.

What we tested

We sent a zero-value self-transfer and checked latest and pending immediately after the RPC accepted the send.

Testnet (tx nonce 227):

  • latest: 227 — unchanged
  • pending: 228 — bumped
  • Current TXM would retry immediately
  • latest caught up to 228 after ~7.4s

Mainnet (tx nonce 85):

  • latest: 85 — unchanged
  • pending: 86 — bumped
  • Same outcome: current TXM would retry immediately
  • latest caught up to 86 after ~9.7s

Same pattern on both: RPC accepts the tx right away, but latest lags by several seconds.

Fix

Before falling back to the gas-bump path, poll SequenceAt with backoff:

  • Initial check immediately after send
  • Up to 3 more checks, 10s apart
  • If latest advances during polling → treat broadcast as successful (no resend)
  • If still flat after ~30s → existing Underpriced / gas-bump retry path

Implemented in sequenceAtAfterBroadcastWithRetries.

Note: why not pending?

We also tried accepting the broadcast when eth_getTransactionCount(addr, "pending") advanced before latest did. That fixed the false-positive retries in our repro, but we did not ship it.

If we treat a bumped pending as success, we skip the gas-bump retry path entirely. In cases where the tx was not actually accepted — both pending and latest stay flat — we would never reach Underpriced and the tx could sit in in_progress indefinitely instead of recovering via a bumped resend.

Polling latest with backoff keeps the existing escape hatch: wait out normal Hedera lag, but still gas-bump if the mined nonce never moves.

After a successful send, validateOnChainSequence only checked the mined
(latest) nonce. On Hedera that count can lag mempool acceptance by
several seconds, which triggered gas-bump retries on the same nonce.

Also consult PendingSequenceAt so a tx accepted into the mempool is
treated as successful even before latest advances.
@github-actions

github-actions Bot commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

👋 HelloKashif, thanks for creating this pull request!

To help reviewers, please consider creating future PRs as drafts first. This allows you to self-review and make any final changes before notifying the team.

Once you're ready, you can mark it as "Ready for review" to request feedback. Thanks!

@github-actions

github-actions Bot commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

✅ API Diff Results - github.com/smartcontractkit/chainlink-framework/chains

✅ Compatible Changes (1)

txmgr/types (1)
  • HederaBroadcastConfig — ➕ Added

📄 View full apidiff report

@HelloKashif HelloKashif changed the title fix(txmgr): stop Hedera same-nonce broadcast retries fix(txmgr): Prevent Hedera same-nonce spam broadcast retries for successful txs Jul 2, 2026
@HelloKashif HelloKashif changed the title fix(txmgr): Prevent Hedera same-nonce spam broadcast retries for successful txs fix: Prevent Hedera same-nonce spam broadcast retries for successful txs Jul 2, 2026
@HelloKashif HelloKashif changed the title fix: Prevent Hedera same-nonce spam broadcast retries for successful txs fix: Prevent Hedera same-nonce spam retries for successful txs Jul 2, 2026
@HelloKashif
HelloKashif enabled auto-merge (squash) July 2, 2026 05:42
@HelloKashif
HelloKashif disabled auto-merge July 2, 2026 05:42
@HelloKashif
HelloKashif enabled auto-merge (squash) July 2, 2026 05:42
@HelloKashif
HelloKashif disabled auto-merge July 2, 2026 07:02
@HelloKashif
HelloKashif force-pushed the kashif/hedera-nonce-spam-fix branch from d9a5c57 to c0f8853 Compare July 3, 2026 06:19
Comment thread chains/txmgr/broadcaster.go Outdated
Comment thread chains/txmgr/broadcaster.go Outdated
Comment on lines +38 to +41
maxHederaBroadcastRetries = 3

// hederaDefaultSequencePollInterval is the delay between Hedera SequenceAt re-polls after a successful send.
hederaDefaultSequencePollInterval = 10 * time.Second

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.

Not PR feedback really but since you're the Hedera expert, is there any fear that legitimate Underpriced errors would slow down the TXM with these configs? In the worst case, 30s could be too much for a product like Data Feeds

@HelloKashif HelloKashif Jul 7, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Yeah for this, I checked and unfortunately the way Hedera works it's difficult to detect the error. Hedera accepts even underpriced txs and then reverts on chain so there is no way to detect if a tx was due to nonce issue or underpricing until the tx gets included. And in my tests I found that even without a mempool (allegedly) the latest vs pending tags are lagging about 10 seconds. So even in the best case there will be a 10 second delay to confirm tx problem which is unavoidable for DF.

I also confirmed that this is not just a CCIP issue, even DF writes are seeing the same WRONG_NONCE reverts for eg here for Hedera feeds. So technically each DF call is already about 30sec delayed due to bumping retries

@amit-momin would you prefer if we can make this configurable via toml in chainlink-evm?

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.

I see so we need this delay for DF even. Although I assume, they may be more open to spending the extra gas for wrong_nonce reverts if that means they catch true underpriced issues quicker.

A separate concern I still have after looking at this code again is the 10s poll interval.If the nonce advances at the 11s, we would still wait till 20s to check. Since this is just a simple NonceAt call, we should increase how frequently we check for the nonce. Something like every second or 2 seconds. Then the cut off instead of number of retries (hederaDefaultSequencePollRetries) should be a overall timeout. Hope this doesn't complicate things even more.

Then I think we may want to make that overall timeout configurable and set the default to maybe 10s so we're still bumping prices as soon as possible in the happy path for DF if that latest v pending lag is usually 10s. Then CCIP can set it to something like 30s so yall are extra sure it's a true underpriced issue. What do you think?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

yeah agreed, its best to make this fully backwards compatible so no change for DF by default. and expose this as config.

Polling is disabled unless txConfig implements the new optional interface
with a positive timeout, preserving legacy single-check behavior for existing
nodes. When enabled, poll every 2s by default until the configured overall
timeout elapses instead of using a fixed retry count.
@HelloKashif
HelloKashif removed the request for review from fernandezlautaro July 8, 2026 11:06

Copilot AI 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.

Pull request overview

This PR introduces a Hedera-specific post-broadcast nonce (sequence) polling mechanism to reduce false-positive “underpriced” retries when eth_getTransactionCount(..., "latest") lags behind RPC acceptance, while also adding configurability for polling behavior.

Changes:

  • Added HederaBroadcastConfig interface to allow configuring Hedera sequence polling timeout/interval.
  • Updated Hedera post-send validation to use a polling helper (pollSequenceAtAfterBroadcast) before falling back to the existing bumped-fee retry path.
  • Added unit tests covering the polling helper’s behavior (legacy single-check, success after advance, timeout, and context cancellation).

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 4 comments.

File Description
chains/txmgr/types/config.go Adds an optional config interface for Hedera sequence polling timeout/interval.
chains/txmgr/broadcaster.go Implements Hedera post-broadcast SequenceAt polling and wiring into sequence validation.
chains/txmgr/broadcaster_hedera_test.go Adds unit tests for the new polling helper function.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +739 to +743
select {
case <-ctx.Done():
return nextSeqOnChain, ctx.Err()
case <-time.After(wait):
}
Comment on lines +40 to +42
// hederaDefaultSequencePollInterval is the delay between Hedera SequenceAt re-polls when polling is enabled
// and no interval is configured.
hederaDefaultSequencePollInterval = 2 * time.Second
require.NoError(t, err)
assert.Equal(t, int64(86), got)
assert.Equal(t, int32(1), calls.Load())
assert.Less(t, time.Since(start), pollInterval)
require.NoError(t, err)
assert.Equal(t, int64(85), got)
assert.GreaterOrEqual(t, calls.Load(), int32(2))
assert.LessOrEqual(t, time.Since(start), pollTimeout+pollInterval)
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.

3 participants