use std::sync::Arc;
use serde::de::DeserializeOwned;
use crate::contract::ContractClient;
use crate::error::Error;
use crate::types::{
AccountId, ChainId, Gas, GlobalContractRef, IntoNearToken, NearToken, PublicKey, PublishMode,
SecretKey, TryIntoAccountId,
};
use super::query::{AccessKeysQuery, AccountExistsQuery, AccountQuery, BalanceQuery, ViewCall};
use super::rpc::{MAINNET, RetryConfig, RpcClient, TESTNET};
use super::signer::{InMemorySigner, Signer};
use super::transaction::{CallBuilder, TransactionBuilder};
use crate::types::TxExecutionStatus;
pub trait SandboxNetwork {
fn rpc_url(&self) -> &str;
fn root_account_id(&self) -> &str;
fn root_secret_key(&self) -> &str;
fn chain_id(&self) -> Option<&str> {
None
}
}
#[derive(Clone)]
pub struct Near {
rpc: Arc<RpcClient>,
signer: Option<Arc<dyn Signer>>,
chain_id: ChainId,
max_nonce_retries: u32,
}
impl Near {
pub fn mainnet() -> NearBuilder {
NearBuilder::new(MAINNET.rpc_url, ChainId::mainnet())
}
pub fn testnet() -> NearBuilder {
NearBuilder::new(TESTNET.rpc_url, ChainId::testnet())
}
pub fn custom(rpc_url: impl Into<String>) -> NearBuilder {
NearBuilder::new(rpc_url, ChainId::new("custom"))
}
pub fn from_env() -> Result<Near, Error> {
let network = std::env::var("NEAR_NETWORK").ok();
let chain_id_override = std::env::var("NEAR_CHAIN_ID").ok();
let account_id = std::env::var("NEAR_ACCOUNT_ID").ok();
let private_key = std::env::var("NEAR_PRIVATE_KEY").ok();
let mut builder = match network.as_deref() {
Some("mainnet") => Near::mainnet(),
Some("testnet") | None => Near::testnet(),
Some(url) => Near::custom(url),
};
if let Some(id) = chain_id_override {
builder = builder.chain_id(id);
}
match (account_id, private_key) {
(Some(account), Some(key)) => {
builder = builder.credentials(&key, account)?;
}
(Some(_), None) => {
return Err(Error::Config(
"NEAR_ACCOUNT_ID is set but NEAR_PRIVATE_KEY is missing".into(),
));
}
(None, Some(_)) => {
return Err(Error::Config(
"NEAR_PRIVATE_KEY is set but NEAR_ACCOUNT_ID is missing".into(),
));
}
(None, None) => {
}
}
if let Ok(retries) = std::env::var("NEAR_MAX_NONCE_RETRIES") {
let retries: u32 = retries.parse().map_err(|_| {
Error::Config(format!(
"NEAR_MAX_NONCE_RETRIES must be a non-negative integer, got: {retries}"
))
})?;
builder = builder.max_nonce_retries(retries);
}
Ok(builder.build())
}
pub fn sandbox(network: &impl SandboxNetwork) -> Near {
let secret_key: SecretKey = network
.root_secret_key()
.parse()
.expect("sandbox should provide valid secret key");
let account_id: AccountId = network
.root_account_id()
.parse()
.expect("sandbox should provide valid account id");
let signer = InMemorySigner::from_secret_key(account_id, secret_key)
.expect("sandbox should provide valid account id");
Near {
rpc: Arc::new(RpcClient::new(network.rpc_url())),
signer: Some(Arc::new(signer)),
chain_id: ChainId::new(network.chain_id().unwrap_or("sandbox")),
max_nonce_retries: 3,
}
}
pub fn rpc(&self) -> &RpcClient {
&self.rpc
}
pub fn rpc_url(&self) -> &str {
self.rpc.url()
}
pub fn account_id(&self) -> &AccountId {
self.signer
.as_ref()
.expect("account_id() called on a Near client without a signer configured — use try_account_id() or configure a signer")
.account_id()
}
pub fn try_account_id(&self) -> Option<&AccountId> {
self.signer.as_ref().map(|s| s.account_id())
}
pub fn public_key(&self) -> Option<PublicKey> {
self.signer.as_ref().map(|s| s.public_key())
}
pub fn signer(&self) -> Option<Arc<dyn Signer>> {
self.signer.clone()
}
pub fn chain_id(&self) -> &ChainId {
&self.chain_id
}
pub fn max_nonce_retries(mut self, retries: u32) -> Near {
self.max_nonce_retries = retries;
self
}
pub fn with_signer(&self, signer: impl Signer + 'static) -> Near {
Near {
rpc: self.rpc.clone(),
signer: Some(Arc::new(signer)),
chain_id: self.chain_id.clone(),
max_nonce_retries: self.max_nonce_retries,
}
}
pub fn balance(&self, account_id: impl TryIntoAccountId) -> BalanceQuery {
let account_id = account_id
.try_into_account_id()
.expect("invalid account ID");
BalanceQuery::new(self.rpc.clone(), account_id)
}
pub fn account(&self, account_id: impl TryIntoAccountId) -> AccountQuery {
let account_id = account_id
.try_into_account_id()
.expect("invalid account ID");
AccountQuery::new(self.rpc.clone(), account_id)
}
pub fn account_exists(&self, account_id: impl TryIntoAccountId) -> AccountExistsQuery {
let account_id = account_id
.try_into_account_id()
.expect("invalid account ID");
AccountExistsQuery::new(self.rpc.clone(), account_id)
}
pub fn view<T>(&self, contract_id: impl TryIntoAccountId, method: &str) -> ViewCall<T> {
let contract_id = contract_id
.try_into_account_id()
.expect("invalid account ID");
ViewCall::new(self.rpc.clone(), contract_id, method.to_string())
}
pub fn access_keys(&self, account_id: impl TryIntoAccountId) -> AccessKeysQuery {
let account_id = account_id
.try_into_account_id()
.expect("invalid account ID");
AccessKeysQuery::new(self.rpc.clone(), account_id)
}
pub async fn sign_message(
&self,
params: crate::types::nep413::SignMessageParams,
) -> Result<crate::types::nep413::SignedMessage, Error> {
let signer = self.signer.as_ref().ok_or(Error::NoSigner)?;
let key = signer.key();
key.sign_nep413(signer.account_id(), ¶ms)
.await
.map_err(Error::Signing)
}
pub fn transfer(
&self,
receiver: impl TryIntoAccountId,
amount: impl IntoNearToken,
) -> TransactionBuilder {
self.transaction(receiver).transfer(amount)
}
pub fn call(&self, contract_id: impl TryIntoAccountId, method: &str) -> CallBuilder {
self.transaction(contract_id).call(method)
}
pub fn deploy(&self, code: impl Into<Vec<u8>>) -> TransactionBuilder {
let account_id = self.account_id().clone();
self.transaction(account_id).deploy(code)
}
pub fn deploy_from(&self, contract_ref: impl GlobalContractRef) -> TransactionBuilder {
let account_id = self.account_id().clone();
self.transaction(account_id).deploy_from(contract_ref)
}
pub fn publish(&self, code: impl Into<Vec<u8>>, mode: PublishMode) -> TransactionBuilder {
let account_id = self.account_id().clone();
self.transaction(account_id).publish(code, mode)
}
pub fn add_full_access_key(&self, public_key: PublicKey) -> TransactionBuilder {
let account_id = self.account_id().clone();
self.transaction(account_id).add_full_access_key(public_key)
}
pub fn delete_key(&self, public_key: PublicKey) -> TransactionBuilder {
let account_id = self.account_id().clone();
self.transaction(account_id).delete_key(public_key)
}
pub fn transaction(&self, receiver_id: impl TryIntoAccountId) -> TransactionBuilder {
let receiver_id = receiver_id
.try_into_account_id()
.expect("invalid account ID");
TransactionBuilder::new(
self.rpc.clone(),
self.signer.clone(),
receiver_id,
self.max_nonce_retries,
)
}
pub fn state_init(
&self,
state_init: crate::types::DeterministicAccountStateInit,
deposit: impl IntoNearToken,
) -> TransactionBuilder {
let deposit = deposit
.into_near_token()
.expect("invalid deposit amount - use NearToken::from_str() for user input");
let receiver_id = state_init.derive_account_id();
self.transaction(receiver_id)
.add_action(crate::types::Action::state_init(state_init, deposit))
}
pub async fn send(
&self,
signed_tx: &crate::types::SignedTransaction,
) -> Result<crate::types::FinalExecutionOutcome, Error> {
let response = self
.send_with_options(signed_tx, TxExecutionStatus::ExecutedOptimistic)
.await?;
let outcome = response.outcome.ok_or_else(|| {
Error::InvalidTransaction(format!(
"RPC returned no execution outcome for transaction {} at wait level ExecutedOptimistic",
response.transaction_hash
))
})?;
use crate::types::{FinalExecutionStatus, TxExecutionError};
match outcome.status {
FinalExecutionStatus::Failure(TxExecutionError::InvalidTxError(e)) => {
Err(Error::InvalidTx(Box::new(e)))
}
_ => Ok(outcome),
}
}
pub async fn send_with_options(
&self,
signed_tx: &crate::types::SignedTransaction,
wait_until: TxExecutionStatus,
) -> Result<crate::types::SendTxResponse, Error> {
Ok(self.rpc.send_tx(signed_tx, wait_until).await?)
}
pub async fn view_with_args<T: DeserializeOwned + Send + 'static, A: serde::Serialize>(
&self,
contract_id: impl TryIntoAccountId,
method: &str,
args: &A,
) -> Result<T, Error> {
let contract_id = contract_id.try_into_account_id()?;
ViewCall::new(self.rpc.clone(), contract_id, method.to_string())
.args(args)
.await
}
pub async fn call_with_args<A: serde::Serialize>(
&self,
contract_id: impl TryIntoAccountId,
method: &str,
args: &A,
) -> Result<crate::types::FinalExecutionOutcome, Error> {
self.call(contract_id, method).args(args).await
}
pub async fn call_with_options<A: serde::Serialize>(
&self,
contract_id: impl TryIntoAccountId,
method: &str,
args: &A,
gas: Gas,
deposit: NearToken,
) -> Result<crate::types::FinalExecutionOutcome, Error> {
self.call(contract_id, method)
.args(args)
.gas(gas)
.deposit(deposit)
.await
}
pub fn contract<T: crate::Contract>(&self, contract_id: impl TryIntoAccountId) -> T::Client {
let contract_id = contract_id
.try_into_account_id()
.expect("invalid account ID");
T::Client::new(self.clone(), contract_id)
}
pub fn ft(
&self,
contract: impl crate::tokens::IntoContractId,
) -> Result<crate::tokens::FungibleToken, Error> {
let contract_id = contract.into_contract_id(&self.chain_id)?;
Ok(crate::tokens::FungibleToken::new(
self.rpc.clone(),
self.signer.clone(),
contract_id,
self.max_nonce_retries,
))
}
pub fn nft(
&self,
contract: impl crate::tokens::IntoContractId,
) -> Result<crate::tokens::NonFungibleToken, Error> {
let contract_id = contract.into_contract_id(&self.chain_id)?;
Ok(crate::tokens::NonFungibleToken::new(
self.rpc.clone(),
self.signer.clone(),
contract_id,
self.max_nonce_retries,
))
}
}
impl std::fmt::Debug for Near {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Near")
.field("rpc", &self.rpc)
.field("account_id", &self.try_account_id())
.finish()
}
}
pub struct NearBuilder {
rpc_url: String,
signer: Option<Arc<dyn Signer>>,
retry_config: RetryConfig,
chain_id: ChainId,
max_nonce_retries: u32,
}
impl NearBuilder {
fn new(rpc_url: impl Into<String>, chain_id: ChainId) -> Self {
Self {
rpc_url: rpc_url.into(),
signer: None,
retry_config: RetryConfig::default(),
chain_id,
max_nonce_retries: 3,
}
}
pub fn signer(mut self, signer: impl Signer + 'static) -> Self {
self.signer = Some(Arc::new(signer));
self
}
pub fn credentials(
mut self,
private_key: impl AsRef<str>,
account_id: impl TryIntoAccountId,
) -> Result<Self, Error> {
let signer = InMemorySigner::new(account_id, private_key)?;
self.signer = Some(Arc::new(signer));
Ok(self)
}
pub fn chain_id(mut self, chain_id: impl Into<String>) -> Self {
self.chain_id = ChainId::new(chain_id);
self
}
pub fn retry_config(mut self, config: RetryConfig) -> Self {
self.retry_config = config;
self
}
pub fn max_nonce_retries(mut self, retries: u32) -> Self {
self.max_nonce_retries = retries;
self
}
pub fn build(self) -> Near {
Near {
rpc: Arc::new(RpcClient::with_retry_config(
self.rpc_url,
self.retry_config,
)),
signer: self.signer,
chain_id: self.chain_id,
max_nonce_retries: self.max_nonce_retries,
}
}
}
impl From<NearBuilder> for Near {
fn from(builder: NearBuilder) -> Self {
builder.build()
}
}
pub const SANDBOX_ROOT_ACCOUNT: &str = "sandbox";
pub const SANDBOX_ROOT_SECRET_KEY: &str = "ed25519:3JoAjwLppjgvxkk6kNsu5wQj3FfUJnpBKWieC73hVTpBeA6FZiCc5tfyZL3a3tHeQJegQe4qGSv8FLsYp7TYd1r6";
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_near_mainnet_builder() {
let near = Near::mainnet().build();
assert!(near.rpc_url().contains("fastnear") || near.rpc_url().contains("near"));
assert!(near.try_account_id().is_none()); }
#[test]
fn test_near_testnet_builder() {
let near = Near::testnet().build();
assert!(near.rpc_url().contains("fastnear") || near.rpc_url().contains("test"));
assert!(near.try_account_id().is_none());
}
#[test]
fn test_near_custom_builder() {
let near = Near::custom("https://custom-rpc.example.com").build();
assert_eq!(near.rpc_url(), "https://custom-rpc.example.com");
}
#[test]
fn test_near_with_credentials() {
let near = Near::testnet()
.credentials(
"ed25519:3tgdk2wPraJzT4nsTuf86UX41xgPNk3MHnq8epARMdBNs29AFEztAuaQ7iHddDfXG9F2RzV1XNQYgJyAyoW51UBB",
"alice.testnet",
)
.unwrap()
.build();
assert_eq!(near.account_id().as_str(), "alice.testnet");
}
#[test]
fn test_near_with_signer() {
let signer = InMemorySigner::new(
"bob.testnet",
"ed25519:3tgdk2wPraJzT4nsTuf86UX41xgPNk3MHnq8epARMdBNs29AFEztAuaQ7iHddDfXG9F2RzV1XNQYgJyAyoW51UBB",
).unwrap();
let near = Near::testnet().signer(signer).build();
assert_eq!(near.account_id().as_str(), "bob.testnet");
}
#[test]
fn test_near_debug() {
let near = Near::testnet().build();
let debug = format!("{:?}", near);
assert!(debug.contains("Near"));
assert!(debug.contains("rpc"));
}
#[test]
fn test_near_rpc_accessor() {
let near = Near::testnet().build();
let rpc = near.rpc();
assert!(!rpc.url().is_empty());
}
#[test]
fn test_near_builder_new() {
let builder = NearBuilder::new("https://example.com", ChainId::new("custom"));
let near = builder.build();
assert_eq!(near.rpc_url(), "https://example.com");
}
#[test]
fn test_near_builder_retry_config() {
let config = RetryConfig {
max_retries: 10,
initial_delay_ms: 200,
max_delay_ms: 10000,
};
let near = Near::testnet().retry_config(config).build();
assert!(!near.rpc_url().is_empty());
}
#[test]
fn test_near_builder_from_trait() {
let builder = Near::testnet();
let near: Near = builder.into();
assert!(!near.rpc_url().is_empty());
}
#[test]
fn test_near_builder_credentials_invalid_key() {
let result = Near::testnet().credentials("invalid-key", "alice.testnet");
assert!(result.is_err());
}
#[test]
fn test_near_builder_credentials_invalid_account() {
let result = Near::testnet().credentials(
"ed25519:3tgdk2wPraJzT4nsTuf86UX41xgPNk3MHnq8epARMdBNs29AFEztAuaQ7iHddDfXG9F2RzV1XNQYgJyAyoW51UBB",
"",
);
assert!(result.is_err());
}
struct MockSandbox {
rpc_url: String,
root_account: String,
root_key: String,
}
impl SandboxNetwork for MockSandbox {
fn rpc_url(&self) -> &str {
&self.rpc_url
}
fn root_account_id(&self) -> &str {
&self.root_account
}
fn root_secret_key(&self) -> &str {
&self.root_key
}
}
#[test]
fn test_sandbox_network_trait() {
let mock = MockSandbox {
rpc_url: "http://127.0.0.1:3030".to_string(),
root_account: "sandbox".to_string(),
root_key: SANDBOX_ROOT_SECRET_KEY.to_string(),
};
let near = Near::sandbox(&mock);
assert_eq!(near.rpc_url(), "http://127.0.0.1:3030");
assert_eq!(near.account_id().as_str(), "sandbox");
}
#[test]
fn test_sandbox_constants() {
assert_eq!(SANDBOX_ROOT_ACCOUNT, "sandbox");
assert!(SANDBOX_ROOT_SECRET_KEY.starts_with("ed25519:"));
}
#[test]
fn test_near_clone() {
let near1 = Near::testnet().build();
let near2 = near1.clone();
assert_eq!(near1.rpc_url(), near2.rpc_url());
}
#[test]
fn test_near_with_signer_derived() {
let near = Near::testnet().build();
assert!(near.try_account_id().is_none());
let signer = InMemorySigner::new(
"alice.testnet",
"ed25519:3tgdk2wPraJzT4nsTuf86UX41xgPNk3MHnq8epARMdBNs29AFEztAuaQ7iHddDfXG9F2RzV1XNQYgJyAyoW51UBB",
).unwrap();
let alice = near.with_signer(signer);
assert_eq!(alice.account_id().as_str(), "alice.testnet");
assert_eq!(alice.rpc_url(), near.rpc_url()); assert!(near.try_account_id().is_none()); }
#[test]
fn test_near_with_signer_multiple_accounts() {
let near = Near::testnet().build();
let alice = near.with_signer(InMemorySigner::new(
"alice.testnet",
"ed25519:3tgdk2wPraJzT4nsTuf86UX41xgPNk3MHnq8epARMdBNs29AFEztAuaQ7iHddDfXG9F2RzV1XNQYgJyAyoW51UBB",
).unwrap());
let bob = near.with_signer(InMemorySigner::new(
"bob.testnet",
"ed25519:3tgdk2wPraJzT4nsTuf86UX41xgPNk3MHnq8epARMdBNs29AFEztAuaQ7iHddDfXG9F2RzV1XNQYgJyAyoW51UBB",
).unwrap());
assert_eq!(alice.account_id().as_str(), "alice.testnet");
assert_eq!(bob.account_id().as_str(), "bob.testnet");
assert_eq!(alice.rpc_url(), bob.rpc_url()); }
#[test]
fn test_near_max_nonce_retries() {
let near = Near::testnet().build();
assert_eq!(near.max_nonce_retries, 3);
let near = near.max_nonce_retries(10);
assert_eq!(near.max_nonce_retries, 10);
let near = near.max_nonce_retries(0);
assert_eq!(near.max_nonce_retries, 0);
}
#[test]
fn test_from_env_scenarios() {
fn clear_env() {
unsafe {
std::env::remove_var("NEAR_CHAIN_ID");
std::env::remove_var("NEAR_NETWORK");
std::env::remove_var("NEAR_ACCOUNT_ID");
std::env::remove_var("NEAR_PRIVATE_KEY");
std::env::remove_var("NEAR_MAX_NONCE_RETRIES");
}
}
clear_env();
{
let near = Near::from_env().unwrap();
assert!(
near.rpc_url().contains("test") || near.rpc_url().contains("fastnear"),
"Expected testnet URL, got: {}",
near.rpc_url()
);
assert!(near.try_account_id().is_none());
}
clear_env();
unsafe {
std::env::set_var("NEAR_NETWORK", "mainnet");
}
{
let near = Near::from_env().unwrap();
assert!(
near.rpc_url().contains("mainnet") || near.rpc_url().contains("fastnear"),
"Expected mainnet URL, got: {}",
near.rpc_url()
);
assert!(near.try_account_id().is_none());
}
clear_env();
unsafe {
std::env::set_var("NEAR_NETWORK", "https://custom-rpc.example.com");
}
{
let near = Near::from_env().unwrap();
assert_eq!(near.rpc_url(), "https://custom-rpc.example.com");
}
clear_env();
unsafe {
std::env::set_var("NEAR_NETWORK", "testnet");
std::env::set_var("NEAR_ACCOUNT_ID", "alice.testnet");
std::env::set_var(
"NEAR_PRIVATE_KEY",
"ed25519:3tgdk2wPraJzT4nsTuf86UX41xgPNk3MHnq8epARMdBNs29AFEztAuaQ7iHddDfXG9F2RzV1XNQYgJyAyoW51UBB",
);
}
{
let near = Near::from_env().unwrap();
assert_eq!(near.account_id().as_str(), "alice.testnet");
}
clear_env();
unsafe {
std::env::set_var("NEAR_ACCOUNT_ID", "alice.testnet");
}
{
let result = Near::from_env();
assert!(
result.is_err(),
"Expected error when account set without key"
);
let err = result.unwrap_err();
assert!(
err.to_string().contains("NEAR_PRIVATE_KEY"),
"Error should mention NEAR_PRIVATE_KEY: {}",
err
);
}
clear_env();
unsafe {
std::env::set_var(
"NEAR_PRIVATE_KEY",
"ed25519:3tgdk2wPraJzT4nsTuf86UX41xgPNk3MHnq8epARMdBNs29AFEztAuaQ7iHddDfXG9F2RzV1XNQYgJyAyoW51UBB",
);
}
{
let result = Near::from_env();
assert!(
result.is_err(),
"Expected error when key set without account"
);
let err = result.unwrap_err();
assert!(
err.to_string().contains("NEAR_ACCOUNT_ID"),
"Error should mention NEAR_ACCOUNT_ID: {}",
err
);
}
clear_env();
unsafe {
std::env::set_var("NEAR_MAX_NONCE_RETRIES", "10");
}
{
let near = Near::from_env().unwrap();
assert_eq!(near.max_nonce_retries, 10);
}
clear_env();
unsafe {
std::env::set_var("NEAR_MAX_NONCE_RETRIES", "abc");
}
{
let result = Near::from_env();
assert!(
result.is_err(),
"Expected error for non-numeric NEAR_MAX_NONCE_RETRIES"
);
let err = result.unwrap_err();
assert!(
err.to_string().contains("NEAR_MAX_NONCE_RETRIES"),
"Error should mention NEAR_MAX_NONCE_RETRIES: {}",
err
);
}
clear_env();
unsafe {
std::env::set_var("NEAR_MAX_NONCE_RETRIES", "0");
}
{
let near = Near::from_env().expect("0 retries should be valid");
assert_eq!(near.max_nonce_retries, 0);
}
clear_env();
unsafe {
std::env::set_var("NEAR_NETWORK", "https://rpc.pinet.near.org");
std::env::set_var("NEAR_CHAIN_ID", "pinet");
}
{
let near = Near::from_env().unwrap();
assert_eq!(near.rpc_url(), "https://rpc.pinet.near.org");
assert_eq!(near.chain_id().as_str(), "pinet");
}
clear_env();
unsafe {
std::env::set_var("NEAR_CHAIN_ID", "my-chain");
}
{
let near = Near::from_env().unwrap();
assert!(
near.rpc_url().contains("test") || near.rpc_url().contains("fastnear"),
"Expected testnet URL, got: {}",
near.rpc_url()
);
assert_eq!(near.chain_id().as_str(), "my-chain");
}
clear_env();
}
}