#![deny(missing_docs)]
pub mod hd_wallet;
mod retry;
pub mod transaction_simulator;
pub mod unified;
#[cfg(feature = "ws")]
pub mod websocket;
use self::retry::{retry_network, DEFAULT_RETRY_DELAY};
use crate::{
neo_clients::{APITrait, HttpProvider, RpcCache, RpcClient},
neo_error::unified::{ErrorRecovery, NeoError},
neo_types::{ContractParameter, ScriptHash, ScriptHashExtension, StackItem},
neo_wallets::wallet::Wallet,
};
use hex_literal::hex;
use num_bigint::{BigInt, Sign};
use num_traits::ToPrimitive;
use serde::{Deserialize, Serialize};
use std::fmt;
use std::sync::Arc;
use std::time::Duration;
#[derive(Debug)]
pub struct Neo {
client: Arc<RpcClient<HttpProvider>>,
network: Network,
endpoint: String,
cache: Option<RpcCache>,
config: SdkConfig,
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub enum Network {
MainNet,
TestNet,
Custom(String),
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct SdkConfig {
pub timeout: Duration,
pub retries: u32,
pub cache_enabled: bool,
pub metrics_enabled: bool,
}
impl SdkConfig {
#[must_use]
pub fn builder() -> SdkConfigBuilder {
SdkConfigBuilder::default()
}
}
#[derive(Debug, Default, Clone)]
pub struct SdkConfigBuilder {
timeout: Option<Duration>,
retries: Option<u32>,
cache_enabled: Option<bool>,
metrics_enabled: Option<bool>,
}
impl SdkConfigBuilder {
pub fn timeout(mut self, val: Duration) -> Self {
self.timeout = Some(val);
self
}
pub fn retries(mut self, val: u32) -> Self {
self.retries = Some(val);
self
}
pub fn cache_enabled(mut self, val: bool) -> Self {
self.cache_enabled = Some(val);
self
}
pub fn metrics_enabled(mut self, val: bool) -> Self {
self.metrics_enabled = Some(val);
self
}
pub fn build(self) -> SdkConfig {
let default = SdkConfig::default();
SdkConfig {
timeout: self.timeout.unwrap_or(default.timeout),
retries: self.retries.unwrap_or(default.retries),
cache_enabled: self.cache_enabled.unwrap_or(default.cache_enabled),
metrics_enabled: self.metrics_enabled.unwrap_or(default.metrics_enabled),
}
}
}
impl Default for SdkConfig {
fn default() -> Self {
Self {
timeout: Duration::from_secs(30),
retries: 3,
cache_enabled: true,
metrics_enabled: false,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DecimalAmount {
raw: String,
decimals: u8,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum DecimalAmountParseError {
Empty,
NegativeNotAllowed,
InvalidFormat,
InvalidCharacter,
TooManyFractionalDigits {
provided: usize,
allowed: u8,
},
}
impl fmt::Display for DecimalAmountParseError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Empty => f.write_str("amount is empty"),
Self::NegativeNotAllowed => f.write_str("amount must be non-negative"),
Self::InvalidFormat => f.write_str("invalid amount format"),
Self::InvalidCharacter => f.write_str("amount contains invalid characters"),
Self::TooManyFractionalDigits { provided, allowed } => {
write!(f, "too many fractional digits: provided {}, allowed {}", provided, allowed)
},
}
}
}
impl std::error::Error for DecimalAmountParseError {}
impl DecimalAmount {
pub fn from_raw(raw: impl Into<String>, decimals: u8) -> Self {
let raw = raw.into();
let raw = raw.trim();
let raw = raw.trim_start_matches('+');
let raw = if raw.chars().all(|c| c.is_ascii_digit()) {
let raw = raw.trim_start_matches('0');
if raw.is_empty() {
"0".to_string()
} else {
raw.to_string()
}
} else {
"0".to_string()
};
Self { raw, decimals }
}
pub fn parse(amount: &str, decimals: u8) -> Result<Self, DecimalAmountParseError> {
let amount = amount.trim();
if amount.is_empty() {
return Err(DecimalAmountParseError::Empty);
}
if amount.starts_with('-') {
return Err(DecimalAmountParseError::NegativeNotAllowed);
}
if !amount.chars().any(|c| c.is_ascii_digit()) {
return Err(DecimalAmountParseError::InvalidFormat);
}
let mut iter = amount.split('.');
let whole = iter.next().unwrap_or("");
let frac = iter.next();
if iter.next().is_some() {
return Err(DecimalAmountParseError::InvalidFormat);
}
let whole = if whole.is_empty() { "0" } else { whole };
let frac = frac.unwrap_or("");
if !whole.chars().all(|c| c.is_ascii_digit()) || !frac.chars().all(|c| c.is_ascii_digit()) {
return Err(DecimalAmountParseError::InvalidCharacter);
}
let allowed = decimals as usize;
let mut frac = frac.to_string();
if frac.len() > allowed {
let (kept, extra) = frac.split_at(allowed);
if extra.chars().all(|c| c == '0') {
frac = kept.to_string();
} else {
return Err(DecimalAmountParseError::TooManyFractionalDigits {
provided: frac.len(),
allowed: decimals,
});
}
}
while frac.len() < allowed {
frac.push('0');
}
let whole = whole.trim_start_matches('0');
let whole = if whole.is_empty() { "0" } else { whole };
let raw = format!("{}{}", whole, frac);
Ok(Self::from_raw(raw, decimals))
}
pub fn raw(&self) -> &str {
&self.raw
}
pub fn decimals(&self) -> u8 {
self.decimals
}
pub fn to_fixed_string(&self) -> String {
let decimals = self.decimals as usize;
if decimals == 0 {
return self.raw.clone();
}
let raw = self.raw.as_str();
let len = raw.len();
if len > decimals {
let (int_part, frac_part) = raw.split_at(len - decimals);
format!("{}.{}", int_part, frac_part)
} else {
let zeros = "0".repeat(decimals - len);
format!("0.{}{}", zeros, raw)
}
}
pub fn raw_i64(&self) -> Option<i64> {
self.raw.parse::<i64>().ok()
}
}
impl fmt::Display for DecimalAmount {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.to_fixed_string())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Balance {
pub neo: u64,
pub gas: DecimalAmount,
pub tokens: Vec<TokenBalance>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TokenBalance {
pub contract: ScriptHash,
pub symbol: String,
pub amount: DecimalAmount,
}
pub type TxHash = String;
#[derive(Debug, Clone)]
pub enum Token {
NEO,
GAS,
Custom(ScriptHash),
}
impl Token {
pub const NEO_HASH: [u8; 20] = hex!("ef4073a0f2b305a38ec4050e4d3d28bc40ea63f5");
pub const GAS_HASH: [u8; 20] = hex!("d2a4cff31913016155e38e474a2c06d08be276cf");
pub fn contract_hash(&self) -> ScriptHash {
match self {
Token::NEO => ScriptHash::from(Self::NEO_HASH),
Token::GAS => ScriptHash::from(Self::GAS_HASH),
Token::Custom(hash) => *hash,
}
}
}
fn no_default_account_error() -> NeoError {
NeoError::Wallet {
message: "No default account set in wallet".to_string(),
source: None,
recovery: ErrorRecovery::new()
.suggest("Set a default account using set_default_account()")
.suggest("Add an account to the wallet first"),
}
}
fn invalid_address_error<E: fmt::Display>(field: &str, value: &str, err: E) -> NeoError {
NeoError::Validation {
message: format!("Invalid {} address: {}", field, err),
field: field.to_string(),
value: Some(value.to_string()),
recovery: ErrorRecovery::new()
.suggest("Check the address format")
.suggest("Ensure it's a valid Neo N3 address"),
}
}
async fn send_tx_with_retry<'a>(
tx: &mut crate::neo_builder::Transaction<'a, HttpProvider>,
attempts: u32,
delay: Duration,
context: &str,
) -> Result<crate::neo_protocol::RawTransaction, NeoError> {
let attempts = attempts.max(1);
let mut last_err: Option<String> = None;
for attempt in 1..=attempts {
match tx.send_tx().await {
Ok(result) => return Ok(result),
Err(err) => {
if attempt < attempts {
tracing::warn!(
attempt = attempt,
max_attempts = attempts,
context = %context,
error = %err,
"send_tx failed; retrying"
);
tokio::time::sleep(delay).await;
}
last_err = Some(err.to_string());
},
}
}
Err(NeoError::network(context, last_err.expect("loop ran at least once")))
}
impl Neo {
pub async fn testnet() -> Result<Self, NeoError> {
Self::builder().network(Network::TestNet).build().await
}
pub async fn mainnet() -> Result<Self, NeoError> {
Self::builder().network(Network::MainNet).build().await
}
pub async fn connect(endpoint: impl Into<String>) -> Result<Self, NeoError> {
Self::builder().network(Network::Custom(endpoint.into())).build().await
}
pub async fn from_env() -> Result<Self, NeoError> {
match std::env::var("NEO_RPC_URL") {
Ok(url) if !url.is_empty() => Self::connect(url).await,
_ => Self::testnet().await,
}
}
#[must_use]
pub fn builder() -> NeoBuilder {
NeoBuilder::default()
}
pub async fn get_balance(&self, address: &str) -> Result<Balance, NeoError> {
use crate::neo_types::ScriptHashExtension;
let script_hash = ScriptHash::from_address(address)
.map_err(|e| invalid_address_error("address", address, e))?;
if let Some(cache) = &self.cache {
let cache_key = format!("balance:{}", address);
if let Some(cached) = cache.get(&cache_key).await {
if let Ok(balance) = serde_json::from_value::<Balance>(cached) {
return Ok(balance);
}
}
}
let max_attempts = self.config.retries.saturating_add(1);
let neo_hash = ScriptHash::from(Token::NEO_HASH);
let neo_balance =
retry_network("fetch NEO balance", max_attempts, DEFAULT_RETRY_DELAY, || async {
self.client
.invoke_function(
&neo_hash,
"balanceOf".to_string(),
vec![ContractParameter::h160(&script_hash)],
None,
)
.await
})
.await?;
let gas_hash = ScriptHash::from(Token::GAS_HASH);
let gas_balance =
retry_network("fetch GAS balance", max_attempts, DEFAULT_RETRY_DELAY, || async {
self.client
.invoke_function(
&gas_hash,
"balanceOf".to_string(),
vec![ContractParameter::h160(&script_hash)],
None,
)
.await
})
.await?;
let neo = neo_balance
.stack
.first()
.ok_or_else(|| invalid_balance_response("NEO", "missing stack item"))?;
let neo = parse_balance_stack_item_u64(neo, "NEO")?;
let gas_raw = gas_balance
.stack
.first()
.ok_or_else(|| invalid_balance_response("GAS", "missing stack item"))?;
let gas_raw = parse_balance_stack_item_u64(gas_raw, "GAS")?;
let gas = DecimalAmount::from_raw(gas_raw.to_string(), 8);
let nep17 =
retry_network("fetch NEP-17 balances", max_attempts, DEFAULT_RETRY_DELAY, || async {
self.client.get_nep17_balances(script_hash).await
})
.await?;
let tokens = nep17
.balances
.into_iter()
.filter(|b| b.asset_hash != neo_hash && b.asset_hash != gas_hash)
.map(|b| {
let decimals = parse_nep17_decimals(b.decimals.as_deref(), &b.asset_hash)?;
let amount = DecimalAmount::from_raw(b.amount, decimals);
Ok(TokenBalance {
contract: b.asset_hash,
symbol: b
.symbol
.clone()
.or_else(|| b.name.clone())
.unwrap_or_else(|| b.asset_hash.to_hex()),
amount,
})
})
.collect::<Result<Vec<_>, NeoError>>()?;
let balance = Balance { neo, gas, tokens };
if let Some(cache) = &self.cache {
if let Ok(value) = serde_json::to_value(&balance) {
cache.cache_balance(address.to_string(), value).await;
}
}
Ok(balance)
}
pub async fn transfer(
&self,
from: &Wallet,
to: &str,
amount: u64,
token: Token,
) -> Result<TxHash, NeoError> {
use crate::neo_builder::{AccountSigner, CallFlags, ScriptBuilder, TransactionBuilder};
use crate::neo_types::ScriptHashExtension;
use crate::neo_wallets::WalletTrait;
let max_attempts = self.config.retries.saturating_add(1);
let from_account = from.default_account().ok_or_else(no_default_account_error)?;
let to_hash =
ScriptHash::from_address(to).map_err(|e| invalid_address_error("to", to, e))?;
let contract_hash = token.contract_hash();
let amount_i64 = i64::try_from(amount).map_err(|_| NeoError::Validation {
message: "Amount is too large to fit Neo VM integer".to_string(),
field: "amount".to_string(),
value: Some(amount.to_string()),
recovery: ErrorRecovery::new()
.suggest("Use a smaller amount")
.suggest("Split into multiple transfers if needed"),
})?;
let mut sb = ScriptBuilder::new();
sb.contract_call(
&contract_hash,
"transfer",
&[
ContractParameter::h160(&from_account.get_script_hash()),
ContractParameter::h160(&to_hash),
ContractParameter::integer(amount_i64),
ContractParameter::any(),
],
Some(CallFlags::All),
)
.map_err(|e| {
NeoError::contract(
"Failed to build transfer script",
Some(contract_hash.to_hex()),
Some("transfer".into()),
e,
)
})?;
let signer = AccountSigner::called_by_entry(from_account)
.map_err(|e| NeoError::transaction("Failed to create signer", e))?;
let current_height = retry_network(
"fetch current block height",
max_attempts,
DEFAULT_RETRY_DELAY,
|| async { self.client.get_block_count().await },
)
.await?;
let mut tb = TransactionBuilder::with_client(self.client.as_ref());
tb.extend_script(sb.to_bytes());
tb.set_signers(vec![signer.into()])
.map_err(|e| NeoError::transaction("Failed to set signers", e))?;
tb.valid_until_block(current_height + 5760)
.map_err(|e| NeoError::transaction("Invalid valid-until-block", e))?;
let mut tx = tb
.sign()
.await
.map_err(|e| NeoError::transaction("Failed to sign transfer", e))?;
let result = send_tx_with_retry(
&mut tx,
max_attempts,
DEFAULT_RETRY_DELAY,
"send transfer transaction",
)
.await?;
Ok(result.hash.to_string())
}
pub async fn deploy_contract(
&self,
deployer: &Wallet,
nef: Vec<u8>,
manifest: String,
) -> Result<ScriptHash, NeoError> {
use crate::neo_builder::{AccountSigner, CallFlags, ScriptBuilder, TransactionBuilder};
use crate::neo_types::{ContractManifest, NefFile, ScriptHashExtension};
use crate::neo_wallets::WalletTrait;
let max_attempts = self.config.retries.saturating_add(1);
let deployer_account = deployer.default_account().ok_or_else(no_default_account_error)?;
let nef_file = NefFile::deserialize(&nef).map_err(|e| NeoError::Validation {
message: format!("Invalid NEF file: {}", e),
field: "nef".to_string(),
value: None,
recovery: ErrorRecovery::new()
.suggest("Ensure the NEF bytes are valid")
.suggest("Load the NEF file using std::fs::read"),
})?;
let manifest_bytes = manifest.as_bytes().to_vec();
let manifest_struct: ContractManifest =
serde_json::from_str(&manifest).map_err(|e| NeoError::Validation {
message: format!("Invalid manifest JSON: {}", e),
field: "manifest".to_string(),
value: None,
recovery: ErrorRecovery::new()
.suggest("Ensure the manifest is valid JSON")
.suggest("Provide the full contract manifest"),
})?;
let contract_name = manifest_struct.name.clone().ok_or_else(|| NeoError::Validation {
message: "Manifest is missing contract name".to_string(),
field: "manifest.name".to_string(),
value: None,
recovery: ErrorRecovery::new()
.suggest("Include a `name` field in the manifest")
.suggest("Check your contract compiler output"),
})?;
let nef_checksum = {
if nef_file.checksum.len() != 4 {
return Err(NeoError::Validation {
message: "NEF checksum length is invalid".to_string(),
field: "nef.checksum".to_string(),
value: None,
recovery: ErrorRecovery::new().suggest("Provide a valid NEF file"),
});
}
let mut arr = [0u8; 4];
arr.copy_from_slice(&nef_file.checksum);
arr.reverse();
u32::from_be_bytes(arr)
};
let contract_script = ScriptBuilder::build_contract_script(
&deployer_account.get_script_hash(),
nef_checksum,
&contract_name,
)
.map_err(|e| {
NeoError::contract("Failed to derive contract script", None, Some("deploy".into()), e)
})?;
let expected_hash = ScriptHash::from_script(&contract_script);
let management_hash = ScriptHash::from(hex!("fffdc93764dbaddd97c48f252a53ea4643faa3fd"));
let mut sb = ScriptBuilder::new();
sb.contract_call(
&management_hash,
"deploy",
&[
ContractParameter::byte_array(nef),
ContractParameter::byte_array(manifest_bytes),
ContractParameter::any(),
],
Some(CallFlags::All),
)
.map_err(|e| {
NeoError::contract(
"Failed to build deploy script",
Some(management_hash.to_hex()),
Some("deploy".into()),
e,
)
})?;
let signer = AccountSigner::called_by_entry(deployer_account)
.map_err(|e| NeoError::transaction("Failed to create signer", e))?;
let current_height = retry_network(
"fetch current block height",
max_attempts,
DEFAULT_RETRY_DELAY,
|| async { self.client.get_block_count().await },
)
.await?;
let mut tb = TransactionBuilder::with_client(self.client.as_ref());
tb.extend_script(sb.to_bytes());
tb.set_signers(vec![signer.into()])
.map_err(|e| NeoError::transaction("Failed to set signer", e))?;
tb.valid_until_block(current_height + 2400)
.map_err(|e| NeoError::transaction("Invalid valid-until-block", e))?;
let mut tx = tb
.sign()
.await
.map_err(|e| NeoError::transaction("Failed to sign deploy transaction", e))?;
let _ = send_tx_with_retry(
&mut tx,
max_attempts,
DEFAULT_RETRY_DELAY,
"send deploy transaction",
)
.await?;
Ok(expected_hash)
}
pub async fn invoke_read(
&self,
contract: &ScriptHash,
method: &str,
params: Vec<ContractParameter>,
) -> Result<serde_json::Value, NeoError> {
use crate::neo_types::ScriptHashExtension;
let max_attempts = self.config.retries.saturating_add(1);
let result =
retry_network("invoke read-only method", max_attempts, DEFAULT_RETRY_DELAY, || async {
self.client
.invoke_function(contract, method.to_string(), params.clone(), None)
.await
})
.await?;
if result.has_state_fault() {
return Err(NeoError::Contract {
message: result
.exception
.clone()
.unwrap_or_else(|| "Invocation resulted in FAULT state".to_string()),
contract: Some(contract.to_hex()),
method: Some(method.to_string()),
source: None,
recovery: ErrorRecovery::new()
.suggest("Check contract parameters")
.suggest("Ensure the method is safe/read-only"),
});
}
serde_json::to_value(result).map_err(|e| NeoError::Other {
message: format!("Failed to serialize invocation result: {}", e),
source: None,
recovery: ErrorRecovery::new(),
})
}
pub async fn invoke_write(
&self,
signer: &Wallet,
contract: &ScriptHash,
method: &str,
params: Vec<ContractParameter>,
) -> Result<TxHash, NeoError> {
use crate::neo_builder::{AccountSigner, CallFlags, ScriptBuilder, TransactionBuilder};
use crate::neo_types::ScriptHashExtension;
use crate::neo_wallets::WalletTrait;
let max_attempts = self.config.retries.saturating_add(1);
let signer_account = signer.default_account().ok_or_else(no_default_account_error)?;
let mut sb = ScriptBuilder::new();
sb.contract_call(contract, method, params.as_slice(), Some(CallFlags::All))
.map_err(|e| {
NeoError::contract(
"Failed to build invocation script",
Some(contract.to_hex()),
Some(method.to_string()),
e,
)
})?;
let signer_obj = AccountSigner::called_by_entry(signer_account)
.map_err(|e| NeoError::transaction("Failed to create signer", e))?;
let current_height = retry_network(
"fetch current block height",
max_attempts,
DEFAULT_RETRY_DELAY,
|| async { self.client.get_block_count().await },
)
.await?;
let mut tb = TransactionBuilder::with_client(self.client.as_ref());
tb.extend_script(sb.to_bytes());
tb.set_signers(vec![signer_obj.into()])
.map_err(|e| NeoError::transaction("Failed to set signer", e))?;
tb.valid_until_block(current_height + 2400)
.map_err(|e| NeoError::transaction("Invalid valid-until-block", e))?;
let mut tx = tb
.sign()
.await
.map_err(|e| NeoError::transaction("Failed to sign invocation transaction", e))?;
let result = send_tx_with_retry(
&mut tx,
max_attempts,
DEFAULT_RETRY_DELAY,
"send invocation transaction",
)
.await?;
Ok(result.hash.to_string())
}
pub async fn wait_for_confirmation(
&self,
tx_hash: &str,
timeout: Duration,
) -> Result<(), NeoError> {
use primitive_types::H256;
use std::str::FromStr;
use std::time::Instant;
let tx_h256 = H256::from_str(tx_hash).map_err(|e| NeoError::Validation {
message: format!("Invalid transaction hash: {}", e),
field: "tx_hash".to_string(),
value: Some(tx_hash.to_string()),
recovery: ErrorRecovery::new().suggest("Provide a valid 0x-prefixed transaction hash"),
})?;
let start = Instant::now();
while start.elapsed() < timeout {
match self.client.get_application_log(tx_h256).await {
Ok(_) => return Ok(()),
Err(_) => tokio::time::sleep(Duration::from_secs(1)).await,
}
}
Err(NeoError::Timeout {
duration: timeout,
operation: "wait_for_confirmation".to_string(),
recovery: ErrorRecovery::new()
.suggest("Increase the timeout duration")
.suggest("Check the transaction hash")
.retryable(true),
})
}
pub async fn get_block_height(&self) -> Result<u32, NeoError> {
let max_attempts = self.config.retries.saturating_add(1);
retry_network("fetch block height", max_attempts, DEFAULT_RETRY_DELAY, || async {
self.client.get_block_count().await
})
.await
}
pub fn client(&self) -> &RpcClient<HttpProvider> {
&self.client
}
pub fn endpoint(&self) -> &str {
&self.endpoint
}
pub fn network(&self) -> &Network {
&self.network
}
}
#[derive(Debug)]
pub struct NeoBuilder {
network: Network,
config: SdkConfig,
}
impl Default for NeoBuilder {
fn default() -> Self {
Self { network: Network::TestNet, config: SdkConfig::default() }
}
}
impl NeoBuilder {
pub fn network(mut self, network: Network) -> Self {
self.network = network;
self
}
pub fn endpoint(mut self, endpoint: impl Into<String>) -> Self {
self.network = Network::Custom(endpoint.into());
self
}
pub fn config(mut self, config: SdkConfig) -> Self {
self.config = config;
self
}
pub fn timeout(mut self, timeout: Duration) -> Self {
self.config.timeout = timeout;
self
}
pub fn retries(mut self, retries: u32) -> Self {
self.config.retries = retries;
self
}
pub fn cache(mut self, enabled: bool) -> Self {
self.config.cache_enabled = enabled;
self
}
pub fn metrics(mut self, enabled: bool) -> Self {
self.config.metrics_enabled = enabled;
self
}
pub async fn build(self) -> Result<Neo, NeoError> {
let endpoint = match &self.network {
Network::MainNet => "https://mainnet1.neo.org:443".to_string(),
Network::TestNet => "https://testnet1.neo.coz.io:443".to_string(),
Network::Custom(url) => url.clone(),
};
let url = url::Url::parse(&endpoint).map_err(|e| NeoError::Network {
message: format!("Invalid RPC endpoint URL: {}", e),
source: None,
recovery: ErrorRecovery::new()
.suggest("Check the RPC endpoint URL")
.suggest("Ensure it includes a valid scheme (http/https)"),
})?;
let http_client =
reqwest::Client::builder().timeout(self.config.timeout).build().map_err(|e| {
NeoError::Network {
message: format!("Failed to build HTTP client: {}", e),
source: None,
recovery: ErrorRecovery::new()
.suggest("Check your TLS configuration")
.suggest("Verify system root certificates are available"),
}
})?;
let provider = HttpProvider::new_with_client(url, http_client);
let client = Arc::new(RpcClient::new(provider));
let max_attempts = self.config.retries.saturating_add(1);
retry_network("connect to Neo network", max_attempts, DEFAULT_RETRY_DELAY, || async {
client.get_block_count().await
})
.await?;
let cache = self.config.cache_enabled.then(RpcCache::new_rpc_cache);
Ok(Neo { client, network: self.network, endpoint, cache, config: self.config })
}
}
#[derive(Debug)]
#[allow(dead_code)]
pub struct Transfer {
from: Wallet,
to: String,
amount: u64,
token: Token,
memo: Option<String>,
}
impl Transfer {
pub fn new(from: Wallet, to: impl Into<String>, amount: u64, token: Token) -> Self {
Self { from, to: to.into(), amount, token, memo: None }
}
pub fn with_memo(mut self, memo: impl Into<String>) -> Self {
self.memo = Some(memo.into());
self
}
pub async fn execute(self, client: &RpcClient<HttpProvider>) -> Result<TxHash, NeoError> {
use crate::neo_builder::{AccountSigner, CallFlags, ScriptBuilder, TransactionBuilder};
use crate::neo_types::ScriptHashExtension;
use crate::neo_wallets::WalletTrait;
let from_account = self.from.default_account().ok_or_else(no_default_account_error)?;
let to_hash = ScriptHash::from_address(&self.to)
.map_err(|e| invalid_address_error("to", &self.to, e))?;
let contract_hash = self.token.contract_hash();
let amount_i64 = i64::try_from(self.amount).map_err(|_| NeoError::Validation {
message: "Amount is too large to fit Neo VM integer".to_string(),
field: "amount".to_string(),
value: Some(self.amount.to_string()),
recovery: ErrorRecovery::new().suggest("Use a smaller amount"),
})?;
let mut sb = ScriptBuilder::new();
sb.contract_call(
&contract_hash,
"transfer",
&[
ContractParameter::h160(&from_account.get_script_hash()),
ContractParameter::h160(&to_hash),
ContractParameter::integer(amount_i64),
ContractParameter::any(),
],
Some(CallFlags::All),
)
.map_err(|e| {
NeoError::contract(
"Failed to build transfer script",
Some(contract_hash.to_hex()),
Some("transfer".into()),
e,
)
})?;
let signer = AccountSigner::called_by_entry(from_account)
.map_err(|e| NeoError::transaction("Failed to create signer", e))?;
let current_height = client
.get_block_count()
.await
.map_err(|e| NeoError::network("Failed to fetch current block height", e))?;
let mut tb = TransactionBuilder::with_client(client);
tb.extend_script(sb.to_bytes());
tb.set_signers(vec![signer.into()])
.map_err(|e| NeoError::transaction("Failed to set signers", e))?;
tb.valid_until_block(current_height + 5760)
.map_err(|e| NeoError::transaction("Invalid valid-until-block", e))?;
let mut tx = tb
.sign()
.await
.map_err(|e| NeoError::transaction("Failed to sign transfer", e))?;
let result = tx
.send_tx()
.await
.map_err(|e| NeoError::transaction("Failed to send transfer", e))?;
Ok(result.hash.to_string())
}
}
fn invalid_balance_response(token: &str, detail: impl Into<String>) -> NeoError {
let detail = detail.into();
NeoError::Other {
message: format!("Invalid {token} balance response: {detail}"),
source: None,
recovery: ErrorRecovery::new()
.suggest("Retry against another RPC endpoint")
.suggest("Inspect the raw balance response from the node"),
}
}
fn parse_balance_stack_item_u64(item: &StackItem, token: &str) -> Result<u64, NeoError> {
let bytes = item.as_bytes().ok_or_else(|| {
invalid_balance_response(token, "balance stack item is not byte-convertible")
})?;
let value = BigInt::from_signed_bytes_le(&bytes);
match value.sign() {
Sign::Minus => Err(invalid_balance_response(token, "balance cannot be negative")),
_ => value
.to_u64()
.ok_or_else(|| invalid_balance_response(token, "balance does not fit into u64")),
}
}
fn parse_nep17_decimals(decimals: Option<&str>, asset_hash: &ScriptHash) -> Result<u8, NeoError> {
let raw = decimals.ok_or_else(|| NeoError::Other {
message: format!("Missing decimals for NEP-17 token {}", asset_hash.to_hex()),
source: None,
recovery: ErrorRecovery::new()
.suggest("Retry against another RPC endpoint")
.suggest("Verify the token metadata is available from the node"),
})?;
raw.parse::<u8>().map_err(|_| NeoError::Other {
message: format!("Invalid decimals '{}' for NEP-17 token {}", raw, asset_hash.to_hex()),
source: None,
recovery: ErrorRecovery::new()
.suggest("Retry against another RPC endpoint")
.suggest("Verify the token contract returns a valid decimals value"),
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::neo_types::StackItem;
#[test]
fn test_builder_configuration() {
let builder = Neo::builder()
.network(Network::TestNet)
.timeout(Duration::from_secs(60))
.retries(5)
.cache(true)
.metrics(false);
assert_eq!(builder.config.timeout, Duration::from_secs(60));
assert_eq!(builder.config.retries, 5);
assert!(builder.config.cache_enabled);
assert!(!builder.config.metrics_enabled);
}
#[test]
fn endpoint_shortcut_picks_custom_network() {
let builder = Neo::builder().endpoint("https://example.com:443");
match &builder.network {
Network::Custom(url) => assert_eq!(url, "https://example.com:443"),
other => panic!("expected Network::Custom, got {other:?}"),
}
}
#[test]
fn config_setter_replaces_entire_config() {
let cfg = SdkConfig::builder()
.timeout(Duration::from_secs(7))
.retries(2)
.cache_enabled(false)
.metrics_enabled(true)
.build();
let builder = Neo::builder().config(cfg.clone()).network(Network::TestNet);
assert_eq!(builder.config.timeout, Duration::from_secs(7));
assert_eq!(builder.config.retries, 2);
assert!(!builder.config.cache_enabled);
assert!(builder.config.metrics_enabled);
}
#[test]
fn token_contract_hash_returns_native_hashes() {
let neo = Token::NEO.contract_hash();
let gas = Token::GAS.contract_hash();
assert_eq!(<[u8; 20]>::from(neo), Token::NEO_HASH);
assert_eq!(<[u8; 20]>::from(gas), Token::GAS_HASH);
let custom = ScriptHash::zero();
assert_eq!(Token::Custom(custom).contract_hash(), custom);
}
#[tokio::test]
async fn connect_with_malformed_url_returns_network_error() {
let err = Neo::connect("not-a-url").await.unwrap_err();
assert_eq!(err.kind(), crate::neo_error::unified::NeoErrorKind::Network);
assert!(err.message().to_lowercase().contains("invalid"));
}
#[tokio::test]
async fn from_env_without_env_var_falls_back_to_testnet() {
let prior = std::env::var_os("NEO_RPC_URL");
std::env::remove_var("NEO_RPC_URL");
let result = Neo::from_env().await;
if let Some(value) = prior {
std::env::set_var("NEO_RPC_URL", value);
}
if let Err(err) = result {
let kind = err.kind();
assert!(
matches!(kind, crate::neo_error::unified::NeoErrorKind::Network),
"unexpected error kind {kind:?}: {err}"
);
}
}
#[tokio::test]
async fn from_env_with_malformed_url_yields_network_error() {
let prior = std::env::var_os("NEO_RPC_URL");
std::env::set_var("NEO_RPC_URL", "::::not a url::::");
let result = Neo::from_env().await;
match prior {
Some(value) => std::env::set_var("NEO_RPC_URL", value),
None => std::env::remove_var("NEO_RPC_URL"),
}
let err = result.expect_err("malformed NEO_RPC_URL must error");
assert_eq!(err.kind(), crate::neo_error::unified::NeoErrorKind::Network);
}
#[test]
fn test_parse_balance_stack_item_u64_rejects_negative_value() {
let item = StackItem::Integer { value: -1 };
let err = parse_balance_stack_item_u64(&item, "NEO").unwrap_err();
match err {
NeoError::Other { message, .. } => {
assert!(message.contains("balance cannot be negative"));
},
other => panic!("expected balance parsing error, got {other:?}"),
}
}
#[test]
fn test_parse_nep17_decimals_rejects_invalid_value() {
let asset_hash = ScriptHash::zero();
let err = parse_nep17_decimals(Some("not-a-u8"), &asset_hash).unwrap_err();
match err {
NeoError::Other { message, .. } => {
assert!(message.contains("Invalid decimals"));
},
other => panic!("expected decimals parsing error, got {other:?}"),
}
}
}