use std::collections::{HashMap, HashSet};
use std::sync::{Arc, OnceLock};
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::error::TransportError;
use crate::method_safety::MethodSafety;
use crate::request::{JsonRpcRequest, JsonRpcResponse};
use crate::transport::{HealthStatus, RpcTransport};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum SolanaCommitment {
Processed,
#[default]
Confirmed,
Finalized,
}
impl SolanaCommitment {
pub fn as_str(&self) -> &'static str {
match self {
Self::Processed => "processed",
Self::Confirmed => "confirmed",
Self::Finalized => "finalized",
}
}
pub fn is_safe_for_indexing(&self) -> bool {
matches!(self, Self::Finalized)
}
pub fn is_safe_for_display(&self) -> bool {
matches!(self, Self::Confirmed | Self::Finalized)
}
}
impl std::fmt::Display for SolanaCommitment {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
pub fn classify_solana_method(method: &str) -> MethodSafety {
if solana_unsafe_methods().contains(method) {
MethodSafety::Unsafe
} else if solana_idempotent_methods().contains(method) {
MethodSafety::Idempotent
} else {
MethodSafety::Safe
}
}
pub fn is_solana_safe_to_retry(method: &str) -> bool {
classify_solana_method(method) == MethodSafety::Safe
}
pub fn is_solana_safe_to_dedup(method: &str) -> bool {
classify_solana_method(method) == MethodSafety::Safe
}
pub fn is_solana_cacheable(method: &str) -> bool {
classify_solana_method(method) == MethodSafety::Safe
}
fn solana_unsafe_methods() -> &'static HashSet<&'static str> {
static UNSAFE: OnceLock<HashSet<&'static str>> = OnceLock::new();
UNSAFE.get_or_init(|| ["requestAirdrop"].into_iter().collect())
}
fn solana_idempotent_methods() -> &'static HashSet<&'static str> {
static IDEMPOTENT: OnceLock<HashSet<&'static str>> = OnceLock::new();
IDEMPOTENT.get_or_init(|| ["sendTransaction"].into_iter().collect())
}
fn accepts_commitment(method: &str) -> bool {
static SET: OnceLock<HashSet<&'static str>> = OnceLock::new();
SET.get_or_init(|| {
[
"getAccountInfo",
"getBalance",
"getBlock",
"getBlockHeight",
"getEpochInfo",
"getLatestBlockhash",
"getSlot",
"getTransaction",
"getSignaturesForAddress",
"getTokenAccountBalance",
"getTokenAccountsByOwner",
"getProgramAccounts",
"getMultipleAccounts",
"sendTransaction",
"simulateTransaction",
"getSignatureStatuses",
]
.into_iter()
.collect()
})
.contains(method)
}
#[derive(Debug, Clone)]
pub struct SolanaCuCostTable {
costs: HashMap<String, u32>,
default_cost: u32,
}
impl SolanaCuCostTable {
pub fn defaults() -> Self {
let mut table = Self::new(15);
let entries: &[(&str, u32)] = &[
("getAccountInfo", 10),
("getBalance", 10),
("getBlock", 50),
("getBlockHeight", 5),
("getTransaction", 20),
("getProgramAccounts", 100),
("getSignaturesForAddress", 30),
("getTokenAccountsByOwner", 30),
("getSlot", 5),
("getLatestBlockhash", 10),
("sendTransaction", 10),
("simulateTransaction", 50),
("getMultipleAccounts", 30),
];
for &(method, cost) in entries {
table.costs.insert(method.to_string(), cost);
}
table
}
pub fn new(default_cost: u32) -> Self {
Self {
costs: HashMap::new(),
default_cost,
}
}
pub fn set_cost(&mut self, method: &str, cost: u32) {
self.costs.insert(method.to_string(), cost);
}
pub fn cost_for(&self, method: &str) -> u32 {
self.costs.get(method).copied().unwrap_or(self.default_cost)
}
}
impl Default for SolanaCuCostTable {
fn default() -> Self {
Self::defaults()
}
}
pub struct SolanaTransport {
inner: Arc<dyn RpcTransport>,
commitment: SolanaCommitment,
}
impl SolanaTransport {
pub fn new(inner: Arc<dyn RpcTransport>, commitment: SolanaCommitment) -> Self {
Self { inner, commitment }
}
pub fn with_commitment(&self, commitment: SolanaCommitment) -> Self {
Self {
inner: Arc::clone(&self.inner),
commitment,
}
}
pub fn commitment(&self) -> SolanaCommitment {
self.commitment
}
fn inject_commitment(&self, req: &mut JsonRpcRequest) {
if !accepts_commitment(&req.method) {
return;
}
let commitment_value = Value::String(self.commitment.as_str().to_string());
if let Some(Value::Object(map)) = req.params.last_mut() {
map.entry("commitment").or_insert(commitment_value);
return;
}
let mut config = serde_json::Map::new();
config.insert("commitment".to_string(), commitment_value);
req.params.push(Value::Object(config));
}
}
#[async_trait]
impl RpcTransport for SolanaTransport {
async fn send(&self, req: JsonRpcRequest) -> Result<JsonRpcResponse, TransportError> {
let mut req = req;
self.inject_commitment(&mut req);
self.inner.send(req).await
}
async fn send_batch(
&self,
reqs: Vec<JsonRpcRequest>,
) -> Result<Vec<JsonRpcResponse>, TransportError> {
let reqs: Vec<JsonRpcRequest> = reqs
.into_iter()
.map(|mut r| {
self.inject_commitment(&mut r);
r
})
.collect();
self.inner.send_batch(reqs).await
}
fn health(&self) -> HealthStatus {
self.inner.health()
}
fn url(&self) -> &str {
self.inner.url()
}
}
pub fn solana_mainnet_endpoints() -> Vec<&'static str> {
vec![
"https://api.mainnet-beta.solana.com",
"https://solana-mainnet.g.alchemy.com/v2",
"https://rpc.ankr.com/solana",
]
}
pub fn solana_devnet_endpoints() -> Vec<&'static str> {
vec!["https://api.devnet.solana.com"]
}
pub fn solana_testnet_endpoints() -> Vec<&'static str> {
vec!["https://api.testnet.solana.com"]
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Mutex;
struct MockTransport {
url: String,
sent: Mutex<Vec<JsonRpcRequest>>,
}
impl MockTransport {
fn new(url: &str) -> Self {
Self {
url: url.to_string(),
sent: Mutex::new(Vec::new()),
}
}
fn sent_requests(&self) -> Vec<JsonRpcRequest> {
self.sent.lock().unwrap().clone()
}
}
#[async_trait]
impl RpcTransport for MockTransport {
async fn send(&self, req: JsonRpcRequest) -> Result<JsonRpcResponse, TransportError> {
self.sent.lock().unwrap().push(req.clone());
Ok(JsonRpcResponse {
jsonrpc: "2.0".into(),
id: req.id,
result: Some(Value::Null),
error: None,
})
}
fn url(&self) -> &str {
&self.url
}
}
#[test]
fn classify_safe_methods() {
let safe_methods = [
"getBalance",
"getSlot",
"getAccountInfo",
"getBlock",
"getBlockHeight",
"getBlockProduction",
"getBlockCommitment",
"getBlockTime",
"getClusterNodes",
"getEpochInfo",
"getEpochSchedule",
"getFeeForMessage",
"getFirstAvailableBlock",
"getGenesisHash",
"getHealth",
"getHighestSnapshotSlot",
"getIdentity",
"getInflationGovernor",
"getInflationRate",
"getInflationReward",
"getLargestAccounts",
"getLatestBlockhash",
"getLeaderSchedule",
"getMaxRetransmitSlot",
"getMaxShredInsertSlot",
"getMinimumBalanceForRentExemption",
"getMultipleAccounts",
"getProgramAccounts",
"getRecentPerformanceSamples",
"getRecentPrioritizationFees",
"getSignatureStatuses",
"getSignaturesForAddress",
"getSlotLeader",
"getSlotLeaders",
"getStakeActivation",
"getStakeMinimumDelegation",
"getSupply",
"getTokenAccountBalance",
"getTokenAccountsByDelegate",
"getTokenAccountsByOwner",
"getTokenLargestAccounts",
"getTokenSupply",
"getTransaction",
"getTransactionCount",
"getVersion",
"getVoteAccounts",
"isBlockhashValid",
"minimumLedgerSlot",
"simulateTransaction",
];
for method in &safe_methods {
assert_eq!(
classify_solana_method(method),
MethodSafety::Safe,
"expected {method} to be Safe"
);
}
}
#[test]
fn classify_idempotent_methods() {
assert_eq!(
classify_solana_method("sendTransaction"),
MethodSafety::Idempotent,
);
}
#[test]
fn classify_unsafe_methods() {
assert_eq!(
classify_solana_method("requestAirdrop"),
MethodSafety::Unsafe,
);
}
#[test]
fn retry_safety() {
assert!(is_solana_safe_to_retry("getBalance"));
assert!(is_solana_safe_to_retry("getSlot"));
assert!(is_solana_safe_to_retry("getAccountInfo"));
assert!(!is_solana_safe_to_retry("sendTransaction"));
assert!(!is_solana_safe_to_retry("requestAirdrop"));
}
#[test]
fn commitment_default() {
assert_eq!(SolanaCommitment::default(), SolanaCommitment::Confirmed);
}
#[test]
fn commitment_serialization() {
let json = serde_json::to_string(&SolanaCommitment::Processed).unwrap();
assert_eq!(json, "\"processed\"");
let json = serde_json::to_string(&SolanaCommitment::Confirmed).unwrap();
assert_eq!(json, "\"confirmed\"");
let json = serde_json::to_string(&SolanaCommitment::Finalized).unwrap();
assert_eq!(json, "\"finalized\"");
let parsed: SolanaCommitment = serde_json::from_str("\"finalized\"").unwrap();
assert_eq!(parsed, SolanaCommitment::Finalized);
}
#[test]
fn commitment_safety() {
assert!(SolanaCommitment::Finalized.is_safe_for_indexing());
assert!(SolanaCommitment::Finalized.is_safe_for_display());
assert!(!SolanaCommitment::Confirmed.is_safe_for_indexing());
assert!(SolanaCommitment::Confirmed.is_safe_for_display());
assert!(!SolanaCommitment::Processed.is_safe_for_indexing());
assert!(!SolanaCommitment::Processed.is_safe_for_display());
}
#[test]
fn cu_cost_table() {
let table = SolanaCuCostTable::defaults();
assert_eq!(table.cost_for("getProgramAccounts"), 100);
assert_eq!(table.cost_for("getSlot"), 5);
assert_eq!(table.cost_for("getBlock"), 50);
assert_eq!(table.cost_for("getBalance"), 10);
assert_eq!(table.cost_for("sendTransaction"), 10);
assert_eq!(table.cost_for("simulateTransaction"), 50);
assert_eq!(table.cost_for("getMultipleAccounts"), 30);
assert!(table.cost_for("getProgramAccounts") > table.cost_for("getSlot"));
assert_eq!(table.cost_for("someUnknownMethod"), 15);
let mut custom = SolanaCuCostTable::new(42);
custom.set_cost("getSlot", 999);
assert_eq!(custom.cost_for("getSlot"), 999);
assert_eq!(custom.cost_for("anythingElse"), 42);
}
#[tokio::test]
async fn inject_commitment() {
let mock = Arc::new(MockTransport::new("https://api.devnet.solana.com"));
let transport = SolanaTransport::new(
Arc::clone(&mock) as Arc<dyn RpcTransport>,
SolanaCommitment::Finalized,
);
let req = JsonRpcRequest::new(
1,
"getBalance",
vec![Value::String(
"83astBRguLMdt2h5U1Tbd4hAZbs9sRhfns3EGNHpGT8o".into(),
)],
);
transport.send(req).await.unwrap();
let sent = mock.sent_requests();
assert_eq!(sent.len(), 1);
let last_param = sent[0].params.last().unwrap();
assert_eq!(
last_param.get("commitment").and_then(Value::as_str),
Some("finalized"),
);
let mut config = serde_json::Map::new();
config.insert("commitment".to_string(), Value::String("processed".into()));
let req = JsonRpcRequest::new(
2,
"getAccountInfo",
vec![
Value::String("Vote111111111111111111111111111111111111111".into()),
Value::Object(config),
],
);
transport.send(req).await.unwrap();
let sent = mock.sent_requests();
assert_eq!(sent.len(), 2);
let last_param = sent[1].params.last().unwrap();
assert_eq!(
last_param.get("commitment").and_then(Value::as_str),
Some("processed"),
);
let req = JsonRpcRequest::new(3, "getVersion", vec![]);
transport.send(req).await.unwrap();
let sent = mock.sent_requests();
assert_eq!(sent.len(), 3);
assert!(sent[2].params.is_empty());
}
#[tokio::test]
async fn solana_transport_delegates() {
let mock = Arc::new(MockTransport::new("https://api.mainnet-beta.solana.com"));
let transport = SolanaTransport::new(
Arc::clone(&mock) as Arc<dyn RpcTransport>,
SolanaCommitment::Confirmed,
);
assert_eq!(transport.url(), "https://api.mainnet-beta.solana.com");
assert_eq!(transport.health(), HealthStatus::Unknown);
let req = JsonRpcRequest::new(1, "getSlot", vec![]);
let resp = transport.send(req).await.unwrap();
assert!(resp.is_ok());
let reqs = vec![
JsonRpcRequest::new(2, "getSlot", vec![]),
JsonRpcRequest::new(3, "getBlockHeight", vec![]),
];
let resps = transport.send_batch(reqs).await.unwrap();
assert_eq!(resps.len(), 2);
assert!(resps.iter().all(|r| r.is_ok()));
let finalized = transport.with_commitment(SolanaCommitment::Finalized);
assert_eq!(finalized.commitment(), SolanaCommitment::Finalized);
assert_eq!(finalized.url(), "https://api.mainnet-beta.solana.com");
}
#[test]
fn endpoints_not_empty() {
assert!(!solana_mainnet_endpoints().is_empty());
assert!(!solana_devnet_endpoints().is_empty());
assert!(!solana_testnet_endpoints().is_empty());
assert!(solana_mainnet_endpoints().contains(&"https://api.mainnet-beta.solana.com"));
}
#[test]
fn unknown_method_is_safe() {
assert_eq!(
classify_solana_method("customFooBarMethod"),
MethodSafety::Safe,
);
assert!(is_solana_safe_to_retry("customFooBarMethod"));
assert!(is_solana_safe_to_dedup("customFooBarMethod"));
assert!(is_solana_cacheable("customFooBarMethod"));
}
#[test]
fn commitment_display() {
assert_eq!(SolanaCommitment::Processed.to_string(), "processed");
assert_eq!(SolanaCommitment::Confirmed.to_string(), "confirmed");
assert_eq!(SolanaCommitment::Finalized.to_string(), "finalized");
}
#[test]
fn dedup_and_cacheable() {
assert!(is_solana_safe_to_dedup("getBalance"));
assert!(!is_solana_safe_to_dedup("sendTransaction"));
assert!(!is_solana_safe_to_dedup("requestAirdrop"));
assert!(is_solana_cacheable("getAccountInfo"));
assert!(!is_solana_cacheable("sendTransaction"));
assert!(!is_solana_cacheable("requestAirdrop"));
}
#[tokio::test]
async fn inject_commitment_in_batch() {
let mock = Arc::new(MockTransport::new("https://api.devnet.solana.com"));
let transport = SolanaTransport::new(
Arc::clone(&mock) as Arc<dyn RpcTransport>,
SolanaCommitment::Finalized,
);
let reqs = vec![
JsonRpcRequest::new(1, "getBalance", vec![Value::String("addr1".into())]),
JsonRpcRequest::new(2, "getVersion", vec![]),
];
transport.send_batch(reqs).await.unwrap();
let sent = mock.sent_requests();
assert_eq!(sent.len(), 2);
let balance_params = &sent[0].params;
let config = balance_params.last().unwrap();
assert_eq!(
config.get("commitment").and_then(Value::as_str),
Some("finalized"),
);
assert!(sent[1].params.is_empty());
}
#[test]
fn cu_cost_table_default_trait() {
let table: SolanaCuCostTable = Default::default();
assert_eq!(table.cost_for("getBlock"), 50);
assert_eq!(table.cost_for("unknown"), 15);
}
}