near-api 0.8.6

Rust library to interact with NEAR Protocol via RPC API
Documentation
use near_api_types::AccountId;
use near_openapi_client::Client;
use reqwest::header::{HeaderValue, InvalidHeaderValue};
use url::Url;

use crate::errors::RetryError;

#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
/// Specifies the retry strategy for RPC endpoint requests.
pub enum RetryMethod {
    /// Exponential backoff strategy with configurable initial delay and multiplication factor.
    /// The delay is calculated as: `initial_sleep * factor^retry_number`
    ExponentialBackoff {
        /// The initial delay duration before the first retry
        initial_sleep: std::time::Duration,
        /// The multiplication factor for calculating subsequent delays
        factor: u8,
    },
    /// Fixed delay strategy with constant sleep duration
    Fixed {
        /// The constant delay duration between retries
        sleep: std::time::Duration,
    },
}

#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
/// Configuration for a [NEAR RPC](https://docs.near.org/api/rpc/providers) endpoint with retry and backoff settings.
pub struct RPCEndpoint {
    /// The URL of the RPC endpoint
    pub url: url::Url,
    /// Optional API key for authenticated requests
    pub bearer_header: Option<String>,
    /// Number of consecutive failures to move on to the next endpoint.
    pub retries: u8,
    /// The retry method to use
    pub retry_method: RetryMethod,
}

impl RPCEndpoint {
    /// Constructs a new RPC endpoint configuration with default settings.
    ///
    /// The default retry method is `ExponentialBackoff` with an initial sleep of 10ms and a factor of 2.
    /// The delays will be 10ms, 20ms, 40ms, 80ms, 160ms.
    pub const fn new(url: url::Url) -> Self {
        Self {
            url,
            bearer_header: None,
            retries: 5,
            // 10ms, 20ms, 40ms, 80ms, 160ms
            retry_method: RetryMethod::ExponentialBackoff {
                initial_sleep: std::time::Duration::from_millis(10),
                factor: 2,
            },
        }
    }

    /// Constructs default mainnet configuration.
    pub fn mainnet() -> Self {
        Self::new("https://free.rpc.fastnear.com".parse().unwrap())
    }

    /// Constructs default mainnet archival configuration.
    pub fn mainnet_archival() -> Self {
        Self::new("https://archival-rpc.mainnet.fastnear.com".parse().unwrap())
    }

    /// Constructs default testnet configuration.
    pub fn testnet() -> Self {
        Self::new("https://test.rpc.fastnear.com".parse().unwrap())
    }

    /// Constructs default testnet archival configuration.
    pub fn testnet_archival() -> Self {
        Self::new("https://archival-rpc.testnet.fastnear.com".parse().unwrap())
    }

    /// Set API key for the endpoint.
    pub fn with_api_key(mut self, api_key: String) -> Self {
        self.bearer_header = Some(format!("Bearer {api_key}"));
        self
    }

    /// Set number of retries for the endpoint before moving on to the next one.
    pub const fn with_retries(mut self, retries: u8) -> Self {
        self.retries = retries;
        self
    }

    pub const fn with_retry_method(mut self, retry_method: RetryMethod) -> Self {
        self.retry_method = retry_method;
        self
    }

    pub fn get_sleep_duration(&self, retry: usize) -> std::time::Duration {
        match self.retry_method {
            RetryMethod::ExponentialBackoff {
                initial_sleep,
                factor,
            } => initial_sleep * ((factor as u32).pow(retry as u32)),
            RetryMethod::Fixed { sleep } => sleep,
        }
    }

    pub(crate) fn client(&self) -> Result<Client, InvalidHeaderValue> {
        let dur = std::time::Duration::from_secs(15);
        let mut client = reqwest::ClientBuilder::new()
            .connect_timeout(dur)
            .timeout(dur);

        if let Some(rpc_api_key) = &self.bearer_header {
            let mut headers = reqwest::header::HeaderMap::new();

            let mut header = HeaderValue::from_str(rpc_api_key)?;
            header.set_sensitive(true);

            headers.insert(
                reqwest::header::HeaderName::from_static("authorization"),
                header.clone(),
            );
            headers.insert(
                reqwest::header::HeaderName::from_static("x-api-key"),
                header,
            );
            client = client.default_headers(headers);
        };
        Ok(near_openapi_client::Client::new_with_client(
            self.url.as_ref().trim_end_matches('/'),
            client.build().unwrap(),
        ))
    }
}

#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
/// Configuration for a NEAR network including RPC endpoints and network-specific settings.
///
/// # Multiple RPC endpoints
///
/// This struct is used to configure multiple RPC endpoints for a NEAR network.
/// It allows for failover between endpoints in case of a failure.
///
///
/// ## Example
/// ```rust,no_run
/// use near_api::*;
///
/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
/// let config = NetworkConfig {
///     rpc_endpoints: vec![RPCEndpoint::mainnet(), RPCEndpoint::new("https://near.lava.build".parse()?)],
///     ..NetworkConfig::mainnet()
/// };
/// # Ok(())
/// # }
/// ```
pub struct NetworkConfig {
    /// Human readable name of the network (e.g. "mainnet", "testnet")
    pub network_name: String,
    /// List of [RPC endpoints](https://docs.near.org/api/rpc/providers) to use with failover
    pub rpc_endpoints: Vec<RPCEndpoint>,
    /// Account ID used for [linkdrop functionality](https://docs.near.org/build/primitives/linkdrop)
    pub linkdrop_account_id: Option<AccountId>,
    /// Account ID of the [NEAR Social contract](https://docs.near.org/social/contract)
    pub near_social_db_contract_account_id: Option<AccountId>,
    /// URL of the network's faucet service
    pub faucet_url: Option<url::Url>,
    /// URL for the [meta transaction relayer](https://docs.near.org/concepts/abstraction/relayers) service
    pub meta_transaction_relayer_url: Option<url::Url>,
    /// URL for the [fastnear](https://docs.near.org/tools/ecosystem-apis/fastnear-api) service.
    ///
    /// Currently, unused. See [#30](https://github.com/near/near-api-rs/issues/30)
    pub fastnear_url: Option<url::Url>,
    /// Account ID of the [staking pools factory](https://github.com/NearSocial/social-db)
    pub staking_pools_factory_account_id: Option<AccountId>,
}

impl NetworkConfig {
    /// Constructs default mainnet configuration.
    pub fn mainnet() -> Self {
        Self {
            network_name: "mainnet".to_string(),
            rpc_endpoints: vec![RPCEndpoint::mainnet()],
            linkdrop_account_id: Some("near".parse().unwrap()),
            near_social_db_contract_account_id: Some("social.near".parse().unwrap()),
            faucet_url: None,
            meta_transaction_relayer_url: None,
            fastnear_url: Some("https://api.fastnear.com/".parse().unwrap()),
            staking_pools_factory_account_id: Some("poolv1.near".parse().unwrap()),
        }
    }

    /// Constructs default mainnet archival configuration.
    pub fn mainnet_archival() -> Self {
        Self {
            network_name: "mainnet-archival".to_string(),
            rpc_endpoints: vec![RPCEndpoint::mainnet_archival()],
            linkdrop_account_id: Some("near".parse().unwrap()),
            near_social_db_contract_account_id: Some("social.near".parse().unwrap()),
            faucet_url: None,
            meta_transaction_relayer_url: None,
            fastnear_url: Some("https://api.fastnear.com/".parse().unwrap()),
            staking_pools_factory_account_id: Some("poolv1.near".parse().unwrap()),
        }
    }

    /// Constructs default testnet configuration.
    pub fn testnet() -> Self {
        Self {
            network_name: "testnet".to_string(),
            rpc_endpoints: vec![RPCEndpoint::testnet()],
            linkdrop_account_id: Some("testnet".parse().unwrap()),
            near_social_db_contract_account_id: Some("v1.social08.testnet".parse().unwrap()),
            faucet_url: Some("https://helper.nearprotocol.com/account".parse().unwrap()),
            meta_transaction_relayer_url: None,
            fastnear_url: None,
            staking_pools_factory_account_id: Some("pool.f863973.m0".parse().unwrap()),
        }
    }

    /// Constructs default testnet archival configuration.
    pub fn testnet_archival() -> Self {
        Self {
            network_name: "testnet-archival".to_string(),
            rpc_endpoints: vec![RPCEndpoint::testnet_archival()],
            linkdrop_account_id: Some("testnet".parse().unwrap()),
            near_social_db_contract_account_id: Some("v1.social08.testnet".parse().unwrap()),
            faucet_url: Some("https://helper.nearprotocol.com/account".parse().unwrap()),
            meta_transaction_relayer_url: None,
            fastnear_url: None,
            staking_pools_factory_account_id: Some("pool.f863973.m0".parse().unwrap()),
        }
    }

    pub fn from_rpc_url(name: &str, rpc_url: Url) -> Self {
        Self {
            network_name: name.to_string(),
            rpc_endpoints: vec![RPCEndpoint::new(rpc_url)],
            linkdrop_account_id: None,
            near_social_db_contract_account_id: None,
            faucet_url: None,
            fastnear_url: None,
            meta_transaction_relayer_url: None,
            staking_pools_factory_account_id: None,
        }
    }
}

#[derive(Debug)]
/// Represents the possible outcomes of a retry-able operation.
pub enum RetryResponse<R, E> {
    /// Operation succeeded with result R
    Ok(R),
    /// Operation failed with error E, should be retried
    Retry(E),
    /// Operation failed with critical error E, should not be retried
    Critical(E),
}

impl<R, E> From<Result<R, E>> for RetryResponse<R, E> {
    fn from(value: Result<R, E>) -> Self {
        match value {
            Ok(value) => Self::Ok(value),
            Err(value) => Self::Retry(value),
        }
    }
}

/// Retry a task with exponential backoff and failover.
///
/// # Arguments
/// * `network` - The network configuration to use for the retry-able operation.
/// * `task` - The task to retry.
pub async fn retry<R, E, T, F>(network: NetworkConfig, mut task: F) -> Result<R, RetryError<E>>
where
    F: FnMut(Client) -> T + Send,
    T: core::future::Future<Output = RetryResponse<R, E>> + Send,
    T::Output: Send,
    E: Send,
{
    if network.rpc_endpoints.is_empty() {
        return Err(RetryError::NoRpcEndpoints);
    }

    let mut last_error = None;
    for endpoint in network.rpc_endpoints.iter() {
        let client = endpoint
            .client()
            .map_err(|e| RetryError::InvalidApiKey(e))?;
        for retry in 0..endpoint.retries {
            let result = task(client.clone()).await;
            match result {
                RetryResponse::Ok(result) => return Ok(result),
                RetryResponse::Retry(error) => {
                    last_error = Some(error);
                    tokio::time::sleep(endpoint.get_sleep_duration(retry as usize)).await;
                }
                RetryResponse::Critical(result) => return Err(RetryError::Critical(result)),
            }
        }
    }
    Err(RetryError::RetriesExhausted(last_error.expect(
        "Logic error: last_error should be Some when all retries are exhausted",
    )))
}