26 KiB
Durable Transaction Nonces
This guide covers Solana's durable transaction nonces, which enable transactions to remain valid indefinitely by replacing the time-limited recent blockhash mechanism. Essential for offline signing, multi-signature coordination, and scheduled transaction execution.
Introduction
The Expiration Problem
Solana transactions normally include a recent_blockhash field that serves two purposes:
- Double-spend prevention: Ensures each transaction is unique and can only be processed once
- Transaction freshness: Limits transaction validity to prevent spam and stale transactions
The limitation:
- Recent blockhashes expire after 150 blocks (~60-90 seconds)
- Transactions with expired blockhashes are permanently rejected
- Cannot be re-validated, even with identical content
Critical constraint:
Transaction must be:
1. Signed with recent blockhash
2. Submitted to network
3. Processed by validator
4. Confirmed in block
All within ~60-90 seconds!
Problems This Creates
Hardware Wallet Users:
- Fetch blockhash from network
- Transfer to air-gapped device
- User reviews and signs (can take minutes)
- Transfer back to online device
- Submit to network
- Risk: Blockhash expires during manual review
Multi-Signature Wallets (DAOs, Squads, Realms):
- Create transaction with blockhash
- Send to signer 1 for approval (hours/days)
- Send to signer 2 for approval (hours/days)
- Send to signer N for approval
- Risk: Blockhash expires while collecting signatures
Scheduled Transactions:
- Want to pre-sign transaction for future execution
- E.g., vesting unlock, scheduled payment, conditional trade
- Risk: Cannot pre-sign hours/days in advance
Cross-Chain Bridges:
- Wait for finality on source chain (minutes/hours)
- Sign transaction on destination chain
- Risk: Blockhash expires during cross-chain confirmation
The Solution: Durable Nonces
Durable nonces replace recent_blockhash with a stored on-chain value that:
- ✅ Never expires (remains valid indefinitely)
- ✅ Changes with each use (prevents replay attacks)
- ✅ Enables offline signing without time pressure
- ✅ Supports multi-signature coordination
- ✅ Allows pre-signing transactions for future execution
Key insight: Instead of using the blockchain's recent history (blockhashes) to ensure uniqueness, durable nonces use a dedicated account that stores a nonce value and advances it after each transaction.
How Durable Nonces Work
Core Mechanism
- Nonce Account: On-chain account (owned by System Program) storing a 32-byte nonce value
- Transaction Structure: Use nonce value as
recent_blockhashfield - Nonce Advancement: First instruction MUST advance the nonce to prevent replay
- Authority Control: Only nonce authority can advance nonce or authorize transactions
Transaction Flow
Normal Transaction:
1. Fetch recent blockhash (expires in 90s)
2. Build transaction with blockhash
3. Sign transaction
4. Submit (must be within 90s)
5. Process and confirm
Durable Nonce Transaction:
1. Create nonce account (one-time setup)
2. Fetch current nonce value (no expiration!)
3. Build transaction with nonce as blockhash
4. Add advance_nonce instruction (MUST be first)
5. Sign transaction (no time pressure)
6. Submit anytime (minutes, hours, days later)
7. Process: advances nonce, executes instructions
Double-Spend Prevention
Without expiration, how does it prevent double-spending?
The nonce value changes after each transaction:
// Transaction 1 with nonce value "ABC123..."
{
recent_blockhash: "ABC123...", // Current nonce value
instructions: [
advance_nonce_account(...), // Changes nonce to "XYZ789..."
transfer(...),
]
}
// If you try to submit Transaction 1 again:
// Runtime checks: Is "ABC123..." the current nonce?
// NO! It's now "XYZ789..."
// Transaction REJECTED (nonce mismatch)
Critical: The runtime always advances the nonce, even if the transaction fails after the advance instruction. This prevents replay attacks.
Nonce Account Structure
Account Layout
Nonce accounts are owned by the System Program and have this structure:
pub struct NonceState {
pub version: NonceVersion,
}
pub enum NonceVersion {
Legacy(Box<NonceData>),
Current(Box<NonceData>),
}
pub struct NonceData {
pub authority: Pubkey, // Who can authorize nonce operations
pub durable_nonce: Hash, // The actual nonce value (32 bytes)
pub fee_calculator: FeeCalculator, // Historic fee data
}
Account requirements:
- Owner: System Program (
11111111111111111111111111111111) - Size: 80 bytes
- Rent exemption: Required (minimum ~0.00144768 SOL)
- Authority: Can be any pubkey (keypair or PDA)
Nonce Authority
The authority pubkey controls the nonce account:
- Can: Advance nonce, withdraw funds, authorize nonce transactions, change authority
- Cannot: Execute other instructions without nonce advancement (runtime enforces this)
Authority options:
- Keypair: Direct control (hot wallet, cold wallet)
- PDA: Program-controlled nonces (advanced use case)
- Multisig: Multiple signers required (DAO wallets)
Creating Nonce Accounts
Using Native Rust
use solana_sdk::{
instruction::Instruction,
pubkey::Pubkey,
signature::{Keypair, Signer},
system_instruction,
sysvar::rent::Rent,
transaction::Transaction,
};
use solana_client::rpc_client::RpcClient;
fn create_nonce_account(
rpc_client: &RpcClient,
payer: &Keypair,
nonce_account: &Keypair,
authority: &Pubkey,
) -> Result<(), Box<dyn std::error::Error>> {
// Calculate rent-exempt balance for nonce account
let rent = rpc_client.get_minimum_balance_for_rent_exemption(80)?;
// Create account instruction
let create_account_ix = system_instruction::create_account(
&payer.pubkey(),
&nonce_account.pubkey(),
rent, // Lamports (rent-exempt minimum)
80, // Space (nonce account size)
&solana_program::system_program::id(), // Owner (System Program)
);
// Initialize nonce instruction
let initialize_nonce_ix = system_instruction::initialize_nonce_account(
&nonce_account.pubkey(),
authority, // Nonce authority
);
// Build transaction
let recent_blockhash = rpc_client.get_latest_blockhash()?;
let transaction = Transaction::new_signed_with_payer(
&[create_account_ix, initialize_nonce_ix],
Some(&payer.pubkey()),
&[payer, nonce_account], // Both payer and nonce account must sign
recent_blockhash,
);
// Send transaction
let signature = rpc_client.send_and_confirm_transaction(&transaction)?;
println!("Created nonce account: {}", signature);
Ok(())
}
Single-Step Creation
There's also a convenience function that combines both steps:
let instruction = system_instruction::create_nonce_account(
&payer.pubkey(),
&nonce_account.pubkey(),
authority,
rent_lamports,
);
// This creates a single instruction that:
// 1. Creates the account
// 2. Initializes it as a nonce account
Using CLI
# Generate keypair for nonce account
solana-keygen new -o nonce-account.json
# Create nonce account
solana create-nonce-account nonce-account.json 0.0015
# Verify creation
solana nonce nonce-account.json
# Output: Current nonce value (32-byte hash)
Querying Nonce Accounts
Fetching Nonce Value
use solana_sdk::account::Account;
use solana_program::system_program;
fn get_nonce_value(
rpc_client: &RpcClient,
nonce_pubkey: &Pubkey,
) -> Result<Hash, Box<dyn std::error::Error>> {
// Fetch account data
let account = rpc_client.get_account(nonce_pubkey)?;
// Verify it's a nonce account
if account.owner != system_program::id() {
return Err("Account is not owned by System Program".into());
}
// Deserialize nonce data
let nonce_data = bincode::deserialize::<NonceState>(&account.data)?;
match nonce_data {
NonceState::Current(data) => Ok(data.durable_nonce),
NonceState::Legacy(data) => Ok(data.durable_nonce),
}
}
Parsing Nonce Account
use solana_program::nonce::state::{Data, State};
fn parse_nonce_account(account_data: &[u8]) -> Result<Data, Box<dyn std::error::Error>> {
let state: State = bincode::deserialize(account_data)?;
match state {
State::Initialized(data) => Ok(data),
State::Uninitialized => Err("Nonce account not initialized".into()),
}
}
// Access nonce components
fn display_nonce_info(nonce_data: &Data) {
println!("Authority: {}", nonce_data.authority);
println!("Nonce value: {}", nonce_data.blockhash);
println!("Fee calculator: {:?}", nonce_data.fee_calculator);
}
Building Transactions with Durable Nonces
Transaction Structure
Critical requirements:
- First instruction MUST be
advance_nonce_account - Use nonce value as
recent_blockhash - Sign with nonce authority (in addition to other required signers)
use solana_sdk::{
hash::Hash,
instruction::Instruction,
message::Message,
signature::{Keypair, Signer},
system_instruction,
transaction::Transaction,
};
fn build_nonce_transaction(
nonce_pubkey: &Pubkey,
nonce_authority: &Keypair,
nonce_value: Hash,
instructions: Vec<Instruction>,
payer: &Keypair,
) -> Transaction {
// 1. Create advance_nonce instruction (MUST BE FIRST)
let advance_nonce_ix = system_instruction::advance_nonce_account(
nonce_pubkey,
&nonce_authority.pubkey(),
);
// 2. Combine with your instructions
let mut all_instructions = vec![advance_nonce_ix];
all_instructions.extend(instructions);
// 3. Build message with nonce as blockhash
let message = Message::new_with_blockhash(
&all_instructions,
Some(&payer.pubkey()),
&nonce_value, // Use nonce value instead of recent blockhash!
);
// 4. Sign with both payer and nonce authority
let mut signers = vec![payer];
if nonce_authority.pubkey() != payer.pubkey() {
signers.push(nonce_authority);
}
Transaction::new(&signers, message, nonce_value)
}
Complete Example: Transfer with Durable Nonce
fn transfer_with_nonce(
rpc_client: &RpcClient,
nonce_account: &Pubkey,
nonce_authority: &Keypair,
payer: &Keypair,
recipient: &Pubkey,
amount: u64,
) -> Result<(), Box<dyn std::error::Error>> {
// 1. Fetch current nonce value
let nonce_value = get_nonce_value(rpc_client, nonce_account)?;
// 2. Create transfer instruction
let transfer_ix = system_instruction::transfer(
&payer.pubkey(),
recipient,
amount,
);
// 3. Build transaction with nonce
let transaction = build_nonce_transaction(
nonce_account,
nonce_authority,
nonce_value,
vec![transfer_ix],
payer,
);
// 4. Can now submit immediately or store for later
// No expiration pressure!
let signature = rpc_client.send_and_confirm_transaction(&transaction)?;
println!("Transfer completed: {}", signature);
Ok(())
}
Serializing for Offline Signing
use base58::ToBase58;
fn serialize_for_offline_signing(transaction: &Transaction) -> String {
// Serialize transaction to bytes
let serialized = bincode::serialize(transaction).unwrap();
// Encode as base58 for transport
serialized.to_base58()
}
fn deserialize_signed_transaction(base58_tx: &str) -> Transaction {
use base58::FromBase58;
let bytes = base58_tx.from_base58().unwrap();
bincode::deserialize(&bytes).unwrap()
}
Managing Nonce Accounts
Advancing Nonce
Automatic advancement: When you submit a transaction with a durable nonce, the runtime automatically advances the nonce as part of processing the advance_nonce_account instruction.
Manual advancement (without submitting transaction):
fn advance_nonce_manually(
rpc_client: &RpcClient,
nonce_account: &Pubkey,
nonce_authority: &Keypair,
payer: &Keypair,
) -> Result<(), Box<dyn std::error::Error>> {
let advance_ix = system_instruction::advance_nonce_account(
nonce_account,
&nonce_authority.pubkey(),
);
let recent_blockhash = rpc_client.get_latest_blockhash()?;
let transaction = Transaction::new_signed_with_payer(
&[advance_ix],
Some(&payer.pubkey()),
&[payer, nonce_authority],
recent_blockhash,
);
rpc_client.send_and_confirm_transaction(&transaction)?;
Ok(())
}
When to manually advance:
- Before reusing nonce for a new transaction
- To invalidate a previously signed transaction
- Regular rotation for security
Withdrawing from Nonce Account
fn withdraw_from_nonce(
rpc_client: &RpcClient,
nonce_account: &Pubkey,
nonce_authority: &Keypair,
recipient: &Pubkey,
amount: u64,
payer: &Keypair,
) -> Result<(), Box<dyn std::error::Error>> {
let withdraw_ix = system_instruction::withdraw_nonce_account(
nonce_account,
&nonce_authority.pubkey(),
recipient,
amount,
);
let recent_blockhash = rpc_client.get_latest_blockhash()?;
let transaction = Transaction::new_signed_with_payer(
&[withdraw_ix],
Some(&payer.pubkey()),
&[payer, nonce_authority],
recent_blockhash,
);
rpc_client.send_and_confirm_transaction(&transaction)?;
Ok(())
}
Important: Must maintain rent-exempt minimum balance. Can only withdraw to zero if closing the account.
Changing Nonce Authority
fn change_nonce_authority(
rpc_client: &RpcClient,
nonce_account: &Pubkey,
current_authority: &Keypair,
new_authority: &Pubkey,
payer: &Keypair,
) -> Result<(), Box<dyn std::error::Error>> {
let authorize_ix = system_instruction::authorize_nonce_account(
nonce_account,
¤t_authority.pubkey(),
new_authority,
);
let recent_blockhash = rpc_client.get_latest_blockhash()?;
let transaction = Transaction::new_signed_with_payer(
&[authorize_ix],
Some(&payer.pubkey()),
&[payer, current_authority],
recent_blockhash,
);
rpc_client.send_and_confirm_transaction(&transaction)?;
Ok(())
}
Use cases:
- Transfer control to PDA for program-managed nonces
- Rotate keys for security
- Transfer to multisig for DAO control
Offline Signing Workflows
Hardware Wallet Flow
Setup (online device):
// 1. Create nonce account (one-time)
create_nonce_account(&rpc_client, &payer, &nonce_account, &hw_wallet_pubkey)?;
// 2. Fetch nonce value
let nonce_value = get_nonce_value(&rpc_client, &nonce_account.pubkey())?;
// 3. Build unsigned transaction
let unsigned_tx = build_nonce_transaction(
&nonce_account.pubkey(),
&hw_wallet_keypair, // Will be replaced with actual signature
nonce_value,
vec![transfer_ix],
&payer,
);
// 4. Serialize for hardware wallet
let serialized = serialize_for_offline_signing(&unsigned_tx);
// 5. Transfer to hardware wallet (USB, QR code, etc.)
Signing (air-gapped hardware wallet):
// 1. Receive serialized transaction
let tx = deserialize_signed_transaction(&serialized);
// 2. Display to user for review (no time pressure!)
// User reviews: recipient, amount, etc.
// 3. Sign with hardware wallet private key
// (Hardware wallet handles this internally)
// 4. Export signed transaction
let signed_serialized = serialize_for_offline_signing(&signed_tx);
// 5. Transfer back to online device
Submission (online device):
// 1. Receive signed transaction
let signed_tx = deserialize_signed_transaction(&signed_serialized);
// 2. Submit to network (can be hours/days after signing!)
let signature = rpc_client.send_and_confirm_transaction(&signed_tx)?;
Multi-Signature Coordination
DAO Proposal Execution Flow:
// 1. Proposer creates transaction with nonce
let nonce_value = get_nonce_value(&rpc_client, &dao_nonce_account)?;
let proposal_tx = build_nonce_transaction(
&dao_nonce_account,
&dao_authority, // PDA controlled by governance
nonce_value,
vec![execute_proposal_ix],
&proposer,
);
// 2. Serialize and store in DAO state
let tx_data = bincode::serialize(&proposal_tx)?;
// Store tx_data in proposal account
// 3. Members vote over time (hours/days)
// Each vote increments approval count
// 4. When threshold reached, anyone can execute
let stored_tx: Transaction = bincode::deserialize(&proposal.tx_data)?;
// 5. Submit (nonce ensures it's still valid!)
rpc_client.send_and_confirm_transaction(&stored_tx)?;
CLI Multi-Sig Example
First co-signer (offline):
solana transfer \
--from sender.json \
--sign-only \
--nonce nonce-account.json \
--nonce-authority nonce-authority.json \
--blockhash <NONCE_VALUE> \
--fee-payer co-sender.json \
receiver.json 0.1
# Output:
# Pubkey=Signature
# 5nZ8nY5...=4SBv7Xp...
Second co-signer (online, hours/days later):
solana transfer \
--from sender.json \
--nonce nonce-account.json \
--nonce-authority nonce-authority.json \
--blockhash <NONCE_VALUE> \
--fee-payer sender.json \
--signer 5nZ8nY5...=4SBv7Xp... \
receiver.json 0.1
Security Considerations
The Neodyme Vulnerability (2020)
Historic issue: Before Solana v1.3, there was a critical vulnerability in how durable nonce transactions were processed:
The bug:
- Transaction with durable nonce starts processing
- Runtime advances nonce (changes state)
- Later instruction in transaction fails
- Runtime rolls back ALL state changes
- BUG: Nonce advancement was rolled back too!
- Attacker could replay the transaction
The exploit:
// Malicious transaction:
{
instructions: [
advance_nonce(...), // Advances nonce
write_arbitrary_data(...), // Attacker's payload
fail_intentionally(...), // Forces transaction to fail
]
}
// After rollback:
// - Nonce reverted to original value
// - Arbitrary data write WAS NOT rolled back
// - Can replay transaction infinitely!
Impact: Could write arbitrary data to any account by replaying failed transactions.
Fix (Solana v1.3+): Nonce advancement is now permanent even on transaction failure. The runtime explicitly handles nonce accounts separately from normal rollback logic.
Lesson: This demonstrates why nonce advancement MUST happen regardless of transaction success/failure.
Best Practices
1. Never reuse nonce without advancing
// BAD: Reusing nonce value
let nonce = get_nonce_value(&rpc, &nonce_account)?;
let tx1 = build_nonce_transaction(&nonce_account, &auth, nonce, vec![ix1], &payer);
let tx2 = build_nonce_transaction(&nonce_account, &auth, nonce, vec![ix2], &payer);
// If tx1 fails, tx2 might also fail with "nonce mismatch"
// GOOD: Advance between uses
let nonce1 = get_nonce_value(&rpc, &nonce_account)?;
let tx1 = build_nonce_transaction(&nonce_account, &auth, nonce1, vec![ix1], &payer);
rpc.send_and_confirm_transaction(&tx1)?;
// Fetch fresh nonce (it was advanced)
let nonce2 = get_nonce_value(&rpc, &nonce_account)?;
let tx2 = build_nonce_transaction(&nonce_account, &auth, nonce2, vec![ix2], &payer);
2. Protect nonce authority
// Use cold storage for nonce authority
// OR use PDA with program logic to restrict usage
let authority_pda = Pubkey::find_program_address(
&[b"nonce_authority", dao.key().as_ref()],
program_id,
);
3. Maintain rent exemption
// Check before withdrawal
let nonce_account = rpc.get_account(&nonce_pubkey)?;
let rent = rpc.get_minimum_balance_for_rent_exemption(80)?;
if nonce_account.lamports - withdraw_amount < rent {
return Err("Would violate rent exemption".into());
}
4. Verify nonce advancement in transaction
// In your program that uses nonce transactions:
pub fn process_instruction(
program_id: &Pubkey,
accounts: &[AccountInfo],
instruction_data: &[u8],
) -> ProgramResult {
// First account should be nonce account
let nonce_account = &accounts[0];
// Verify it's a valid nonce account
if nonce_account.owner != &system_program::id() {
return Err(ProgramError::InvalidAccountData);
}
// Verify advance_nonce was called
// (Runtime enforces this, but you can add checks)
Ok(())
}
5. Monitor nonce account balance
// Periodic check (e.g., daily job)
fn check_nonce_health(rpc: &RpcClient, nonce: &Pubkey) -> Result<(), String> {
let account = rpc.get_account(nonce)
.map_err(|_| "Nonce account not found")?;
let rent = rpc.get_minimum_balance_for_rent_exemption(80)
.map_err(|_| "Failed to fetch rent")?;
if account.lamports < rent {
return Err(format!(
"Nonce account below rent exemption: {} < {}",
account.lamports, rent
));
}
Ok(())
}
Use Cases
1. Scheduled Payments (Vesting)
// Pre-sign monthly vesting releases
fn create_vesting_schedule(
rpc: &RpcClient,
nonce_account: &Pubkey,
nonce_authority: &Keypair,
recipient: &Pubkey,
amount_per_month: u64,
months: usize,
) -> Result<Vec<Transaction>, Box<dyn std::error::Error>> {
let mut transactions = Vec::new();
for month in 0..months {
// Fetch current nonce
let nonce = get_nonce_value(rpc, nonce_account)?;
// Create transfer
let transfer_ix = system_instruction::transfer(
&nonce_authority.pubkey(),
recipient,
amount_per_month,
);
// Build nonce transaction
let tx = build_nonce_transaction(
nonce_account,
nonce_authority,
nonce,
vec![transfer_ix],
nonce_authority,
);
transactions.push(tx);
// Advance nonce for next month's transaction
advance_nonce_manually(rpc, nonce_account, nonce_authority, nonce_authority)?;
}
Ok(transactions)
}
// Executor submits each month
fn execute_vesting_payment(
rpc: &RpcClient,
pre_signed_tx: &Transaction,
) -> Result<(), Box<dyn std::error::Error>> {
// No time pressure - can submit anytime!
rpc.send_and_confirm_transaction(pre_signed_tx)?;
Ok(())
}
2. Conditional Trades (Limit Orders)
// Pre-sign trade execution at specific price
fn create_limit_order(
nonce: &Pubkey,
authority: &Keypair,
swap_instruction: Instruction, // Execute when price reached
) -> Transaction {
let nonce_value = /* fetch nonce */;
build_nonce_transaction(
nonce,
authority,
nonce_value,
vec![swap_instruction],
authority,
)
}
// Bot monitors price and submits when condition met
fn execute_limit_order(rpc: &RpcClient, current_price: f64, limit_tx: &Transaction) {
if current_price >= target_price {
rpc.send_transaction(limit_tx).ok(); // Submit pre-signed transaction
}
}
3. Cross-Chain Bridges
// Sign Solana transaction while waiting for Ethereum finality
async fn bridge_from_ethereum_to_solana(
eth_tx_hash: H256,
solana_mint_ix: Instruction,
nonce_account: &Pubkey,
nonce_authority: &Keypair,
) -> Result<(), Box<dyn std::error::Error>> {
// 1. Pre-sign Solana mint transaction
let nonce = get_nonce_value(&solana_rpc, nonce_account)?;
let mint_tx = build_nonce_transaction(
nonce_account,
nonce_authority,
nonce,
vec![solana_mint_ix],
nonce_authority,
);
// 2. Wait for Ethereum finality (12+ minutes)
wait_for_ethereum_finality(eth_tx_hash).await?;
// 3. Submit Solana transaction (still valid!)
solana_rpc.send_and_confirm_transaction(&mint_tx)?;
Ok(())
}
4. DAO Governance Execution
Already covered in multi-sig example above - proposals can be voted on over days/weeks, then executed with pre-signed transaction.
CLI Reference
Create nonce account:
solana create-nonce-account <KEYPAIR_PATH> <AMOUNT>
Get current nonce:
solana nonce <NONCE_ACCOUNT>
Manually advance nonce:
solana new-nonce <NONCE_ACCOUNT>
Get nonce account info:
solana nonce-account <NONCE_ACCOUNT>
Withdraw from nonce:
solana withdraw-from-nonce-account <NONCE_ACCOUNT> <DESTINATION> <AMOUNT>
Change nonce authority:
solana authorize-nonce-account <NONCE_ACCOUNT> <NEW_AUTHORITY>
Sign transaction offline:
solana <COMMAND> \
--sign-only \
--nonce <NONCE_ACCOUNT> \
--nonce-authority <AUTHORITY_KEYPAIR> \
--blockhash <NONCE_VALUE>
Submit pre-signed transaction:
solana <COMMAND> \
--nonce <NONCE_ACCOUNT> \
--nonce-authority <AUTHORITY_KEYPAIR> \
--blockhash <NONCE_VALUE> \
--signer <PUBKEY=SIGNATURE>
Limitations and Considerations
Transaction size:
- Adding
advance_nonce_accountinstruction adds ~40 bytes - May push transaction over size limit if already near maximum
Extra signature requirement:
- Nonce authority must sign (if different from fee payer)
- Increases transaction complexity
Rent cost:
- Each nonce account requires ~0.0015 SOL rent-exempt minimum
- For many scheduled transactions, can become expensive
Nonce advancement overhead:
- Compute units to advance nonce (~few hundred CU)
- Minimal but worth considering for CU-constrained transactions
Cannot mix recent blockhashes and nonces:
- Transaction uses either recent blockhash OR durable nonce
- Cannot use both in the same transaction
Resources
Official Documentation
Code Examples
Security Analysis
- Neodyme: Nonce Upon a Time - Historic vulnerability analysis