use crate::chains::{Balance, ChainClient, Token, Transaction};
use crate::config::ChainsConfig;
use crate::error::{Result, ScopeError};
use crate::http::{HttpClient, Request};
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use std::sync::Arc;
const DEFAULT_SOLANA_RPC: &str = "https://api.mainnet-beta.solana.com";
#[allow(dead_code)] const SOLSCAN_API_URL: &str = "https://api.solscan.io";
const SOL_DECIMALS: u8 = 9;
#[derive(Clone)]
pub struct SolanaClient {
http: Arc<dyn HttpClient>,
rpc_url: String,
#[allow(dead_code)] solscan_api_key: Option<String>,
}
#[derive(Debug, Serialize)]
struct RpcRequest<'a, T: Serialize> {
jsonrpc: &'a str,
id: u64,
method: &'a str,
params: T,
}
#[derive(Debug, Deserialize)]
struct RpcResponse<T> {
result: Option<T>,
error: Option<RpcError>,
}
#[derive(Debug, Deserialize)]
struct RpcError {
code: i64,
message: String,
}
#[derive(Debug, Deserialize)]
struct BalanceResponse {
value: u64,
}
#[derive(Debug, Deserialize)]
struct TokenAccountsResponse {
value: Vec<TokenAccountInfo>,
}
#[derive(Debug, Deserialize)]
struct TokenAccountInfo {
pubkey: String,
account: TokenAccountData,
}
#[derive(Debug, Deserialize)]
struct TokenAccountData {
data: TokenAccountParsedData,
}
#[derive(Debug, Deserialize)]
struct TokenAccountParsedData {
parsed: TokenAccountParsedInfo,
}
#[derive(Debug, Deserialize)]
struct TokenAccountParsedInfo {
info: TokenInfo,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct TokenInfo {
mint: String,
token_amount: TokenAmount,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
#[allow(dead_code)] struct TokenAmount {
amount: String,
decimals: u8,
ui_amount: Option<f64>,
ui_amount_string: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct TokenBalance {
pub mint: String,
pub token_account: String,
pub raw_amount: String,
pub ui_amount: f64,
pub decimals: u8,
pub symbol: Option<String>,
pub name: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
#[allow(dead_code)] struct SignatureInfo {
signature: String,
slot: u64,
block_time: Option<i64>,
err: Option<serde_json::Value>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct SolanaTransactionResult {
#[serde(default)]
slot: Option<u64>,
#[serde(default)]
block_time: Option<i64>,
#[serde(default)]
transaction: Option<SolanaTransactionData>,
#[serde(default)]
meta: Option<SolanaTransactionMeta>,
}
#[derive(Debug, Deserialize)]
struct SolanaTransactionData {
#[serde(default)]
message: Option<SolanaTransactionMessage>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct SolanaTransactionMessage {
#[serde(default)]
account_keys: Option<Vec<AccountKeyEntry>>,
}
#[derive(Debug, Deserialize)]
#[serde(untagged)]
enum AccountKeyEntry {
String(String),
Object {
pubkey: String,
#[serde(default)]
#[allow(dead_code)]
signer: bool,
},
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct SolanaTransactionMeta {
#[serde(default)]
fee: Option<u64>,
#[serde(default)]
pre_balances: Option<Vec<u64>>,
#[serde(default)]
post_balances: Option<Vec<u64>>,
#[serde(default)]
err: Option<serde_json::Value>,
}
#[derive(Debug, Deserialize)]
#[allow(dead_code)] struct SolscanAccountInfo {
lamports: u64,
#[serde(rename = "type")]
account_type: Option<String>,
}
impl SolanaClient {
#[deprecated(
since = "0.8.0",
note = "pass the transport: use `SolanaClient::new_with_http`"
)]
pub fn new(config: &ChainsConfig) -> Result<Self> {
Self::new_with_http(config, crate::http::shared::fallback())
}
pub fn new_with_http(config: &ChainsConfig, http: Arc<dyn HttpClient>) -> Result<Self> {
let rpc_url = config
.solana_rpc
.as_deref()
.unwrap_or(DEFAULT_SOLANA_RPC)
.to_string();
Ok(Self {
http,
rpc_url,
solscan_api_key: config.api_keys.get("solscan").cloned(),
})
}
pub fn with_rpc_url(rpc_url: &str) -> Self {
Self {
http: crate::http::shared::fallback(),
rpc_url: rpc_url.to_string(),
solscan_api_key: None,
}
}
pub fn chain_name(&self) -> &str {
"solana"
}
pub fn native_token_symbol(&self) -> &str {
"SOL"
}
pub async fn get_balance(&self, address: &str) -> Result<Balance> {
validate_solana_address(address)?;
let request = RpcRequest {
jsonrpc: "2.0",
id: 1,
method: "getBalance",
params: vec![address],
};
tracing::debug!(url = %self.rpc_url, address = %address, "Fetching Solana balance");
let body = serde_json::to_string(&request)?;
let response: RpcResponse<BalanceResponse> = self
.http
.send(Request::post_json(&self.rpc_url, body))
.await?
.json()?;
if let Some(error) = response.error {
return Err(ScopeError::Chain(format!(
"Solana RPC error ({}): {}",
error.code, error.message
)));
}
let balance = response
.result
.ok_or_else(|| ScopeError::Chain("Empty RPC response".to_string()))?;
let lamports = balance.value;
let sol = lamports as f64 / 10_f64.powi(SOL_DECIMALS as i32);
Ok(Balance {
raw: lamports.to_string(),
formatted: format!("{:.9} SOL", sol),
decimals: SOL_DECIMALS,
symbol: "SOL".to_string(),
usd_value: None, })
}
pub async fn get_token_info(&self, mint_address: &str) -> Result<Token> {
validate_solana_address(mint_address)?;
let request = RpcRequest {
jsonrpc: "2.0",
id: 1,
method: "getAccountInfo",
params: serde_json::json!([mint_address, { "encoding": "base64" }]),
};
tracing::debug!(
url = %self.rpc_url,
mint = %mint_address,
"Fetching SPL mint info"
);
#[derive(Deserialize)]
struct AccountInfoResult {
value: Option<AccountInfoValue>,
}
#[derive(Deserialize)]
struct AccountInfoValue {
data: Option<Vec<String>>,
}
let body = serde_json::to_string(&request)?;
let response: RpcResponse<AccountInfoResult> = self
.http
.send(Request::post_json(&self.rpc_url, body))
.await?
.json()?;
if let Some(error) = response.error {
return Err(ScopeError::Chain(format!(
"Solana RPC error ({}): {}",
error.code, error.message
)));
}
let account = response.result.and_then(|r| r.value).ok_or_else(|| {
ScopeError::NotFound(format!("Mint account not found: {}", mint_address))
})?;
let data_b64 = account
.data
.and_then(|d| d.into_iter().next())
.ok_or_else(|| {
ScopeError::Chain(format!("No account data for mint: {}", mint_address))
})?;
let data = base64::Engine::decode(&base64::engine::general_purpose::STANDARD, &data_b64)
.map_err(|e| ScopeError::Chain(format!("Failed to decode mint data: {}", e)))?;
const DECIMALS_OFFSET: usize = 40;
let decimals = if data.len() > DECIMALS_OFFSET {
data[DECIMALS_OFFSET]
} else {
return Err(ScopeError::Chain(format!(
"Invalid mint account data (too short): {}",
mint_address
)));
};
let short_mint = crate::display::text::abbreviate(mint_address);
Ok(Token {
contract_address: mint_address.to_string(),
symbol: short_mint,
name: "SPL Token".to_string(),
decimals,
})
}
pub async fn enrich_balance_usd(&self, balance: &mut Balance) {
let dex = crate::chains::DexClient::new_with_http(self.http.clone());
if let Some(price) = dex.get_native_token_price("solana").await {
let lamports: f64 = balance.raw.parse().unwrap_or(0.0);
let sol = lamports / 10_f64.powi(SOL_DECIMALS as i32);
balance.usd_value = Some(sol * price);
}
}
pub async fn get_token_balances(&self, address: &str) -> Result<Vec<TokenBalance>> {
validate_solana_address(address)?;
const TOKEN_PROGRAM_ID: &str = "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA";
let request = serde_json::json!({
"jsonrpc": "2.0",
"id": 1,
"method": "getTokenAccountsByOwner",
"params": [
address,
{ "programId": TOKEN_PROGRAM_ID },
{ "encoding": "jsonParsed" }
]
});
tracing::debug!(url = %self.rpc_url, address = %address, "Fetching SPL token balances");
let body = serde_json::to_string(&request)?;
let response: RpcResponse<TokenAccountsResponse> = self
.http
.send(Request::post_json(&self.rpc_url, body))
.await?
.json()?;
if let Some(error) = response.error {
return Err(ScopeError::Chain(format!(
"Solana RPC error ({}): {}",
error.code, error.message
)));
}
let accounts = response
.result
.ok_or_else(|| ScopeError::Chain("Empty RPC response".to_string()))?;
let token_balances: Vec<TokenBalance> = accounts
.value
.into_iter()
.filter_map(|account| {
let info = &account.account.data.parsed.info;
let ui_amount = info.token_amount.ui_amount.unwrap_or(0.0);
if ui_amount == 0.0 {
return None;
}
Some(TokenBalance {
mint: info.mint.clone(),
token_account: account.pubkey,
raw_amount: info.token_amount.amount.clone(),
ui_amount,
decimals: info.token_amount.decimals,
symbol: None, name: None,
})
})
.collect();
Ok(token_balances)
}
pub async fn get_signatures(&self, address: &str, limit: u32) -> Result<Vec<String>> {
let infos = self.get_signature_infos(address, limit).await?;
Ok(infos.into_iter().map(|s| s.signature).collect())
}
async fn get_signature_infos(&self, address: &str, limit: u32) -> Result<Vec<SignatureInfo>> {
validate_solana_address(address)?;
#[derive(Serialize)]
struct GetSignaturesParams<'a> {
limit: u32,
#[serde(skip_serializing_if = "Option::is_none")]
before: Option<&'a str>,
}
let request = RpcRequest {
jsonrpc: "2.0",
id: 1,
method: "getSignaturesForAddress",
params: (
address,
GetSignaturesParams {
limit,
before: None,
},
),
};
tracing::debug!(
url = %self.rpc_url,
address = %address,
limit = %limit,
"Fetching Solana transaction signatures"
);
let body = serde_json::to_string(&request)?;
let response: RpcResponse<Vec<SignatureInfo>> = self
.http
.send(Request::post_json(&self.rpc_url, body))
.await?
.json()?;
if let Some(error) = response.error {
return Err(ScopeError::Chain(format!(
"Solana RPC error ({}): {}",
error.code, error.message
)));
}
response
.result
.ok_or_else(|| ScopeError::Chain("Empty RPC response".to_string()))
}
pub async fn get_transaction(&self, signature: &str) -> Result<Transaction> {
validate_solana_signature(signature)?;
let request = RpcRequest {
jsonrpc: "2.0",
id: 1,
method: "getTransaction",
params: serde_json::json!([
signature,
{
"encoding": "jsonParsed",
"maxSupportedTransactionVersion": 0
}
]),
};
tracing::debug!(
url = %self.rpc_url,
signature = %signature,
"Fetching Solana transaction"
);
let body = serde_json::to_string(&request)?;
let response: RpcResponse<SolanaTransactionResult> = self
.http
.send(Request::post_json(&self.rpc_url, body))
.await?
.json()?;
if let Some(error) = response.error {
return Err(ScopeError::Chain(format!(
"Solana RPC error ({}): {}",
error.code, error.message
)));
}
let tx_result = response
.result
.ok_or_else(|| ScopeError::NotFound(format!("Transaction not found: {}", signature)))?;
let from = tx_result
.transaction
.as_ref()
.and_then(|tx| tx.message.as_ref())
.and_then(|msg| msg.account_keys.as_ref())
.and_then(|keys| keys.first())
.map(|key| match key {
AccountKeyEntry::String(s) => s.clone(),
AccountKeyEntry::Object { pubkey, .. } => pubkey.clone(),
})
.unwrap_or_default();
let value = tx_result
.meta
.as_ref()
.and_then(|meta| {
let pre = meta.pre_balances.as_ref()?;
let post = meta.post_balances.as_ref()?;
if pre.len() >= 2 && post.len() >= 2 {
let fee = meta.fee.unwrap_or(0);
let sent = pre[0].saturating_sub(post[0]).saturating_sub(fee);
if sent > 0 {
let sol = sent as f64 / 10_f64.powi(SOL_DECIMALS as i32);
return Some(format!("{:.9}", sol));
}
}
None
})
.unwrap_or_else(|| "0".to_string());
let to = tx_result
.transaction
.as_ref()
.and_then(|tx| tx.message.as_ref())
.and_then(|msg| msg.account_keys.as_ref())
.and_then(|keys| {
if keys.len() >= 2 {
Some(match &keys[1] {
AccountKeyEntry::String(s) => s.clone(),
AccountKeyEntry::Object { pubkey, .. } => pubkey.clone(),
})
} else {
None
}
});
let fee = tx_result
.meta
.as_ref()
.and_then(|meta| meta.fee)
.unwrap_or(0);
let status = tx_result.meta.as_ref().map(|meta| meta.err.is_none());
Ok(Transaction {
hash: signature.to_string(),
block_number: tx_result.slot,
timestamp: tx_result.block_time.map(|t| t as u64),
from,
to,
value,
gas_limit: 0, gas_used: None,
gas_price: fee.to_string(), nonce: 0,
input: String::new(),
status,
})
}
pub async fn get_transactions(&self, address: &str, limit: u32) -> Result<Vec<Transaction>> {
validate_solana_address(address)?;
let sig_infos = self.get_signature_infos(address, limit).await?;
let transactions: Vec<Transaction> = sig_infos
.into_iter()
.map(|info| Transaction {
hash: info.signature,
block_number: Some(info.slot),
timestamp: info.block_time.map(|t| t as u64),
from: address.to_string(),
to: None,
value: "0".to_string(),
gas_limit: 0,
gas_used: None,
gas_price: "0".to_string(),
nonce: 0,
input: String::new(),
status: Some(info.err.is_none()),
})
.collect();
Ok(transactions)
}
pub async fn get_slot(&self) -> Result<u64> {
let request = RpcRequest {
jsonrpc: "2.0",
id: 1,
method: "getSlot",
params: (),
};
let body = serde_json::to_string(&request)?;
let response: RpcResponse<u64> = self
.http
.send(Request::post_json(&self.rpc_url, body))
.await?
.json()?;
if let Some(error) = response.error {
return Err(ScopeError::Chain(format!(
"Solana RPC error ({}): {}",
error.code, error.message
)));
}
response
.result
.ok_or_else(|| ScopeError::Chain("Empty RPC response".to_string()))
}
}
impl Default for SolanaClient {
fn default() -> Self {
Self {
http: crate::http::shared::fallback(),
rpc_url: DEFAULT_SOLANA_RPC.to_string(),
solscan_api_key: None,
}
}
}
pub fn validate_solana_address(address: &str) -> Result<()> {
if address.is_empty() {
return Err(ScopeError::InvalidAddress("Address cannot be empty".into()));
}
if address.len() < 32 || address.len() > 44 {
return Err(ScopeError::InvalidAddress(format!(
"Solana address must be 32-44 characters, got {}: {}",
address.len(),
address
)));
}
match bs58::decode(address).into_vec() {
Ok(bytes) => {
if bytes.len() != 32 {
return Err(ScopeError::InvalidAddress(format!(
"Solana address must decode to 32 bytes, got {}: {}",
bytes.len(),
address
)));
}
}
Err(e) => {
return Err(ScopeError::InvalidAddress(format!(
"Invalid base58 encoding: {}: {}",
e, address
)));
}
}
Ok(())
}
pub fn validate_solana_signature(signature: &str) -> Result<()> {
if signature.is_empty() {
return Err(ScopeError::InvalidHash("Signature cannot be empty".into()));
}
if signature.len() < 80 || signature.len() > 90 {
return Err(ScopeError::InvalidHash(format!(
"Solana signature must be 80-90 characters, got {}: {}",
signature.len(),
signature
)));
}
match bs58::decode(signature).into_vec() {
Ok(bytes) => {
if bytes.len() != 64 {
return Err(ScopeError::InvalidHash(format!(
"Solana signature must decode to 64 bytes, got {}: {}",
bytes.len(),
signature
)));
}
}
Err(e) => {
return Err(ScopeError::InvalidHash(format!(
"Invalid base58 encoding: {}: {}",
e, signature
)));
}
}
Ok(())
}
#[async_trait]
impl ChainClient for SolanaClient {
fn chain_name(&self) -> &str {
"solana"
}
fn native_token_symbol(&self) -> &str {
"SOL"
}
async fn get_balance(&self, address: &str) -> Result<Balance> {
self.get_balance(address).await
}
async fn enrich_balance_usd(&self, balance: &mut Balance) {
self.enrich_balance_usd(balance).await
}
async fn get_transaction(&self, hash: &str) -> Result<Transaction> {
self.get_transaction(hash).await
}
async fn get_transactions(&self, address: &str, limit: u32) -> Result<Vec<Transaction>> {
self.get_transactions(address, limit).await
}
async fn get_block_number(&self) -> Result<u64> {
self.get_slot().await
}
async fn get_token_info(&self, address: &str) -> Result<Token> {
self.get_token_info(address).await
}
async fn get_token_balances(&self, address: &str) -> Result<Vec<crate::chains::TokenBalance>> {
let solana_balances = self.get_token_balances(address).await?;
Ok(solana_balances
.into_iter()
.map(|tb| crate::chains::TokenBalance {
token: Token {
contract_address: tb.mint.clone(),
symbol: tb
.symbol
.unwrap_or_else(|| tb.mint[..8.min(tb.mint.len())].to_string()),
name: tb.name.unwrap_or_else(|| "SPL Token".to_string()),
decimals: tb.decimals,
},
balance: tb.raw_amount,
formatted_balance: format!("{:.6}", tb.ui_amount),
usd_value: None,
})
.collect())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::chains::{Balance, ChainClient};
const VALID_ADDRESS: &str = "DRpbCBMxVnDK7maPM5tGv6MvB3v1sRMC86PZ8okm21hy";
const VALID_SIGNATURE: &str =
"5VERv8NMvzbJMEkV8xnrLkEaWRtSz9CosKDYjCJjBRnbJLgp8uirBgmQpjKhoR4tjF3ZpRzrFmBV6UjKdiSZkQUW";
#[test]
fn test_validate_solana_address_valid() {
assert!(validate_solana_address(VALID_ADDRESS).is_ok());
}
#[test]
fn test_validate_solana_address_empty() {
let result = validate_solana_address("");
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("empty"));
}
#[test]
fn test_validate_solana_address_too_short() {
let result = validate_solana_address("DRpbCBMxVnDK7maPM5t");
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("32-44"));
}
#[test]
fn test_validate_solana_address_too_long() {
let long_addr = "DRpbCBMxVnDK7maPM5tGv6MvB3v1sRMC86PZ8okm21hyAAAAAAAAAAAA";
let result = validate_solana_address(long_addr);
assert!(result.is_err());
}
#[test]
fn test_validate_solana_address_invalid_base58() {
let result = validate_solana_address("0RpbCBMxVnDK7maPM5tGv6MvB3v1sRMC86PZ8okm21hy");
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("base58"));
}
#[test]
fn test_validate_solana_address_wrong_decoded_length() {
let result = validate_solana_address("abcdefghijabcdefghijabcdefghijab");
assert!(result.is_err());
}
#[test]
fn test_validate_solana_signature_valid() {
assert!(validate_solana_signature(VALID_SIGNATURE).is_ok());
}
#[test]
fn test_validate_solana_signature_empty() {
let result = validate_solana_signature("");
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("empty"));
}
#[test]
fn test_validate_solana_signature_too_short() {
let result = validate_solana_signature("abc");
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("80-90"));
}
#[test]
fn test_solana_client_default() {
let client = SolanaClient::default();
assert_eq!(client.chain_name(), "solana");
assert_eq!(client.native_token_symbol(), "SOL");
assert!(client.rpc_url.contains("mainnet-beta"));
}
#[test]
fn test_solana_client_with_rpc_url() {
let client = SolanaClient::with_rpc_url("https://custom.rpc.com");
assert_eq!(client.rpc_url, "https://custom.rpc.com");
}
#[test]
fn test_solana_client_new() {
let config = ChainsConfig::default();
let client = SolanaClient::new_with_http(&config, crate::http::test_transport());
assert!(client.is_ok());
}
#[test]
fn test_solana_client_new_with_custom_rpc() {
let config = ChainsConfig {
solana_rpc: Some("https://my-solana-rpc.com".to_string()),
..Default::default()
};
let client = SolanaClient::new_with_http(&config, crate::http::test_transport()).unwrap();
assert_eq!(client.rpc_url, "https://my-solana-rpc.com");
}
#[test]
fn test_solana_client_new_with_api_key() {
use std::collections::HashMap;
let mut api_keys = HashMap::new();
api_keys.insert("solscan".to_string(), "test-key".to_string());
let config = ChainsConfig {
api_keys,
..Default::default()
};
let client = SolanaClient::new_with_http(&config, crate::http::test_transport()).unwrap();
assert_eq!(client.solscan_api_key, Some("test-key".to_string()));
}
#[test]
fn test_rpc_request_serialization() {
let request = RpcRequest {
jsonrpc: "2.0",
id: 1,
method: "getBalance",
params: vec!["test"],
};
let json = serde_json::to_string(&request).unwrap();
assert!(json.contains("jsonrpc"));
assert!(json.contains("getBalance"));
}
#[test]
fn test_rpc_response_deserialization() {
let json = r#"{"jsonrpc":"2.0","result":{"value":1000000000},"id":1}"#;
let response: RpcResponse<BalanceResponse> = serde_json::from_str(json).unwrap();
assert!(response.result.is_some());
assert_eq!(response.result.unwrap().value, 1_000_000_000);
}
#[test]
fn test_rpc_error_deserialization() {
let json =
r#"{"jsonrpc":"2.0","error":{"code":-32600,"message":"Invalid request"},"id":1}"#;
let response: RpcResponse<BalanceResponse> = serde_json::from_str(json).unwrap();
assert!(response.error.is_some());
let error = response.error.unwrap();
assert_eq!(error.code, -32600);
assert_eq!(error.message, "Invalid request");
}
#[tokio::test]
async fn test_get_balance() {
let mut server = mockito::Server::new_async().await;
let _mock = server
.mock("POST", "/")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"jsonrpc":"2.0","result":{"value":5000000000},"id":1}"#)
.create_async()
.await;
let client = SolanaClient::with_rpc_url(&server.url());
let balance = client.get_balance(VALID_ADDRESS).await.unwrap();
assert_eq!(balance.raw, "5000000000");
assert_eq!(balance.symbol, "SOL");
assert_eq!(balance.decimals, 9);
assert!(balance.formatted.contains("5.000000000"));
}
#[tokio::test]
async fn test_get_balance_zero() {
let mut server = mockito::Server::new_async().await;
let _mock = server
.mock("POST", "/")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"jsonrpc":"2.0","result":{"value":0},"id":1}"#)
.create_async()
.await;
let client = SolanaClient::with_rpc_url(&server.url());
let balance = client.get_balance(VALID_ADDRESS).await.unwrap();
assert_eq!(balance.raw, "0");
assert!(balance.formatted.contains("0.000000000"));
}
#[tokio::test]
async fn test_get_balance_rpc_error() {
let mut server = mockito::Server::new_async().await;
let _mock = server
.mock("POST", "/")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(
r#"{"jsonrpc":"2.0","error":{"code":-32600,"message":"Invalid params"},"id":1}"#,
)
.create_async()
.await;
let client = SolanaClient::with_rpc_url(&server.url());
let result = client.get_balance(VALID_ADDRESS).await;
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("RPC error"));
}
#[tokio::test]
async fn test_get_balance_empty_response() {
let mut server = mockito::Server::new_async().await;
let _mock = server
.mock("POST", "/")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"jsonrpc":"2.0","id":1}"#)
.create_async()
.await;
let client = SolanaClient::with_rpc_url(&server.url());
let result = client.get_balance(VALID_ADDRESS).await;
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("Empty RPC"));
}
#[tokio::test]
async fn test_get_balance_invalid_address() {
let client = SolanaClient::default();
let result = client.get_balance("invalid").await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_get_transaction() {
let mut server = mockito::Server::new_async().await;
let _mock = server
.mock("POST", "/")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(
r#"{"jsonrpc":"2.0","result":{
"slot":123456789,
"blockTime":1700000000,
"transaction":{
"message":{
"accountKeys":[
{"pubkey":"DRpbCBMxVnDK7maPM5tGv6MvB3v1sRMC86PZ8okm21hy","signer":true},
{"pubkey":"9WzDXwBbmkg8ZTbNMqUxvQRAyrZzDsGYdLVL9zYtAWWM","signer":false}
]
}
},
"meta":{
"fee":5000,
"preBalances":[10000000000,5000000000],
"postBalances":[8999995000,6000000000],
"err":null
}
},"id":1}"#,
)
.create_async()
.await;
let client = SolanaClient::with_rpc_url(&server.url());
let tx = client.get_transaction(VALID_SIGNATURE).await.unwrap();
assert_eq!(tx.hash, VALID_SIGNATURE);
assert_eq!(tx.from, "DRpbCBMxVnDK7maPM5tGv6MvB3v1sRMC86PZ8okm21hy");
assert_eq!(
tx.to,
Some("9WzDXwBbmkg8ZTbNMqUxvQRAyrZzDsGYdLVL9zYtAWWM".to_string())
);
assert_eq!(tx.block_number, Some(123456789));
assert_eq!(tx.timestamp, Some(1700000000));
assert!(tx.status.unwrap()); assert_eq!(tx.gas_price, "5000"); }
#[tokio::test]
async fn test_get_transaction_failed() {
let mut server = mockito::Server::new_async().await;
let _mock = server
.mock("POST", "/")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"jsonrpc":"2.0","result":{
"slot":100,
"transaction":{"message":{"accountKeys":["DRpbCBMxVnDK7maPM5tGv6MvB3v1sRMC86PZ8okm21hy"]}},
"meta":{"fee":5000,"preBalances":[1000],"postBalances":[1000],"err":{"InstructionError":[0,{"Custom":1}]}}
},"id":1}"#)
.create_async()
.await;
let client = SolanaClient::with_rpc_url(&server.url());
let tx = client.get_transaction(VALID_SIGNATURE).await.unwrap();
assert!(!tx.status.unwrap()); }
#[tokio::test]
async fn test_get_transaction_not_found() {
let mut server = mockito::Server::new_async().await;
let _mock = server
.mock("POST", "/")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"jsonrpc":"2.0","result":null,"id":1}"#)
.create_async()
.await;
let client = SolanaClient::with_rpc_url(&server.url());
let result = client.get_transaction(VALID_SIGNATURE).await;
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("not found"));
}
#[tokio::test]
async fn test_get_transaction_string_account_keys() {
let mut server = mockito::Server::new_async().await;
let _mock = server
.mock("POST", "/")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"jsonrpc":"2.0","result":{
"slot":100,
"transaction":{"message":{"accountKeys":["DRpbCBMxVnDK7maPM5tGv6MvB3v1sRMC86PZ8okm21hy","9WzDXwBbmkg8ZTbNMqUxvQRAyrZzDsGYdLVL9zYtAWWM"]}},
"meta":{"fee":5000,"preBalances":[1000000000,0],"postBalances":[999995000,0],"err":null}
},"id":1}"#)
.create_async()
.await;
let client = SolanaClient::with_rpc_url(&server.url());
let tx = client.get_transaction(VALID_SIGNATURE).await.unwrap();
assert_eq!(tx.from, "DRpbCBMxVnDK7maPM5tGv6MvB3v1sRMC86PZ8okm21hy");
assert_eq!(
tx.to,
Some("9WzDXwBbmkg8ZTbNMqUxvQRAyrZzDsGYdLVL9zYtAWWM".to_string())
);
}
#[tokio::test]
async fn test_get_signatures() {
let mut server = mockito::Server::new_async().await;
let _mock = server
.mock("POST", "/")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"jsonrpc":"2.0","result":[
{"signature":"5VERv8NMvzbJMEkV8xnrLkEaWRtSz9CosKDYjCJjBRnbJLgp8uirBgmQpjKhoR4tjF3ZpRzrFmBV6UjKdiSZkQUW","slot":100,"blockTime":1700000000,"err":null},
{"signature":"4VERv8NMvzbJMEkV8xnrLkEaWRtSz9CosKDYjCJjBRnbJLgp8uirBgmQpjKhoR4tjF3ZpRzrFmBV6UjKdiSZkQUX","slot":101,"blockTime":1700000060,"err":{"InstructionError":[0,{"Custom":1}]}}
],"id":1}"#)
.create_async()
.await;
let client = SolanaClient::with_rpc_url(&server.url());
let sigs = client.get_signatures(VALID_ADDRESS, 10).await.unwrap();
assert_eq!(sigs.len(), 2);
assert!(sigs[0].starts_with("5VERv8"));
}
#[tokio::test]
async fn test_get_transactions() {
let mut server = mockito::Server::new_async().await;
let _mock = server
.mock("POST", "/")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"jsonrpc":"2.0","result":[
{"signature":"5VERv8NMvzbJMEkV8xnrLkEaWRtSz9CosKDYjCJjBRnbJLgp8uirBgmQpjKhoR4tjF3ZpRzrFmBV6UjKdiSZkQUW","slot":100,"blockTime":1700000000,"err":null},
{"signature":"4VERv8NMvzbJMEkV8xnrLkEaWRtSz9CosKDYjCJjBRnbJLgp8uirBgmQpjKhoR4tjF3ZpRzrFmBV6UjKdiSZkQUX","slot":101,"blockTime":1700000060,"err":{"InstructionError":[0,{"Custom":1}]}}
],"id":1}"#)
.create_async()
.await;
let client = SolanaClient::with_rpc_url(&server.url());
let txs = client.get_transactions(VALID_ADDRESS, 10).await.unwrap();
assert_eq!(txs.len(), 2);
assert!(txs[0].status.unwrap()); assert!(!txs[1].status.unwrap()); assert_eq!(txs[0].block_number, Some(100));
assert_eq!(txs[0].timestamp, Some(1700000000));
}
#[tokio::test]
async fn test_get_token_balances() {
let mut server = mockito::Server::new_async().await;
let _mock = server
.mock("POST", "/")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(
r#"{"jsonrpc":"2.0","result":{"value":[
{
"pubkey":"TokenAccAddr1",
"account":{
"data":{
"parsed":{
"info":{
"mint":"EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v",
"tokenAmount":{
"amount":"1000000",
"decimals":6,
"uiAmount":1.0,
"uiAmountString":"1"
}
}
}
}
}
},
{
"pubkey":"TokenAccAddr2",
"account":{
"data":{
"parsed":{
"info":{
"mint":"So11111111111111111111111111111111111111112",
"tokenAmount":{
"amount":"0",
"decimals":9,
"uiAmount":0.0,
"uiAmountString":"0"
}
}
}
}
}
}
]},"id":1}"#,
)
.create_async()
.await;
let client = SolanaClient::with_rpc_url(&server.url());
let balances = client.get_token_balances(VALID_ADDRESS).await.unwrap();
assert_eq!(balances.len(), 1);
assert_eq!(
balances[0].mint,
"EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"
);
assert_eq!(balances[0].ui_amount, 1.0);
assert_eq!(balances[0].decimals, 6);
}
#[tokio::test]
async fn test_get_token_balances_rpc_error() {
let mut server = mockito::Server::new_async().await;
let _mock = server
.mock("POST", "/")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(
r#"{"jsonrpc":"2.0","error":{"code":-32600,"message":"Invalid params"},"id":1}"#,
)
.create_async()
.await;
let client = SolanaClient::with_rpc_url(&server.url());
let result = client.get_token_balances(VALID_ADDRESS).await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_get_slot() {
let mut server = mockito::Server::new_async().await;
let _mock = server
.mock("POST", "/")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"jsonrpc":"2.0","result":256000000,"id":1}"#)
.create_async()
.await;
let client = SolanaClient::with_rpc_url(&server.url());
let slot = client.get_slot().await.unwrap();
assert_eq!(slot, 256000000);
}
#[tokio::test]
async fn test_get_slot_error() {
let mut server = mockito::Server::new_async().await;
let _mock = server
.mock("POST", "/")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(
r#"{"jsonrpc":"2.0","error":{"code":-32005,"message":"Node is behind"},"id":1}"#,
)
.create_async()
.await;
let client = SolanaClient::with_rpc_url(&server.url());
let result = client.get_slot().await;
assert!(result.is_err());
}
#[test]
fn test_validate_solana_signature_invalid_base58() {
let bad_sig = "0OIl00000000000000000000000000000000000000000000000000000000000000000000000000000000000000";
let result = validate_solana_signature(bad_sig);
assert!(result.is_err());
}
#[test]
fn test_validate_solana_signature_wrong_decoded_length() {
let short = "11111111111111111111111111111111"; let result = validate_solana_signature(short);
assert!(result.is_err());
}
#[tokio::test]
async fn test_get_transaction_rpc_error() {
let mut server = mockito::Server::new_async().await;
let _mock = server
.mock("POST", "/")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"jsonrpc":"2.0","error":{"code":-32600,"message":"Transaction not found"},"id":1}"#)
.create_async()
.await;
let client = SolanaClient::with_rpc_url(&server.url());
let result = client
.get_transaction("5VERv8NMvzbJMEkV8xnrLkEaWRtSz9CosKDYjCJjBRnbJLgp8uirBgmQpjKhoR4tjF3ZpRzrFmBV6UjKdiSZkQUW")
.await;
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("RPC error"));
}
#[tokio::test]
async fn test_solana_chain_client_trait_chain_name() {
let client = SolanaClient::with_rpc_url("http://localhost:8899");
let chain_client: &dyn ChainClient = &client;
assert_eq!(chain_client.chain_name(), "solana");
assert_eq!(chain_client.native_token_symbol(), "SOL");
}
#[tokio::test]
async fn test_chain_client_trait_get_balance() {
let mut server = mockito::Server::new_async().await;
let _mock = server
.mock("POST", "/")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(
r#"{"jsonrpc":"2.0","result":{"context":{"slot":1},"value":1000000000},"id":1}"#,
)
.create_async()
.await;
let client = SolanaClient::with_rpc_url(&server.url());
let chain_client: &dyn ChainClient = &client;
let balance = chain_client.get_balance(VALID_ADDRESS).await.unwrap();
assert_eq!(balance.symbol, "SOL");
}
#[tokio::test]
async fn test_chain_client_trait_get_block_number() {
let mut server = mockito::Server::new_async().await;
let _mock = server
.mock("POST", "/")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"jsonrpc":"2.0","result":250000000,"id":1}"#)
.create_async()
.await;
let client = SolanaClient::with_rpc_url(&server.url());
let chain_client: &dyn ChainClient = &client;
let slot = chain_client.get_block_number().await.unwrap();
assert_eq!(slot, 250000000);
}
#[tokio::test]
async fn test_chain_client_trait_get_token_balances() {
let mut server = mockito::Server::new_async().await;
let _mock = server
.mock("POST", "/")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(
r#"{"jsonrpc":"2.0","result":{"context":{"slot":1},"value":[
{
"pubkey":"TokenAccAddr1",
"account":{
"data":{
"parsed":{
"info":{
"mint":"EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v",
"tokenAmount":{
"amount":"1000000",
"decimals":6,
"uiAmount":1.0,
"uiAmountString":"1"
}
}
}
}
}
}
]},"id":1}"#,
)
.create_async()
.await;
let client = SolanaClient::with_rpc_url(&server.url());
let chain_client: &dyn ChainClient = &client;
let balances = chain_client
.get_token_balances(VALID_ADDRESS)
.await
.unwrap();
assert!(!balances.is_empty());
assert_eq!(
balances[0].token.contract_address,
"EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"
);
}
#[tokio::test]
async fn test_chain_client_trait_get_transaction_solana() {
let mut server = mockito::Server::new_async().await;
let _mock = server
.mock("POST", "/")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(
r#"{"jsonrpc":"2.0","result":{
"slot":200000000,
"blockTime":1700000000,
"meta":{
"fee":5000,
"preBalances":[1000000000,500000000],
"postBalances":[999995000,500005000],
"err":null
},
"transaction":{
"message":{
"accountKeys":[
"DRpbCBMxVnDK7maPM5tGv6MvB3v1sRMC86PZ8okm21hy",
"EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"
]
},
"signatures":["5VERv8NMhCTbSNjqo3hFKXwDVbxZFTkHxRejuuG5VBERKKCrgLjfyZ5mhCBvNB3qNm4Z9gFZ7Py3HT7bJGUCmAh"]
}
},"id":1}"#,
)
.create_async()
.await;
let client = SolanaClient::with_rpc_url(&server.url());
let chain_client: &dyn ChainClient = &client;
let tx = chain_client
.get_transaction("5VERv8NMhCTbSNjqo3hFKXwDVbxZFTkHxRejuuG5VBERKKCrgLjfyZ5mhCBvNB3qNm4Z9gFZ7Py3HT7bJGUCmAh")
.await
.unwrap();
assert!(!tx.hash.is_empty());
assert!(tx.timestamp.is_some());
}
#[tokio::test]
async fn test_chain_client_trait_get_transactions_solana() {
let mut server = mockito::Server::new_async().await;
let _mock = server
.mock("POST", "/")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(
r#"{"jsonrpc":"2.0","result":[
{
"signature":"5VERv8NMhCTbSNjqo3hFKXwDVbxZFTkHxRejuuG5VBERKKCrgLjfyZ5mhCBvNB3qNm4Z9gFZ7Py3HT7bJGUCmAh",
"slot":200000000,
"blockTime":1700000000,
"err":null,
"memo":null
}
],"id":1}"#,
)
.create_async()
.await;
let client = SolanaClient::with_rpc_url(&server.url());
let chain_client: &dyn ChainClient = &client;
let txs = chain_client
.get_transactions(VALID_ADDRESS, 10)
.await
.unwrap();
assert!(!txs.is_empty());
}
#[test]
fn test_validate_solana_signature_wrong_byte_length() {
let long_sig = "1".repeat(88); let result = validate_solana_signature(&long_sig);
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains("64 bytes") || err.contains("base58"));
}
#[tokio::test]
async fn test_rpc_error_response() {
let mut server = mockito::Server::new_async().await;
let _mock = server
.mock("POST", "/")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(
r#"{"jsonrpc":"2.0","error":{"code":-32600,"message":"Invalid request"},"id":1}"#,
)
.create_async()
.await;
let client = SolanaClient::with_rpc_url(&server.url());
let result = client.get_balance(VALID_ADDRESS).await;
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("RPC error"));
}
#[tokio::test]
async fn test_get_token_info_success() {
let mut mint_data = vec![0u8; 41];
mint_data[40] = 6;
let data_b64 =
base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &mint_data);
let mut server = mockito::Server::new_async().await;
let _mock = server
.mock("POST", "/")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(format!(
r#"{{"jsonrpc":"2.0","result":{{"value":{{"data":["{}"]}}}},"id":1}}"#,
data_b64
))
.create_async()
.await;
let client = SolanaClient::with_rpc_url(&server.url());
let token = client
.get_token_info("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v")
.await
.unwrap();
assert_eq!(token.decimals, 6);
assert_eq!(
token.contract_address,
"EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"
);
assert_eq!(token.symbol, "EPjFWd…Dt1v");
assert_eq!(token.name, "SPL Token");
}
#[tokio::test]
async fn test_get_token_info_decimals_nine() {
let mut mint_data = vec![0u8; 41];
mint_data[40] = 9;
let data_b64 =
base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &mint_data);
let mut server = mockito::Server::new_async().await;
let _mock = server
.mock("POST", "/")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(format!(
r#"{{"jsonrpc":"2.0","result":{{"value":{{"data":["{}"]}}}},"id":1}}"#,
data_b64
))
.create_async()
.await;
let client = SolanaClient::with_rpc_url(&server.url());
let token = client
.get_token_info("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v")
.await
.unwrap();
assert_eq!(token.decimals, 9);
assert_eq!(token.symbol, "EPjFWd…Dt1v"); }
#[tokio::test]
async fn test_get_token_info_rpc_error() {
let mut server = mockito::Server::new_async().await;
let _mock = server
.mock("POST", "/")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(
r#"{"jsonrpc":"2.0","error":{"code":-32602,"message":"Invalid params"},"id":1}"#,
)
.create_async()
.await;
let client = SolanaClient::with_rpc_url(&server.url());
let result = client
.get_token_info("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v")
.await;
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("RPC error"));
}
#[tokio::test]
async fn test_get_token_info_not_found() {
let mut server = mockito::Server::new_async().await;
let _mock = server
.mock("POST", "/")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"jsonrpc":"2.0","result":{"value":null},"id":1}"#)
.create_async()
.await;
let client = SolanaClient::with_rpc_url(&server.url());
let result = client
.get_token_info("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v")
.await;
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("not found"));
}
#[tokio::test]
async fn test_get_token_info_no_account_data() {
let mut server = mockito::Server::new_async().await;
let _mock = server
.mock("POST", "/")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"jsonrpc":"2.0","result":{"value":{"data":null}},"id":1}"#)
.create_async()
.await;
let client = SolanaClient::with_rpc_url(&server.url());
let result = client
.get_token_info("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v")
.await;
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("No account data"));
}
#[tokio::test]
async fn test_get_token_info_empty_data_array() {
let mut server = mockito::Server::new_async().await;
let _mock = server
.mock("POST", "/")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"jsonrpc":"2.0","result":{"value":{"data":[]}},"id":1}"#)
.create_async()
.await;
let client = SolanaClient::with_rpc_url(&server.url());
let result = client
.get_token_info("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v")
.await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_get_token_info_invalid_base64() {
let mut server = mockito::Server::new_async().await;
let _mock = server
.mock("POST", "/")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"jsonrpc":"2.0","result":{"value":{"data":["!!!invalid!!!"]}},"id":1}"#)
.create_async()
.await;
let client = SolanaClient::with_rpc_url(&server.url());
let result = client
.get_token_info("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v")
.await;
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("decode"));
}
#[tokio::test]
async fn test_get_token_info_data_too_short() {
let short_data = vec![0u8; 20];
let data_b64 =
base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &short_data);
let mut server = mockito::Server::new_async().await;
let _mock = server
.mock("POST", "/")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(format!(
r#"{{"jsonrpc":"2.0","result":{{"value":{{"data":["{}"]}}}},"id":1}}"#,
data_b64
))
.create_async()
.await;
let client = SolanaClient::with_rpc_url(&server.url());
let result = client
.get_token_info("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v")
.await;
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("too short"));
}
#[tokio::test]
async fn test_get_token_info_invalid_address() {
let client = SolanaClient::default();
let result = client.get_token_info("bad").await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_get_transaction_minimal_no_transaction_meta() {
let mut server = mockito::Server::new_async().await;
let _mock = server
.mock("POST", "/")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"jsonrpc":"2.0","result":{"slot":100},"id":1}"#)
.create_async()
.await;
let client = SolanaClient::with_rpc_url(&server.url());
let tx = client.get_transaction(VALID_SIGNATURE).await.unwrap();
assert_eq!(tx.hash, VALID_SIGNATURE);
assert_eq!(tx.from, "");
assert_eq!(tx.to, None);
assert_eq!(tx.value, "0");
assert_eq!(tx.gas_price, "0");
assert_eq!(tx.block_number, Some(100));
assert_eq!(tx.status, None);
}
#[tokio::test]
async fn test_get_transaction_single_account_key_to_none() {
let mut server = mockito::Server::new_async().await;
let _mock = server
.mock("POST", "/")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"jsonrpc":"2.0","result":{
"slot":100,
"blockTime":1700000000,
"transaction":{"message":{"accountKeys":["DRpbCBMxVnDK7maPM5tGv6MvB3v1sRMC86PZ8okm21hy"]}},
"meta":{"fee":5000,"preBalances":[1000],"postBalances":[500],"err":null}
},"id":1}"#)
.create_async()
.await;
let client = SolanaClient::with_rpc_url(&server.url());
let tx = client.get_transaction(VALID_SIGNATURE).await.unwrap();
assert_eq!(tx.from, "DRpbCBMxVnDK7maPM5tGv6MvB3v1sRMC86PZ8okm21hy");
assert_eq!(tx.to, None); assert_eq!(tx.value, "0"); }
#[tokio::test]
async fn test_get_transaction_value_zero_when_no_send() {
let mut server = mockito::Server::new_async().await;
let _mock = server
.mock("POST", "/")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(
r#"{"jsonrpc":"2.0","result":{
"slot":100,
"transaction":{"message":{"accountKeys":["A","B"]}},
"meta":{"fee":5000,"preBalances":[1000,500],"postBalances":[1000,500],"err":null}
},"id":1}"#,
)
.create_async()
.await;
let client = SolanaClient::with_rpc_url(&server.url());
let tx = client.get_transaction(VALID_SIGNATURE).await.unwrap();
assert_eq!(tx.value, "0"); }
#[tokio::test]
async fn test_get_transaction_meta_no_fee() {
let mut server = mockito::Server::new_async().await;
let _mock = server
.mock("POST", "/")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"jsonrpc":"2.0","result":{
"slot":100,
"transaction":{"message":{"accountKeys":["A","B"]}},
"meta":{"preBalances":[10000000000,0],"postBalances":[8999995000,1000005000],"err":null}
},"id":1}"#)
.create_async()
.await;
let client = SolanaClient::with_rpc_url(&server.url());
let tx = client.get_transaction(VALID_SIGNATURE).await.unwrap();
assert_eq!(tx.gas_price, "0"); }
#[tokio::test]
async fn test_get_transaction_invalid_signature() {
let client = SolanaClient::default();
let result = client.get_transaction("invalid").await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_get_signatures_empty_response() {
let mut server = mockito::Server::new_async().await;
let _mock = server
.mock("POST", "/")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"jsonrpc":"2.0","id":1}"#)
.create_async()
.await;
let client = SolanaClient::with_rpc_url(&server.url());
let result = client.get_signatures(VALID_ADDRESS, 10).await;
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("Empty RPC"));
}
#[tokio::test]
async fn test_get_signatures_invalid_address() {
let client = SolanaClient::default();
let result = client.get_signatures("x", 10).await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_get_signatures_rpc_error() {
let mut server = mockito::Server::new_async().await;
let _mock = server
.mock("POST", "/")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(
r#"{"jsonrpc":"2.0","error":{"code":-32600,"message":"Invalid address"},"id":1}"#,
)
.create_async()
.await;
let client = SolanaClient::with_rpc_url(&server.url());
let result = client.get_signatures(VALID_ADDRESS, 10).await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_get_transactions_invalid_address() {
let client = SolanaClient::default();
let result = client.get_transactions("invalid-addr", 10).await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_get_slot_empty_response() {
let mut server = mockito::Server::new_async().await;
let _mock = server
.mock("POST", "/")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"jsonrpc":"2.0","id":1}"#)
.create_async()
.await;
let client = SolanaClient::with_rpc_url(&server.url());
let result = client.get_slot().await;
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("Empty RPC"));
}
#[tokio::test]
async fn test_get_token_balances_ui_amount_null() {
let mut server = mockito::Server::new_async().await;
let _mock = server
.mock("POST", "/")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(
r#"{"jsonrpc":"2.0","result":{"value":[{
"pubkey":"TokenAccAddr1",
"account":{
"data":{
"parsed":{
"info":{
"mint":"EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v",
"tokenAmount":{
"amount":"0",
"decimals":6,
"uiAmount":null,
"uiAmountString":"0"
}
}
}
}
}
}]},"id":1}"#,
)
.create_async()
.await;
let client = SolanaClient::with_rpc_url(&server.url());
let balances = client.get_token_balances(VALID_ADDRESS).await.unwrap();
assert_eq!(balances.len(), 0);
}
#[test]
fn test_token_balance_serialization() {
let tb = TokenBalance {
mint: "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v".to_string(),
token_account: "TokenAcc1".to_string(),
raw_amount: "1000000".to_string(),
ui_amount: 1.5,
decimals: 6,
symbol: Some("USDC".to_string()),
name: Some("USD Coin".to_string()),
};
let json = serde_json::to_string(&tb).unwrap();
assert!(json.contains("USDC"));
assert!(json.contains("1000000"));
}
#[tokio::test]
async fn test_chain_client_get_token_balances_short_mint() {
let mut server = mockito::Server::new_async().await;
let _mock = server
.mock("POST", "/")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(
r#"{"jsonrpc":"2.0","result":{"value":[{
"pubkey":"TokenAcc1",
"account":{
"data":{
"parsed":{
"info":{
"mint":"Short",
"tokenAmount":{
"amount":"100",
"decimals":2,
"uiAmount":1.0,
"uiAmountString":"1"
}
}
}
}
}
}]},"id":1}"#,
)
.create_async()
.await;
let client = SolanaClient::with_rpc_url(&server.url());
let chain_client: &dyn ChainClient = &client;
let balances = chain_client
.get_token_balances("DRpbCBMxVnDK7maPM5tGv6MvB3v1sRMC86PZ8okm21hy")
.await
.unwrap();
assert_eq!(balances.len(), 1);
assert_eq!(balances[0].token.symbol, "Short");
}
#[test]
fn test_validate_solana_signature_too_long() {
let too_long = "1".repeat(91);
let result = validate_solana_signature(&too_long);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("80-90"));
}
#[tokio::test]
async fn test_enrich_balance_usd_invalid_raw_does_not_panic() {
let client = SolanaClient::default();
let mut balance = Balance {
raw: "not-a-number".to_string(),
formatted: "0 SOL".to_string(),
decimals: 9,
symbol: "SOL".to_string(),
usd_value: None,
};
client.enrich_balance_usd(&mut balance).await;
assert!(balance.usd_value.is_none() || balance.usd_value == Some(0.0));
}
#[tokio::test]
async fn test_chain_client_trait_enrich_balance_usd() {
let client = SolanaClient::default();
let chain_client: &dyn ChainClient = &client;
let mut balance = Balance {
raw: "not-a-number".to_string(),
formatted: "0 SOL".to_string(),
decimals: 9,
symbol: "SOL".to_string(),
usd_value: None,
};
chain_client.enrich_balance_usd(&mut balance).await;
assert!(balance.usd_value.is_none() || balance.usd_value == Some(0.0));
}
#[tokio::test]
async fn test_chain_client_trait_get_token_info() {
let mut mint_data = vec![0u8; 41];
mint_data[40] = 18;
let data_b64 =
base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &mint_data);
let mut server = mockito::Server::new_async().await;
let _mock = server
.mock("POST", "/")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(format!(
r#"{{"jsonrpc":"2.0","result":{{"value":{{"data":["{}"]}}}},"id":1}}"#,
data_b64
))
.create_async()
.await;
let client = SolanaClient::with_rpc_url(&server.url());
let chain_client: &dyn ChainClient = &client;
let token = chain_client
.get_token_info("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v")
.await
.unwrap();
assert_eq!(token.decimals, 18);
}
#[test]
fn test_solscan_account_info_deserialization() {
let json = r#"{"lamports":1000000,"type":"account"}"#;
let info: SolscanAccountInfo = serde_json::from_str(json).unwrap();
assert_eq!(info.lamports, 1000000);
assert_eq!(info.account_type, Some("account".to_string()));
}
}