I've spent years making retries harmless in payment and messaging pipelines. Ethereum and Solana solve the same problem with nonces and blockhashes, and the classic double-pay bug is the one I already know.
Learning in public. I haven’t shipped anything on-chain yet. These posts map what I know from production payments and ledgers onto how blockchains handle the same problems.
I haven't shipped on-chain code. I'm teaching myself blockchain engineering, and the fastest way in has been through problems I've already solved off-chain. The first one is replay.
At CoStar, one piece of the campaign-send path I owned was a strict idempotency key: a deterministic key per send, checked before anything leaves the building, so a redelivered message is a no-op instead of a second email. In FieldDay, the dunning retries that chase failed payments are idempotent on the provider's message ID for the same reason. Queues redeliver. Clients retry. Anything that assumes "exactly once" is eventually wrong.
A blockchain has the same problem with worse stakes. A signed transfer is a bearer instrument: anyone who sees the bytes can submit them again. So every chain needs an answer to "what stops this from running twice?" Ethereum and Solana give different answers, and both map onto patterns I already use.
Ethereum: a sequence number per account.
Every Ethereum transaction from an externally owned account carries a nonce. The
network only executes it if the nonce equals the account's current count of sent
transactions, then increments the count. The same signed transaction can't run twice
because, after the first run, its nonce is in the past.
That makes the nonce an idempotency key with an ordering rule bolted on, and the ordering rule is where the operational pain lives:
- Gaps block everything behind them. If nonce 41 is stuck (underpriced, dropped from mempools), nonces 42 through 50 wait behind it. One bad transaction stalls the whole account.
- Replacing is how you cancel. To unstick 41, you send a different transaction with the same nonce and a higher fee. Whichever one is included wins; the other becomes invalid.
- Concurrent senders need an allocator. Two backend workers signing from one hot wallet will both read "next nonce is 41" and collide. You end up building a sequence allocator, with the same care you'd give a database sequence that must never leave holes.
Cross-chain replay is handled separately: since EIP-155, the chain ID is part of what gets signed, so a transaction signed for one EVM chain isn't valid on another.
Solana: a signature with an expiry date.
Solana has no per-account counter. Instead, every transaction includes a recent blockhash, and it's only valid while that blockhash is among the most recent 150 or so blocks. Within that window, validators remember the signatures they've processed and drop duplicates. Outside it, the transaction is simply too old to process.
So the transaction's signature is the idempotency key, and the blockhash is the key's time to live. Duplicates are deduplicated while they could still land, and after that they can't land at all, so nobody needs to remember them forever.
The window is counted in blocks, not seconds, and that matters. On October 9, 2026
Solana finished moving to 200ms slots. The window is still about 150 blocks, which is
now roughly 30 seconds of wall-clock time instead of about a minute. Any code that
assumed "I have a minute to get this signed and sent" just lost half its budget. Read
lastValidBlockHeight from the RPC instead of hard-coding a duration.
The design is nicer than Ethereum's for parallel senders: there's no shared counter, so two workers can sign from the same account without coordinating. The cost is that every transaction has a deadline.
The bug both chains teach: retry versus re-sign.
Here's the mistake I'd recognize from any payment system. A Solana transfer is sent, and the confirmation doesn't come back. Network blip, slow RPC, who knows. The client "retries" by building the transfer again: fetch a fresh blockhash, sign, send.
That isn't a retry. A fresh blockhash means a new signature, which makes it a new transaction with a new idempotency key. If the first one was merely slow, both land, and the user paid twice.
The safe version keeps one live transaction per intent:
- Resend the same signed bytes while the original blockhash is still valid. Same signature, so the network deduplicates. That's a true retry.
- Wait for the deadline. Only once the current block height is past
lastValidBlockHeight, and the signature still isn't found, do you know the first attempt can never land. - Only then re-sign with a fresh blockhash, and record the new signature against the same intent.
The Ethereum version of the same bug is sending a "retry" with a new nonce instead of replacing the stuck one with the same nonce. Different mechanism, same root cause: a retry minted a new key.
// One payment intent → at most one transaction that can still land.
async function sendOnce(intent: Intent, conn: Connection) {
let attempt = await store.currentAttempt(intent.id); // signed bytes + deadline
if (!attempt) attempt = await store.saveAttempt(intent.id, await signFresh(intent, conn));
while (true) {
const { value } = await conn.getSignatureStatuses([attempt.signature]);
const status = value[0];
if (status?.err) return store.fail(intent.id, status.err);
if (status?.confirmationStatus === "confirmed" || status?.confirmationStatus === "finalized")
return store.markLanded(intent.id, attempt.signature);
if ((await conn.getBlockHeight()) > attempt.lastValidBlockHeight) {
// Past the deadline, the old attempt can never land. Check once more in case it
// landed in the gap since the status read above. Only then is a new signature safe.
const { value: [late] } = await conn.getSignatureStatuses([attempt.signature], {
searchTransactionHistory: true,
});
if (late && !late.err) return store.markLanded(intent.id, attempt.signature);
attempt = await store.saveAttempt(intent.id, await signFresh(intent, conn));
}
await conn.sendRawTransaction(attempt.bytes, { skipPreflight: true }); // same bytes = true retry
await sleep(2_000);
}
}The intent ID is my old friend, the application-level idempotency key. The chain's replay protection sits underneath it. It doesn't replace it.
Durable nonces: removing the deadline, and the safety it gave you.
Solana does offer a way around the expiry: a durable nonce. You create a nonce account that stores a value, use that value in place of the recent blockhash, and make the first instruction advance the nonce. The transaction no longer expires, which is what you want for offline signing or a multisig that takes days to collect signatures.
From the payments side, that's a signed bearer instrument with no expiry. It's valid until someone advances the nonce or it gets submitted. That's useful, and it's also exactly the kind of artifact I'd want an inventory of. Solana's own docs flag that durable nonces may be deprecated in a future release, which is one more reason to keep them rare.
The mapping I keep in my head.
| Off-chain pattern | Ethereum | Solana |
|---|---|---|
| Idempotency key | Account nonce | Transaction signature |
| Key TTL | None (valid until replaced or used) | ~150 blocks via recent blockhash |
| Ordering | Strict per account | None per account |
| Safe retry | Same nonce, higher fee | Same signed bytes, until expiry |
| Long-lived intent | Leave it pending | Durable nonce |
None of this replaced what I knew. It gave the old rule, "a retry must reuse the key",
new names. Next I want to build the sendOnce loop above against devnet and break it on
purpose: kill the process between send and confirm, and prove the intent still lands
exactly once.
Owen
Thanks for reading. Cheers.
If you spot something wrong in this post, or you’d have done it differently, tell me. I’d rather be corrected than confidently wrong. owenlrichards2000@gmail.com
— Owen