Strike Log

LND-04

Retries Without Duplicates: Resend the Bytes, Not the Build

The dangerous retry is not the one that sends too often. It is the one that quietly builds a second transaction while the first is still alive, and then reports one outcome for two things that can both execute.

Fault card

Symptom
A swap executes twice, or a run spends more than its intent list
Mechanism
A rebuilt transaction is a new signature, not a replacement
Instrument
Attempt log keyed by intent, signature status per attempt
Correction
One intent, one signed artefact, until its deadline passes

Resending the identical signed bytes of a Solana transaction is safe: the runtime deduplicates by signature and the blockhash bounds the replay window. Rebuilding the same intent is not safe, because the rebuilt message has a different signature and the network treats it as an unrelated transaction that can execute in addition to the first.

The rule in one line

One intent produces one signed artefact, and that artefact is resent until it lands or its deadline passes. Nothing about it changes in between. If the intent needs different parameters, that is a new intent, and it does not start until the previous artefact is provably dead.

Everything else in this entry is a consequence of that rule. It is short because it has to survive being applied at three in the morning by someone reading a stack trace, and any version with an exception clause will be applied wrongly under exactly the conditions that make duplicates expensive.

Why resending is safe

A transaction's signature is a function of the entire message, including the blockhash, the instructions and the account list. The runtime keeps a cache of signatures it has recently processed and refuses to process one twice. A second submission of an already-executed transaction is rejected with a status saying so, which is a confirmation of landing rather than a failure.

The blockhash provides the other half of the guarantee. Because the message is only acceptable while its blockhash is inside the acceptance window, there is no long tail during which an old copy might resurface. The transaction is either processed inside its window or it is permanently dead, and the cache only has to cover that window.

Together these two properties mean resending costs requests and nothing else. It does not risk double execution, it does not need coordination, and it is the correct response to the failure class where a transaction was accepted by an endpoint and then never seen again. Retry documentation published for the Solana send path describes the same mechanism from the client side.

Why rebuilding is not

Change one byte and the signature changes. To the network, the rebuilt transaction has no relationship to the original: it is a different message, subject to its own validity window, competing for its own place in a block. If the original is still live, there are now two live transactions expressing one intention.

The failure this produces is not a crash. Both transactions land, both execute, and the account ends up with twice the position change and twice the fees. Nothing in the logs says duplicate, because from the runtime's point of view nothing unusual happened. The only place the problem is visible is in a ledger that counts intents alongside signatures, and most senders count only signatures.

This is why the most common source of duplicates is a well-intentioned retry helper. A wrapper that catches a timeout and calls the build function again looks defensive and is the exact mechanism that produces the fault. The wrapper has to be given the signed artefact, not the recipe.

The asymmetry is worth stating plainly. Resending too often costs requests. Rebuilding once too early costs a duplicate execution and its fees. A retry design should be biased toward the first mistake, because the first is recoverable and the second is not.

What breaks a deterministic build

If a builder is deterministic, the same intent produces the same bytes and an accidental rebuild is harmless. Most builders are not, and the sources of non-determinism are usually invisible until they cause a duplicate.

SourceWhy it changes the bytesFixCost of the fix
Fresh blockhash on rebuildThe blockhash is part of the signed messagePin the blockhash to the artefact, not the sendNone; it is a code ordering change
Re-quoted routeAmounts and account lists move with the quoteQuote once per intent and store the resultA slightly staler price on the retry
Timestamps or nonces in memo dataAny changing field changes the hashRemove them, or fix them at intent creationLoss of a debugging field unless it is stored elsewhere
Unordered account collectionsIteration order of a hash map is not stableSort account lists explicitlyA few lines and a test
Dynamic compute unit priceThe fee instruction is part of the messageSet it once per artefact; escalation is a new intentYou cannot chase a rising fee market mid-flight

The last row is the one operators resist, because it removes a lever they want. It does not remove it permanently; it defers it. A fee can be escalated on the next intent, once the current artefact is dead. What cannot happen is escalating and keeping the old transaction alive at the same time, which is the arrangement that produces two fills.

The attempt state machine

A retry loop is easier to reason about as a state machine than as a set of conditionals, because the terminal states are then explicit and unreachable states become obvious. Five states are enough.

StateEntered whenLeaves toTerminal
BuiltBytes signed, deadline recordedIn flightNo
In flightFirst send returned a signatureLanded, ExpiredNo
Landed and succeededStatus appeared with no error-Yes
Landed and failedStatus appeared with an error object-Yes
ExpiredLast valid block height passed, no status-Yes

Three terminal states and no others. There is no timeout state, because a timeout is not a fact about the transaction. There is no unknown state, because the deadline resolves every attempt eventually. And there is no retrying state, because retrying is an activity performed while in flight rather than a condition of the transaction.

Written this way, the duplicate hazard becomes structural rather than accidental. A new artefact for the same intent may only be created when the previous one reached a terminal state. That single guard, checked in one place, is easier to keep correct than a scatter of conditions inside a retry helper.

Cadence and who owns the retry

Two parties can resend: your loop and the RPC node you submitted through. The send call accepts a maximum retry count, and the default behaviour has the node rebroadcast on your behalf. If your loop also resends, the effective rate is the sum of two things you are only measuring one of.

Picking one owner is the correction, and both choices are defensible. Letting the node own it is less code and less observable. Owning it yourself means setting the node's retry count to zero and running your own cadence, which costs you the loop and gives you a request count you can actually reconcile against your endpoint bill.

On cadence itself, the usual backoff intuition transfers poorly. Exponential backoff exists to protect an overloaded service, and here the constraint is a hard deadline rather than a service under stress. A steady interval that fits a known number of resends inside the window is easier to budget: choose the interval, divide the remaining window by it, and you know exactly how many attempts you have bought.

  • Decide the owner of the retry explicitly, and write it down where the send options are set.
  • Express the cadence in blocks rather than milliseconds, because the deadline is in blocks.
  • Stop resending the moment a status appears, not after the next scheduled tick.
  • Stop resending when the deadline passes, because further sends are discarded by the network.
  • Count resends per attempt, not just attempts, or endpoint usage will be a mystery.
  • Log the response of every resend, including the already-processed rejection, which is a landing signal.

Escalating a fee without duplicating

The situation is common: an attempt is in flight, the network has become busier, and a higher priority fee would help. There are exactly two honest options and they price differently.

The patient option waits for the original artefact to reach a terminal state, then builds a new intent with a higher fee. It cannot produce a duplicate. It costs whatever remains of the original window, which during a busy period is precisely the time you wanted to save.

The aggressive option sends a second artefact while the first is still live and accepts that both may land. This is only defensible when the intent is genuinely idempotent at the application level, or when the duplicate cost is small enough to be treated as an expense. It must never be the default, and it must be a decision made in the design rather than by a helper function at runtime.

There is no third option that gets both properties. The fee lives inside the signed message, so changing it changes the message. Any tool that claims to bump a fee on a live transaction is really sending a second one, and the question to ask is what it does when the first one lands anyway. This is the same class of question worth asking about custody, key handling and abort behaviour when deciding whether is a Solana volume bot safe has an answer you find acceptable for your own funds.

Idempotency above the chain

Signature deduplication protects one artefact. It does not protect an intention, because the network has no idea two different signatures were meant to be the same trade. That protection has to be built one layer up, in the record that decides whether an intent is allowed to produce another artefact at all.

The structure that works is unremarkable and is the same one used for payment retries anywhere else: a stable identifier created when the intention is formed, and a record that owns every attempt made for it. The identifier is generated before any network call, never regenerated, and never derived from anything that changes. Everything downstream refers to it.

  1. Create the intent record first. Amount, venue, signer, tolerance and a stable identifier, written before a blockhash is fetched. Costs one write and makes every later question answerable.
  2. Attach each artefact to the intent. Signature, blockhash, last valid block height, send time. One intent may accumulate several artefacts over its life, but only in sequence.
  3. Guard artefact creation on terminal state. A new artefact is refused unless the previous one is landed or expired. This is the single check that prevents duplicates, and it belongs in one function.
  4. Close the intent, not the attempt. An intent finishes when it has a landed artefact or when the operator gives up on it. Attempts finish on their own deadlines and say nothing about the intent's outcome.
  5. Reconcile counts at the end of a run. Intents, artefacts and landed artefacts should satisfy an obvious relationship. When they do not, the retry guard leaked, and the ledger will show exactly where.

The cost of this is real but small: one persistent record per intention and a discipline about where artefacts are created. What it buys is a run whose reported numbers survive scrutiny, and a duplicate that cannot happen quietly. Systems without it usually discover the problem through a balance that does not match the plan, which is the most expensive way to find out.

A worked retry ledger

The figures below are illustrative and chosen for the arithmetic. Only the base fee of 5,000 lamports per signature is a protocol constant.

DesignIntentsSignatures createdLandedBase fees paid
Resend identical bytes only200200170850,000 lamports
Rebuild on every timeout2003402641,320,000 lamports

The second row looks better on landing count and is worse in every way that matters. It landed 264 transactions for 200 intentions, so 64 of them were unintended positions. It paid 55 percent more in base fees and an unknown amount more in priority fees, and its landing rate of 264 over 340 is not comparable to the first row's 170 over 200 because the denominators mean different things.

That comparability problem is the quiet damage. A run that rebuilds cannot report a landing rate against intents, only against signatures, and signatures are a number it generates itself. The measurement stops describing the network and starts describing the retry helper.

Handing the loop to something else

Retry correctness is unglamorous work that has to be right rather than clever, which makes it a reasonable thing to delegate if the send path is not what you are trying to learn. A hosted Solana volume bot platform owns this loop on your behalf, and the transfer is only sound if you can still see the two numbers that expose a bad loop.

Those numbers are intents and signatures. If a report shows only one of them, a rebuild-on-timeout design and a resend-only design are indistinguishable from the outside, and the first will look like the better performer. Asking for both is not an unreasonable request, and the answer is a fair proxy for how carefully the rest of the send path was built.

The rule survives delegation unchanged. One intent, one signed artefact, resent until it lands or dies, and no new artefact until the previous one is terminal. Whoever runs the loop, that is what correct looks like, and everything else in a retry design is a preference.

Questions this entry keeps getting

Is it safe to send the same Solana transaction twice?

Yes, if it is byte-for-byte the same transaction. The runtime keeps a cache of recently processed signatures and rejects a second submission of one it has already executed, and once the referenced blockhash ages out the transaction cannot be processed at all. Resending identical signed bytes is the standard way to survive a dropped packet.

What makes a retry produce a duplicate?

Rebuilding. A transaction rebuilt with a fresh blockhash, a fresh quote or a different compute price is a different message with a different signature, and the network has no way to know it was meant to replace the first one. If the original is still inside its validity window, both can land and both can execute.

Should the client or the RPC node handle retries?

One of them, not both. The send call accepts a maximum retry count, and leaving it at the default means the node rebroadcasts on your behalf while your loop may also be resending. Setting that count to zero and owning the cadence yourself makes the behaviour observable, at the cost of writing the loop.

How does an attempt end correctly?

On evidence, not on a timer. Either a signature status appears, which ends the attempt as landed and either succeeded or failed, or the last valid block height passes with no status, which ends it as expired. Those are the only two terminal conditions, and everything else is still in flight.

Can I raise the priority fee on a transaction that is already in flight?

Not on that transaction. The fee is set by an instruction inside the message, so changing it means a new message and a new signature. The safe sequence is to wait for the original to expire and then send a replacement, which costs the remaining window. The fast sequence is to send both, which accepts the risk that both land.

Does an already processed error mean the retry failed?

It means the opposite. The signature is already on chain, so the transaction landed and the resend was correctly suppressed. A log that records this as an error produces a failure rate contaminated with successes, which is one of the most common measurement faults in automated senders.

Filed under Landing. Arithmetic on this page is either a documented protocol constant or an illustrative example built from numbers you supply. If a figure here is wrong or has moved, send the desk a correction and the entry gets amended rather than quietly rewritten.