979 lines
26 KiB
Markdown
979 lines
26 KiB
Markdown
# 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)
|