If you've ever built anything on Solana that involved offline signing, hardware wallets, or multi-sig flows, you've hit the same wall: transactions expire in about 60–90 seconds. The blockhash you used to sign the transaction? Gone. Transaction? Dead on arrival.
Durable nonces solve this completely. They let you sign a transaction today and submit it a week from now. No expiry. This guide covers how they work and how to use them with Solana's native web3.js — no frameworks, no abstractions.
What Is a Durable Nonce?
Normally, every Solana transaction includes a recent blockhash. The cluster rejects transactions with a blockhash older than ~150 slots (roughly 60–90 seconds). This is a replay-attack protection mechanism.
A durable nonce replaces the recent blockhash with a nonce value stored in a special on-chain account. Instead of expiring with time, the nonce only advances when the transaction is actually submitted and processed. Until then, it stays valid indefinitely.
The key properties:
- The nonce account stores one nonce value at a time
- Every durable nonce transaction must start with a
SystemProgram.nonceAdvanceinstruction - The nonce advances atomically with the rest of your transaction
- Only the nonce authority can advance or withdraw from the nonce account
When to Use Durable Nonces
- Offline signing: sign on an air-gapped machine, submit later
- Multi-sig flows: collect signatures over hours or days
- Scheduled transactions: prepare a transaction now, execute it at a trigger
- Hardware wallet UX: avoid re-signing due to timeout
Setting Up
Install the Solana web3.js SDK:
npm install @solana/web3.jsImport what you need:
import {
Connection,
Keypair,
SystemProgram,
Transaction,
NonceAccount,
NONCE_ACCOUNT_LENGTH,
sendAndConfirmTransaction,
PublicKey,
clusterApiUrl,
} from "@solana/web3.js";Step 1: Create the Nonce Account
A nonce account is a regular on-chain account with enough lamports to be rent-exempt and owned by the System Program. You need to:
- Generate or designate a keypair for the nonce account
- Fund it with enough lamports (rent-exempt minimum)
- Initialize it with
SystemProgram.createNonceAccount
async function createNonceAccount(
connection: Connection,
feePayer: Keypair,
nonceAuthority: PublicKey
): Promise<Keypair> {
const nonceKeypair = Keypair.generate();
const rentExemptBalance =
await connection.getMinimumBalanceForRentExemption(NONCE_ACCOUNT_LENGTH);
const transaction = new Transaction().add(
// Step 1: allocate + fund the nonce account
SystemProgram.createAccount({
fromPubkey: feePayer.publicKey,
newAccountPubkey: nonceKeypair.publicKey,
lamports: rentExemptBalance,
space: NONCE_ACCOUNT_LENGTH,
programId: SystemProgram.programId,
}),
// Step 2: initialize it as a nonce account
SystemProgram.nonceInitialize({
noncePubkey: nonceKeypair.publicKey,
authorizedPubkey: nonceAuthority,
})
);
await sendAndConfirmTransaction(connection, transaction, [
feePayer,
nonceKeypair,
]);
console.log("Nonce account created:", nonceKeypair.publicKey.toBase58());
return nonceKeypair;
}Step 2: Read the Current Nonce Value
Before building your durable transaction, you need to fetch the current nonce value stored in the account:
async function getNonceValue(
connection: Connection,
nonceAccountPubkey: PublicKey
): Promise<string> {
const accountInfo = await connection.getAccountInfo(nonceAccountPubkey);
if (!accountInfo) {
throw new Error("Nonce account not found");
}
const nonceAccount = NonceAccount.fromAccountData(accountInfo.data);
return nonceAccount.nonce;
}The nonce field is the value you will use in place of a recent blockhash when building your transaction.
Step 3: Build the Durable Nonce Transaction
This is the critical part. Every durable nonce transaction must begin with a SystemProgram.nonceAdvance instruction. This is non-negotiable — the runtime enforces it.
async function buildDurableTransaction(
connection: Connection,
nonceAccountPubkey: PublicKey,
nonceAuthority: Keypair,
sender: Keypair,
recipient: PublicKey,
lamports: number
): Promise<Transaction> {
// Fetch the current nonce value
const nonce = await getNonceValue(connection, nonceAccountPubkey);
const transaction = new Transaction({
// Use the nonce value as the recent blockhash
recentBlockhash: nonce,
feePayer: sender.publicKey,
});
// CRITICAL: nonceAdvance must be the first instruction
transaction.add(
SystemProgram.nonceAdvance({
noncePubkey: nonceAccountPubkey,
authorizedPubkey: nonceAuthority.publicKey,
})
);
// Add your actual instruction(s) after nonceAdvance
transaction.add(
SystemProgram.transfer({
fromPubkey: sender.publicKey,
toPubkey: recipient,
lamports,
})
);
// Sign with all required signers
transaction.sign(sender, nonceAuthority);
return transaction;
}You can now serialize this transaction and store it. It will remain valid until submitted:
// Serialize for storage or offline transport
const serialized = transaction.serialize().toString("base64");
console.log("Signed transaction (store this):", serialized);Step 4: Submit the Transaction Later
When you're ready to submit — could be minutes, days, or weeks later:
async function submitDurableTransaction(
connection: Connection,
serializedTransaction: string
): Promise<string> {
const buffer = Buffer.from(serializedTransaction, "base64");
const signature = await connection.sendRawTransaction(buffer, {
skipPreflight: false,
preflightCommitment: "confirmed",
});
await connection.confirmTransaction(signature, "confirmed");
console.log("Transaction confirmed:", signature);
return signature;
}Full Example: Putting It All Together
import {
Connection,
Keypair,
SystemProgram,
Transaction,
NonceAccount,
NONCE_ACCOUNT_LENGTH,
sendAndConfirmTransaction,
LAMPORTS_PER_SOL,
clusterApiUrl,
} from "@solana/web3.js";
async function main() {
const connection = new Connection(clusterApiUrl("devnet"), "confirmed");
// Generate keypairs for the example
const feePayer = Keypair.generate();
const nonceAuthority = Keypair.generate();
const sender = feePayer; // using feePayer as sender for simplicity
const recipient = Keypair.generate().publicKey;
// Airdrop some SOL to feePayer on devnet
const airdropSignature = await connection.requestAirdrop(
feePayer.publicKey,
2 * LAMPORTS_PER_SOL
);
await connection.confirmTransaction(airdropSignature);
// 1. Create the nonce account
const nonceKeypair = await createNonceAccount(
connection,
feePayer,
nonceAuthority.publicKey
);
// 2. Build a durable transaction
const durableTx = await buildDurableTransaction(
connection,
nonceKeypair.publicKey,
nonceAuthority,
sender,
recipient,
0.1 * LAMPORTS_PER_SOL
);
// 3. Serialize and "store" it
const serialized = durableTx.serialize().toString("base64");
console.log("\nTransaction signed and serialized.");
console.log("You can submit this at any time.\n");
// 4. Submit (in a real scenario, this could happen much later)
const signature = await submitDurableTransaction(connection, serialized);
console.log("Signature:", signature);
}
main().catch(console.error);Common Gotchas
The nonceAdvance instruction must be first. If you add any instruction before it, the transaction will fail. Solana's runtime checks this explicitly.
Only the nonce authority can advance the nonce. If you lose the authority keypair, the nonce account is stuck and you cannot use it anymore. Keep it safe.
One nonce per transaction. Each nonce account can only be used for one pending transaction at a time. If you have multiple in-flight durable transactions, you need multiple nonce accounts.
The nonce advances on success AND on failure. Even if your transaction fails (for any reason other than the nonce instruction itself), the nonce advances. You cannot reuse the same nonce value after a failed attempt.
Check nonce validity before submitting. If someone else advances your nonce account in the meantime, your serialized transaction becomes invalid. Always verify the nonce value still matches before submitting.
async function isNonceStillValid(
connection: Connection,
nonceAccountPubkey: PublicKey,
expectedNonce: string
): Promise<boolean> {
const currentNonce = await getNonceValue(connection, nonceAccountPubkey);
return currentNonce === expectedNonce;
}Withdrawing from a Nonce Account
When you no longer need the nonce account, you can withdraw the lamports back:
async function closeNonceAccount(
connection: Connection,
nonceAccountPubkey: PublicKey,
nonceAuthority: Keypair,
destination: PublicKey
): Promise<void> {
const accountInfo = await connection.getAccountInfo(nonceAccountPubkey);
if (!accountInfo) throw new Error("Nonce account not found");
const transaction = new Transaction().add(
SystemProgram.nonceWithdraw({
noncePubkey: nonceAccountPubkey,
authorizedPubkey: nonceAuthority.publicKey,
toPubkey: destination,
lamports: accountInfo.lamports,
})
);
await sendAndConfirmTransaction(connection, transaction, [nonceAuthority]);
console.log("Nonce account closed, lamports returned.");
}Summary
Durable nonces are one of the most underused primitives on Solana. If you're building anything that involves deferred signing, multi-party flows, or hardware wallets, they're the right tool. The mental model is simple:
- Create and fund a nonce account
- Fetch the nonce value and use it as your
recentBlockhash - Always start with
nonceAdvanceas your first instruction - Sign and serialize — submit whenever you're ready
The transaction will wait for you. The nonce won't expire until you use it.
If this helped, follow for more deep dives into Solana native programming. Questions or corrections? Drop them in the comments.