Skip to main content

coldstar_network/
lib.rs

1//! Full Solana RPC client for Coldstar cold wallet.
2//!
3//! Ported from the Python `src/network.py` in `coldstar-devsyrem`.
4//! Uses `reqwest::blocking` with JSON-RPC 2.0 format for all Solana
5//! RPC interactions.
6
7pub mod token;
8
9use std::thread;
10use std::time::Duration;
11
12use serde::{Deserialize, Serialize};
13use serde_json::{json, Value};
14use thiserror::Error;
15
16use coldstar_config::{ColdstarConfig, LAMPORTS_PER_SOL};
17
18// ---------------------------------------------------------------------------
19// Error types
20// ---------------------------------------------------------------------------
21
22/// Errors that can occur during RPC operations.
23#[derive(Debug, Error)]
24pub enum RpcError {
25    /// HTTP transport error.
26    #[error("HTTP error: {0}")]
27    Http(#[from] reqwest::Error),
28
29    /// The RPC endpoint returned a JSON-RPC error object.
30    #[error("RPC error {code}: {message}")]
31    Rpc { code: i64, message: String },
32
33    /// The response JSON could not be parsed or was missing expected fields.
34    #[error("Invalid response: {0}")]
35    InvalidResponse(String),
36
37    /// The provided RPC URL failed validation.
38    #[error("Invalid RPC URL: {0}")]
39    InvalidUrl(String),
40
41    /// The provided Solana address is malformed.
42    #[error("Invalid address: {0}")]
43    InvalidAddress(String),
44
45    /// A transaction confirmation timed out.
46    #[error("Transaction confirmation timed out after {0}s")]
47    Timeout(u64),
48
49    /// Serialization / deserialization error.
50    #[error("JSON error: {0}")]
51    Json(#[from] serde_json::Error),
52}
53
54/// Convenience alias used throughout this crate.
55pub type Result<T> = std::result::Result<T, RpcError>;
56
57// ---------------------------------------------------------------------------
58// Data types
59// ---------------------------------------------------------------------------
60
61/// On-chain account information returned by `getAccountInfo`.
62#[derive(Debug, Clone, Serialize, Deserialize)]
63pub struct AccountInfo {
64    /// Account balance in lamports.
65    pub lamports: u64,
66    /// Account owner program (base-58).
67    pub owner: String,
68    /// Account data encoded as base-64.
69    pub data: String,
70    /// Whether the account is executable.
71    pub executable: bool,
72    /// Epoch at which this account will next owe rent.
73    pub rent_epoch: u64,
74}
75
76/// High-level network information.
77#[derive(Debug, Clone, Serialize, Deserialize)]
78pub struct NetworkInfo {
79    /// Solana core version string.
80    pub version: String,
81    /// Current slot.
82    pub slot: u64,
83    /// Current epoch number.
84    pub epoch: u64,
85    /// RPC URL that produced this info.
86    pub rpc_url: String,
87}
88
89/// A single entry from `getSignaturesForAddress`.
90#[derive(Debug, Clone, Serialize, Deserialize)]
91pub struct TransactionRecord {
92    /// Transaction signature (base-58).
93    pub signature: String,
94    /// Slot the transaction was processed in.
95    pub slot: u64,
96    /// Block time as Unix timestamp (may be absent for very old txns).
97    pub block_time: Option<i64>,
98    /// Whether the transaction had an error.
99    pub err: Option<Value>,
100    /// Optional memo string.
101    pub memo: Option<String>,
102    /// Confirmation status.
103    pub confirmation_status: Option<String>,
104}
105
106/// Detailed transaction data returned by `getTransaction`.
107#[derive(Debug, Clone, Serialize, Deserialize)]
108pub struct TransactionDetails {
109    /// Slot the transaction landed in.
110    pub slot: u64,
111    /// Block time as Unix timestamp.
112    pub block_time: Option<i64>,
113    /// The full transaction JSON (jsonParsed encoding).
114    pub transaction: Value,
115    /// Transaction metadata (fee, balances, logs, etc.).
116    pub meta: Option<Value>,
117}
118
119// ---------------------------------------------------------------------------
120// SolanaRpcClient
121// ---------------------------------------------------------------------------
122
123/// Blocking Solana JSON-RPC 2.0 client.
124///
125/// Mirrors the Python `SolanaNetwork` class from `coldstar-devsyrem`.
126#[derive(Debug)]
127pub struct SolanaRpcClient {
128    rpc_url: String,
129    client: reqwest::blocking::Client,
130}
131
132impl SolanaRpcClient {
133    // ── Constructor ──────────────────────────────────────────────────
134
135    /// Create a new client pointing at `rpc_url`.
136    ///
137    /// The URL must start with `http://` or `https://`. If `None` is
138    /// passed the default from [`ColdstarConfig`] is used.
139    pub fn new(rpc_url: Option<&str>) -> Result<Self> {
140        let url = match rpc_url {
141            Some(u) => u.to_string(),
142            None => ColdstarConfig::from_env().rpc_url,
143        };
144
145        // Validate URL scheme.
146        if !url.starts_with("http://") && !url.starts_with("https://") {
147            return Err(RpcError::InvalidUrl(
148                "URL must start with http:// or https://".into(),
149            ));
150        }
151
152        // Basic length / content check.
153        if url.len() < 10 {
154            return Err(RpcError::InvalidUrl("URL is too short".into()));
155        }
156
157        let client = reqwest::blocking::Client::builder()
158            .timeout(Duration::from_secs(30))
159            .build()?;
160
161        Ok(Self {
162            rpc_url: url,
163            client,
164        })
165    }
166
167    /// Build a client from an existing [`ColdstarConfig`].
168    pub fn from_config(cfg: &ColdstarConfig) -> Result<Self> {
169        Self::new(Some(&cfg.rpc_url))
170    }
171
172    /// Return the RPC URL this client is connected to.
173    pub fn rpc_url(&self) -> &str {
174        &self.rpc_url
175    }
176
177    /// Expose the underlying blocking HTTP client (used by [`token::TokenFetcher`]).
178    pub(crate) fn http_client(&self) -> &reqwest::blocking::Client {
179        &self.client
180    }
181
182    // ── Low-level JSON-RPC helper ────────────────────────────────────
183
184    /// Send a JSON-RPC 2.0 request and return the raw response [`Value`].
185    fn rpc_request(&self, method: &str, params: Value) -> Result<Value> {
186        let payload = json!({
187            "jsonrpc": "2.0",
188            "id": 1,
189            "method": method,
190            "params": params,
191        });
192
193        let resp = self
194            .client
195            .post(&self.rpc_url)
196            .header("Content-Type", "application/json")
197            .json(&payload)
198            .send()?;
199
200        let body: Value = resp.json()?;
201
202        // Check for JSON-RPC error envelope.
203        if let Some(err) = body.get("error") {
204            let code = err.get("code").and_then(Value::as_i64).unwrap_or(-1);
205            let message = err
206                .get("message")
207                .and_then(Value::as_str)
208                .unwrap_or("Unknown RPC error")
209                .to_string();
210            return Err(RpcError::Rpc { code, message });
211        }
212
213        Ok(body)
214    }
215
216    // ── Public API ───────────────────────────────────────────────────
217
218    /// Get SOL balance for `pubkey` in **lamports**.
219    pub fn get_balance(&self, pubkey: &str) -> Result<u64> {
220        validate_address(pubkey)?;
221
222        let body = self.rpc_request("getBalance", json!([pubkey]))?;
223
224        body.get("result")
225            .and_then(|r| r.get("value"))
226            .and_then(Value::as_u64)
227            .ok_or_else(|| RpcError::InvalidResponse("missing result.value".into()))
228    }
229
230    /// Get SOL balance for `pubkey` in **SOL** (floating point).
231    pub fn get_balance_sol(&self, pubkey: &str) -> Result<f64> {
232        let lamports = self.get_balance(pubkey)?;
233        Ok(lamports as f64 / LAMPORTS_PER_SOL as f64)
234    }
235
236    /// Fetch the latest blockhash and last valid block height.
237    pub fn get_latest_blockhash(&self) -> Result<String> {
238        let body = self.rpc_request(
239            "getLatestBlockhash",
240            json!([{"commitment": "finalized"}]),
241        )?;
242
243        let value = body
244            .get("result")
245            .and_then(|r| r.get("value"))
246            .ok_or_else(|| RpcError::InvalidResponse("missing result.value".into()))?;
247
248        let blockhash = value
249            .get("blockhash")
250            .and_then(Value::as_str)
251            .ok_or_else(|| RpcError::InvalidResponse("missing blockhash".into()))?;
252
253        // Validate blockhash format (base-58, 32-44 chars).
254        if blockhash.len() < 32 || blockhash.len() > 44 {
255            return Err(RpcError::InvalidResponse(
256                "blockhash has invalid length".into(),
257            ));
258        }
259
260        Ok(blockhash.to_string())
261    }
262
263    /// Fetch the latest blockhash together with the last valid block height.
264    pub fn get_latest_blockhash_with_height(&self) -> Result<(String, u64)> {
265        let body = self.rpc_request(
266            "getLatestBlockhash",
267            json!([{"commitment": "finalized"}]),
268        )?;
269
270        let value = body
271            .get("result")
272            .and_then(|r| r.get("value"))
273            .ok_or_else(|| RpcError::InvalidResponse("missing result.value".into()))?;
274
275        let blockhash = value
276            .get("blockhash")
277            .and_then(Value::as_str)
278            .ok_or_else(|| RpcError::InvalidResponse("missing blockhash".into()))?
279            .to_string();
280
281        let height = value
282            .get("lastValidBlockHeight")
283            .and_then(Value::as_u64)
284            .ok_or_else(|| {
285                RpcError::InvalidResponse("missing lastValidBlockHeight".into())
286            })?;
287
288        Ok((blockhash, height))
289    }
290
291    /// Get the minimum lamport balance required for rent exemption at
292    /// `data_len` bytes of account data.
293    pub fn get_minimum_balance_for_rent_exemption(&self, data_len: usize) -> Result<u64> {
294        let body = self.rpc_request(
295            "getMinimumBalanceForRentExemption",
296            json!([data_len]),
297        )?;
298
299        body.get("result")
300            .and_then(Value::as_u64)
301            .ok_or_else(|| RpcError::InvalidResponse("missing result".into()))
302    }
303
304    /// Broadcast a signed transaction encoded as **base-64**.
305    ///
306    /// Returns the transaction signature on success.
307    pub fn send_transaction(&self, signed_tx_base64: &str) -> Result<String> {
308        let body = self.rpc_request(
309            "sendTransaction",
310            json!([
311                signed_tx_base64,
312                {"encoding": "base64", "preflightCommitment": "finalized"}
313            ]),
314        )?;
315
316        body.get("result")
317            .and_then(Value::as_str)
318            .map(String::from)
319            .ok_or_else(|| RpcError::InvalidResponse("missing result signature".into()))
320    }
321
322    /// Poll `getSignatureStatuses` until the transaction reaches
323    /// `confirmed` or `finalized`, or the timeout elapses.
324    ///
325    /// `timeout_secs` defaults to 30 if set to 0.
326    pub fn confirm_transaction(&self, signature: &str, timeout_secs: u64) -> Result<bool> {
327        let timeout = if timeout_secs == 0 { 30 } else { timeout_secs };
328        let deadline = std::time::Instant::now() + Duration::from_secs(timeout);
329
330        while std::time::Instant::now() < deadline {
331            match self.rpc_request("getSignatureStatuses", json!([[signature]])) {
332                Ok(body) => {
333                    if let Some(statuses) = body
334                        .get("result")
335                        .and_then(|r| r.get("value"))
336                        .and_then(Value::as_array)
337                    {
338                        if let Some(status) = statuses.first().and_then(Value::as_object) {
339                            // Check for transaction-level error.
340                            if status.get("err").is_some()
341                                && !status["err"].is_null()
342                            {
343                                return Ok(false);
344                            }
345                            if let Some(cs) =
346                                status.get("confirmationStatus").and_then(Value::as_str)
347                            {
348                                if cs == "confirmed" || cs == "finalized" {
349                                    return Ok(true);
350                                }
351                            }
352                        }
353                    }
354                }
355                Err(_) => { /* retry */ }
356            }
357            thread::sleep(Duration::from_secs(1));
358        }
359
360        Err(RpcError::Timeout(timeout))
361    }
362
363    /// Request an airdrop of `lamports` to `pubkey` (devnet/testnet only).
364    ///
365    /// Returns the airdrop transaction signature.
366    pub fn request_airdrop(&self, pubkey: &str, lamports: u64) -> Result<String> {
367        validate_address(pubkey)?;
368
369        let body = self.rpc_request("requestAirdrop", json!([pubkey, lamports]))?;
370
371        body.get("result")
372            .and_then(Value::as_str)
373            .map(String::from)
374            .ok_or_else(|| RpcError::InvalidResponse("missing airdrop signature".into()))
375    }
376
377    /// Get on-chain account information for `pubkey`.
378    ///
379    /// Returns `None` when the account does not exist.
380    pub fn get_account_info(&self, pubkey: &str) -> Result<Option<AccountInfo>> {
381        validate_address(pubkey)?;
382
383        let body = self.rpc_request(
384            "getAccountInfo",
385            json!([pubkey, {"encoding": "base64"}]),
386        )?;
387
388        let value = body
389            .get("result")
390            .and_then(|r| r.get("value"));
391
392        match value {
393            Some(v) if !v.is_null() => {
394                let lamports = v.get("lamports").and_then(Value::as_u64).unwrap_or(0);
395                let owner = v
396                    .get("owner")
397                    .and_then(Value::as_str)
398                    .unwrap_or("")
399                    .to_string();
400                let data = v
401                    .get("data")
402                    .and_then(Value::as_array)
403                    .and_then(|a| a.first())
404                    .and_then(Value::as_str)
405                    .unwrap_or("")
406                    .to_string();
407                let executable = v
408                    .get("executable")
409                    .and_then(Value::as_bool)
410                    .unwrap_or(false);
411                let rent_epoch = v.get("rentEpoch").and_then(Value::as_u64).unwrap_or(0);
412
413                Ok(Some(AccountInfo {
414                    lamports,
415                    owner,
416                    data,
417                    executable,
418                    rent_epoch,
419                }))
420            }
421            _ => Ok(None),
422        }
423    }
424
425    /// Check whether the RPC endpoint is healthy.
426    pub fn is_connected(&self) -> bool {
427        match self.rpc_request("getHealth", json!([])) {
428            Ok(body) => body
429                .get("result")
430                .and_then(Value::as_str)
431                .map(|s| s == "ok")
432                .unwrap_or(false),
433            Err(_) => false,
434        }
435    }
436
437    /// Collect high-level network information (version, slot, epoch).
438    pub fn get_network_info(&self) -> Result<NetworkInfo> {
439        let version_body = self.rpc_request("getVersion", json!([]))?;
440        let slot_body = self.rpc_request("getSlot", json!([]))?;
441        let epoch_body = self.rpc_request("getEpochInfo", json!([]))?;
442
443        let version = version_body
444            .get("result")
445            .and_then(|r| r.get("solana-core"))
446            .and_then(Value::as_str)
447            .unwrap_or("Unknown")
448            .to_string();
449
450        let slot = slot_body
451            .get("result")
452            .and_then(Value::as_u64)
453            .unwrap_or(0);
454
455        let epoch = epoch_body
456            .get("result")
457            .and_then(|r| r.get("epoch"))
458            .and_then(Value::as_u64)
459            .unwrap_or(0);
460
461        Ok(NetworkInfo {
462            version,
463            slot,
464            epoch,
465            rpc_url: self.rpc_url.clone(),
466        })
467    }
468
469    /// Get recent transaction signatures for `pubkey`.
470    pub fn get_transaction_history(
471        &self,
472        pubkey: &str,
473        limit: usize,
474    ) -> Result<Vec<TransactionRecord>> {
475        validate_address(pubkey)?;
476
477        let body = self.rpc_request(
478            "getSignaturesForAddress",
479            json!([pubkey, {"limit": limit}]),
480        )?;
481
482        let entries = body
483            .get("result")
484            .and_then(Value::as_array)
485            .ok_or_else(|| RpcError::InvalidResponse("missing result array".into()))?;
486
487        let mut records = Vec::with_capacity(entries.len());
488        for entry in entries {
489            records.push(TransactionRecord {
490                signature: entry
491                    .get("signature")
492                    .and_then(Value::as_str)
493                    .unwrap_or("")
494                    .to_string(),
495                slot: entry.get("slot").and_then(Value::as_u64).unwrap_or(0),
496                block_time: entry.get("blockTime").and_then(Value::as_i64),
497                err: entry.get("err").cloned(),
498                memo: entry
499                    .get("memo")
500                    .and_then(Value::as_str)
501                    .map(String::from),
502                confirmation_status: entry
503                    .get("confirmationStatus")
504                    .and_then(Value::as_str)
505                    .map(String::from),
506            });
507        }
508
509        Ok(records)
510    }
511
512    /// Get full details for a single transaction by signature.
513    pub fn get_transaction_details(&self, signature: &str) -> Result<TransactionDetails> {
514        let body = self.rpc_request(
515            "getTransaction",
516            json!([
517                signature,
518                {"encoding": "jsonParsed", "maxSupportedTransactionVersion": 0}
519            ]),
520        )?;
521
522        let result = body
523            .get("result")
524            .ok_or_else(|| RpcError::InvalidResponse("missing result".into()))?;
525
526        if result.is_null() {
527            return Err(RpcError::InvalidResponse(
528                "transaction not found".into(),
529            ));
530        }
531
532        Ok(TransactionDetails {
533            slot: result.get("slot").and_then(Value::as_u64).unwrap_or(0),
534            block_time: result.get("blockTime").and_then(Value::as_i64),
535            transaction: result
536                .get("transaction")
537                .cloned()
538                .unwrap_or(Value::Null),
539            meta: result.get("meta").cloned(),
540        })
541    }
542}
543
544// ---------------------------------------------------------------------------
545// Validation helpers
546// ---------------------------------------------------------------------------
547
548/// Validate that `address` looks like a valid Solana base-58 public key.
549pub(crate) fn validate_address(address: &str) -> Result<()> {
550    if address.is_empty() {
551        return Err(RpcError::InvalidAddress("address is empty".into()));
552    }
553    if address.len() < 32 || address.len() > 44 {
554        return Err(RpcError::InvalidAddress(format!(
555            "expected 32-44 chars, got {}",
556            address.len()
557        )));
558    }
559    // Base-58 character set (no 0, O, I, l).
560    if !address
561        .chars()
562        .all(|c| c.is_ascii_alphanumeric() && c != '0' && c != 'O' && c != 'I' && c != 'l')
563    {
564        return Err(RpcError::InvalidAddress(
565            "contains invalid base-58 characters".into(),
566        ));
567    }
568    Ok(())
569}
570
571// ---------------------------------------------------------------------------
572// Tests
573// ---------------------------------------------------------------------------
574
575#[cfg(test)]
576mod tests {
577    use super::*;
578
579    #[test]
580    fn rejects_invalid_url_no_scheme() {
581        let err = SolanaRpcClient::new(Some("not-a-url")).unwrap_err();
582        assert!(matches!(err, RpcError::InvalidUrl(_)));
583    }
584
585    #[test]
586    fn rejects_short_url() {
587        let err = SolanaRpcClient::new(Some("http://x")).unwrap_err();
588        assert!(matches!(err, RpcError::InvalidUrl(_)));
589    }
590
591    #[test]
592    fn accepts_valid_devnet_url() {
593        let client = SolanaRpcClient::new(Some("https://api.devnet.solana.com")).unwrap();
594        assert_eq!(client.rpc_url(), "https://api.devnet.solana.com");
595    }
596
597    #[test]
598    fn uses_config_default_when_none() {
599        let client = SolanaRpcClient::new(None).unwrap();
600        // Should resolve to the default from ColdstarConfig (devnet).
601        assert!(client.rpc_url().contains("solana.com"));
602    }
603
604    #[test]
605    fn validate_address_ok() {
606        // Well-known system program address.
607        assert!(validate_address("11111111111111111111111111111111").is_ok());
608    }
609
610    #[test]
611    fn validate_address_empty() {
612        assert!(validate_address("").is_err());
613    }
614
615    #[test]
616    fn validate_address_too_short() {
617        assert!(validate_address("abc").is_err());
618    }
619
620    #[test]
621    fn validate_address_bad_chars() {
622        // 'O' is not valid base-58.
623        assert!(validate_address("OOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOO").is_err());
624    }
625
626    #[test]
627    fn network_info_struct_serializes() {
628        let info = NetworkInfo {
629            version: "1.18.0".into(),
630            slot: 123456,
631            epoch: 42,
632            rpc_url: "https://api.devnet.solana.com".into(),
633        };
634        let json = serde_json::to_string(&info).unwrap();
635        assert!(json.contains("1.18.0"));
636    }
637}