use alloy::primitives::{Address, Uint};
use alloy::providers::{Provider, ProviderBuilder};
use alloy_chains::NamedChain;
use eyre::Result;
use url::Url;
#[cfg(feature = "solana")]
use eyre::eyre;
#[cfg(feature = "solana")]
use solana_client::nonblocking::rpc_client::RpcClient as SolanaRpcClient;
#[cfg(feature = "solana")]
use solana_sdk::pubkey::Pubkey;
#[cfg(feature = "solana")]
use std::str::FromStr;
use crate::commands::config::config_pb::{Chain, Token};
pub const ARCH_SOLANA: &str = "Solana";
pub const ARCH_EVM: &str = "EVM";
pub enum ChainClient {
Evm {
rpc_url: String,
chain_id: u32,
},
#[cfg(feature = "solana")]
Solana {
client: SolanaRpcClient,
},
}
impl ChainClient {
pub fn from_chain_config(chain: &Chain) -> Result<Self> {
let rpc_url = resolve_rpc_url(&chain.network, &chain.rpc_url)?;
if chain.architecture.eq_ignore_ascii_case(ARCH_SOLANA) {
#[cfg(feature = "solana")]
{
return Ok(ChainClient::Solana {
client: SolanaRpcClient::new(rpc_url),
});
}
#[cfg(not(feature = "solana"))]
{
return Err(eyre::eyre!(
"chain '{}' is Solana but the `solana` feature is disabled",
chain.network
));
}
}
Ok(ChainClient::Evm {
rpc_url,
chain_id: chain.chain_id,
})
}
pub async fn native_balance(&self, address: &str) -> Result<u128> {
match self {
ChainClient::Evm { rpc_url, .. } => {
let url = Url::parse(rpc_url)?;
let provider = ProviderBuilder::new().connect_http(url);
let addr: Address = address.parse()?;
let balance: Uint<256, 4> = provider.get_balance(addr).await?;
Ok(balance.try_into().unwrap_or(u128::MAX))
}
#[cfg(feature = "solana")]
ChainClient::Solana { client } => {
let pubkey = Pubkey::from_str(address)
.map_err(|e| eyre!("invalid Solana address: {}", e))?;
let lamports = client.get_balance(&pubkey).await?;
Ok(lamports as u128)
}
}
}
pub async fn token_balance(&self, token: &Token, owner: &str) -> Result<u128> {
match self {
ChainClient::Evm { rpc_url, chain_id } => {
use crate::evm::rpc::IERC20;
let url = Url::parse(rpc_url)?;
let provider = match named_chain_for(*chain_id as u64) {
Some(named) => ProviderBuilder::new()
.with_chain(named)
.connect_http(url)
.erased(),
None => ProviderBuilder::new().connect_http(url).erased(),
};
let token_addr: Address = token.address.parse()?;
let owner_addr: Address = owner.parse()?;
let contract = IERC20::new(token_addr, &provider);
let result: Uint<256, 4> = contract.balanceOf(owner_addr).call().await?;
Ok(result.try_into().unwrap_or(u128::MAX))
}
#[cfg(feature = "solana")]
ChainClient::Solana { client } => {
let owner_pubkey = Pubkey::from_str(owner)
.map_err(|e| eyre!("invalid Solana owner address: {}", e))?;
let mint_pubkey = Pubkey::from_str(&token.address)
.map_err(|e| eyre!("invalid Solana mint address: {}", e))?;
let ata =
crate::solana::derive_associated_token_account(&owner_pubkey, &mint_pubkey);
match client.get_token_account_balance(&ata).await {
Ok(b) => b
.amount
.parse::<u128>()
.map_err(|e| eyre!("invalid Solana token balance: {}", e)),
Err(_) => Ok(0),
}
}
}
}
}
pub fn chain_rpc_url(chain: &crate::commands::config::config_pb::Chain) -> Result<String> {
resolve_rpc_url(&chain.network, &chain.rpc_url)
}
pub fn named_chain_for(chain_id: u64) -> Option<NamedChain> {
NamedChain::try_from(chain_id).ok()
}
pub fn rpc_override_env_key(network: &str) -> String {
let suffix: String = network
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() {
c.to_ascii_uppercase()
} else {
'_'
}
})
.collect();
format!("ASPENS_RPC_URL_{suffix}")
}
pub fn resolve_rpc_url(network: &str, server_rpc_url: &str) -> Result<String> {
let override_val = std::env::var(rpc_override_env_key(network)).ok();
resolve_rpc_url_with(network, override_val.as_deref(), server_rpc_url)
}
fn resolve_rpc_url_with(
network: &str,
override_val: Option<&str>,
server_rpc_url: &str,
) -> Result<String> {
if let Some(v) = override_val {
let v = v.trim();
if !v.is_empty() {
return Ok(v.to_string());
}
}
let server = server_rpc_url.trim();
if !server.is_empty() && Url::parse(server).is_ok() {
return Ok(server.to_string());
}
Err(eyre::eyre!(
"no usable RPC endpoint for chain '{network}': the server masks rpc_url in its config \
(it can embed an API key). Set {} to your own RPC URL for '{network}'.",
rpc_override_env_key(network)
))
}
#[cfg(test)]
mod named_chain_tests {
use super::*;
#[test]
fn known_ids_resolve_to_their_named_chain() {
assert_eq!(named_chain_for(114), Some(NamedChain::FlareCoston2));
assert_eq!(named_chain_for(999), Some(NamedChain::Hyperliquid));
}
#[test]
fn hyperevm_testnet_is_unnamed_not_an_error() {
assert_eq!(named_chain_for(998), None);
}
#[test]
fn an_unknown_id_never_substitutes_a_different_chain() {
for id in [998u64, 31337, 1337, 424242] {
match named_chain_for(id) {
None => {}
Some(c) => assert_eq!(
u64::from(c),
id,
"id {id} resolved to a DIFFERENT chain ({c:?}) — a silent substitution"
),
}
}
}
}
#[cfg(test)]
mod rpc_resolve_tests {
use super::*;
#[test]
fn env_key_uppercases_and_sanitizes() {
assert_eq!(
rpc_override_env_key("base-sepolia"),
"ASPENS_RPC_URL_BASE_SEPOLIA"
);
assert_eq!(rpc_override_env_key("anvil-1"), "ASPENS_RPC_URL_ANVIL_1");
assert_eq!(
rpc_override_env_key("solana-devnet"),
"ASPENS_RPC_URL_SOLANA_DEVNET"
);
}
#[test]
fn override_wins_over_masked_server_value() {
let got = resolve_rpc_url_with("net", Some("https://my.rpc/v2/key"), "********").unwrap();
assert_eq!(got, "https://my.rpc/v2/key");
}
#[test]
fn blank_override_falls_through_to_usable_server_value() {
let got = resolve_rpc_url_with("net", Some(" "), "https://server.example").unwrap();
assert_eq!(got, "https://server.example");
}
#[test]
fn no_override_uses_unmasked_server_value() {
let got = resolve_rpc_url_with("net", None, "http://localhost:8545").unwrap();
assert_eq!(got, "http://localhost:8545");
}
#[test]
fn masked_value_without_override_errors_with_env_key() {
let err = resolve_rpc_url_with("base-sepolia", None, "********")
.unwrap_err()
.to_string();
assert!(
err.contains("ASPENS_RPC_URL_BASE_SEPOLIA"),
"actionable: {err}"
);
}
#[test]
fn empty_server_without_override_errors() {
assert!(resolve_rpc_url_with("net", None, "").is_err());
}
#[test]
fn from_chain_config_rejects_a_masked_url_rather_than_building_a_broken_client() {
let chain = Chain {
network: "rpc-mask-2-regression".to_string(),
rpc_url: "********".to_string(),
chain_id: 114,
..Default::default()
};
let err = match ChainClient::from_chain_config(&chain) {
Ok(_) => panic!("a masked rpc_url must not yield a client"),
Err(e) => e.to_string(),
};
assert!(
err.contains("ASPENS_RPC_URL_RPC_MASK_2_REGRESSION"),
"error should name the env key to set, got: {err}"
);
}
#[test]
fn from_chain_config_keeps_using_a_usable_server_url() {
let chain = Chain {
network: "rpc-mask-2-unmasked".to_string(),
rpc_url: "http://localhost:8545".to_string(),
chain_id: 31337,
..Default::default()
};
match ChainClient::from_chain_config(&chain).expect("usable url builds a client") {
ChainClient::Evm { rpc_url, chain_id } => {
assert_eq!(rpc_url, "http://localhost:8545");
assert_eq!(chain_id, 31337);
}
#[cfg(feature = "solana")]
ChainClient::Solana { .. } => panic!("expected an EVM client"),
}
}
}
#[cfg(all(test, feature = "solana"))]
mod tests {
use super::*;
use crate::solana::derive_associated_token_account;
#[test]
fn ata_derivation_matches_known_pair() {
let owner = Pubkey::from_str("11111111111111111111111111111112").unwrap();
let mint = Pubkey::from_str("So11111111111111111111111111111111111111112").unwrap();
let ata = derive_associated_token_account(&owner, &mint);
assert_ne!(ata, Pubkey::default());
}
}