Files
gh-tenequm-claude-plugins-s…/skills/solana-development/references/transaction-lifecycle.md
2025-11-30 09:01:25 +08:00

979 lines
26 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Transaction Lifecycle: Submission, Retry, and Confirmation
This guide covers the complete lifecycle of Solana transactions from submission to confirmation, including why transactions get dropped, retry strategies, commitment levels, and monitoring patterns for production systems.
## Transaction Journey Overview
### The Full Path
```
[1] Client Creates and signs transaction
[2] RPC Node Validates and forwards
[3] Leader's TPU Transaction Processing Unit pipeline
├─ Fetch Stage Receives from network
├─ SigVerify Stage Verifies signatures
├─ Banking Stage Executes transactions
├─ PoH Service Records in Proof of History
└─ Broadcast Stage Shares with cluster
[4] Cluster Validation Validators vote on blocks
[5] Confirmation Levels
├─ Processed Included in block by leader
├─ Confirmed Supermajority voted (~66% stake)
└─ Finalized 32+ confirmed blocks after (~13 seconds)
```
### Time
line
**Normal flow:**
- Client → RPC: Instant (local network)
- RPC → Leader: 100-400ms (network latency)
- Leader processing: 400-600ms (slot time)
- Confirmed: ~1-2 slots (~800-1200ms)
- Finalized: ~32 slots (~13+ seconds)
**Total time (happy path):** ~1-15 seconds
## Blockhash Expiration
### How Blockhashes Work
Solana transactions include a `recent_blockhash` field for two purposes:
1. **Uniqueness**: Ensures each transaction is unique (prevents duplicates)
2. **Freshness**: Limits transaction validity to prevent spam
**Critical constraint:**
```rust
// Solana runtime maintains BlockhashQueue
struct BlockhashQueue {
last_hash: Hash,
ages: HashMap<Hash, HashAge>,
max_age: usize, // Currently 151
}
// Transaction validation:
fn is_valid_blockhash(blockhash: &Hash, queue: &BlockhashQueue) -> bool {
queue.ages.contains_key(blockhash) // Must be in last 151 blockhashes
}
```
### The 151-Block Window
**How it works:**
1. Each slot produces a new blockhash (~400-600ms per slot)
2. Runtime keeps last 151 blockhashes in `BlockhashQueue`
3. Transactions checked against this queue
4. If blockhash older than 150 blocks → **REJECTED**
**Calculation:**
```
151 blockhashes × ~600ms average slot time = ~90 seconds maximum
151 blockhashes × ~400ms minimum slot time = ~60 seconds minimum
Effective window: 60-90 seconds
```
**Critical**: Once a blockhash exits the queue (>150 blocks old), transactions using it can **never** be processed. They're permanently invalid.
### Detecting Expiration
**Using `lastValidBlockHeight`:**
```rust
use solana_client::rpc_client::RpcClient;
use solana_sdk::commitment_config::CommitmentConfig;
async fn check_transaction_expiration(
rpc_client: &RpcClient,
last_valid_block_height: u64,
) -> bool {
// Get current block height
let current_block_height = rpc_client
.get_block_height()
.unwrap_or(0);
// Transaction expired if current height > last valid height
current_block_height > last_valid_block_height
}
```
**Getting `lastValidBlockHeight`:**
```rust
let blockhash_response = rpc_client.get_latest_blockhash()?;
let blockhash = blockhash_response.value.0;
let last_valid_block_height = blockhash_response.value.1; // Blocks until expiration
println!("Blockhash: {}", blockhash);
println!("Valid until block: {}", last_valid_block_height);
```
### Why Transactions Expire
**Design rationale:**
1. **Prevents replay attacks**: Old transactions can't be resubmitted years later
2. **Manages state bloat**: Runtime doesn't need infinite blockhash history
3. **Network spam protection**: Attackers can't flood network with ancient transactions
4. **Simplifies fee markets**: Recent activity determines current conditions
**Trade-off**: 60-90 second window requires responsive clients and reliable networking.
## How Transactions Get Dropped
### Before Processing
**1. UDP Packet Loss**
Solana uses UDP for transaction forwarding (performance over reliability):
```
Client → RPC: UDP packet
RPC → Leader: UDP packet
Packet loss rate: 0.1-5% depending on network conditions
```
**Impact**: Transaction silently dropped, never reaches leader.
**Detection**: No error, no confirmation - transaction just disappears.
**Solution**: Retry mechanism (RPC default behavior).
**2. RPC Node Congestion**
RPC nodes maintain transaction queues:
```rust
// RPC node queue limits
const MAX_TRANSACTIONS_QUEUE: usize = 10_000;
// When queue full:
if queue.len() >= MAX_TRANSACTIONS_QUEUE {
return Err("Transaction queue full, try again");
}
```
**Impact**: New transactions rejected when queue full.
**Detection**: RPC returns error immediately.
**Solution**: Back off and retry, or use different RPC endpoint.
**3. RPC Node Lag**
RPC nodes can fall behind cluster:
```rust
// Check RPC health
let processed_slot = rpc_client.get_slot()?;
let max_shred_insert_slot = rpc_client.get_max_shred_insert_slot()?;
let lag = max_shred_insert_slot.saturating_sub(processed_slot);
if lag > 50 {
println!("WARNING: RPC is {} slots behind", lag);
// Consider using different RPC node
}
```
**Impact**: Fetches stale blockhashes that expire quickly.
**Solution**: Monitor RPC health, use multiple RPC providers.
**4. Blockhash from Minority Fork**
Clusters occasionally fork temporarily (~5% of slots):
```
Majority fork: Block A → Block B → Block C
Minority fork: Block A → Block X (abandoned)
```
If you fetch blockhash from minority fork:
- Blockhash is valid on minority fork
- Majority fork has different blockhash
- Transaction **never** valid on majority fork
**Impact**: Transaction permanently invalid (never in BlockhashQueue of majority fork).
**Detection**: Transaction never confirms, blockhash never appears in majority chain.
**Solution**: Use `confirmed` commitment level when fetching blockhashes (not `processed`).
### After Processing But Before Finalization
**5. Leader on Minority Fork**
Transaction processed by leader, but leader's block abandoned by cluster:
```
1. Leader processes transaction in slot 1000
2. Cluster votes on slot 1000
3. Supermajority votes for different fork
4. Leader's block (and transaction) discarded
```
**Impact**: Transaction processed but not confirmed. Must resubmit.
**Detection**: Transaction shows as processed but never confirmed.
**Solution**: Wait for `confirmed` level before assuming success.
**6. Transaction Expiration During Retry**
Default RPC retry behavior has limitations:
```rust
// RPC retry logic (simplified):
while !finalized && !expired {
forward_to_leader();
sleep(2_seconds);
}
// Problem: What if we can't determine expiration?
// RPC may stop retrying early!
```
**Impact**: RPC stops retrying before transaction actually expires.
**Solution**: Implement custom retry logic with explicit expiration tracking.
## Commitment Levels
### Understanding Commitment
Solana has three commitment levels representing stages of finality:
```
Processed
↓ (1-2 slots later)
Confirmed
↓ (32+ slots later, ~13 seconds)
Finalized
```
### Processed
**Definition**: Transaction processed by leader and included in a block.
**Characteristics:**
- Fastest (most recent)
- Least safe (~5% chance of being on abandoned fork)
- Can be rolled back if fork abandoned
**When to use:**
- Real-time UX updates (show pending state)
- Price feeds where staleness is worse than occasional rollback
- **NOT for blockhash fetching** (risk of minority fork blockhash)
**Example:**
```rust
use solana_client::rpc_config::RpcSendTransactionConfig;
use solana_sdk::commitment_config::CommitmentLevel;
let config = RpcSendTransactionConfig {
skip_preflight: false,
preflight_commitment: Some(CommitmentLevel::Processed),
..Default::default()
};
// Risky! Blockhash might be from minority fork
let signature = rpc_client.send_transaction_with_config(&transaction, config)?;
```
### Confirmed
**Definition**: Supermajority of validators voted for the block containing the transaction.
**Characteristics:**
- Fast (~1-2 slots, ~600-1200ms)
- Safe (~<0.1% chance of rollback in normal conditions)
- **RECOMMENDED for blockhash fetching**
**When to use:**
- **Default choice** for most operations
- Blockhash fetching (balance of speed and safety)
- Transaction submission (preflight commitment)
- Confirmation monitoring
**Example:**
```rust
let commitment = CommitmentConfig::confirmed();
// Fetch blockhash at confirmed level
let recent_blockhash = rpc_client.get_latest_blockhash_with_commitment(commitment)?;
// Set preflight commitment to match
let config = RpcSendTransactionConfig {
preflight_commitment: Some(CommitmentLevel::Confirmed),
..Default::default()
};
```
### Finalized
**Definition**: 32+ confirmed blocks have been built on top (mathematically impossible to rollback).
**Characteristics:**
- Slowest (~13+ seconds)
- 100% safe (impossible to rollback)
- Guaranteed by consensus algorithm
**When to use:**
- Financial settlement
- Legal/compliance requirements
- Cross-chain bridges
- Critical state changes
**Example:**
```rust
let commitment = CommitmentConfig::finalized();
// Wait for finalization
rpc_client.confirm_transaction_with_spinner(
&signature,
&recent_blockhash,
commitment,
)?;
```
### Preflight Commitment Matching
**Critical rule**: Preflight commitment MUST match blockhash fetch commitment.
**Why:**
```rust
// Scenario: Mismatch
let blockhash = rpc.get_latest_blockhash_with_commitment(confirmed)?; // confirmed
let config = RpcSendTransactionConfig {
preflight_commitment: Some(CommitmentLevel::Processed), // processed (WRONG!)
..Default::default()
};
// RPC tries to simulate at processed level
// But blockhash only exists at confirmed level
// Result: "Blockhash not found" error
```
**Correct approach:**
```rust
let commitment = CommitmentConfig::confirmed();
// Fetch blockhash
let blockhash_response = rpc.get_latest_blockhash_with_commitment(commitment)?;
let blockhash = blockhash_response.0;
// Match preflight commitment
let config = RpcSendTransactionConfig {
preflight_commitment: Some(CommitmentLevel::Confirmed),
..Default::default()
};
let signature = rpc.send_transaction_with_config(&transaction, config)?;
```
## RPC Retry Behavior
### Default Retry Logic
RPC nodes automatically retry transactions:
```rust
// Simplified RPC retry algorithm:
const RETRY_INTERVAL: Duration = Duration::from_secs(2);
const MAX_QUEUE_SIZE: usize = 10_000;
loop {
if transaction.is_finalized() {
return Ok(signature);
}
if queue.len() >= MAX_QUEUE_SIZE {
return Err("Queue full");
}
if can_determine_expiration() {
if transaction.is_expired() {
return Err("Blockhash expired");
}
} else {
// Conservative: retry only once if can't determine expiration
if retry_count > 1 {
return Ok(signature); // Might not actually be finalized!
}
}
forward_to_current_leader();
forward_to_next_leader();
sleep(RETRY_INTERVAL);
retry_count += 1;
}
```
### Leader Forwarding
RPC forwards transactions to:
1. **Current leader**: For immediate processing
2. **Next leader**: In case current leader rotation happens
**Why both?**
- Leader rotation happens every 4 slots (~1.6-2.4 seconds)
- Transaction might arrive during rotation
- Next leader can process in upcoming slots
### Queue Pressure
During congestion:
```
Queue size: 10,000 transactions
New transaction arrives:
if queue.is_full():
reject("Transaction queue full")
else:
queue.push(transaction)
retry_until_finalized()
```
**User experience:**
- Fresh transactions rejected when queue full
- Older transactions keep retrying
- Can create priority inversion (old low-priority tx blocks new high-priority tx)
**Solution**: Use `maxRetries: 0` to take manual control during congestion.
## Custom Retry Strategies
### Manual Retry Loop
Taking full control:
```rust
use solana_client::rpc_client::RpcClient;
use solana_sdk::signature::Signature;
use std::time::Duration;
use tokio::time::sleep;
async fn send_transaction_with_retry(
rpc_client: &RpcClient,
transaction: &Transaction,
last_valid_block_height: u64,
) -> Result<Signature, Box<dyn std::error::Error>> {
let config = RpcSendTransactionConfig {
skip_preflight: true, // Already validated
max_retries: Some(0), // Manual retry control
..Default::default()
};
let signature = rpc_client.send_transaction_with_config(
transaction,
config,
)?;
// Manual retry loop
loop {
// Check if transaction confirmed
match rpc_client.get_signature_status(&signature)? {
Some(Ok(_)) => {
println!("Transaction confirmed!");
return Ok(signature);
}
Some(Err(e)) => {
return Err(format!("Transaction failed: {:?}", e).into());
}
None => {
// Not processed yet, continue
}
}
// Check expiration
let current_block_height = rpc_client.get_block_height()?;
if current_block_height > last_valid_block_height {
return Err("Transaction expired".into());
}
// Resubmit
rpc_client.send_transaction_with_config(transaction, config)?;
// Wait before next retry
sleep(Duration::from_millis(500)).await;
}
}
```
### Exponential Backoff
Reduce network load during congestion:
```rust
async fn retry_with_exponential_backoff(
rpc_client: &RpcClient,
transaction: &Transaction,
last_valid_block_height: u64,
) -> Result<Signature, Box<dyn std::error::Error>> {
let signature = rpc_client.send_transaction(transaction)?;
let mut retry_delay = Duration::from_millis(500);
const MAX_DELAY: Duration = Duration::from_secs(8);
loop {
match rpc_client.get_signature_status(&signature)? {
Some(Ok(_)) => return Ok(signature),
Some(Err(e)) => return Err(e.into()),
None => {
// Check expiration
if rpc_client.get_block_height()? > last_valid_block_height {
return Err("Expired".into());
}
// Resubmit
rpc_client.send_transaction(transaction)?;
// Exponential backoff
sleep(retry_delay).await;
retry_delay = std::cmp::min(retry_delay * 2, MAX_DELAY);
}
}
}
}
```
### Constant Interval (Mango Approach)
Aggressive resubmission:
```rust
async fn retry_constant_interval(
rpc_client: &RpcClient,
transaction: &Transaction,
last_valid_block_height: u64,
) -> Result<Signature, Box<dyn std::error::Error>> {
let signature = rpc_client.send_transaction(transaction)?;
const RETRY_INTERVAL: Duration = Duration::from_millis(500);
loop {
match rpc_client.get_signature_status(&signature)? {
Some(Ok(_)) => return Ok(signature),
Some(Err(e)) => return Err(e.into()),
None => {
if rpc_client.get_block_height()? > last_valid_block_height {
return Err("Expired".into());
}
// Constant interval resubmission
rpc_client.send_transaction(transaction)?;
sleep(RETRY_INTERVAL).await;
}
}
}
}
```
**Trade-offs:**
- **Exponential backoff**: Network-friendly, slower confirmation
- **Constant interval**: Faster confirmation, more network load
- **Choice depends on**: Application needs, RPC provider limits, congestion levels
## Confirmation Monitoring
### Polling for Confirmation
**Basic polling:**
```rust
use solana_sdk::signature::Signature;
fn wait_for_confirmation(
rpc_client: &RpcClient,
signature: &Signature,
commitment: CommitmentConfig,
) -> Result<(), Box<dyn std::error::Error>> {
loop {
match rpc_client.get_signature_status_with_commitment(
signature,
commitment,
)? {
Some(Ok(_)) => {
println!("Transaction confirmed at {:?}", commitment);
return Ok(());
}
Some(Err(e)) => {
return Err(format!("Transaction failed: {:?}", e).into());
}
None => {
std::thread::sleep(Duration::from_millis(500));
}
}
}
}
```
**With timeout:**
```rust
use std::time::{Duration, Instant};
fn wait_for_confirmation_with_timeout(
rpc_client: &RpcClient,
signature: &Signature,
timeout: Duration,
) -> Result<bool, Box<dyn std::error::Error>> {
let start = Instant::now();
while start.elapsed() < timeout {
match rpc_client.get_signature_status(signature)? {
Some(Ok(_)) => return Ok(true),
Some(Err(e)) => return Err(e.into()),
None => std::thread::sleep(Duration::from_millis(500)),
}
}
Ok(false) // Timed out
}
```
### Using `confirm_transaction`
Built-in helper with expiration tracking:
```rust
let commitment = CommitmentConfig::confirmed();
// Method 1: With blockhash context
rpc_client.confirm_transaction_with_spinner(
&signature,
&recent_blockhash,
commitment,
)?;
// Method 2: With last valid block height (recommended)
let result = rpc_client.confirm_transaction_with_commitment(
&signature,
commitment,
)?;
if result.value {
println!("Transaction confirmed!");
} else {
println!("Transaction not confirmed (might have expired)");
}
```
### WebSocket Subscriptions (Real-Time)
For real-time updates without polling:
```rust
use solana_client::pubsub_client::PubsubClient;
use solana_sdk::commitment_config::CommitmentConfig;
async fn subscribe_to_signature(
ws_url: &str,
signature: &Signature,
) -> Result<(), Box<dyn std::error::Error>> {
let pubsub_client = PubsubClient::new(ws_url).await?;
let (mut stream, unsubscribe) = pubsub_client
.signature_subscribe(signature, Some(CommitmentConfig::confirmed()))
.await?;
// Wait for notification
while let Some(response) = stream.next().await {
match response.value {
solana_client::rpc_response::RpcSignatureResult::ProcessedSignature(_) => {
println!("Transaction confirmed!");
break;
}
}
}
unsubscribe().await;
Ok(())
}
```
**Advantages:**
- Real-time notification (no polling delay)
- Lower RPC load
- Immediate feedback
**Disadvantages:**
- WebSocket connection overhead
- Need to handle disconnections
- Not all RPC providers support WebSockets
## Best Practices
### 1. Fetch Fresh Blockhashes
```rust
// BAD: Fetch once and reuse
let blockhash = rpc.get_latest_blockhash()?;
for tx in transactions {
// All use same blockhash (increases expiration risk)
send_transaction(tx, &blockhash)?;
}
// GOOD: Fetch fresh blockhash for each transaction
for tx in transactions {
let blockhash = rpc.get_latest_blockhash()?;
send_transaction(tx, &blockhash)?;
}
// BETTER: Fetch fresh blockhash right before signing
fn prepare_and_send(user_action: Action) {
// User initiates action
let blockhash = rpc.get_latest_blockhash()?; // Fetch now!
// Build and sign (fast)
let tx = build_transaction(user_action, &blockhash);
sign_transaction(&tx);
// Submit immediately
send_transaction(&tx)?;
}
```
### 2. Use Confirmed Commitment
```rust
// RECOMMENDED: Confirmed commitment
let commitment = CommitmentConfig::confirmed();
let blockhash = rpc.get_latest_blockhash_with_commitment(commitment)?;
// Risks minority fork
let blockhash = rpc.get_latest_blockhash_with_commitment(
CommitmentConfig::processed()
)?; // Avoid!
```
### 3. Match Preflight Commitment
```rust
let commitment = CommitmentConfig::confirmed();
// Fetch blockhash
let (blockhash, last_valid_block_height) = rpc
.get_latest_blockhash_with_commitment(commitment)?;
// Match preflight commitment
let config = RpcSendTransactionConfig {
preflight_commitment: Some(CommitmentLevel::Confirmed), // MATCH!
..Default::default()
};
```
### 4. Track Expiration Explicitly
```rust
// Get expiration info
let (blockhash, last_valid_block_height) = rpc.get_latest_blockhash()?;
// Check before retry
fn should_retry(rpc: &RpcClient, last_valid: u64) -> bool {
rpc.get_block_height().unwrap_or(0) <= last_valid
}
```
### 5. Monitor RPC Health
```rust
async fn check_rpc_health(rpc: &RpcClient) -> bool {
let processed = rpc.get_slot().unwrap_or(0);
let max_shred = rpc.get_max_shred_insert_slot().unwrap_or(0);
let lag = max_shred.saturating_sub(processed);
if lag > 50 {
eprintln!("RPC lagging by {} slots", lag);
return false;
}
true
}
```
### 6. Implement Proper Error Handling
```rust
match rpc.send_transaction(&tx) {
Ok(signature) => {
println!("Submitted: {}", signature);
// Wait for confirmation
}
Err(e) => {
if e.to_string().contains("BlockhashNotFound") {
// Blockhash expired, fetch fresh one
let new_blockhash = rpc.get_latest_blockhash()?;
// Re-sign transaction with new blockhash
} else if e.to_string().contains("AlreadyProcessed") {
// Transaction already submitted (safe to ignore)
} else {
// Other error, handle appropriately
return Err(e.into());
}
}
}
```
### 7. Use Skip Preflight Judiciously
```rust
// When to skip preflight:
// - During congestion (preflight adds latency)
// - When retrying (already validated once)
// - When you're confident about transaction validity
let config = RpcSendTransactionConfig {
skip_preflight: true, // Skip simulation
preflight_commitment: Some(CommitmentLevel::Confirmed),
max_retries: Some(0),
..Default::default()
};
// Still recommended: Simulate ONCE before skip_preflight
rpc.simulate_transaction(&tx)?; // Catch errors
// Then submit with skip_preflight for speed
```
## Production Patterns
### High-Throughput System
```rust
struct TransactionSubmitter {
rpc_client: Arc<RpcClient>,
retry_queue: Arc<Mutex<VecDeque<RetryableTransaction>>>,
}
struct RetryableTransaction {
transaction: Transaction,
signature: Signature,
last_valid_block_height: u64,
submitted_at: Instant,
retry_count: usize,
}
impl TransactionSubmitter {
async fn submit_transaction(&self, tx: Transaction) -> Result<Signature, Error> {
let (blockhash, last_valid) = self.rpc_client.get_latest_blockhash()?;
// Submit initial
let signature = self.rpc_client.send_transaction(&tx)?;
// Add to retry queue
let retryable = RetryableTransaction {
transaction: tx,
signature,
last_valid_block_height: last_valid,
submitted_at: Instant::now(),
retry_count: 0,
};
self.retry_queue.lock().unwrap().push_back(retryable);
Ok(signature)
}
async fn retry_worker(&self) {
loop {
sleep(Duration::from_millis(500)).await;
let mut queue = self.retry_queue.lock().unwrap();
for tx in queue.iter_mut() {
// Check if confirmed
match self.rpc_client.get_signature_status(&tx.signature) {
Ok(Some(Ok(_))) => {
// Confirmed, remove from queue (handle in cleanup pass)
continue;
}
Ok(Some(Err(_))) => {
// Failed, remove from queue
continue;
}
_ => {
// Not confirmed, check expiration
let current_height = self.rpc_client.get_block_height().unwrap_or(0);
if current_height > tx.last_valid_block_height {
// Expired, remove from queue
continue;
}
// Retry
let _ = self.rpc_client.send_transaction(&tx.transaction);
tx.retry_count += 1;
}
}
}
// Cleanup confirmed/failed/expired
queue.retain(|tx| {
matches!(
self.rpc_client.get_signature_status(&tx.signature),
Ok(None) // Still pending
)
});
}
}
}
```
### Wallet Integration
```rust
async fn wallet_send_transaction(
rpc: &RpcClient,
unsigned_tx: Transaction,
signer: &dyn Signer,
) -> Result<Signature, Error> {
// Fetch blockhash immediately before signing
let (blockhash, last_valid) = rpc.get_latest_blockhash()?;
// Update transaction with fresh blockhash
let mut tx = unsigned_tx.clone();
tx.message.recent_blockhash = blockhash;
// Sign
tx.sign(&[signer], blockhash);
// Simulate first
rpc.simulate_transaction(&tx)?;
// Submit with retry
let signature = tx.signatures[0];
send_with_retry(rpc, &tx, last_valid).await?;
Ok(signature)
}
```
## Resources
### Official Documentation
- [Transaction Retry Guide](https://solana.com/developers/guides/advanced/retry)
- [Transaction Confirmation Guide](https://solana.com/developers/guides/advanced/confirmation)
### Technical References
- [RpcClient Source](https://github.com/solana-labs/solana/blob/master/client/src/rpc_client.rs)
- [Transaction Source](https://github.com/solana-labs/solana/blob/master/sdk/src/transaction/mod.rs)
- [BlockhashQueue Source](https://github.com/solana-labs/solana/blob/master/runtime/src/blockhash_queue.rs)
### Community Resources
- [Solana Cookbook - Transactions](https://solanacookbook.com/references/basic-transactions.html)
- [Solana Stack Exchange - Transaction Questions](https://solana.stackexchange.com/questions/tagged/transaction)