Skip to content

fix(svm): re-broadcast Solana tx during confirmation polling#3494

Open
droplet-rl wants to merge 1 commit into
masterfrom
droplet/svm-rebroadcast-during-poll
Open

fix(svm): re-broadcast Solana tx during confirmation polling#3494
droplet-rl wants to merge 1 commit into
masterfrom
droplet/svm-rebroadcast-during-poll

Conversation

@droplet-rl

Copy link
Copy Markdown
Contributor

Summary

_sendAndPoll (src/utils/TransactionUtils.ts) broadcasts a Solana tx exactly once via signAndSendTransaction, then polls getSignatureStatuses for the full budget (25 cycles × 600ms = 15s). If the first-targeted leader skips or drops the tx — common under congestion or with an uncompetitive priority fee — the tx sits in mempool until some later leader happens to ingest it, often beyond our polling budget. That's the failure mode that's been firing as

Solana tx <sig> did not confirm within 15000ms; cannot advance minContextSlot for dependent sends

at Dataworker.ts:3088 (introduced by #3469) on zion-across-l2-executor-solana.

Evidence the tx is landing late, not the RPC lagging

Lined up the on-chain blockTime against the Slack error timestamp for 7 recent failures:

Tx Slack error ts On-chain blockTime Δ
2pDsTZYW… 1781500623.67 1781500622 +1.7s
2TwxY6pM… 1781496122.19 1781496120 +2.2s
5XzGj7ob… 1781488017.42 1781488013 +4.4s
4i8oPkEJ… 1781484417.95 1781484420 −2.0s
4cz5sHCJ… 1781464621.15 1781464619 +2.2s
2rLphv2d… 1781453816.29 1781453814 +2.3s
4UzFw7kD… 1781447532.05 1781447529 +3.0s

Every signature finalised on chain within ±5s of our timeout firing, one of them ~2s after the timeout — so the tx had landed; we just gave up before observing it. RPC lag isn't the culprit.

Frequency on zion-across-l2-executor-solana: 31 of these errors since 2026-06-01 (~2/day). No funds at risk — the next dataworker run sees the leaf as already executed and skips — but it's a steady page-equivalent and a real liveness gap on a heavily-watched bot.

Change

In _sendAndPoll, re-broadcast the same signed wire tx every SOLANA_REBROADCAST_EVERY_CYCLES = 3 polling cycles (~1.8s, a few Solana slots) with skipPreflight: true. The signature is deterministic from the tx body + sigs, so re-broadcasts are idempotent — the polling loop remains the source of truth for confirmation. Errors thrown by re-broadcast (e.g. AlreadyProcessed once the tx has landed, transient provider failures) are intentionally swallowed.

Implementation detail: extracted the prepare/sign/broadcast steps from signAndSendTransaction into a private _prepareAndSendSolanaTransaction helper that returns the signature plus the serialized wire tx and the send options used, so _sendAndPoll can re-broadcast the same tx without re-signing or re-fetching the blockhash. Public signAndSendTransaction signature is unchanged.

Trade-offs considered

  • Why not just bump SVM_REFUND_LEAF_SEND_POLL_CYCLES? Extending the budget would mask the symptom (some txs would happen to land within the wider window) but every send pays the wall-clock cost on every failure, and the actual problem — leader skipping our send and no resend ever happening — would persist. Re-broadcasting is what the standard Solana send-and-confirm loop does.
  • Why skipPreflight: true on re-broadcasts? Preflight already ran on the initial send and either passed (so subsequent state-based rejections on a no-op re-broadcast are spurious) or failed (in which case we wouldn't be here). Skipping it keeps the RPC roundtrip cheap.
  • Why swallow re-broadcast errors? Once the tx has actually landed, the RPC will reject sendTransaction with AlreadyProcessed — that's fine, it means the next poll will see confirmed. Other transient errors are equally non-fatal because the polling loop is what decides success/failure.
  • Why cadence 3 cycles, not e.g. every cycle? Solana slot time is ~400ms; re-broadcasting every poll (600ms) would create unnecessary RPC pressure with little benefit, since the leader assignment changes every 4 slots (~1.6s). 3 × 600ms = 1.8s lands the re-broadcast on a fresh leader window.
  • Why a constant instead of an env knob? Keeping the patch minimal; the cadence is meaningful in terms of Solana's slot/leader cycle and shouldn't need per-deployment tuning. Easy to promote to env-configurable later.
  • Blockhash expiration? Solana keeps the last 150 blockhashes (~60s); within the 15s budget the original blockhash stays valid. If the budget is later widened past ~50s we'd need to refresh, but that's out of scope here.

Out of scope / follow-ups

  • Independent: check what SVM_PRIORITY_FEE_OVERRIDE the executor bot is running with. Even with rebroadcast, an uncompetitive priority fee will keep us in the tail of leader queues — would be worth bumping if it's at default.
  • An SDK-side equivalent fix in @across-protocol/sdk if any downstream consumers use a similar send-and-poll pattern.

Doc updates

No README.md / AGENTS.md updates needed — internal hardening of an existing private code path with no operator-facing contract change.

Test plan

  • yarn typecheck
  • yarn lint
  • npx hardhat test test/TransactionUtils.svm.ts — 8 passing, including 3 new tests:
    • does not rebroadcast when the tx confirms on the first poll
    • rebroadcasts the same wire tx with skipPreflight=true every 3 polling cycles
    • swallows errors thrown by the rebroadcast sendTransaction and keeps polling
  • Deploy to zion-across-l2-executor-solana and watch for the next ~48h. Win condition: zero new did not confirm within 15000ms pages, or — if any do fire — the on-chain landing time is now well past our polling window rather than within ±5s of it (which would indicate priority fee, not send-side, as the remaining gap).

Threads

🤖 Generated with Claude Code

`_sendAndPoll` previously broadcast a tx exactly once and then polled
`getSignatureStatuses` for the full budget (25 cycles × 600ms = 15s).
If the first-targeted leader skipped or dropped the tx — common under
congestion or with an uncompetitive priority fee — the tx would sit in
mempool until some later leader happened to pick it up, often beyond
our polling budget.

Empirical evidence on `zion-across-l2-executor-solana` (~31 occurrences
in 15 days): every timed-out signature finalised on chain within ±5s of
the timeout firing, one of them 2s *after* the timeout. So the tx
landed; we just gave up before observing it. RPC lag is not the issue.

Re-broadcast the same serialized wire tx every 3 polling cycles
(~1.8s, a few Solana slots) with `skipPreflight: true`. The signature
is deterministic from body+sigs so re-broadcasts are idempotent; the
polling loop remains the source of truth for confirmation status.
Errors thrown by re-broadcast (e.g. AlreadyProcessed once the tx has
landed, or transient provider failures) are intentionally swallowed.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.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