Solana gives you replay protection for free, and it protects less than most people assume.
★A signature is included at most once. That is the entire guarantee — and it is a guarantee about bytes, not about intent.★
The Line That Decides Everything
// ★Safe. Same bytes, same signature, included at most once.★
await connection.sendRawTransaction(raw);
await connection.sendRawTransaction(raw);
await connection.sendRawTransaction(raw);
// ★Not safe. New blockhash → new signature → a second execution.★
const rebuilt = buildTransaction(intent, await freshBlockhash());
await connection.sendRawTransaction(rebuilt.serialize());
Both look like "retrying." Only one of them is.
★Every duplicate-execution bug on Solana lives on the wrong side of this line.★ A retry loop that resends the serialized bytes cannot double-spend. A retry loop that reconstructs the transaction can, and will, the first time a submission lands without being observed.
The dangerous sequence — and note that sendRawTransaction returning an error does not mean the bytes never arrived:
send tx A → network hiccup, response lost
you assume failure → rebuild as tx B
tx A lands ← ★you never saw it★
tx B lands ← ★executed twice★
Resolve Before You Rebuild
The rule that makes rebuilding safe: never rebuild without first establishing what happened to the previous signature.
async function safeRetry(intent, lastSig, lastValidBlockHeight) {
if (lastSig) {
const { value } = await connection.getSignatureStatuses([lastSig]);
const st = value[0];
if (st && !st.err) return { status: "already_done", sig: lastSig };
if (st && st.err) return { status: "reverted", err: st.err };
// ★Null status: only safe to rebuild once the window has closed.★
const height = await connection.getBlockHeight();
if (height <= lastValidBlockHeight) {
return { status: "still_pending" }; // ★keep resending, do not rebuild★
}
}
return { status: "rebuild_ok" };
}
★The middle branch is the one that gets skipped.★ A null status inside the validity window means "not yet," not "never." Rebuilding there is exactly how you end up with two live transactions expressing the same intent.
If your retry logic is correct and transactions are still expiring, a free BoltTx key is one line to test the submission path.
Deduplicate on Intent, Not on Signature
Signature-level protection cannot help when the same decision is made twice, because each decision produces different bytes.
// ★A user double-taps. Two intents, two signatures, two executions.★
const key = hashIntent({ userId, action, mint, amount, windowMinute });
if (await recentlyExecuted(key)) {
return { skipped: "duplicate intent" };
}
await markExecuting(key);
★The window is part of the key.★ Without it, a user who legitimately wants to buy the same token twice is blocked. With too coarse a window, a fast repeat is allowed through. Bucketing by minute is a common compromise, and the right value depends on how quickly a genuine repeat is plausible.
Where this matters most: Telegram bots (double taps), AI agents (retry loops and re-prompts), webhooks (at-least-once delivery), and any queue with automatic retries.
Put the Guard On Chain When Funds Move
Client-side deduplication races against your own retries, against a restart, and against another instance of your service.
★An on-chain check does not race.★
// The program refuses a second execution.
require!(!position.settled, ErrorCode::AlreadySettled);
position.settled = true;
Three ways to express it:
A state flag. Simple, and enough for one-shot operations like settling a position.
A sequence number. The instruction carries an expected nonce; the program rejects anything that does not match.
A per-epoch or per-period marker. For actions that should happen once per interval rather than once ever.
★For anything moving user funds, the extra account is worth it.★ Every client-side guard has a failure mode where two processes both pass the check; an on-chain guard has none.
Idempotent by Construction
Some operations do not need a guard because repeating them changes nothing:
// ★Succeeds whether or not the account already exists.★
createAssociatedTokenAccountIdempotentInstruction(payer, ata, owner, mint)
★Prefer the idempotent variant wherever one exists.★ The non-idempotent version fails when the account is already there, which reverts the entire atomic transaction — so on a retry, a previously successful step becomes the reason the whole thing fails.
The general shape worth looking for: an instruction that asserts a desired end state rather than performing a delta. "Ensure this account exists" retries safely; "create this account" does not.
Multi-Instance Deployments
Two processes running the same logic is where careful single-process idempotency stops working.
// ★Atomic claim, not check-then-act.★
const claimed = await db.query(
`INSERT INTO executions (key, status)
VALUES ($1, 'running')
ON CONFLICT (key) DO NOTHING
RETURNING key`,
[intentKey],
);
if (!claimed.rowCount) return { skipped: "claimed elsewhere" };
★A SELECT followed by an INSERT is not a guard.★ Both instances can read "not executed" before either writes. The uniqueness constraint has to do the work, in one statement.
This is also the reason multi-region bot deployments need one region deciding and several submitting the same signed bytes — independently built transactions have different signatures, and both can land.
What Landing Looks Like
Real transactions through our delivery nodes: median confirmation 336ms — under one slot.
★Predictable landing reduces how often you face the ambiguous case at all.★ Most duplicate-execution incidents start with a transaction whose fate was unclear long enough that someone decided to rebuild.
Where BoltTx Fits
We handle submission. Idempotency design stays in your code and in your program.
Submissions route through our own delivery nodes in four regions with stake-weighted routing and no public mempool exposure, so a transaction is not observable in transit before it lands.
You sign locally. ★We never modify transaction contents, so bytes you resend through us produce the same signature and cannot execute twice.★ We never hold funds and never sign. The tip travels inside the transaction, paid on chain from your own wallet, and reverts with the transaction if it fails, because that is how Solana handles atomic transactions. You pay only on transactions that reach the chain.
Get a free API key. No monthly fee:
const connection = new Connection("https://la.bolttx.io/?api-key=YOUR_KEY");
FAQ
Can a Solana transaction execute twice? Not the same signed transaction — a signature is included at most once. But a rebuilt transaction has a different signature and can execute even if the original already landed.
Is it safe to resend the same Solana transaction? Yes. Identical bytes produce an identical signature, so continuous resubmission until the blockhash expires is the standard pattern and cannot double-execute.
Why is rebuilding a transaction dangerous? A fresh blockhash produces a new signature, which Solana treats as an entirely separate transaction. If the original landed unobserved, both execute.
When is it safe to rebuild? Only after establishing what happened to the previous signature. If the status is null but the blockhash is still valid, keep resending — rebuilding there is how duplicates happen.
How do I make retries idempotent? Resend serialized bytes rather than reconstructing. Where a rebuild is unavoidable, resolve the prior signature first, and put the real guard on chain for anything moving funds.
How do I stop a user double-tapping from buying twice? Deduplicate on a hash of the intent within a time window, before building anything. Signature-level protection does not help, since two taps produce two different transactions.
What should be in an intent deduplication key? The user, the action, the parameters, and a time bucket. Without the bucket a legitimate repeat is blocked; with too coarse a bucket a fast repeat slips through.
How do I prevent duplicates across multiple instances? An atomic claim in shared storage, such as an insert with a uniqueness constraint. A read followed by a write is not a guard, since both instances can read the same state first.
How do I enforce idempotency on chain? A state flag, a sequence number, or a per-period marker checked inside the program. Unlike client-side checks, an on-chain guard cannot be raced by two processes.
What is an idempotent instruction?
One that asserts a desired end state rather than performing a delta. createAssociatedTokenAccountIdempotentInstruction succeeds whether or not the account exists, so retries are safe.
Why does my retry fail with an account already exists error? You used a non-idempotent create instruction. On the retry, the previously successful creation becomes the reason the whole atomic transaction reverts.
Does Solana have nonces like Ethereum? Not for ordinary transactions — replay protection comes from the blockhash and the signature. Durable nonces exist for delayed submission and also act as a single-use guard.
Can two regions running my bot both execute the same trade? Yes, if each builds its own transaction. Have one region decide and fan the same signed bytes to several submission points, since identical bytes are safe anywhere.
What happens if I lose the response after sending? The transaction may have been submitted successfully. Check the signature status before assuming failure, because assuming failure and rebuilding is the classic path to a double execution.
Should I record the signature before or after sending? Before. If you record only after success, a crash between sending and recording leaves a transaction that landed but is marked incomplete, and the restart sends it again.
Is a database transaction enough to prevent duplicates? It prevents two of your own writes, not two chain executions. Once a transaction is submitted, only the signature or an on-chain check governs whether it can run twice.
Related Reading
- Solana Transaction Stuck Pending
- Solana Transaction Retry Patterns
- Solana Airdrop Distribution
- Where to Deploy a Solana Trading Bot
- Solana Transaction Landing