diff --git a/Cargo.lock b/Cargo.lock index 129b0e30..e26b62b0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4047,6 +4047,7 @@ dependencies = [ "solana-account-decoder-client-types", "solana-clock", "solana-pubkey", + "strum 0.27.1", ] [[package]] @@ -4061,6 +4062,7 @@ dependencies = [ "futures", "ic-cdk", "ic-test-utilities-load-wasm", + "num-traits", "pocket-ic", "regex", "serde", @@ -4074,6 +4076,7 @@ dependencies = [ "solana-client", "solana-pubkey", "solana-rpc-client-api", + "strum 0.27.1", "tokio", ] diff --git a/canister/scripts/examples.sh b/canister/scripts/examples.sh index 00ea79ab..2094eddd 100755 --- a/canister/scripts/examples.sh +++ b/canister/scripts/examples.sh @@ -11,8 +11,6 @@ FLAGS="--network=$NETWORK --identity=$IDENTITY --wallet=$WALLET" dfx canister call sol_rpc getProviders $FLAGS || exit 1 # Get the last finalized slot on Mainnet with a 2-out-of-3 strategy -# TODO XC-321: get cycle cost by query method -CYCLES="2B" GET_SLOT_PARAMS="( variant { Default = variant { Mainnet } }, opt record { @@ -23,11 +21,10 @@ GET_SLOT_PARAMS="( }, opt record { minContextSlot = null; commitment = opt variant { finalized } }, )" +CYCLES=$(dfx canister call sol_rpc getSlotCyclesCost "$GET_SLOT_PARAMS" $FLAGS --output json | jq '.Ok' --raw-output || exit 1) dfx canister call sol_rpc getSlot "$GET_SLOT_PARAMS" $FLAGS --with-cycles "$CYCLES" || exit 1 -# Get the System Program account info on Mainnet with a 2-out-of-3 strategy -# TODO XC-321: get cycle cost by query method -CYCLES="2B" +# Get the USDC mint account info on Mainnet with a 2-out-of-3 strategy GET_ACCOUNT_INFO_PARAMS="( variant { Default = variant { Mainnet } }, opt record { @@ -44,4 +41,5 @@ GET_ACCOUNT_INFO_PARAMS="( minContextSlot = null; }, )" +CYCLES=$(dfx canister call sol_rpc getAccountInfoCyclesCost "$GET_ACCOUNT_INFO_PARAMS" $FLAGS --output json | jq '.Ok' --raw-output || exit 1) dfx canister call sol_rpc getAccountInfo "$GET_ACCOUNT_INFO_PARAMS" $FLAGS --with-cycles "$CYCLES" || exit 1 diff --git a/canister/sol_rpc_canister.did b/canister/sol_rpc_canister.did index 26adaa83..9aa3afe1 100644 --- a/canister/sol_rpc_canister.did +++ b/canister/sol_rpc_canister.did @@ -150,6 +150,10 @@ type RejectionCode = variant { CanisterReject; }; +// Cycles cost of a request made to the SOL RPC canister. +// E.g., the cycle cost for `getSlot` can be retrieved by calling `getSlotCyclesCost`. +type RequestCostResult = variant { Ok : nat; Err : RpcError }; + // Represents the encoding of the return value of the `getAccountInfo` Solana RPC method. type GetAccountInfoEncoding = variant { // Return the account data encoded in base-58. This is slow and limited to less than 129 bytes of account data. @@ -273,10 +277,10 @@ type GetSlotParams = record { minContextSlot: opt nat64; }; -// Represents the result of a generic RPC request. +// Represents the result of a raw JSON-RPC request. type RequestResult = variant { Ok : text; Err : RpcError }; -// Represents an aggregated result from multiple RPC calls for a generic RPC request. +// Represents an aggregated result from multiple RPC calls for a raw JSON-RPC request. type MultiRequestResult = variant { Consistent : RequestResult; Inconsistent : vec record { RpcSource; RequestResult }; @@ -337,10 +341,13 @@ service : (InstallArgs,) -> { // Call the Solana `getAccountInfo` RPC method and return the resulting slot. getAccountInfo : (RpcSources, opt RpcConfig, GetAccountInfoParams) -> (MultiGetAccountInfoResult); + getAccountInfoCyclesCost : (RpcSources, opt RpcConfig, GetAccountInfoParams) -> (RequestCostResult) query; // Call the Solana `getSlot` RPC method and return the resulting slot. getSlot : (RpcSources, opt GetSlotRpcConfig, opt GetSlotParams) -> (MultiGetSlotResult); + getSlotCyclesCost : (RpcSources, opt GetSlotRpcConfig, opt GetSlotParams) -> (RequestCostResult) query; - // Make a generic RPC request that sends the given json_rpc_payload. - request : (RpcSources, opt RpcConfig, json_rpc_paylod: text) -> (MultiRequestResult) + // Make a raw JSON-RPC request that sends the given json_rpc_payload. + jsonRequest : (RpcSources, opt RpcConfig, json_rpc_payload: text) -> (MultiRequestResult); + jsonRequestCyclesCost : (RpcSources, opt RpcConfig, json_rpc_payload: text) -> (RequestCostResult) query; }; diff --git a/canister/src/candid_rpc/mod.rs b/canister/src/candid_rpc/mod.rs index 2684cd56..80e1f238 100644 --- a/canister/src/candid_rpc/mod.rs +++ b/canister/src/candid_rpc/mod.rs @@ -1,20 +1,13 @@ use crate::{ - add_metric_entry, - metrics::RpcMethod, - providers::get_provider, - rpc_client::{ReducedResult, SolRpcClient}, - types::RoundingError, + add_metric_entry, metrics::RpcMethod, providers::get_provider, rpc_client::ReducedResult, util::hostname_from_url, }; use canhttp::multi::ReductionError; -use serde::Serialize; use sol_rpc_types::{ - AccountInfo, GetAccountInfoParams, GetSlotParams, MultiRpcResult, RpcAccess, RpcAuth, - RpcConfig, RpcResult, RpcSource, RpcSources, Slot, SupportedRpcProvider, + MultiRpcResult, RpcAccess, RpcAuth, RpcError, RpcSource, SupportedRpcProvider, }; -use std::fmt::Debug; -fn process_result(method: RpcMethod, result: ReducedResult) -> MultiRpcResult { +pub fn process_result(method: RpcMethod, result: ReducedResult) -> MultiRpcResult { match result { Ok(value) => MultiRpcResult::Consistent(Ok(value)), Err(err) => match err { @@ -40,6 +33,10 @@ fn process_result(method: RpcMethod, result: ReducedResult) -> MultiRpcRes } } +pub fn process_error>(error: E) -> MultiRpcResult { + MultiRpcResult::Consistent(Err(error.into())) +} + pub fn hostname(provider: SupportedRpcProvider) -> Option { let url = match provider.access { RpcAccess::Authenticated { auth, .. } => match auth { @@ -50,50 +47,3 @@ pub fn hostname(provider: SupportedRpcProvider) -> Option { }; hostname_from_url(url.as_str()) } - -/// Adapt the `SolRpcClient` to the `Candid` interface used by the SOL-RPC canister. -pub struct CandidRpcClient { - client: SolRpcClient, -} - -impl CandidRpcClient { - pub fn new(source: RpcSources, config: Option) -> RpcResult { - Self::new_with_rounding_error(source, config, None) - } - - pub fn new_with_rounding_error( - source: RpcSources, - config: Option, - rounding_error: Option, - ) -> RpcResult { - Ok(Self { - client: SolRpcClient::new(source, config, rounding_error)?, - }) - } - - pub async fn get_account_info( - &self, - params: GetAccountInfoParams, - ) -> MultiRpcResult> { - process_result( - RpcMethod::GetAccountInfo, - self.client.get_account_info(params.into()).await, - ) - .map(|maybe_account| maybe_account.map(AccountInfo::from)) - } - - pub async fn get_slot(&self, params: Option) -> MultiRpcResult { - process_result(RpcMethod::GetSlot, self.client.get_slot(params).await) - } - - pub async fn raw_request( - &self, - request: canhttp::http::json::JsonRpcRequest, - ) -> MultiRpcResult - where - I: Serialize + Clone + Debug, - { - process_result(RpcMethod::Generic, self.client.raw_request(request).await) - .map(|value| value.to_string()) - } -} diff --git a/canister/src/http/mod.rs b/canister/src/http/mod.rs index 3cc5abf0..fc6a3c47 100644 --- a/canister/src/http/mod.rs +++ b/canister/src/http/mod.rs @@ -1,4 +1,4 @@ -mod errors; +pub mod errors; use crate::{ add_metric_entry, @@ -27,7 +27,7 @@ use canlog::log; use http::{header::CONTENT_TYPE, HeaderValue}; use ic_cdk::api::management_canister::http_request::CanisterHttpRequestArgument; use serde::{de::DeserializeOwned, Serialize}; -use sol_rpc_types::{Mode, RpcError}; +use sol_rpc_types::{JsonRpcError, RpcError}; use std::fmt::Debug; use tower::{ layer::util::{Identity, Stack}, @@ -40,7 +40,7 @@ use tower_http::{set_header::SetRequestHeaderLayer, ServiceBuilderExt}; pub fn http_client( rpc_method: MetricRpcMethod, retry: bool, -) -> impl Service, Response = HttpJsonRpcResponse, Error = RpcError> +) -> impl Service, Response = O, Error = RpcError> where I: Serialize + Clone + Debug, O: DeserializeOwned + Debug, @@ -56,6 +56,7 @@ where None }; ServiceBuilder::new() + .map_result(extract_json_rpc_response) .map_err(|e: HttpClientError| RpcError::from(e)) .option_layer(maybe_retry) .option_layer(maybe_unique_id) @@ -157,6 +158,18 @@ where .service(canhttp::Client::new_with_error::()) } +fn extract_json_rpc_response( + result: Result, RpcError>, +) -> Result { + match result?.into_body().into_result() { + Ok(value) => Ok(value), + Err(json_rpc_error) => Err(RpcError::JsonRpcError(JsonRpcError { + code: json_rpc_error.code, + message: json_rpc_error.message, + })), + } +} + fn generate_request_id(request: HttpJsonRpcRequest) -> HttpJsonRpcRequest { let (parts, mut body) = request.into_parts(); body.set_id(next_request_id()); @@ -220,7 +233,7 @@ impl ChargingPolicyWithCollateral { fn new_from_state(s: &State) -> Self { Self::new( s.get_num_subnet_nodes(), - !matches!(s.get_mode(), Mode::Demo), + !s.is_demo_mode_active(), COLLATERAL_CYCLES_PER_NODE, ) } diff --git a/canister/src/main.rs b/canister/src/main.rs index a04b5638..e5e5a6a5 100644 --- a/canister/src/main.rs +++ b/canister/src/main.rs @@ -1,20 +1,21 @@ use candid::candid_method; -use canhttp::http::json::JsonRpcRequest; use canlog::{log, Log, Sort}; use ic_cdk::{api::is_controller, query, update}; use ic_metrics_encoder::MetricsEncoder; use sol_rpc_canister::{ - candid_rpc::CandidRpcClient, + candid_rpc::{process_error, process_result}, http_types, lifecycle, logs::Priority, + memory::State, memory::{mutate_state, read_state}, metrics::encode_metrics, + metrics::RpcMethod, providers::{get_provider, PROVIDERS}, - types::RoundingError, + rpc_client::MultiRpcRequest, }; use sol_rpc_types::{ AccountInfo, GetAccountInfoParams, GetSlotParams, GetSlotRpcConfig, MultiRpcResult, RpcAccess, - RpcConfig, RpcError, RpcSources, Slot, SupportedRpcProvider, SupportedRpcProviderId, + RpcConfig, RpcResult, RpcSources, Slot, SupportedRpcProvider, SupportedRpcProviderId, }; use std::str::FromStr; @@ -81,10 +82,27 @@ async fn get_account_info( config: Option, params: GetAccountInfoParams, ) -> MultiRpcResult> { - match CandidRpcClient::new(source, config) { - Ok(client) => client.get_account_info(params).await, - Err(err) => Err(err).into(), + match MultiRpcRequest::get_account_info(source, config.unwrap_or_default(), params) { + Ok(request) => { + process_result(RpcMethod::GetAccountInfo, request.send_and_reduce().await).into() + } + Err(e) => process_error(e), + } +} + +#[query(name = "getAccountInfoCyclesCost")] +#[candid_method(query, rename = "getAccountInfoCyclesCost")] +async fn get_account_info_cycles_cost( + source: RpcSources, + config: Option, + params: GetAccountInfoParams, +) -> RpcResult { + if read_state(State::is_demo_mode_active) { + return Ok(0); } + MultiRpcRequest::get_account_info(source, config.unwrap_or_default(), params)? + .cycles_cost() + .await } #[update(name = "getSlot")] @@ -94,42 +112,61 @@ async fn get_slot( config: Option, params: Option, ) -> MultiRpcResult { - let rounding_error = config - .as_ref() - .and_then(|c| c.rounding_error) - .map(RoundingError::from); - match CandidRpcClient::new_with_rounding_error( + match MultiRpcRequest::get_slot( source, - config.map(RpcConfig::from), - rounding_error, + config.unwrap_or_default(), + params.unwrap_or_default(), ) { - Ok(client) => client.get_slot(params).await, - Err(err) => Err(err).into(), + Ok(request) => process_result(RpcMethod::GetSlot, request.send_and_reduce().await), + Err(e) => process_error(e), + } +} + +#[query(name = "getSlotCyclesCost")] +#[candid_method(query, rename = "getSlotCyclesCost")] +async fn get_slot_cycles_cost( + source: RpcSources, + config: Option, + params: Option, +) -> RpcResult { + if read_state(State::is_demo_mode_active) { + return Ok(0); } + MultiRpcRequest::get_slot( + source, + config.unwrap_or_default(), + params.unwrap_or_default(), + )? + .cycles_cost() + .await } -#[update] -#[candid_method] -async fn request( +#[update(name = "jsonRequest")] +#[candid_method(rename = "jsonRequest")] +async fn json_request( source: RpcSources, config: Option, json_rpc_payload: String, ) -> MultiRpcResult { - let request: JsonRpcRequest = match serde_json::from_str(&json_rpc_payload) { - Ok(req) => req, - Err(e) => { - return Err(RpcError::ValidationError(format!( - "Invalid JSON RPC request: {e}" - ))) - .into() - } - }; - match CandidRpcClient::new(source, config) { - Ok(client) => client.raw_request(request).await, - Err(err) => Err(err).into(), + match MultiRpcRequest::json_request(source, config.unwrap_or_default(), json_rpc_payload) { + Ok(request) => process_result(RpcMethod::JsonRequest, request.send_and_reduce().await) + .map(|value| value.to_string()), + Err(e) => process_error(e), } } +#[query(name = "jsonRequestCyclesCost")] +#[candid_method(query, rename = "jsonRequestCyclesCost")] +async fn json_request_cycles_cost( + source: RpcSources, + config: Option, + json_rpc_payload: String, +) -> RpcResult { + MultiRpcRequest::json_request(source, config.unwrap_or_default(), json_rpc_payload)? + .cycles_cost() + .await +} + #[query(hidden = true)] fn http_request(request: http_types::HttpRequest) -> http_types::HttpResponse { match request.path() { diff --git a/canister/src/memory/mod.rs b/canister/src/memory/mod.rs index 0cda4fa5..92ed48d1 100644 --- a/canister/src/memory/mod.rs +++ b/canister/src/memory/mod.rs @@ -151,6 +151,10 @@ impl State { self.mode } + pub fn is_demo_mode_active(&self) -> bool { + self.mode == Mode::Demo + } + pub fn set_mode(&mut self, mode: Mode) { self.mode = mode } diff --git a/canister/src/metrics/mod.rs b/canister/src/metrics/mod.rs index 73c4893b..9160e969 100644 --- a/canister/src/metrics/mod.rs +++ b/canister/src/metrics/mod.rs @@ -143,7 +143,7 @@ pub struct Metrics { pub enum RpcMethod { GetAccountInfo, GetSlot, - Generic, + JsonRequest, } impl RpcMethod { @@ -151,7 +151,7 @@ impl RpcMethod { match self { RpcMethod::GetAccountInfo => "getAccountInfo", RpcMethod::GetSlot => "getSlot", - RpcMethod::Generic => "generic", + RpcMethod::JsonRequest => "jsonRequest", } } } diff --git a/canister/src/providers/tests.rs b/canister/src/providers/tests.rs index 1984dd47..19f55e91 100644 --- a/canister/src/providers/tests.rs +++ b/canister/src/providers/tests.rs @@ -50,3 +50,53 @@ fn should_have_consistent_name_for_cluster() { } }) } + +mod providers_new { + use crate::providers::Providers; + use assert_matches::assert_matches; + use maplit::btreeset; + use sol_rpc_types::{ + ConsensusStrategy, ProviderError, RpcSource, RpcSources, SolanaCluster, + SupportedRpcProviderId, + }; + + #[test] + fn should_fail_when_providers_explicitly_set_to_empty() { + assert_matches!( + Providers::new(RpcSources::Custom(vec![]), ConsensusStrategy::default()), + Err(ProviderError::InvalidRpcConfig(_)) + ); + } + + #[test] + fn should_use_default_providers() { + for cluster in [SolanaCluster::Mainnet, SolanaCluster::Devnet] { + let providers = + Providers::new(RpcSources::Default(cluster), ConsensusStrategy::default()).unwrap(); + assert!(!providers.sources.is_empty()); + } + } + + #[test] + fn should_use_specified_provider() { + let provider1 = SupportedRpcProviderId::AlchemyMainnet; + let provider2 = SupportedRpcProviderId::PublicNodeMainnet; + + let providers = Providers::new( + RpcSources::Custom(vec![ + RpcSource::Supported(provider1), + RpcSource::Supported(provider2), + ]), + ConsensusStrategy::default(), + ) + .unwrap(); + + assert_eq!( + providers.sources, + btreeset! { + RpcSource::Supported(provider1), + RpcSource::Supported(provider2), + } + ); + } +} diff --git a/canister/src/rpc_client/mod.rs b/canister/src/rpc_client/mod.rs index 5c0824f6..b26ace5a 100644 --- a/canister/src/rpc_client/mod.rs +++ b/canister/src/rpc_client/mod.rs @@ -1,74 +1,180 @@ pub mod json; mod sol_rpc; -#[cfg(test)] -mod tests; +use crate::http::errors::HttpClientError; +use crate::http::{service_request_builder, ChargingPolicyWithCollateral}; +use crate::memory::State; use crate::{ http::http_client, - logs::Priority, memory::read_state, metrics::MetricRpcMethod, providers::{request_builder, resolve_rpc_provider, Providers}, - rpc_client::{ - json::GetAccountInfoParams, - sol_rpc::{ResponseSizeEstimate, ResponseTransform, HEADER_SIZE_LIMIT}, - }, + rpc_client::sol_rpc::ResponseTransform, types::RoundingError, }; use canhttp::{ http::json::JsonRpcRequest, multi::{MultiResults, Reduce, ReduceWithEquality, ReduceWithThreshold}, - MaxResponseBytesRequestExtension, TransformContextRequestExtension, + CyclesChargingPolicy, CyclesCostEstimator, MaxResponseBytesRequestExtension, + TransformContextRequestExtension, }; -use canlog::log; +use http::{Request, Response}; +use ic_cdk::api::management_canister::http_request::CanisterHttpRequestArgument as IcHttpRequest; use ic_cdk::api::management_canister::http_request::TransformContext; use serde::{de::DeserializeOwned, Serialize}; use sol_rpc_types::{ - ConsensusStrategy, GetSlotParams, JsonRpcError, ProviderError, RpcConfig, RpcError, RpcSource, - RpcSources, + ConsensusStrategy, GetSlotParams, GetSlotRpcConfig, ProviderError, RpcConfig, RpcError, + RpcResult, RpcSource, RpcSources, }; -use std::{collections::BTreeSet, fmt::Debug}; +use solana_clock::Slot; +use std::fmt::Debug; +use std::marker::PhantomData; use tower::ServiceExt; -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct SolRpcClient { +// This constant is our approximation of the expected header size. +// The HTTP standard doesn't define any limit, and many implementations limit +// the headers size to 8 KiB. We chose a lower limit because headers observed on most providers +// fit in the constant defined below, and if there is a spike, then the payload size adjustment +// should take care of that. +pub const HEADER_SIZE_LIMIT: u64 = 2 * 1024; + +pub struct MultiRpcRequest { providers: Providers, - config: RpcConfig, - rounding_error: RoundingError, + request: JsonRpcRequest, + max_response_bytes: u64, + transform: ResponseTransform, + reduction_strategy: ReductionStrategy, + _marker: PhantomData, +} + +impl MultiRpcRequest { + fn new( + providers: Providers, + request: JsonRpcRequest, + max_response_bytes: u64, + transform: ResponseTransform, + reduction_strategy: ReductionStrategy, + ) -> Self { + Self { + providers, + request, + max_response_bytes, + transform, + reduction_strategy, + _marker: PhantomData, + } + } +} + +impl Clone for MultiRpcRequest { + fn clone(&self) -> Self { + Self { + providers: self.providers.clone(), + request: self.request.clone(), + max_response_bytes: self.max_response_bytes, + transform: self.transform.clone(), + reduction_strategy: self.reduction_strategy.clone(), + _marker: self._marker, + } + } } -impl SolRpcClient { - pub fn new( - source: RpcSources, - config: Option, - rounding_error: Option, +pub type GetAccountInfoRequest = MultiRpcRequest< + json::GetAccountInfoParams, + Option, +>; + +impl GetAccountInfoRequest { + pub fn get_account_info>( + rpc_sources: RpcSources, + config: RpcConfig, + params: Params, ) -> Result { - let config = config.unwrap_or_default(); - let rounding_error = rounding_error.unwrap_or_default(); - let strategy = config.response_consensus.clone().unwrap_or_default(); - Ok(Self { - providers: Providers::new(source, strategy)?, - config, - rounding_error, - }) + let consensus_strategy = config.response_consensus.unwrap_or_default(); + let providers = Providers::new(rpc_sources, consensus_strategy.clone())?; + let max_response_bytes = config + .response_size_estimate + .unwrap_or(1024 + HEADER_SIZE_LIMIT); + + Ok(MultiRpcRequest::new( + providers, + JsonRpcRequest::new("getAccountInfo", params.into()), + max_response_bytes, + ResponseTransform::GetAccountInfo, + ReductionStrategy::from(consensus_strategy), + )) } +} + +pub type GetSlotRequest = MultiRpcRequest, Slot>; - fn providers(&self) -> &BTreeSet { - &self.providers.sources +impl GetSlotRequest { + pub fn get_slot( + rpc_sources: RpcSources, + config: GetSlotRpcConfig, + params: GetSlotParams, + ) -> Result { + let consensus_strategy = config.response_consensus.unwrap_or_default(); + let providers = Providers::new(rpc_sources, consensus_strategy.clone())?; + let max_response_bytes = config + .response_size_estimate + .unwrap_or(1024 + HEADER_SIZE_LIMIT); + let rounding_error = config + .rounding_error + .map(RoundingError::from) + .unwrap_or_default(); + + Ok(MultiRpcRequest::new( + providers, + JsonRpcRequest::new("getSlot", vec![params]), + max_response_bytes, + ResponseTransform::GetSlot(rounding_error), + ReductionStrategy::from(consensus_strategy), + )) } +} - fn response_size_estimate(&self, estimate: u64) -> ResponseSizeEstimate { - ResponseSizeEstimate::new(self.config.response_size_estimate.unwrap_or(estimate)) +pub type JsonRequest = MultiRpcRequest; + +impl JsonRequest { + pub fn json_request( + rpc_sources: RpcSources, + config: RpcConfig, + json_rpc_payload: String, + ) -> RpcResult { + let request: JsonRpcRequest = + match serde_json::from_str(&json_rpc_payload) { + Ok(req) => req, + Err(e) => { + return Err(RpcError::ValidationError(format!( + "Invalid JSON RPC request: {e}" + ))) + } + }; + let consensus_strategy = config.response_consensus.unwrap_or_default(); + let providers = Providers::new(rpc_sources, consensus_strategy.clone())?; + let max_response_bytes = config + .response_size_estimate + .unwrap_or(1024 + HEADER_SIZE_LIMIT); + + Ok(MultiRpcRequest::new( + providers, + request, + max_response_bytes, + ResponseTransform::Raw, + ReductionStrategy::from(consensus_strategy), + )) } +} - fn reduction_strategy(&self) -> ReductionStrategy { - ReductionStrategy::from( - self.config - .response_consensus - .as_ref() - .cloned() - .unwrap_or_default(), - ) +impl MultiRpcRequest { + pub async fn send_and_reduce(self) -> ReducedResult + where + Params: Serialize + Clone + Debug, + Output: Debug + DeserializeOwned + PartialEq + Serialize, + { + let strategy = self.reduction_strategy.clone(); + self.parallel_call().await.reduce(strategy) } /// Query all providers in parallel and return all results. @@ -81,121 +187,110 @@ impl SolRpcClient { /// (e.g., if different providers gave different responses). /// This method is useful for querying data that is critical for the system to ensure that there is no single point of failure, /// e.g., ethereum logs upon which ckETH will be minted. - async fn parallel_call( - &self, - method: impl Into, - params: I, - response_size_estimate: ResponseSizeEstimate, - response_transform: &Option, - ) -> MultiCallResults + async fn parallel_call(self) -> MultiCallResults where - I: Serialize + Clone + Debug, - O: Debug + DeserializeOwned, + Params: Serialize + Clone + Debug, + Output: Debug + DeserializeOwned, { - let providers = self.providers(); - let request_body = JsonRpcRequest::new(method, params); - let effective_size_estimate = response_size_estimate.get(); - let transform_op = response_transform - .as_ref() - .map(|t| { - let mut buf = vec![]; - minicbor::encode(t, &mut buf).unwrap(); - buf - }) - .unwrap_or_default(); - let mut requests = MultiResults::default(); - for provider in providers { - log!( - Priority::Debug, - "[parallel_call]: will call provider: {:?}", - provider - ); - let request = request_builder( - resolve_rpc_provider(provider.clone()), - &read_state(|state| state.get_override_provider()), - ) - .map(|builder| { - builder - .max_response_bytes(effective_size_estimate) - .transform_context(TransformContext::from_name( - "cleanup_response".to_owned(), - transform_op.clone(), - )) - .body(request_body.clone()) - .expect("BUG: invalid request") - }); - requests.insert_once(provider.clone(), request); - } + let num_providers = self.providers.sources.len(); + let rpc_method = MetricRpcMethod::from(self.request.method().to_string()); + let requests = self.create_json_rpc_requests(); - let rpc_method = MetricRpcMethod::from(request_body.method().to_string()); - let client = - http_client(rpc_method, true).map_result(|r| match r?.into_body().into_result() { - Ok(value) => Ok(value), - Err(json_rpc_error) => Err(RpcError::JsonRpcError(JsonRpcError { - code: json_rpc_error.code, - message: json_rpc_error.message, - })), - }); + let client = http_client(rpc_method, true); let (requests, errors) = requests.into_inner(); let (_client, mut results) = canhttp::multi::parallel_call(client, requests).await; results.add_errors(errors); assert_eq!( results.len(), - providers.len(), + num_providers, "BUG: expected 1 result per provider" ); results } - /// Query the Solana [`getAccountInfo`](https://solana.com/docs/rpc/http/getaccountinfo) RPC method. - pub async fn get_account_info( - &self, - params: GetAccountInfoParams, - ) -> ReducedResult> { - self.parallel_call( - "getAccountInfo", - params, - self.response_size_estimate(1024 + HEADER_SIZE_LIMIT), - &Some(ResponseTransform::GetAccountInfo), - ) - .await - .reduce(self.reduction_strategy()) - } + /// Estimate the exact cycles cost for the given request. + /// + /// *IMPORTANT*: the method is *synchronous* in a canister environment. + pub async fn cycles_cost(self) -> RpcResult + where + Params: Serialize + Clone + Debug, + { + async fn extract_request( + request: IcHttpRequest, + ) -> Result, HttpClientError> { + Ok(http::Response::new(request)) + } + + let num_providers = self.providers.sources.len(); + let requests = self.create_json_rpc_requests(); + + let client = service_request_builder() + .service_fn(extract_request) + .map_err(RpcError::from) + .map_response(Response::into_body); + + let (requests, errors) = requests.into_inner(); + if let Some(error) = errors.into_values().next() { + return Err(error); + } + + let (_client, results) = canhttp::multi::parallel_call(client, requests).await; + let (requests, errors) = results.into_inner(); + if !errors.is_empty() { + return Err(errors + .into_values() + .next() + .expect("BUG: errors is not empty")); + } + assert_eq!( + requests.len(), + num_providers, + "BUG: expected 1 result per provider" + ); - /// Query the Solana [`getSlot`](https://solana.com/docs/rpc/http/getslot) RPC method. - pub async fn get_slot( - &self, - params: Option, - ) -> ReducedResult { - self.parallel_call( - "getSlot", - vec![params], - self.response_size_estimate(1024 + HEADER_SIZE_LIMIT), - &Some(ResponseTransform::GetSlot(self.rounding_error)), - ) - .await - .reduce(self.reduction_strategy()) + let mut cycles_to_attach = 0_u128; + let estimator = CyclesCostEstimator::new(read_state(State::get_num_subnet_nodes)); + let policy = ChargingPolicyWithCollateral::default(); + for request in requests.into_values() { + cycles_to_attach += + policy.cycles_to_charge(&request, estimator.cost_of_http_request(&request)); + } + Ok(cycles_to_attach) } - pub async fn raw_request( - &self, - request: JsonRpcRequest, - ) -> ReducedResult + fn create_json_rpc_requests(self) -> MultiCallResults>> where - I: Serialize + Clone + Debug, + Params: Clone, { - self.parallel_call( - request.method(), - vec![request.params()], - self.response_size_estimate(1024 + HEADER_SIZE_LIMIT), - &Some(ResponseTransform::Raw), - ) - .await - .reduce(self.reduction_strategy()) + let transform_op = { + let mut buf = vec![]; + minicbor::encode(&self.transform, &mut buf).unwrap(); + buf + }; + let mut requests = MultiResults::default(); + for provider in self.providers.sources { + let request = request_builder( + resolve_rpc_provider(provider.clone()), + &read_state(|state| state.get_override_provider()), + ) + .map(|builder| { + builder + .max_response_bytes(self.max_response_bytes) + .transform_context(TransformContext::from_name( + "cleanup_response".to_owned(), + transform_op.clone(), + )) + .body(self.request.clone()) + .expect("BUG: invalid request") + }); + requests.insert_once(provider.clone(), request); + } + requests } } +#[derive(Clone, Debug, PartialEq, Eq)] pub enum ReductionStrategy { ByEquality(ReduceWithEquality), ByThreshold(ReduceWithThreshold), diff --git a/canister/src/rpc_client/sol_rpc/mod.rs b/canister/src/rpc_client/sol_rpc/mod.rs index d6b7edb1..f394f973 100644 --- a/canister/src/rpc_client/sol_rpc/mod.rs +++ b/canister/src/rpc_client/sol_rpc/mod.rs @@ -13,25 +13,12 @@ use serde::{de::DeserializeOwned, Serialize}; use serde_json::{from_slice, from_value, to_vec, Value}; use solana_account_decoder_client_types::UiAccount; use solana_clock::Slot; -use std::{fmt, fmt::Debug}; - -// This constant is our approximation of the expected header size. -// The HTTP standard doesn't define any limit, and many implementations limit -// the headers size to 8 KiB. We chose a lower limit because headers observed on most providers -// fit in the constant defined below, and if there is a spike, then the payload size adjustment -// should take care of that. -pub const HEADER_SIZE_LIMIT: u64 = 2 * 1024; - -// This constant comes from the IC specification: -// > If provided, the value must not exceed 2MB -const HTTP_MAX_SIZE: u64 = 2_000_000; - -pub const MAX_PAYLOAD_SIZE: u64 = HTTP_MAX_SIZE - HEADER_SIZE_LIMIT; +use std::fmt::Debug; /// Describes a payload transformation to execute before passing the HTTP response to consensus. /// The purpose of these transformations is to ensure that the response encoding is deterministic /// (the field order is the same). -#[derive(Debug, Decode, Encode)] +#[derive(Clone, Debug, Decode, Encode)] pub enum ResponseTransform { #[n(0)] GetAccountInfo, @@ -90,26 +77,3 @@ fn cleanup_response(mut args: TransformArgs) -> HttpResponse { } args.response } - -#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] -pub struct ResponseSizeEstimate(u64); - -impl ResponseSizeEstimate { - pub fn new(num_bytes: u64) -> Self { - assert!(num_bytes > 0); - assert!(num_bytes <= MAX_PAYLOAD_SIZE); - Self(num_bytes) - } - - /// Describes the expected (90th percentile) number of bytes in the HTTP response body. - /// This number should be less than `MAX_PAYLOAD_SIZE`. - pub fn get(self) -> u64 { - self.0 - } -} - -impl fmt::Display for ResponseSizeEstimate { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}", self.0) - } -} diff --git a/canister/src/rpc_client/tests.rs b/canister/src/rpc_client/tests.rs deleted file mode 100644 index 82aeec5e..00000000 --- a/canister/src/rpc_client/tests.rs +++ /dev/null @@ -1,48 +0,0 @@ -mod sol_rpc_client { - use crate::rpc_client::SolRpcClient; - use assert_matches::assert_matches; - use maplit::btreeset; - use sol_rpc_types::{ - ProviderError, RpcSource, RpcSources, SolanaCluster, SupportedRpcProviderId, - }; - - #[test] - fn should_fail_when_providers_explicitly_set_to_empty() { - assert_matches!( - SolRpcClient::new(RpcSources::Custom(vec![]), None, None), - Err(ProviderError::InvalidRpcConfig(_)) - ); - } - - #[test] - fn should_use_default_providers() { - for cluster in [SolanaCluster::Mainnet, SolanaCluster::Devnet] { - let client = SolRpcClient::new(RpcSources::Default(cluster), None, None).unwrap(); - assert!(!client.providers().is_empty()); - } - } - - #[test] - fn should_use_specified_provider() { - let provider1 = SupportedRpcProviderId::AlchemyMainnet; - let provider2 = SupportedRpcProviderId::PublicNodeMainnet; - - let client = SolRpcClient::new( - RpcSources::Custom(vec![ - RpcSource::Supported(provider1), - RpcSource::Supported(provider2), - ]), - None, - None, - ) - .unwrap(); - - assert_eq!( - client.providers(), - &btreeset! { - RpcSource::Supported(provider1), - RpcSource::Supported(provider2), - } - ); - } -} diff --git a/integration_tests/Cargo.toml b/integration_tests/Cargo.toml index a47a39b7..1505ccc7 100644 --- a/integration_tests/Cargo.toml +++ b/integration_tests/Cargo.toml @@ -14,6 +14,7 @@ canlog = { path = "../canlog" } const_format = { workspace = true } ic-cdk = { workspace = true } ic-test-utilities-load-wasm = { workspace = true } +num-traits = {workspace = true} pocket-ic = { workspace = true } regex = { workspace = true } serde = { workspace = true } @@ -31,4 +32,5 @@ solana-rpc-client-api = { workspace = true } assert_matches = { workspace = true } futures = { workspace = true } solana-client = { workspace = true } +strum = {workspace = true} tokio = { workspace = true } diff --git a/integration_tests/src/lib.rs b/integration_tests/src/lib.rs index 66318fc4..1223f3ec 100644 --- a/integration_tests/src/lib.rs +++ b/integration_tests/src/lib.rs @@ -2,6 +2,7 @@ use async_trait::async_trait; use candid::{decode_args, encode_args, utils::ArgumentEncoder, CandidType, Encode, Principal}; use canlog::{Log, LogEntry}; use ic_cdk::api::call::RejectionCode; +use num_traits::ToPrimitive; use pocket_ic::{ common::rest::{ CanisterHttpReject, CanisterHttpRequest, CanisterHttpResponse, MockCanisterHttpResponse, @@ -180,6 +181,17 @@ impl Setup { SolRpcClient::builder(self.new_live_pocket_ic_runtime(), self.sol_rpc_canister_id) } + pub async fn sol_rpc_canister_cycles_balance(&self) -> u128 { + self.env + .canister_status(self.sol_rpc_canister_id, Some(self.controller)) + .await + .unwrap() + .cycles + .0 + .to_u128() + .unwrap() + } + fn new_pocket_ic_runtime(&self) -> PocketIcRuntime { PocketIcRuntime { env: &self.env, diff --git a/integration_tests/src/mock.rs b/integration_tests/src/mock.rs index faf2a3da..b3d7f1a2 100644 --- a/integration_tests/src/mock.rs +++ b/integration_tests/src/mock.rs @@ -13,13 +13,11 @@ impl From<&serde_json::Value> for MockOutcallBody { value.to_string().into() } } - impl From for MockOutcallBody { fn from(value: serde_json::Value) -> Self { - value.to_string().into() + Self::from(serde_json::to_vec(&value).unwrap()) } } - impl From for MockOutcallBody { fn from(string: String) -> Self { string.as_bytes().to_vec().into() diff --git a/integration_tests/tests/tests.rs b/integration_tests/tests/tests.rs index 2961c69f..4d77f835 100644 --- a/integration_tests/tests/tests.rs +++ b/integration_tests/tests/tests.rs @@ -49,7 +49,7 @@ mod mock_request_tests { assert_matches!( client .mock_http(builder_fn(MockOutcallBuilder::new(200, MOCK_RESPONSE))).build() - .raw_request(get_version_request()) + .json_request(get_version_request()) .with_cycles(0) .send() .await, @@ -383,7 +383,7 @@ mod generic_request_tests { let client = setup.client().build(); let results = client - .raw_request(get_version_request()) + .json_request(get_version_request()) .with_cycles(0) .send() .await @@ -425,7 +425,7 @@ mod generic_request_tests { }), ) .build() - .raw_request(get_version_request()) + .json_request(get_version_request()) .with_cycles(0) .send() .await @@ -615,3 +615,235 @@ fn rpc_sources() -> Vec { ]), ] } + +mod cycles_cost_tests { + use crate::{assert_within, get_version_request}; + use candid::CandidType; + use serde::de::DeserializeOwned; + use serde_json::json; + use sol_rpc_client::{RequestBuilder, SolRpcEndpoint}; + use sol_rpc_int_tests::mock::MockOutcallBuilder; + use sol_rpc_int_tests::{PocketIcRuntime, Setup, SolRpcTestClient}; + use sol_rpc_types::{ + GetAccountInfoParams, GetSlotParams, InstallArgs, Mode, ProviderError, RpcError, + }; + use solana_pubkey::Pubkey; + use std::fmt::Debug; + use strum::IntoEnumIterator; + + #[tokio::test] + async fn should_be_idempotent() { + async fn check( + request: RequestBuilder, Config, Params, CandidOutput, Output>, + ) where + Config: CandidType + Clone + Send, + Params: CandidType + Clone + Send, + { + let cycles_cost_1 = request.clone().request_cost().send().await.unwrap(); + let cycles_cost_2 = request.request_cost().send().await.unwrap(); + assert_eq!(cycles_cost_1, cycles_cost_2); + assert!(cycles_cost_1 > 0); + } + + let setup = Setup::new().await.with_mock_api_keys().await; + let client = setup.client().build(); + + for endpoint in SolRpcEndpoint::iter() { + match endpoint { + SolRpcEndpoint::GetSlot => { + check(client.get_slot().with_params(GetSlotParams::default())).await; + } + SolRpcEndpoint::JsonRequest => { + check(client.json_request(get_version_request())).await; + } + SolRpcEndpoint::GetAccountInfo => { + check( + client.get_account_info(GetAccountInfoParams::from( + "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v" + .parse::() + .unwrap(), + )), + ) + .await; + } + } + } + + setup.drop().await; + } + + #[tokio::test] + async fn should_be_zero_when_in_demo_mode() { + async fn check( + request: RequestBuilder, Config, Params, CandidOutput, Output>, + ) where + Config: CandidType + Clone + Send, + Params: CandidType + Clone + Send, + { + let cycles_cost = request.request_cost().send().await; + assert_eq!(cycles_cost, Ok(0)); + } + + let setup = Setup::new().await.with_mock_api_keys().await; + setup + .upgrade_canister(InstallArgs { + mode: Some(Mode::Demo), + ..Default::default() + }) + .await; + let client = setup.client().build(); + + for endpoint in SolRpcEndpoint::iter() { + match endpoint { + SolRpcEndpoint::GetSlot => { + check(client.get_slot().with_params(GetSlotParams::default())).await; + } + SolRpcEndpoint::JsonRequest => { + check(client.json_request(get_version_request())).await; + } + SolRpcEndpoint::GetAccountInfo => { + check( + client.get_account_info(GetAccountInfoParams::from( + "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v" + .parse::() + .unwrap(), + )), + ) + .await; + } + } + } + + setup.drop().await; + } + + #[tokio::test] + async fn should_get_exact_cycles_cost() { + async fn check( + setup: &Setup, + request: RequestBuilder< + PocketIcRuntime<'_>, + Config, + Params, + sol_rpc_types::MultiRpcResult, + sol_rpc_types::MultiRpcResult, + >, + expected_cycles_cost: u128, + ) where + Config: CandidType + Clone + Send, + Params: CandidType + Clone + Send, + CandidOutput: CandidType + DeserializeOwned, + Output: Debug, + sol_rpc_types::MultiRpcResult: + Into>, + { + let five_percents = 5_u8; + + let cycles_cost = request.clone().request_cost().send().await.unwrap(); + assert_within(cycles_cost, expected_cycles_cost, five_percents); + + let cycles_before = setup.sol_rpc_canister_cycles_balance().await; + // Request with exact cycles amount should succeed + let result = request + .clone() + .with_cycles(cycles_cost) + .send() + .await + .expect_consistent(); + if let Err(RpcError::ProviderError(ProviderError::TooFewCycles { .. })) = result { + panic!("BUG: estimated cycles cost was insufficient!: {result:?}"); + } + let cycles_after = setup.sol_rpc_canister_cycles_balance().await; + let cycles_consumed = cycles_before + cycles_cost - cycles_after; + + assert!( + cycles_after > cycles_before, + "BUG: not enough cycles requested. Requested {cycles_cost} cycles, but consumed {cycles_consumed} cycles" + ); + + // Same request with less cycles should fail. + let results = request + .with_cycles(cycles_cost - 1) + .send() + .await + .expect_inconsistent(); + + assert!( + results.iter().any(|(_provider, result)| matches!( + result, + &Err(RpcError::ProviderError(ProviderError::TooFewCycles { + expected: _, + received: _ + })) + )), + "BUG: Expected at least one TooFewCycles error, but got {results:?}" + ); + + // TODO XC-321: ID in JSON-RPC requests should have a constant byte size. + // JSON-RPC requests for estimating the cycles cost use `0` as an ID + // while the actual requests will use a unique incremental ID, which after a few requests + // will have a bigger binary representation leading to an increase cycles cost for the actual HTTPs outcall. + // As a workaround, we upgrade the SOL RPC canister to reset the requests counter to zero since it's stored on the heap. + setup.upgrade_canister(InstallArgs::default()).await; + } + + let setup = Setup::new().await.with_mock_api_keys().await; + let client = setup + .client() + // The exact cycles cost of an HTTPs outcall is independent of the response, + // so we always return a dummy response so that individual responses + // do not need to be mocked. + .mock_http(MockOutcallBuilder::new(403, json!({}))) + .build(); + + for endpoint in SolRpcEndpoint::iter() { + // To find out the expected_cycles_cost for a new endpoint, set the amount to 0 + // and run the test. It should fail and report the amount of cycles needed. + match endpoint { + SolRpcEndpoint::GetSlot => { + check( + &setup, + client.get_slot().with_params(GetSlotParams::default()), + 1_792_548_000, + ) + .await; + } + SolRpcEndpoint::JsonRequest => { + check( + &setup, + client.json_request(get_version_request()), + 1_790_956_800, + ) + .await; + } + SolRpcEndpoint::GetAccountInfo => { + check( + &setup, + client.get_account_info(GetAccountInfoParams::from( + "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v" + .parse::() + .unwrap(), + )), + 1_793_744_800, + ) + .await; + } + } + } + + setup.drop().await; + } +} +fn assert_within(actual: u128, expected: u128, percentage_error: u8) { + assert!(percentage_error <= 100); + let error_margin = expected.saturating_mul(percentage_error as u128) / 100; + let lower_bound = expected.saturating_sub(error_margin); + let upper_bound = expected.saturating_add(error_margin); + assert!( + lower_bound <= actual && actual <= upper_bound, + "Expected {} <= {} <= {}", + lower_bound, + actual, + upper_bound + ); +} diff --git a/libs/client/Cargo.toml b/libs/client/Cargo.toml index d514e26a..359fdf94 100644 --- a/libs/client/Cargo.toml +++ b/libs/client/Cargo.toml @@ -20,4 +20,5 @@ solana-account = { workspace = true } solana-account-decoder-client-types = { workspace = true } solana-clock = { workspace = true } solana-pubkey = { workspace = true } -sol_rpc_types = { path = "../types" } \ No newline at end of file +sol_rpc_types = { path = "../types" } +strum = {workspace = true} \ No newline at end of file diff --git a/libs/client/src/lib.rs b/libs/client/src/lib.rs index 4c76c490..ede0d344 100644 --- a/libs/client/src/lib.rs +++ b/libs/client/src/lib.rs @@ -40,9 +40,9 @@ mod request; -pub use request::{Request, RequestBuilder, SolRpcRequest}; +pub use request::{Request, RequestBuilder, SolRpcEndpoint, SolRpcRequest}; -use crate::request::{GetAccountInfoRequest, GetSlotRequest, RawRequest}; +use crate::request::{GetAccountInfoRequest, GetSlotRequest, JsonRequest}; use async_trait::async_trait; use candid::{utils::ArgumentEncoder, CandidType, Principal}; use ic_cdk::api::call::RejectionCode; @@ -219,8 +219,8 @@ impl SolRpcClient { RequestBuilder::new(self.clone(), GetSlotRequest::default(), 10_000_000_000) } - /// Call `request` on the SOL RPC canister. - pub fn raw_request( + /// Call `jsonRequest` on the SOL RPC canister. + pub fn json_request( &self, json_request: serde_json::Value, ) -> RequestBuilder< @@ -232,7 +232,7 @@ impl SolRpcClient { > { RequestBuilder::new( self.clone(), - RawRequest::try_from(json_request).expect("Client error: invalid JSON request"), + JsonRequest::try_from(json_request).expect("Client error: invalid JSON request"), 10_000_000_000, ) } @@ -275,7 +275,7 @@ impl SolRpcClient { .runtime .update_call::<(RpcSources, Option, Params), CandidOutput>( self.config.sol_rpc_canister, - &request.rpc_method, + request.endpoint.rpc_method(), (request.rpc_sources, request.rpc_config, request.params), request.cycles, ) @@ -283,7 +283,33 @@ impl SolRpcClient { .unwrap_or_else(|e| { panic!( "Client error: failed to call `{}`: {e:?}", - request.rpc_method + request.endpoint.rpc_method() + ) + }) + .into() + } + + async fn execute_cycles_cost_request( + &self, + request: Request, + ) -> Output + where + Config: CandidType + Send, + Params: CandidType + Send, + CandidOutput: Into + CandidType + DeserializeOwned, + { + self.config + .runtime + .query_call::<(RpcSources, Option, Params), CandidOutput>( + self.config.sol_rpc_canister, + request.endpoint.cycles_cost_method(), + (request.rpc_sources, request.rpc_config, request.params), + ) + .await + .unwrap_or_else(|e| { + panic!( + "Client error: failed to call `{}`: {e:?}", + request.endpoint.cycles_cost_method() ) }) .into() diff --git a/libs/client/src/request/mod.rs b/libs/client/src/request/mod.rs index fe742cf9..93133082 100644 --- a/libs/client/src/request/mod.rs +++ b/libs/client/src/request/mod.rs @@ -2,9 +2,11 @@ use crate::{Runtime, SolRpcClient}; use candid::CandidType; use serde::de::DeserializeOwned; use sol_rpc_types::{ - AccountInfo, GetAccountInfoParams, GetSlotParams, GetSlotRpcConfig, RpcConfig, RpcSources, + AccountInfo, GetAccountInfoParams, GetSlotParams, GetSlotRpcConfig, RpcConfig, RpcResult, + RpcSources, }; use solana_clock::Slot; +use strum::EnumIter; /// Solana RPC endpoint supported by the SOL RPC canister. pub trait SolRpcRequest { @@ -18,12 +20,43 @@ pub trait SolRpcRequest { type Output; /// The name of the endpoint on the SOL RPC canister. - fn rpc_method(&self) -> &str; + fn endpoint(&self) -> SolRpcEndpoint; /// Return the request parameters. fn params(self) -> Self::Params; } +/// Endpoint on the SOL RPC canister triggering a call to Solana providers. +#[derive(Clone, Debug, Ord, PartialOrd, Eq, PartialEq, EnumIter)] +pub enum SolRpcEndpoint { + /// `getAccountInfo` endpoint. + GetAccountInfo, + /// `getSlot` endpoint. + GetSlot, + /// `jsonRequest` endpoint. + JsonRequest, +} + +impl SolRpcEndpoint { + /// Method name on the SOL RPC canister + pub fn rpc_method(&self) -> &'static str { + match &self { + SolRpcEndpoint::GetAccountInfo => "getAccountInfo", + SolRpcEndpoint::GetSlot => "getSlot", + SolRpcEndpoint::JsonRequest => "jsonRequest", + } + } + + /// Method name on the SOL RPC canister to estimate the amount of cycles for that request. + pub fn cycles_cost_method(&self) -> &'static str { + match &self { + SolRpcEndpoint::GetAccountInfo => "getAccountInfoCyclesCost", + SolRpcEndpoint::GetSlot => "getSlotCyclesCost", + SolRpcEndpoint::JsonRequest => "jsonRequestCyclesCost", + } + } +} + #[derive(Debug, Clone)] pub struct GetAccountInfoRequest(GetAccountInfoParams); @@ -40,8 +73,8 @@ impl SolRpcRequest for GetAccountInfoRequest { type Output = sol_rpc_types::MultiRpcResult>; - fn rpc_method(&self) -> &str { - "getAccountInfo" + fn endpoint(&self) -> SolRpcEndpoint { + SolRpcEndpoint::GetAccountInfo } fn params(self) -> Self::Params { @@ -58,8 +91,8 @@ impl SolRpcRequest for GetSlotRequest { type CandidOutput = Self::Output; type Output = sol_rpc_types::MultiRpcResult; - fn rpc_method(&self) -> &str { - "getSlot" + fn endpoint(&self) -> SolRpcEndpoint { + SolRpcEndpoint::GetSlot } fn params(self) -> Self::Params { @@ -67,26 +100,26 @@ impl SolRpcRequest for GetSlotRequest { } } -pub struct RawRequest(String); +pub struct JsonRequest(String); -impl TryFrom for RawRequest { +impl TryFrom for JsonRequest { type Error = String; fn try_from(value: serde_json::Value) -> Result { serde_json::to_string(&value) - .map(RawRequest) + .map(JsonRequest) .map_err(|e| e.to_string()) } } -impl SolRpcRequest for RawRequest { +impl SolRpcRequest for JsonRequest { type Config = RpcConfig; type Params = String; type CandidOutput = sol_rpc_types::MultiRpcResult; type Output = sol_rpc_types::MultiRpcResult; - fn rpc_method(&self) -> &str { - "request" + fn endpoint(&self) -> SolRpcEndpoint { + SolRpcEndpoint::JsonRequest } fn params(self) -> Self::Params { @@ -103,6 +136,17 @@ pub struct RequestBuilder { request: Request, } +impl Clone + for RequestBuilder +{ + fn clone(&self) -> Self { + Self { + client: self.client.clone(), + request: self.request.clone(), + } + } +} + impl RequestBuilder { @@ -121,7 +165,7 @@ impl Config: From, { let request = Request { - rpc_method: rpc_request.rpc_method().to_string(), + endpoint: rpc_request.endpoint(), rpc_sources: client.config.rpc_sources.clone(), rpc_config: client.config.rpc_config.clone().map(Config::from), params: rpc_request.params(), @@ -132,6 +176,22 @@ impl RequestBuilder:: { client, request } } + /// Query the cycles cost for that request + pub fn request_cost(self) -> RequestCostBuilder { + RequestCostBuilder { + client: self.client, + request: RequestCost { + endpoint: self.request.endpoint, + rpc_sources: self.request.rpc_sources, + rpc_config: self.request.rpc_config, + params: self.request.params, + cycles: 0, + _candid_marker: Default::default(), + _output_marker: Default::default(), + }, + } + } + /// Change the amount of cycles to send for that request. pub fn with_cycles(mut self, cycles: u128) -> Self { *self.request.cycles_mut() = cycles; @@ -179,9 +239,9 @@ impl } } -/// A request which can be executed with `SolRpcClient::execute_request`. +/// A request which can be executed with `SolRpcClient::execute_request` or `SolRpcClient::execute_query_request`. pub struct Request { - pub(super) rpc_method: String, + pub(super) endpoint: SolRpcEndpoint, pub(super) rpc_sources: RpcSources, pub(super) rpc_config: Option, pub(super) params: Params, @@ -190,6 +250,22 @@ pub struct Request { pub(super) _output_marker: std::marker::PhantomData, } +impl Clone + for Request +{ + fn clone(&self) -> Self { + Self { + endpoint: self.endpoint.clone(), + rpc_sources: self.rpc_sources.clone(), + rpc_config: self.rpc_config.clone(), + params: self.params.clone(), + cycles: self.cycles, + _candid_marker: self._candid_marker, + _output_marker: self._output_marker, + } + } +} + impl Request { /// Get a mutable reference to the cycles. #[inline] @@ -209,3 +285,22 @@ impl Request = Request, RpcResult>; + +#[must_use = "RequestCostBuilder does nothing until you 'send' it"] +pub struct RequestCostBuilder { + client: SolRpcClient, + request: RequestCost, +} + +impl RequestCostBuilder { + /// Constructs the [`Request`] and send it using the [`SolRpcClient`]. + pub async fn send(self) -> RpcResult + where + Config: CandidType + Send, + Params: CandidType + Send, + { + self.client.execute_cycles_cost_request(self.request).await + } +} diff --git a/libs/types/src/solana/mod.rs b/libs/types/src/solana/mod.rs index 7796eb4f..f22d9177 100644 --- a/libs/types/src/solana/mod.rs +++ b/libs/types/src/solana/mod.rs @@ -167,6 +167,16 @@ impl From for solana_account_decoder_client_types::UiAccount { } } +impl From>> + for MultiRpcResult> +{ + fn from( + result: MultiRpcResult>, + ) -> Self { + result.map(|maybe_account| maybe_account.map(AccountInfo::from)) + } +} + /// Represents the data stored in a Solana [account](https://solana.com/docs/references/terminology#account). #[derive(Debug, Clone, Deserialize, Serialize, CandidType, PartialEq)] pub enum AccountData {