1pub 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#[derive(Debug, Error)]
24pub enum RpcError {
25 #[error("HTTP error: {0}")]
27 Http(#[from] reqwest::Error),
28
29 #[error("RPC error {code}: {message}")]
31 Rpc { code: i64, message: String },
32
33 #[error("Invalid response: {0}")]
35 InvalidResponse(String),
36
37 #[error("Invalid RPC URL: {0}")]
39 InvalidUrl(String),
40
41 #[error("Invalid address: {0}")]
43 InvalidAddress(String),
44
45 #[error("Transaction confirmation timed out after {0}s")]
47 Timeout(u64),
48
49 #[error("JSON error: {0}")]
51 Json(#[from] serde_json::Error),
52}
53
54pub type Result<T> = std::result::Result<T, RpcError>;
56
57#[derive(Debug, Clone, Serialize, Deserialize)]
63pub struct AccountInfo {
64 pub lamports: u64,
66 pub owner: String,
68 pub data: String,
70 pub executable: bool,
72 pub rent_epoch: u64,
74}
75
76#[derive(Debug, Clone, Serialize, Deserialize)]
78pub struct NetworkInfo {
79 pub version: String,
81 pub slot: u64,
83 pub epoch: u64,
85 pub rpc_url: String,
87}
88
89#[derive(Debug, Clone, Serialize, Deserialize)]
91pub struct TransactionRecord {
92 pub signature: String,
94 pub slot: u64,
96 pub block_time: Option<i64>,
98 pub err: Option<Value>,
100 pub memo: Option<String>,
102 pub confirmation_status: Option<String>,
104}
105
106#[derive(Debug, Clone, Serialize, Deserialize)]
108pub struct TransactionDetails {
109 pub slot: u64,
111 pub block_time: Option<i64>,
113 pub transaction: Value,
115 pub meta: Option<Value>,
117}
118
119#[derive(Debug)]
127pub struct SolanaRpcClient {
128 rpc_url: String,
129 client: reqwest::blocking::Client,
130}
131
132impl SolanaRpcClient {
133 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 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 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 pub fn from_config(cfg: &ColdstarConfig) -> Result<Self> {
169 Self::new(Some(&cfg.rpc_url))
170 }
171
172 pub fn rpc_url(&self) -> &str {
174 &self.rpc_url
175 }
176
177 pub(crate) fn http_client(&self) -> &reqwest::blocking::Client {
179 &self.client
180 }
181
182 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 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 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 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 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 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 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 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 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 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 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(_) => { }
356 }
357 thread::sleep(Duration::from_secs(1));
358 }
359
360 Err(RpcError::Timeout(timeout))
361 }
362
363 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 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 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 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 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 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
544pub(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 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#[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 assert!(client.rpc_url().contains("solana.com"));
602 }
603
604 #[test]
605 fn validate_address_ok() {
606 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 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}