Idempotency keys for Solana.
Retry without executing twice.
An onchain idempotency layer for Solana actions. Prepend one instruction to the transaction you already build, and a rebuilt retry can no longer execute the same logical intent a second time.
CI runs on a clean Ubuntu runner that has never seen this repository: it installs the pinned
Solana and Anchor toolchains, builds both programs for SBPFv3, verifies every declare_id!
against its committed keypair, checks formatting and lints, runs the full Rust suite against
the compiled SBF artifact in LiteSVM, and runs the SDK typecheck, build, tests and
dual-format check. That is the strongest evidence in this repository — every other number
was measured on the author's machine, and this one is reproduced from a fresh checkout by
someone else's computer.
┌──────────────────────────────────────────────────────────────────┐
│ one atomic Solana transaction │
├──────────────────────────────────────────────────────────────────┤
│ 1. commit_once::claim(namespace, key, payload, retention) │
│ receipt absent → create it, continue │
│ receipt present → ERROR → whole transaction reverts │
│ 2. …your existing business instructions… │
└──────────────────────────────────────────────────────────────────┘
Solana's runtime deduplicates transactions by message hash. Agave's own source says so:
"add the message hash to the status cache to ensure that this message won't be processed again with a different signature"
That protects signed bytes. It does not protect a logical intent.
Your client submits a payment. The response times out. The client cannot tell whether the transaction landed. So it rebuilds — fresh blockhash, a higher priority fee because the first attempt was slow, maybe a different route, re-signed. The message hash is now different, so the runtime sees a brand new transaction. Both can land.
Solana's production-readiness guide, rechecked on 2026-10-08, explains that rebroadcasting the same signed transaction is safe because it preserves its signature. Rebuilding is a different operation: the live A/B below shows two distinct messages executing the same unguarded intent. Application-level intent deduplication is needed for that case.
Transaction-delivery vendors also document retry boundaries. Helius documents that sendTransaction
"does not alter the transaction in any way" and warns that re-signing "can lead to
duplicate transactions being confirmed". Triton tells clients to "handle retries in your
own code."
CommitOnce lets the chain answer "has this guarded intent already happened?" using authority-scoped keys, explicit retention and an atomic receipt. Closely related deployed prior art is compared below.
For one
(authority, namespace, idempotency key)tuple, no more than one guarded transaction may successfully commit during the receipt retention period.
This is at-most-once successful execution within a retention window. Combined with ordinary retry-until-success it gives exactly-once-style application semantics. It is not a claim of universal exactly-once execution, and this repository does not describe it as one anywhere.
Two properties fall out of putting the guard in the same atomic transaction as the business instructions:
- A receipt cannot exist without its action having committed. If a later instruction fails, the receipt creation rolls back too, so a failed attempt stays retryable instead of being permanently poisoned.
- A blocked duplicate cannot partially execute. When
claimerrors, the transaction fails and the business instructions after it never run.
Both are asserted by tests, not just described in prose.
pnpm install
bash scripts/build.sh # builds both programs with the Solana toolchain
bash scripts/test.sh # runs the full suite against the compiled SBF artifactUse it from an application:
import { createCommitOnceClient, encodeIntent } from '@commitonce/solana';
const commitOnce = createCommitOnceClient({ rpc });
// The fingerprint covers what the intent *is* — never how it was transported.
// Including a blockhash, priority fee or signature here would defeat the product.
const claim = await commitOnce.prepare({
authority, // must be a signer on the transaction
namespace: 'payments', // stable scope, so two apps can reuse the same key text
idempotencyKey: 'order-928',
intent: { recipient, amount, mint, memo: 'invoice-928' },
retention: '24h', // optional; defaults to 24h
});
// Prepend to the transaction you were already building. Same atomic transaction.
const transaction = [claim.instruction, ...yourBusinessInstructions];On a rebuilt retry, reuse the same key. The receipt already exists, so claim fails,
the transaction reverts, and your action runs once. See
docs/QUICKSTART.md and
docs/INTEGRATION_PLAYBOOK.md.
Every figure below comes from executing the real compiled program, not from estimation.
Reproduce with bash scripts/test.sh --test benchmarks -- --nocapture.
The structural cost is exact. The compute-unit cost is not. Compute units vary from run to run on both the real cluster and in the in-process test harness, so they are given as ranges and the highest figure is the one to budget against. The byte and account counts are deterministic and are the numbers that actually determine the wire cost and the deposit.
| Business action alone | With the guard | Delta | |
|---|---|---|---|
| Transaction size (legacy) — exact | 273 bytes | 677 bytes | +404 bytes |
| Accounts — exact | 3 | 7 | +4 |
| Compute units — observed range | 4,067 – 10,067 | 12,404 – 22,904 | +8,337 – +12,837 |
| Compute units, per operation | Observed on devnet | In-process harness (median) |
|---|---|---|
claim alone |
9,292 | 9,283 |
claim blocked as a duplicate |
8,407 | 8,053 |
close_receipt (cleanup) |
not measured | 2,701 (no variance observed) |
| Business action alone | 4,067 / 7,067 | 5,567 |
| Receipt account — exact | |
|---|---|
| Data length | 202 bytes |
| Rent deposit | 1,676,400 lamports (0.0016764 SOL) |
claim instruction data |
144 bytes |
claim accounts |
4 |
Reading these honestly.
The structural cost is the solid claim. +404 bytes is 128 bytes of account keys plus a 276-byte instruction. +4 accounts are the receipt PDA, the Instructions sysvar, the System program and the CommitOnce program id — the System program is usually already present, so in practice it is usually +3. That is the whole cost, and it is exact.
The compute cost is the weaker claim, so it is stated as a range. The same unmodified test
binary, running the same byte-identical .so, has reported a bare counter increment anywhere
from 4,067 to 10,067 CU and the guard delta from 8,337 to 12,837. On devnet the claim
instruction consumed 9,292 CU when it succeeded and 8,407 CU when it rejected a duplicate —
higher than the in-process harness reports, which is one more reason to trust the cluster over
the harness. Budget ~23,000 CU for a guarded transaction, which is about 11% of the
200,000 CU default budget and well under the 400,000 CU limit the demo transactions were
granted. The relative cost falls as the guarded action grows, because this baseline is a
trivial counter increment.
The deposit is not a cost. It is fully refunded by close_receipt; only the cleanup
transaction fee is spent. The rent rate is mainnet's 5080 lamports/byte since SIMD-0437 step 2.
Note that the solana-rent Rust crate still ships a stale 6960 lamports/byte, so any estimate
derived from it is 37% too high — the test harness overrides the sysvar to the real value.
Because compute units are not reproducible, the benchmark asserts only the exact structural
numbers and the ordering that must hold (guarded > bare). Asserting a specific compute-unit
value would make the suite flaky and would assert something that is not a property of the
program.
The receipt is a plain program-owned PDA whose address is the intent's identity:
receipt PDA = find_program_address(
seeds = [
b"commit-once", // 11 bytes — domain prefix
authority, // 32 bytes — the signer
namespace_hash, // 32 bytes — sha256("commitonce/namespace/v1" ‖ namespace)
idempotency_key_hash, // 32 bytes — sha256("commitonce/key/v1" ‖ key)
],
program_id = CiiKHnzF1u9Nr5CuD7FgouN5oHs7pNeCUFuRgBCJrLnB,
)
"Has this intent already committed?" is therefore answered by whether an account exists — a check that is atomic with the action it guards and that no amount of rebuilding can bypass, because it lives in chain state rather than in signed bytes.
Four design decisions carry most of the weight:
The authority is in the seeds. A third party who learns your namespace and key derives a
different address. They cannot consume your key and cannot block your intent. This is
enforced by the program's seeds constraint rather than left to callers to remember — which
matters, because an un-scoped "have I done this already?" guard is a griefing vector:
whoever guesses an id first can permanently block it.
Hashes are derived client-side. Fixed-size instruction data, unbounded strings kept out
of PDA seeds, and a derivation pinned to golden hex vectors in two languages (Rust and
TypeScript). packages/sdk/scripts/print-vectors.mjs recomputes the same values from
@solana/kit primitives without importing the SDK, so the two suites cannot agree by
construction.
The payload fingerprint distinguishes "same intent" from "same key, different intent".
Reusing a key with a different fingerprint is reported as IdempotencyConflict rather than
silently returning the first result. The fingerprint covers semantic fields only — recipient,
amount, mint, memo, order id — and never blockhash, signature, priority fee, retry count or
submission route, because including any of those would make a retry look like a new intent
and defeat the entire product.
Retention is explicit and bounded. 0 means permanent. Otherwise the range is one hour
to 365 days. close_receipt refunds the deposit and requires both the monotonic slot
deadline and the wall-clock deadline to have passed, so a change in slot timing can only
ever delay cleanup — never accelerate it into a window where a still-valid signed
duplicate could execute. Cleanup is permissionless but cannot steal: the program constrains
the destination to the refund_destination recorded at claim time.
A durable-nonce transaction never expires, so an old signed duplicate stays executable
forever. Combining one with a cleanable receipt would reopen the duplicate window, so
claim detects a System Program AdvanceNonceAccount in the Instructions sysvar and
refuses unless the retention is permanent.
Full detail, including the account-by-account and byte-by-byte layouts, is in
docs/ARCHITECTURE.md.
Is the devnet deployment the code in this repository? One command answers it:
bash scripts/verify-deployment.shIt dumps both programs from devnet and compares them against the built artifacts. See
EVIDENCE.md §3.
bash verify.sh # everything: 17 steps, PASS/FAIL summary, true exit code
bash scripts/build.sh # builds, then verifies declare_id! against deploy-keys/
bash scripts/test.sh # 60 tests, exit code 0verify.sh is the one to run. It checks prerequisites, builds both programs, verifies
declare_id! against the committed keypairs, runs the Rust suite and the benchmarks, then
typechecks, builds, tests and dual-imports the SDK, typechecks the demo and all four examples,
checks the brand assets against the site palette, and checks the documentation for broken links,
unreadable evidence logs, impossible test counts and corrected claims that have crept back. It
fails loudly rather than silently skipping a step, and a step that could not run counts as a
failure, not a pass. Last full run: RESULT: PASS (17 steps ran and passed).
The tests execute the real compiled SBF artifact through LiteSVM — not a mock, not a reimplementation. They assert on observable onchain state: counter values, account existence, lamport balances, PDA addresses. No test asserts on an error message string.
| Suite | Tests | Covers |
|---|---|---|
invariant.rs |
11 | the core guarantee, atomic rollback, scoping, same-slot races, guard transparency |
retention.rs |
9 | expiry gates, rent refund, permissionless cleanup, boundary values |
security.rs |
10 | griefing, receipt substitution, malformed state, durable-nonce policy |
wire_format.rs |
10 | discriminator and layout pinning, golden hash vectors, rent rate |
benchmarks.rs |
1 | the overhead table above |
The load-bearing one, in outline:
// Two genuinely rebuilt transactions — fresh blockhash, different priority fee —
// so the runtime's message-hash dedup cannot help.
let attempt_1 = env.send_distinct(&[claim, increment], 1);
let attempt_2 = env.send_distinct(&[claim, increment], 2);
assert_success(&attempt_1);
assert_custom_error(&attempt_2, E_ALREADY_COMMITTED);
assert_eq!(env.counter_value(&authority), 1); // ran exactly onceThe same scenario without the guard is a test too
(without_guard_two_rebuilt_transactions_execute_twice), and it demonstrates the bug: the
counter reaches 2.
apps/demo/ runs that A/B against the deployed devnet programs and prints
explorer links. It was executed on 2026-09-21; the raw output is kept at
submission/evidence/devnet-demo-run.log and the
signatures are listed in EVIDENCE.md.
PAYER_KEYPAIR=~/.config/solana/id.json node apps/demo/commitonce-demo.ts scenario counter onchain expected result
A without-guard 2 2 PASS
B with-guard 1 1 PASS
The retry in scenario B did not fail for some unrelated reason: it failed with
AlreadyCommitted, and the guarded increment never ran a second time.
The A/B above rebuilds one intent twice. apps/demo/concurrent-claim.ts
is the harder case: it puts one idempotency key into several transactions at once and fires
them at real devnet validators. Each attempt is a genuinely distinct signed transaction (a
different priority fee, so the runtime's message-hash deduplication cannot connect them), and
they are all built against one blockhash, so they target the same slot.
PAYER_KEYPAIR=~/.config/solana/id.json node apps/demo/concurrent-claim.ts --attempts 5 # priority fee slot outcome error
0 1000 501956389 SUCCESS succeeded
1 8000 501956391 FAILED instruction 1 failed with custom program error 6000
2 15000 501956391 FAILED instruction 1 failed with custom program error 6000
3 22000 501956391 FAILED instruction 1 failed with custom program error 6000
4 29000 501956391 FAILED instruction 1 failed with custom program error 6000
counter before 0 counter after 1 business action executions 1
Exactly one of five committed; the rest were rejected onchain, not in simulation. Raw output:
submission/evidence/devnet-contention-run.log.
One caveat, stated rather than buried: those five attempts landed in two slots. A slot's
leader decides what to pack and a client cannot force two transactions into one slot, so this
proves contention on a live cluster — the same-slot case is proven in-process by
only_the_first_of_many_attempts_commits. Both are real; they are not the same experiment.
This section exists because the honest answer matters more than a clean story, and because a judge or investor will find these anyway.
The retry boundary matters. Solana's production-readiness guide explains safe rebroadcast
of the same signed transaction; the live A/B demonstrates the additional rebuild case.
Squads' nonce-guard
program inspects the Instructions sysvar to detect durable-nonce transactions — the same
introspection technique used here. Anchor's init idiom (create-or-fail on an existing
account) is the same primitive at a smaller scale, and p-never-nonce and
solana-asm/shield ship the "prepend a tiny generic guard program" UX shape.
Helium's lazy_transactions genuinely implements at-most-once execution that survives
rebuilds, but requires instructions to be pre-committed into an authority-owned Merkle tree
keyed by a u32 leaf index, so it is application-specific rather than generic. The
Stripe-style Idempotency-Key pattern is the closest conceptual relative, and SDP's
specification includes transactionId in its fingerprint — a primary-source admission that
HTTP idempotency keys do not solve rebuilt transactions.
The closest product is Light Protocol's nullifier-program
(NFLx5WGPrTHHvdRNsidcrNcLxRruMC92E4yv7zhZBoT), which is real, live on mainnet and devnet,
permissionless, generic, and describes itself in much the same terms. It is not materially
equivalent, for five concrete reasons:
Light nullifier-program |
CommitOnce | |
|---|---|---|
| Seeds | ["nullifier", id] — no signer |
authority-scoped, enforced by seeds |
| Griefing | anyone who guesses an id can claim and block it | a third party derives a different address |
| Dependencies | requires an RPC/prover (create_nullifier_ix(rpc, …), fetch_proof) |
self-contained; derivation is local and pure |
| Storage | compressed accounts, ~15,000 lamports, Light state trees, upgradeable | plain 202-byte PDA, 1,676,400 lamports, refundable |
| Namespaces | none | first-class |
| Status | README: "unaudited, use at your own risk" | also unaudited — see below |
The September research snapshot recorded 71 crate downloads and an npm publication dated 2026-02-05. Package downloads do not measure onchain adoption, and neither figure establishes current usage or maintenance. The technical differences above, rather than an adoption claim, are the basis for continuing this project.
The full landscape survey, with sources and raw notes, is in
docs/PRIOR_ART.md.
What is genuinely new here is not the receipt-PDA-abort-if-exists mechanism, which is old and widely used. It is the combination: an authority-scoped key space enforced by the protocol, a self-contained instruction that needs no RPC or prover, explicit namespaces, a bounded retention window with a refundable deposit and permissionless cleanup, and the productization around it.
programs/
commit-once/ the guard program (Anchor 1.2.0)
demo-counter/ a business program that knows nothing about CommitOnce
programs-native/
native-guard/ a program with no Anchor dependency, used only by tests/native_cpi.rs
to prove a non-Anchor program can CPI into `claim`
packages/sdk/ @commitonce/solana — dual ESM/CJS, zero runtime dependencies
apps/
demo/ the A/B demo CLI, and the live contention test — both run against devnet
web/ the product site — static, no build step
examples/ SOL transfer, SPL transfer, custom program, Jupiter-style swap
docs/ architecture, security model, integration playbook, prior art
submission/ the Colosseum submission package
scripts/ build.sh, test.sh
verify.sh one command that checks all of the above
deploy-keys/ the program keypairs the program IDs are derived from
programs-native/ is separate from programs/ because it cannot be a member of the Anchor
workspace: anchor build refuses a member without an idl-build feature, and a program that is
not written with Anchor has no IDL to give it. It is excluded from the Cargo workspace and built
directly by scripts/build.sh. See docs/ARCHITECTURE.md §12.
| Program ID (all clusters) | CiiKHnzF1u9Nr5CuD7FgouN5oHs7pNeCUFuRgBCJrLnB |
| Demo counter program | EnMnEKVFXFCTTMJYs7C6HhYhsS8MqLrhNVbx11LdeSh5 |
| Rust tests | 60 passing, exit 0 |
| SDK tests | 79 passing |
| Built for | SBPFv3, Anchor 1.2.0, Solana 4.x toolchain |
| Mainnet | not deployed |
| Devnet | deployed and verified live — see EVIDENCE.md §3 |
| Audited | no |
| npm | not published |
Nothing in this repository is claimed to be deployed, audited, integrated by a third party, or used by anyone. Where a capability depends on something this project does not control, that is stated rather than implied.
- The window is finite unless you choose
permanent. After cleanup the key is free again. This is a deliberate trade-off, andclosing_frees_the_key_for_a_new_claimasserts it so it cannot be forgotten. - The authority is a single account. No multisig or threshold authority in the program
itself. A PDA authority — which is what a Squads vault is — needs a CPI that signs for it with
invoke_signed, and both steps are tested (tests/cpi.rs). - Durable-nonce transactions require
retention: 'permanent'. - A conflicting payload is detected, not resolved. The program reports it; deciding what to do is the application's job.
- The fingerprint is only as good as what you put in it. Pass semantic fields, never transport details.
- Unaudited. See
SECURITY.md.
If you are judging this, start with submission/JUDGE_README.md
— it is written for that and links to everything else.
| QUICKSTART | zero to a guarded transaction |
| CONCEPTS | the mental model, and why message-hash dedup is not intent dedup |
| ARCHITECTURE | mechanism, layouts, design decisions |
| SECURITY MODEL | threat model, failure modes, non-goals |
| INTEGRATION PLAYBOOK | adding the guard to an existing app |
| API REFERENCE | every instruction, account, error and export |
| FAQ | the hard questions, answered directly |
| PRIOR ART | the landscape, with sources |
| EVIDENCE | what was verified, and how — the authority on every claim |
Project and process:
| JUDGE README | the entry point for a reviewer, and an index of submission/ |
| TECHNICAL OVERVIEW | the whole system in one document |
| WORK LOG | what was built during the Contest Period, and what went wrong |
| TRACTION | honest zeros, and why they are zeros |
| COLOSSEUM REQUIREMENTS | the official rules, tracked with sources |
| NEEDS OWNER ACTION | what only the owner can do, and why |
| SECURITY | disclosure policy, and the audit status |
| CONTRIBUTING | how to build, test and submit changes |
| RELEASE RUNBOOK | shipping a build |
Apache-2.0. See LICENSE.