diff --git a/Cargo.lock b/Cargo.lock index c7eb9851..751d3889 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4129,6 +4129,7 @@ dependencies = [ "solana-pubkey", "solana-signature", "strum 0.27.1", + "tokio", ] [[package]] diff --git a/libs/client/Cargo.toml b/libs/client/Cargo.toml index 11eb060b..4dc61a2f 100644 --- a/libs/client/Cargo.toml +++ b/libs/client/Cargo.toml @@ -22,4 +22,7 @@ solana-clock = { workspace = true } solana-pubkey = { workspace = true } solana-signature = { workspace = true } sol_rpc_types = { path = "../types" } -strum = {workspace = true} \ No newline at end of file +strum = {workspace = true} + +[dev-dependencies] +tokio = {workspace = true, features = ["full"]} \ No newline at end of file diff --git a/libs/client/src/fixtures/mod.rs b/libs/client/src/fixtures/mod.rs new file mode 100644 index 00000000..e1b5eb82 --- /dev/null +++ b/libs/client/src/fixtures/mod.rs @@ -0,0 +1,119 @@ +//! Simple types to create basic unit tests for the [`crate::SolRpcClient`]. +//! +//! Types and methods for this module are only available for non-canister architecture (non `wasm32`). + +use crate::{ClientBuilder, Runtime}; +use async_trait::async_trait; +use candid::utils::ArgumentEncoder; +use candid::{CandidType, Principal}; +use ic_cdk::api::call::RejectionCode; +use serde::de::DeserializeOwned; +use sol_rpc_types::{AccountData, AccountEncoding, AccountInfo}; + +impl ClientBuilder { + /// Change the runtime to return the same mocked response for both update and query calls. + pub fn with_mocked_response( + self, + mocked_response: Out, + ) -> ClientBuilder { + self.with_runtime(|_runtime| MockRuntime::same_response(mocked_response)) + } + + /// Change the runtime to return different mocked responses between update and query calls. + pub fn with_mocked_responses( + self, + mocked_response_for_update_call: UpdateOut, + mocked_response_for_query_call: QueryOut, + ) -> ClientBuilder { + self.with_runtime(|_runtime| { + MockRuntime::new( + mocked_response_for_update_call, + mocked_response_for_query_call, + ) + }) + } +} + +/// A dummy implementation of [`Runtime`] that always return the same candid-encoded response. +/// +/// Implement your own [`Runtime`] in case a more refined approach is needed. +pub struct MockRuntime { + update_call_result: Vec, + query_call_result: Vec, +} + +impl MockRuntime { + /// Create a new [`MockRuntime`] to always return the given parameter. + pub fn same_response(mocked_response: Out) -> Self { + let result = candid::encode_args((&mocked_response,)) + .expect("Failed to encode Candid mocked response"); + Self { + update_call_result: result.clone(), + query_call_result: result, + } + } + + /// Create a new [`MockRuntime`] to always return the given parameters. + pub fn new( + mocked_update_result: UpdateOut, + mocked_query_result: QueryOut, + ) -> Self { + let update_call_result = candid::encode_args((&mocked_update_result,)) + .expect("Failed to encode Candid mocked response"); + let query_call_result = candid::encode_args((&mocked_query_result,)) + .expect("Failed to encode Candid mocked response"); + Self { + update_call_result, + query_call_result, + } + } +} + +#[async_trait] +impl Runtime for MockRuntime { + async fn update_call( + &self, + _id: Principal, + _method: &str, + _args: In, + _cycles: u128, + ) -> Result + where + In: ArgumentEncoder + Send, + Out: CandidType + DeserializeOwned, + { + Ok(candid::decode_args(&self.update_call_result) + .map(|(r,)| r) + .expect("Failed to decode Candid mocked response")) + } + + async fn query_call( + &self, + _id: Principal, + _method: &str, + _args: In, + ) -> Result + where + In: ArgumentEncoder + Send, + Out: CandidType + DeserializeOwned, + { + Ok(candid::decode_args(&self.query_call_result) + .map(|(r,)| r) + .expect("Failed to decode Candid mocked response")) + } +} + +/// USDC token account [`EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v`](https://solscan.io/token/EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v) on Solana Mainnet. +pub fn usdc_account() -> AccountInfo { + AccountInfo { + lamports: 388_127_047_454, + data: AccountData::Binary( + "KLUv/QBYkQIAAQAAAJj+huiNm+Lqi8HMpIeLKYjCQPUrhCS/tA7Rot3LXhmbQLUAvmbxIwAGAQEAAABicKqKWcWUBbRShshncubNEm6bil06OFNtN/e0FOi2Zw==".to_string(), + AccountEncoding::Base64Zstd, + ), + owner: "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA".to_string(), + executable: false, + rent_epoch: 18_446_744_073_709_551_615, + space: 82, + } +} diff --git a/libs/client/src/lib.rs b/libs/client/src/lib.rs index 0aae2a9e..ee754708 100644 --- a/libs/client/src/lib.rs +++ b/libs/client/src/lib.rs @@ -24,20 +24,102 @@ //! .build(); //! ``` //! +//! ## Estimating the amount of cycles to send +//! +//! Every call made to the SOL RPC canister that triggers HTTPs outcalls (e.g., `getSlot`) +//! needs to attach some cycles to pay for the call. +//! By default, the client will attach some amount of cycles that should be sufficient for most cases. +//! +//! If this is not the case, the amount of cycles to be sent can be changed as follows: +//! 1. Determine the required amount of cycles to send for a particular request. +//! The SOL RPC canister offers some query endpoints (e.g., `getSlotCyclesCost`) for that purpose. +//! This could help establishing a baseline so that the estimated cycles cost for similar requests +//! can be extrapolated from it instead of making additional queries to the SOL RPC canister. +//! 2. Override the amount of cycles to send for that particular request. +//! It's advisable to actually send *more* cycles than required, since *unused cycles will be refunded*. +//! +//! ```rust +//! use sol_rpc_client::SolRpcClient; +//! use sol_rpc_types::MultiRpcResult; +//! +//! # #[tokio::main] +//! # async fn main() -> Result<(), Box> { +//! # use sol_rpc_types::RpcError; +//! let client = SolRpcClient::builder_for_ic() +//! # .with_mocked_responses( +//! # MultiRpcResult::Consistent(Ok(332_577_897_u64)), +//! # Ok::(100_000_000_000), +//! # ) +//! .build(); +//! +//! let request = client.get_slot(); +//! +//! let minimum_required_cycles_amount = request.clone().request_cost().send().await.unwrap(); +//! +//! let slot = request +//! .with_cycles(minimum_required_cycles_amount) +//! .send() +//! .await +//! .expect_consistent(); +//! +//! assert_eq!(slot, Ok(332_577_897_u64)); +//! # Ok(()) +//! # } +//! ``` +//! //! ## Overriding client configuration for a specific call //! -//! It is sometimes desirable to have a custom configuration for a specific call, e.g. to change the amount of cycles attached: +//! Besides changing the amount of cycles for a particular call as described above, +//! it is sometimes desirable to have a custom configuration for a specific +//! call that is different from the one used by the client for all the other calls. +//! +//! For example, maybe for most calls a 2 out-of 3 strategy is good enough, but for `getSlot` +//! your application requires a higher threshold and more robustness with a 3 out-of 5 : //! //! ```rust //! use sol_rpc_client::SolRpcClient; -//! let client = SolRpcClient::builder_for_ic().build(); +//! use sol_rpc_types::{ +//! ConsensusStrategy, GetSlotRpcConfig, MultiRpcResult, RpcConfig, RpcSources, +//! SolanaCluster, +//! }; //! -//! let slot_fut = client.get_slot().with_cycles(42).send(); +//! # #[tokio::main] +//! # async fn main() -> Result<(), Box> { +//! let client = SolRpcClient::builder_for_ic() +//! # .with_mocked_response(MultiRpcResult::Consistent(Ok(332_577_897_u64))) +//! .with_rpc_sources(RpcSources::Default(SolanaCluster::Mainnet)) +//! .with_rpc_config(RpcConfig { +//! response_consensus: Some(ConsensusStrategy::Threshold { +//! total: Some(3), +//! min: 2, +//! }), +//! ..Default::default() +//! }) +//! .build(); +//! +//! let slot = client +//! .get_slot() +//! .with_rpc_config(GetSlotRpcConfig { +//! response_consensus: Some(ConsensusStrategy::Threshold { +//! total: Some(5), +//! min: 3, +//! }), +//! ..Default::default() +//! }) +//! .send() +//! .await +//! .expect_consistent(); +//! +//! assert_eq!(slot, Ok(332_577_897_u64)); +//! # Ok(()) +//! # } //! ``` #![forbid(unsafe_code)] #![forbid(missing_docs)] +#[cfg(not(target_arch = "wasm32"))] +pub mod fixtures; mod request; pub use request::{Request, RequestBuilder, SolRpcEndpoint, SolRpcRequest}; @@ -191,6 +273,34 @@ impl ClientBuilder { impl SolRpcClient { /// Call `getAccountInfo` on the SOL RPC canister. + /// + /// # Examples + /// + /// ```rust + /// use sol_rpc_client::SolRpcClient; + /// use sol_rpc_types::{RpcSources, SolanaCluster}; + /// use solana_pubkey::pubkey; + /// + /// # #[tokio::main] + /// # async fn main() -> Result<(), Box> { + /// # use sol_rpc_client::fixtures::usdc_account; + /// # use sol_rpc_types::{AccountData, AccountEncoding, AccountInfo, MultiRpcResult}; + /// let client = SolRpcClient::builder_for_ic() + /// .with_mocked_response(MultiRpcResult::Consistent(Ok(Some(usdc_account())))) + /// .with_rpc_sources(RpcSources::Default(SolanaCluster::Mainnet)) + /// .build(); + /// + /// let usdc_account = client + /// .get_account_info(pubkey!("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v")) + /// .send() + /// .await + /// .expect_consistent() + /// .unwrap() + /// .unwrap(); + /// + /// assert_eq!(usdc_account.owner, "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA".to_string()); + /// # Ok(()) + /// # } pub fn get_account_info( &self, params: impl Into, @@ -209,6 +319,34 @@ impl SolRpcClient { } /// Call `getSlot` on the SOL RPC canister. + /// + /// # Examples + /// + /// ```rust + /// use sol_rpc_client::SolRpcClient; + /// use sol_rpc_types::{CommitmentLevel, GetSlotParams, MultiRpcResult, RpcSources, SolanaCluster}; + /// + /// # #[tokio::main] + /// # async fn main() -> Result<(), Box> { + /// let client = SolRpcClient::builder_for_ic() + /// # .with_mocked_response(MultiRpcResult::Consistent(Ok(332_577_897_u64))) + /// .with_rpc_sources(RpcSources::Default(SolanaCluster::Mainnet)) + /// .build(); + /// + /// let slot = client + /// .get_slot() + /// .with_params(GetSlotParams { + /// commitment: Some(CommitmentLevel::Finalized), + /// ..Default::default() + /// }) + /// .send() + /// .await + /// .expect_consistent(); + /// + /// assert_eq!(slot, Ok(332_577_897_u64)); + /// # Ok(()) + /// # } + /// ``` pub fn get_slot( &self, ) -> RequestBuilder< @@ -222,6 +360,39 @@ impl SolRpcClient { } /// Call `sendTransaction` on the SOL RPC canister. + /// + /// # Examples + /// + /// See the [basic_solana](https://github.com/dfinity/sol-rpc-canister/tree/main/examples/basic_solana) example + /// to know how to sign a Solana transaction using the [threshold Ed25519 API](https://internetcomputer.org/docs/current/developer-docs/smart-contracts/signatures/signing-messages-t-schnorr). + /// + /// ```rust + /// use sol_rpc_client::SolRpcClient; + /// use sol_rpc_types::{CommitmentLevel, MultiRpcResult, RpcSources, SendTransactionEncoding, SendTransactionParams, SolanaCluster}; + /// + /// # #[tokio::main] + /// # async fn main() -> Result<(), Box> { + /// let client = SolRpcClient::builder_for_ic() + /// # .with_mocked_response(MultiRpcResult::Consistent(Ok("tspfR5p1PFphquz4WzDb7qM4UhJdgQXkEZtW88BykVEdX2zL2kBT9kidwQBviKwQuA3b6GMCR1gknHvzQ3r623T"))) + /// .with_rpc_sources(RpcSources::Default(SolanaCluster::Mainnet)) + /// .build(); + /// + /// let transaction_id = client + /// .send_transaction(SendTransactionParams::from_encoded_transaction( + /// "ASy...pwEC".to_string(), + /// SendTransactionEncoding::Base64, + /// )) + /// .send() + /// .await + /// .expect_consistent(); + /// + /// assert_eq!( + /// transaction_id, + /// Ok("tspfR5p1PFphquz4WzDb7qM4UhJdgQXkEZtW88BykVEdX2zL2kBT9kidwQBviKwQuA3b6GMCR1gknHvzQ3r623T".parse().unwrap()) + /// ); + /// # Ok(()) + /// # } + /// ``` pub fn send_transaction( &self, params: T, @@ -247,6 +418,60 @@ impl SolRpcClient { } /// Call `jsonRequest` on the SOL RPC canister. + /// + /// This method is useful to send any JSON-RPC request in case the SOL RPC canister + /// does not offer a Candid API for the requested JSON-RPC method. + /// + /// # Examples + /// + /// The following example calls `getVersion`: + /// + /// ```rust + /// use sol_rpc_client::SolRpcClient; + /// use serde_json::json; + /// use sol_rpc_types::{MultiRpcResult, RpcSources, SolanaCluster}; + /// + /// # #[tokio::main] + /// # async fn main() -> Result<(), Box> { + /// let client = SolRpcClient::builder_for_ic() + /// # .with_mocked_response(MultiRpcResult::Consistent(Ok(json!({ + /// # "jsonrpc": "2.0", + /// # "result": { + /// # "feature-set": 3271415109_u32, + /// # "solana-core": "2.1.16" + /// # }, + /// # "id": 1 + /// # }) + /// # .to_string()))) + /// .with_rpc_sources(RpcSources::Default(SolanaCluster::Mainnet)) + /// .build(); + /// + /// let version: serde_json::Value = client + /// .json_request(json!({ + /// "jsonrpc": "2.0", + /// "id": 1, + /// "method": "getVersion" + /// })) + /// .send() + /// .await + /// .expect_consistent() + /// .map(|s| serde_json::from_str(&s).unwrap()) + /// .unwrap(); + /// + /// assert_eq!( + /// version, + /// json!({ + /// "jsonrpc": "2.0", + /// "result": { + /// "feature-set": 3271415109_u32, + /// "solana-core": "2.1.16" + /// }, + /// "id": 1 + /// }) + /// ); + /// # Ok(()) + /// # } + /// ``` pub fn json_request( &self, json_request: serde_json::Value,