use std::cell::Cell;
use base64::Engine;
use serde::Deserialize;
use serde_json::{json, Value};
use crate::error::{Error, Result};
use crate::pubkey::Pubkey;
use crate::transport::Transport;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Commitment {
Processed,
#[default]
Confirmed,
Finalized,
}
impl Commitment {
pub const fn as_str(self) -> &'static str {
match self {
Commitment::Processed => "processed",
Commitment::Confirmed => "confirmed",
Commitment::Finalized => "finalized",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Account {
pub lamports: u64,
pub owner: Pubkey,
pub data: Vec<u8>,
pub executable: bool,
pub rent_epoch: u64,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
pub struct UiTokenAmount {
pub amount: String,
pub decimals: u8,
#[serde(default)]
pub ui_amount_string: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TokenAccountBalance {
pub address: Pubkey,
pub amount: u128,
pub decimals: u8,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LatestBlockhash {
pub blockhash: String,
pub last_valid_block_height: u64,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SimulationOutcome {
pub err: Option<String>,
pub logs: Vec<String>,
pub units_consumed: Option<u64>,
}
pub struct RpcClient<T: Transport> {
url: String,
transport: T,
commitment: Commitment,
next_id: Cell<u64>,
}
impl<T: Transport> RpcClient<T> {
pub fn new(url: impl Into<String>, transport: T) -> Self {
Self {
url: url.into(),
transport,
commitment: Commitment::default(),
next_id: Cell::new(1),
}
}
pub fn with_commitment(mut self, commitment: Commitment) -> Self {
self.commitment = commitment;
self
}
pub fn safe_endpoint(&self) -> String {
redact_endpoint(&self.url)
}
fn call(&self, method: &str, params: Value) -> Result<Value> {
let id = self.next_id.get();
self.next_id.set(id.wrapping_add(1));
let body = json!({
"jsonrpc": "2.0",
"id": id,
"method": method,
"params": params,
})
.to_string();
let raw = self.transport.post_json(&self.url, &body)?;
let parsed: Value = serde_json::from_str(&raw)
.map_err(|e| Error::UnexpectedResponse(format!("{method}: not JSON: {e}")))?;
if let Some(err) = parsed.get("error") {
let code = err.get("code").and_then(Value::as_i64).unwrap_or(0);
let message = err
.get("message")
.and_then(Value::as_str)
.unwrap_or("unknown")
.chars()
.take(200)
.collect();
return Err(Error::Rpc { code, message });
}
parsed
.get("result")
.cloned()
.ok_or_else(|| Error::UnexpectedResponse(format!("{method}: no result member")))
}
fn commitment_cfg(&self) -> Value {
json!({ "commitment": self.commitment.as_str() })
}
pub fn get_account(&self, address: &Pubkey) -> Result<Option<Account>> {
let result = self.call(
"getAccountInfo",
json!([
address.to_base58(),
{ "encoding": "base64", "commitment": self.commitment.as_str() }
]),
)?;
parse_account(result.get("value").unwrap_or(&Value::Null))
}
pub fn require_account(&self, address: &Pubkey) -> Result<Account> {
self.get_account(address)?
.ok_or_else(|| Error::AccountNotFound(address.to_base58()))
}
pub fn get_multiple_accounts(&self, addresses: &[Pubkey]) -> Result<Vec<Option<Account>>> {
if addresses.is_empty() {
return Ok(Vec::new());
}
if addresses.len() > 100 {
return Err(Error::InvalidArgument(
"getMultipleAccounts accepts at most 100 addresses".into(),
));
}
let keys: Vec<String> = addresses.iter().map(Pubkey::to_base58).collect();
let result = self.call(
"getMultipleAccounts",
json!([
keys,
{ "encoding": "base64", "commitment": self.commitment.as_str() }
]),
)?;
let values = result
.get("value")
.and_then(Value::as_array)
.ok_or_else(|| Error::UnexpectedResponse("getMultipleAccounts: no value".into()))?;
values.iter().map(parse_account).collect()
}
pub fn get_balance(&self, address: &Pubkey) -> Result<u64> {
let result = self.call(
"getBalance",
json!([address.to_base58(), self.commitment_cfg()]),
)?;
result
.get("value")
.and_then(Value::as_u64)
.ok_or_else(|| Error::UnexpectedResponse("getBalance: no value".into()))
}
pub fn get_latest_blockhash(&self) -> Result<LatestBlockhash> {
let result = self.call("getLatestBlockhash", json!([self.commitment_cfg()]))?;
let value = result
.get("value")
.ok_or_else(|| Error::UnexpectedResponse("getLatestBlockhash: no value".into()))?;
Ok(LatestBlockhash {
blockhash: value
.get("blockhash")
.and_then(Value::as_str)
.ok_or_else(|| Error::UnexpectedResponse("getLatestBlockhash: no blockhash".into()))?
.to_string(),
last_valid_block_height: value
.get("lastValidBlockHeight")
.and_then(Value::as_u64)
.unwrap_or_default(),
})
}
pub fn get_slot(&self) -> Result<u64> {
self.call("getSlot", json!([self.commitment_cfg()]))?
.as_u64()
.ok_or_else(|| Error::UnexpectedResponse("getSlot: not a number".into()))
}
pub fn get_token_supply(&self, mint: &Pubkey) -> Result<UiTokenAmount> {
let result = self.call(
"getTokenSupply",
json!([mint.to_base58(), self.commitment_cfg()]),
)?;
let value = result
.get("value")
.ok_or_else(|| Error::UnexpectedResponse("getTokenSupply: no value".into()))?;
Ok(UiTokenAmount {
amount: value
.get("amount")
.and_then(Value::as_str)
.unwrap_or("0")
.to_string(),
decimals: value.get("decimals").and_then(Value::as_u64).unwrap_or(0) as u8,
ui_amount_string: value
.get("uiAmountString")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string(),
})
}
pub fn get_token_largest_accounts(&self, mint: &Pubkey) -> Result<Vec<TokenAccountBalance>> {
let result = self.call(
"getTokenLargestAccounts",
json!([mint.to_base58(), self.commitment_cfg()]),
)?;
let rows = result
.get("value")
.and_then(Value::as_array)
.ok_or_else(|| Error::UnexpectedResponse("getTokenLargestAccounts: no value".into()))?;
let mut out = Vec::with_capacity(rows.len());
for row in rows {
let address = row
.get("address")
.and_then(Value::as_str)
.ok_or_else(|| Error::UnexpectedResponse("largest accounts: no address".into()))?;
let amount = row
.get("amount")
.and_then(Value::as_str)
.unwrap_or("0")
.parse::<u128>()
.unwrap_or(0);
out.push(TokenAccountBalance {
address: Pubkey::from_base58(address)?,
amount,
decimals: row.get("decimals").and_then(Value::as_u64).unwrap_or(0) as u8,
});
}
Ok(out)
}
pub fn get_minimum_balance_for_rent_exemption(&self, data_len: usize) -> Result<u64> {
self.call("getMinimumBalanceForRentExemption", json!([data_len]))?
.as_u64()
.ok_or_else(|| Error::UnexpectedResponse("rent exemption: not a number".into()))
}
pub fn simulate_unsigned(&self, tx_base64: &str) -> Result<SimulationOutcome> {
let result = self.call(
"simulateTransaction",
json!([
tx_base64,
{
"encoding": "base64",
"sigVerify": false,
"replaceRecentBlockhash": false,
"commitment": self.commitment.as_str(),
}
]),
)?;
let value = result
.get("value")
.ok_or_else(|| Error::UnexpectedResponse("simulateTransaction: no value".into()))?;
Ok(SimulationOutcome {
err: match value.get("err") {
None | Some(Value::Null) => None,
Some(e) => Some(e.to_string().chars().take(300).collect()),
},
logs: value
.get("logs")
.and_then(Value::as_array)
.map(|l| {
l.iter()
.filter_map(Value::as_str)
.map(str::to_string)
.collect()
})
.unwrap_or_default(),
units_consumed: value.get("unitsConsumed").and_then(Value::as_u64),
})
}
pub fn raw_call(&self, method: &str, params: Value) -> Result<Value> {
self.call(method, params)
}
}
fn parse_account(value: &Value) -> Result<Option<Account>> {
if value.is_null() {
return Ok(None);
}
let owner = value
.get("owner")
.and_then(Value::as_str)
.ok_or_else(|| Error::UnexpectedResponse("account: no owner".into()))?;
let data = match value.get("data") {
Some(Value::Array(parts)) => {
let encoded = parts
.first()
.and_then(Value::as_str)
.ok_or_else(|| Error::UnexpectedResponse("account: empty data array".into()))?;
let encoding = parts.get(1).and_then(Value::as_str).unwrap_or("base64");
if encoding != "base64" {
return Err(Error::UnexpectedResponse(format!(
"account: unsupported data encoding `{encoding}`"
)));
}
base64::engine::general_purpose::STANDARD
.decode(encoded)
.map_err(|e| Error::UnexpectedResponse(format!("account: bad base64: {e}")))?
}
Some(Value::Object(_)) => {
return Err(Error::UnexpectedResponse(
"account: jsonParsed encoding is not supported, request base64".into(),
))
}
_ => Vec::new(),
};
Ok(Some(Account {
lamports: value.get("lamports").and_then(Value::as_u64).unwrap_or(0),
owner: Pubkey::from_base58(owner)?,
data,
executable: value
.get("executable")
.and_then(Value::as_bool)
.unwrap_or(false),
rent_epoch: value.get("rentEpoch").and_then(Value::as_u64).unwrap_or(0),
}))
}
pub fn redact_endpoint(url: &str) -> String {
let (scheme, rest) = match url.split_once("://") {
Some((s, r)) => (s, r),
None => return "…".to_string(),
};
let host_end = rest.find(['/', '?']).unwrap_or(rest.len());
let host = &rest[..host_end];
let host = host.rsplit('@').next().unwrap_or(host);
if host_end == rest.len() {
format!("{scheme}://{host}")
} else {
format!("{scheme}://{host}/…")
}
}