blockhash_cache/
lib.rs

1use anyhow::Result;
2use base64::{engine::general_purpose::STANDARD, Engine as _};
3use bincode;
4use once_cell::sync::Lazy;
5use serde::{Deserialize, Serialize};
6use solana_sdk::transaction::VersionedTransaction;
7use std::str::FromStr;
8use std::sync::Arc;
9use std::time::Duration;
10use tokio::sync::RwLock;
11
12// RPC response types
13#[derive(Deserialize, Debug, Serialize)]
14struct RpcResponse {
15    result: BlockhashResponse,
16}
17
18#[derive(Deserialize, Debug, Serialize)]
19struct BlockhashResponse {
20    value: BlockhashValue,
21}
22
23#[derive(Deserialize, Debug, Serialize)]
24struct BlockhashValue {
25    blockhash: String,
26}
27
28#[derive(Debug, thiserror::Error)]
29pub enum BlockhashCacheError {
30    #[error("[BlockhashCache] Failed to fetch blockhash")]
31    FetchError(reqwest::Error),
32
33    #[error("[BlockhashCache] Failed to parse blockhash")]
34    ParseError(reqwest::Error),
35
36    #[error("[BlockhashCache] Failed to convert blockhash to Hash")]
37    HashConversionError,
38}
39
40pub static BLOCKHASH_CACHE: Lazy<BlockhashCache> = Lazy::new(|| {
41    BlockhashCache::new(&std::env::var("SOLANA_RPC_URL").expect("SOLANA_RPC_URL must be set"))
42});
43
44pub struct BlockhashCache {
45    blockhash: Arc<RwLock<solana_sdk::hash::Hash>>,
46    client: reqwest::Client,
47    rpc_url: String,
48}
49
50impl BlockhashCache {
51    pub fn new(rpc_url: &str) -> Self {
52        let client = reqwest::Client::new();
53        let blockhash = Arc::new(RwLock::new(solana_sdk::hash::Hash::default()));
54        let rpc_url = rpc_url.to_string();
55
56        let cache = Self {
57            blockhash,
58            client,
59            rpc_url,
60        };
61        cache.start_update_task();
62        cache
63    }
64
65    fn start_update_task(&self) {
66        let blockhash = self.blockhash.clone();
67        let client = self.client.clone();
68        let rpc_url = self.rpc_url.clone();
69
70        tokio::spawn(async move {
71            loop {
72                match Self::fetch_blockhash(&client, &rpc_url).await {
73                    Ok(new_blockhash) => {
74                        let mut hash_writer = blockhash.write().await;
75                        *hash_writer = new_blockhash;
76                        drop(hash_writer);
77                    }
78                    Err(err) => {
79                        tracing::error!("Failed to fetch blockhash: {}", err);
80                    }
81                }
82
83                tokio::time::sleep(Duration::from_secs(2)).await;
84            }
85        });
86    }
87
88    async fn fetch_blockhash(
89        client: &reqwest::Client,
90        rpc_url: &str,
91    ) -> Result<solana_sdk::hash::Hash, BlockhashCacheError> {
92        let request_body = serde_json::json!({
93            "jsonrpc": "2.0",
94            "id": 1,
95            "method": "getLatestBlockhash",
96            "params": [{"commitment": "finalized"}]
97        });
98
99        let response = client
100            .post(rpc_url)
101            .json(&request_body)
102            .send()
103            .await
104            .map_err(BlockhashCacheError::FetchError)?;
105        let response_json: RpcResponse = response
106            .json()
107            .await
108            .map_err(BlockhashCacheError::ParseError)?;
109
110        // Convert the base58 string to our Hash type
111        let blockhash = response_json.result.value.blockhash;
112        let hash = solana_sdk::hash::Hash::from_str(&blockhash)
113            .map_err(|_| BlockhashCacheError::HashConversionError)?;
114        Ok(hash)
115    }
116
117    pub async fn get_blockhash(&self) -> Result<solana_sdk::hash::Hash, BlockhashCacheError> {
118        let current_hash = self.blockhash.read().await.clone();
119
120        // If we have a valid blockhash (not default), return it
121        if current_hash != solana_sdk::hash::Hash::default() {
122            return Ok(current_hash);
123        }
124
125        // If we don't have a valid blockhash yet, fetch it immediately
126        Self::fetch_blockhash(&self.client, &self.rpc_url).await
127    }
128}
129
130pub fn inject_blockhash_into_encoded_tx(base64_tx: &str, blockhash: &str) -> Result<String> {
131    // Decode base64 transaction into bytes
132    let tx_bytes = STANDARD.decode(base64_tx)?;
133
134    // Try to deserialize as VersionedTransaction first
135    let updated_tx_bytes = match bincode::deserialize::<VersionedTransaction>(&tx_bytes) {
136        Ok(mut transaction) => {
137            // Handle versioned transaction
138            let blockhash = solana_sdk::hash::Hash::from_str(&blockhash)
139                .map_err(|_| anyhow::anyhow!("Invalid blockhash format"))?;
140
141            match &mut transaction.message {
142                solana_sdk::message::VersionedMessage::Legacy(message) => {
143                    message.recent_blockhash = blockhash;
144                }
145                solana_sdk::message::VersionedMessage::V0(message) => {
146                    message.recent_blockhash = blockhash;
147                }
148            }
149            bincode::serialize(&transaction)?
150        }
151        Err(_) => {
152            // Try as standard Transaction
153            let mut transaction =
154                bincode::deserialize::<solana_sdk::transaction::Transaction>(&tx_bytes)?;
155
156            let blockhash = solana_sdk::hash::Hash::from_str(&blockhash)
157                .map_err(|_| anyhow::anyhow!("Invalid blockhash format"))?;
158
159            transaction.message.recent_blockhash = blockhash;
160            bincode::serialize(&transaction)?
161        }
162    };
163
164    // Encode to base64
165    Ok(STANDARD.encode(updated_tx_bytes))
166}
167
168#[cfg(test)]
169mod tests {
170    use super::*;
171    use solana_sdk::{
172        message::Message,
173        pubkey::Pubkey,
174        signature::{Keypair, Signer},
175        system_instruction,
176        transaction::Transaction,
177    };
178
179    #[tokio::test]
180    async fn test_blockhash_cache() {
181        dotenv::dotenv().ok();
182        let blockhash = super::BLOCKHASH_CACHE.get_blockhash().await.unwrap();
183        assert_ne!(blockhash, solana_sdk::hash::Hash::default());
184    }
185
186    #[test]
187    fn test_inject_blockhash_standard_transaction() {
188        // Create a simple standard transaction
189        let payer = Keypair::new();
190        let to = Pubkey::new_unique();
191        let instruction = system_instruction::transfer(&payer.pubkey(), &to, 1000);
192        let message = Message::new(&[instruction], Some(&payer.pubkey()));
193        let tx = Transaction::new_unsigned(message);
194
195        // Serialize and encode the transaction
196        let tx_bytes = bincode::serialize(&tx).unwrap();
197        let base64_tx = STANDARD.encode(&tx_bytes);
198
199        // Test injecting a new blockhash
200        let new_blockhash = "CkqVVMoo6LUqzqKSQVFNL4Yxv3TXyTh1NQxTSG2Z9gTq";
201        let result = inject_blockhash_into_encoded_tx(&base64_tx, new_blockhash).unwrap();
202
203        // Decode and deserialize the result
204        let updated_bytes = STANDARD.decode(result).unwrap();
205        let updated_tx: Transaction = bincode::deserialize(&updated_bytes).unwrap();
206
207        assert_eq!(
208            updated_tx.message.recent_blockhash.to_string(),
209            new_blockhash
210        );
211    }
212}