diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5736eaee..498253ce 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -163,7 +163,5 @@ jobs: run: ../scripts/examples.sh 2>&1 | tee e2e_examples.log - name: "Detect Inconsistent Results" - # TODO XC-292: Should no longer fail once rounding is in - continue-on-error: true working-directory: canister/ci - run: cat e2e_examples.log | grep -e Inconsistent + run: cat e2e_examples.log | grep -q -e Inconsistent && exit 1 || exit 0 diff --git a/Cargo.lock b/Cargo.lock index 72a6c5d9..c6f5eea1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -363,9 +363,9 @@ dependencies = [ [[package]] name = "blake3" -version = "1.7.0" +version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b17679a8d69b6d7fd9cd9801a536cec9fa5e5970b69f9d4747f70b39b031f5e7" +checksum = "34a796731680be7931955498a16a10b2270c7762963d5d570fdbfe02dcbf314f" dependencies = [ "arrayref", "arrayvec 0.7.6", @@ -515,9 +515,9 @@ checksum = "b6b1fc10dbac614ebc03540c9dbd60e83887fda27794998c6528f1782047d540" [[package]] name = "bytemuck_derive" -version = "1.9.2" +version = "1.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ff22c2722516255d1823ce3cc4bc0b154dbc9364be5c905d6baa6eccbbc8774" +checksum = "7ecc273b49b3205b83d648f0690daa588925572cc5063745bfe547fe7ec8e1a1" dependencies = [ "proc-macro2", "quote", @@ -602,7 +602,7 @@ dependencies = [ [[package]] name = "canhttp" version = "0.1.0" -source = "git+https://github.com/dfinity/evm-rpc-canister?rev=b976abe57379be7d2649f6a31522d0bb25c5f455#b976abe57379be7d2649f6a31522d0bb25c5f455" +source = "git+https://github.com/dfinity/evm-rpc-canister#6cade0c6167da858b8d45700db7de44bf627b207" dependencies = [ "assert_matches", "ciborium", @@ -986,9 +986,9 @@ dependencies = [ [[package]] name = "darling" -version = "0.20.10" +version = "0.20.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f63b86c8a8826a49b8c21f08a2d07338eec8d900540f8630dc76284be802989" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" dependencies = [ "darling_core", "darling_macro", @@ -996,9 +996,9 @@ dependencies = [ [[package]] name = "darling_core" -version = "0.20.10" +version = "0.20.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95133861a8032aaea082871032f5815eb9e98cef03fa916ab4500513994df9e5" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" dependencies = [ "fnv", "ident_case", @@ -1010,9 +1010,9 @@ dependencies = [ [[package]] name = "darling_macro" -version = "0.20.10" +version = "0.20.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d336a2a514f6ccccaa3e09b02d41d35330c07ddf03a62165fcec10bb561c7806" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" dependencies = [ "darling_core", "quote", @@ -1309,9 +1309,9 @@ dependencies = [ [[package]] name = "event-listener-strategy" -version = "0.5.3" +version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c3e4e0dd3673c1139bf041f3008816d9cf2946bbfac2945c09e523b8d7b05b2" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" dependencies = [ "event-listener 5.4.0", "pin-project-lite", @@ -1848,9 +1848,9 @@ dependencies = [ [[package]] name = "hyper-util" -version = "0.1.10" +version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df2dcfbe0677734ab2f3ffa7fa7bfd4706bfdc1ef393f2ee30184aed67e631b4" +checksum = "497bbc33a26fdd4af9ed9c70d63f61cf56a938375fbb32df34db9b1cd6d643f2" dependencies = [ "bytes", "futures-channel", @@ -1858,6 +1858,7 @@ dependencies = [ "http 1.3.1", "http-body 1.0.1", "hyper 1.6.0", + "libc", "pin-project-lite", "socket2", "tokio", @@ -1934,15 +1935,6 @@ version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b5c7628eac357aecda461130f8074468be5aa4d258a002032d82d817f79f1f8" -[[package]] -name = "ic-sha3" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3715f0f4370e8ce6aa9805b81e915ef4420c9dfb5209c71489c27e6f98bd5d65" -dependencies = [ - "sha3", -] - [[package]] name = "ic-stable-structures" version = "0.6.8" @@ -2765,9 +2757,9 @@ dependencies = [ [[package]] name = "once_cell" -version = "1.21.1" +version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d75b0bedcc4fe52caa0e03d9f1151a323e4aa5e2d78ba3580400cd3c9e2bc4bc" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" [[package]] name = "opaque-debug" @@ -3995,9 +3987,9 @@ checksum = "7fcf8323ef1faaee30a44a340193b1ac6814fd9b7b4e88e9d4519a3e4abe1cfd" [[package]] name = "socket2" -version = "0.5.8" +version = "0.5.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c970269d99b64e60ec3bd6ad27270092a5394c4e309314b18ae3fe575695fbe8" +checksum = "4f5fd57c80058a56cf5c777ab8a126398ece8e442983605d280a44ce79d0edef" dependencies = [ "libc", "windows-sys 0.52.0", @@ -4020,7 +4012,6 @@ dependencies = [ "http 1.3.1", "ic-cdk", "ic-metrics-encoder", - "ic-sha3", "ic-stable-structures", "maplit", "minicbor", @@ -5539,9 +5530,9 @@ dependencies = [ [[package]] name = "string_cache" -version = "0.8.8" +version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "938d512196766101d333398efde81bc1f37b00cb42c2f8350e5df639f040bbbe" +checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f" dependencies = [ "new_debug_unreachable", "parking_lot", diff --git a/Cargo.toml b/Cargo.toml index b532ae82..86610229 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,13 +31,13 @@ assert_matches = "1.5.0" async-trait = "0.1.88" candid = "0.10.13" candid_parser = "0.1.4" -canhttp = { git = "https://github.com/dfinity/evm-rpc-canister", rev = "b976abe57379be7d2649f6a31522d0bb25c5f455" } +canhttp = { git = "https://github.com/dfinity/evm-rpc-canister", ref = "d7261024d16df7fadd1ee60e4cd70798f3cf5577" } ciborium = "0.2.2" -# Transitive dependency of ic-ed25519 -# See https://forum.dfinity.org/t/module-imports-function-wbindgen-describe-from-wbindgen-placeholder-that-is-not-exported-by-the-runtime/11545/8 const_format = "0.2.34" -derive_more = { version = "2.0.1", features = ["from"] } +derive_more = { version = "2.0.1", features = ["from", "into"] } futures = "0.3.31" +# Transitive dependency of ic-ed25519 +# See https://forum.dfinity.org/t/module-imports-function-wbindgen-describe-from-wbindgen-placeholder-that-is-not-exported-by-the-runtime/11545/8 getrandom = { version = "*", default-features = false, features = ["custom"] } hex = "0.4.3" http = "1.2.0" diff --git a/canister/Cargo.toml b/canister/Cargo.toml index 6a274949..fd57e921 100644 --- a/canister/Cargo.toml +++ b/canister/Cargo.toml @@ -25,7 +25,6 @@ hex = { workspace = true } http = { workspace = true } ic-cdk = { workspace = true } ic-metrics-encoder = { workspace = true } -ic-sha3 = { workspace = true } ic-stable-structures = { workspace = true } maplit = { workspace = true } minicbor = { workspace = true } diff --git a/canister/sol_rpc_canister.did b/canister/sol_rpc_canister.did index edc76822..dc2d32ad 100644 --- a/canister/sol_rpc_canister.did +++ b/canister/sol_rpc_canister.did @@ -33,6 +33,31 @@ type RpcConfig = record { responseConsensus : opt ConsensusStrategy; }; + +// Rounding error for fetching the current slot from Solana using the JSON-RPC interface, meaning slots will be rounded +// down to the nearest multiple of this error when being fetched. +// +// Solana slot time (around 400ms) is faster than the latency of an HTTPs outcall (which involves every node in the +// subnet making an HTTP request), which is typically around a couple of seconds. It is therefore extremely likely that +// the nodes will receive different results and will fail to reach consensus. +// +// Rounding down the slot received by each node artificially increases the slot time observed by each node and therefore +// increases the probability of reaching consensus. In other words, the higher the rounding error, the more likely it is +// that consensus will be reached (which is required for the HTTPs outcall to be successful), but the older the +// resulting slot will be. Certain use cases, such as sending transactions, require a relatively recent block hash (less +// than 150 blocks old) so that too a large rounding error is not advisable. +// +// The default value of 20 has been experimentally shown to likely achieve consensus while still resulting in a slot +// whose corresponding block is "recent enough" to be used in a Solana transaction. +type RoundingError = nat64; + +// Configures how to perform `getSlot` RPC HTTP calls. +type GetSlotRpcConfig = record { + responseSizeEstimate : opt nat64; + responseConsensus : opt ConsensusStrategy; + roundingError : opt RoundingError; +}; + // Defines a consensus strategy for combining responses from different providers. type ConsensusStrategy = variant { Equality; @@ -213,7 +238,7 @@ service : (InstallArgs,) -> { updateApiKeys : (vec record { SupportedProvider; opt text }) -> (); // Call the Solana `getSlot` RPC method and return the resulting slot. - getSlot : (RpcSources, opt RpcConfig, opt GetSlotParams) -> (MultiGetSlotResult); + getSlot : (RpcSources, opt GetSlotRpcConfig, opt GetSlotParams) -> (MultiGetSlotResult); // Make a generic RPC request that sends the given json_rpc_payload. request : (RpcSources, opt RpcConfig, json_rpc_paylod: text) -> (MultiRequestResult) diff --git a/canister/src/candid_rpc/mod.rs b/canister/src/candid_rpc/mod.rs index 5610531f..e52887ae 100644 --- a/canister/src/candid_rpc/mod.rs +++ b/canister/src/candid_rpc/mod.rs @@ -3,6 +3,7 @@ use crate::{ metrics::RpcMethod, providers::get_provider, rpc_client::{ReducedResult, SolRpcClient}, + types::RoundingError, util::hostname_from_url, }; use canhttp::multi::ReductionError; @@ -58,8 +59,16 @@ pub struct CandidRpcClient { 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)?, + client: SolRpcClient::new(source, config, rounding_error)?, }) } diff --git a/canister/src/http/mod.rs b/canister/src/http/mod.rs index 9f6302f4..3cc5abf0 100644 --- a/canister/src/http/mod.rs +++ b/canister/src/http/mod.rs @@ -5,8 +5,7 @@ use crate::{ constants::{COLLATERAL_CYCLES_PER_NODE, CONTENT_TYPE_VALUE}, http::errors::HttpClientError, logs::Priority, - memory::next_request_id, - memory::{read_state, State}, + memory::{next_request_id, read_state, State}, metrics::{MetricRpcHost, MetricRpcMethod}, }; use canhttp::{ diff --git a/canister/src/main.rs b/canister/src/main.rs index 16920a94..deb802e6 100644 --- a/canister/src/main.rs +++ b/canister/src/main.rs @@ -10,9 +10,10 @@ use sol_rpc_canister::{ memory::{mutate_state, read_state}, metrics::encode_metrics, providers::{get_provider, PROVIDERS}, + types::RoundingError, }; use sol_rpc_types::{ - GetSlotParams, MultiRpcResult, RpcAccess, RpcConfig, RpcError, RpcSources, + GetSlotParams, GetSlotRpcConfig, MultiRpcResult, RpcAccess, RpcConfig, RpcError, RpcSources, SupportedRpcProvider, SupportedRpcProviderId, }; use solana_clock::Slot; @@ -78,10 +79,18 @@ async fn update_api_keys(api_keys: Vec<(SupportedRpcProviderId, Option)> #[candid_method(rename = "getSlot")] async fn get_slot( source: RpcSources, - config: Option, + config: Option, params: Option, ) -> MultiRpcResult { - match CandidRpcClient::new(source, config) { + let rounding_error = config + .as_ref() + .and_then(|c| c.rounding_error) + .map(RoundingError::from); + match CandidRpcClient::new_with_rounding_error( + source, + config.map(RpcConfig::from), + rounding_error, + ) { Ok(client) => client.get_slot(params.unwrap_or_default()).await, Err(err) => Err(err).into(), } diff --git a/canister/src/memory/mod.rs b/canister/src/memory/mod.rs index 04a0d15d..0cda4fa5 100644 --- a/canister/src/memory/mod.rs +++ b/canister/src/memory/mod.rs @@ -1,8 +1,10 @@ #[cfg(test)] mod tests; -use crate::metrics::Metrics; -use crate::types::{ApiKey, OverrideProvider}; +use crate::{ + metrics::Metrics, + types::{ApiKey, OverrideProvider}, +}; use candid::{Deserialize, Principal}; use canhttp::http::json::Id; use canlog::LogFilter; diff --git a/canister/src/rpc_client/mod.rs b/canister/src/rpc_client/mod.rs index a652fcb2..1661faf7 100644 --- a/canister/src/rpc_client/mod.rs +++ b/canister/src/rpc_client/mod.rs @@ -9,6 +9,7 @@ use crate::{ metrics::MetricRpcMethod, providers::{request_builder, resolve_rpc_provider, Providers}, rpc_client::sol_rpc::{ResponseSizeEstimate, ResponseTransform, HEADER_SIZE_LIMIT}, + types::RoundingError, }; use canhttp::{ http::json::JsonRpcRequest, @@ -30,15 +31,22 @@ use tower::ServiceExt; pub struct SolRpcClient { providers: Providers, config: RpcConfig, + rounding_error: RoundingError, } impl SolRpcClient { - pub fn new(source: RpcSources, config: Option) -> Result { + pub fn new( + source: RpcSources, + config: Option, + rounding_error: Option, + ) -> 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, }) } @@ -143,7 +151,7 @@ impl SolRpcClient { "getSlot", vec![params], self.response_size_estimate(1024 + HEADER_SIZE_LIMIT), - &Some(ResponseTransform::GetSlot), + &Some(ResponseTransform::GetSlot(self.rounding_error)), ) .await .reduce(self.reduction_strategy()) diff --git a/canister/src/rpc_client/sol_rpc/mod.rs b/canister/src/rpc_client/sol_rpc/mod.rs index 97b320cc..0c04e287 100644 --- a/canister/src/rpc_client/sol_rpc/mod.rs +++ b/canister/src/rpc_client/sol_rpc/mod.rs @@ -1,6 +1,7 @@ #[cfg(test)] mod tests; +use crate::types::RoundingError; use candid::candid_method; use canhttp::http::json::JsonRpcResponse; use ic_cdk::{ @@ -9,6 +10,7 @@ use ic_cdk::{ }; use minicbor::{Decode, Encode}; use serde::{de::DeserializeOwned, Serialize}; +use serde_json::{from_slice, to_vec}; use solana_clock::Slot; use std::{fmt, fmt::Debug}; @@ -31,40 +33,30 @@ pub const MAX_PAYLOAD_SIZE: u64 = HTTP_MAX_SIZE - HEADER_SIZE_LIMIT; #[derive(Debug, Decode, Encode)] pub enum ResponseTransform { #[n(0)] - GetSlot, - #[n(1)] + GetSlot(#[n(1)] RoundingError), + #[n(2)] Raw, } impl ResponseTransform { fn apply(&self, body_bytes: &mut Vec) { - use serde_json::{from_slice, to_vec, Value}; - - fn redact_response(body: &mut Vec) + fn canonicalize(body_bytes: &mut Vec, f: impl FnOnce(T) -> T) where T: Serialize + DeserializeOwned, { - let response: JsonRpcResponse = match from_slice(body) { - Ok(response) => response, - Err(_) => return, - }; - *body = to_vec(&response).expect("BUG: failed to serialize response"); - } - - fn canonicalize(text: &[u8]) -> Option> { - let json = from_slice::(text).ok()?; - to_vec(&json).ok() + if let Ok(Ok(bytes)) = from_slice::(body_bytes).map(f).as_ref().map(to_vec) { + *body_bytes = bytes + } } match self { - // TODO XC-292: Add rounding to the response transform and - // add a unit test simulating consensus when the providers - // return slightly differing results. - Self::GetSlot => redact_response::(body_bytes), + Self::GetSlot(rounding_error) => { + canonicalize::>(body_bytes, |response| { + response.map(|slot| rounding_error.round(slot)) + }); + } Self::Raw => { - if let Some(bytes) = canonicalize(body_bytes) { - *body_bytes = bytes - } + canonicalize::(body_bytes, std::convert::identity); } } } diff --git a/canister/src/rpc_client/sol_rpc/tests.rs b/canister/src/rpc_client/sol_rpc/tests.rs index 2fb7efb2..1340f6bb 100644 --- a/canister/src/rpc_client/sol_rpc/tests.rs +++ b/canister/src/rpc_client/sol_rpc/tests.rs @@ -1 +1,66 @@ -// TODO XC-292: Add unit tests +use super::*; + +mod normalization_tests { + use super::*; + + #[test] + fn should_normalize_raw_response() { + assert_normalized_equal( + &ResponseTransform::Raw, + r#"{"k1":"v1","k2":"v2"}"#, + r#"{"k1":"v1","k2":"v2"}"#, + ); + assert_normalized_equal( + &ResponseTransform::Raw, + r#"{"k1":"v1","k2":"v2"}"#, + r#"{"k2":"v2","k1":"v1"}"#, + ); + assert_normalized_not_equal( + &ResponseTransform::Raw, + r#"{"k1":"v1","k2":"v2"}"#, + r#"{"k1":"v1","k3":"v3"}"#, + ); + } + + #[test] + fn should_normalize_get_slot_response() { + assert_normalized_equal( + &ResponseTransform::GetSlot(RoundingError::default()), + "329535108", + "329535108", + ); + assert_normalized_equal( + &ResponseTransform::GetSlot(RoundingError::default()), + "329535108", + "329535116", + ); + assert_normalized_not_equal( + &ResponseTransform::GetSlot(RoundingError::default()), + "329535108", + "329535128", + ); + } + + fn normalize_result(transform: &ResponseTransform, result: &str) -> String { + fn add_envelope(reply: &str) -> Vec { + format!("{{\"jsonrpc\": \"2.0\", \"id\": 1, \"result\": {}}}", reply).into_bytes() + } + let mut response = add_envelope(result); + transform.apply(&mut response); + String::from_utf8(response).unwrap() + } + + fn assert_normalized_equal(transform: &ResponseTransform, left: &str, right: &str) { + assert_eq!( + normalize_result(transform, left), + normalize_result(transform, right) + ); + } + + fn assert_normalized_not_equal(transform: &ResponseTransform, left: &str, right: &str) { + assert_ne!( + normalize_result(transform, left), + normalize_result(transform, right) + ); + } +} diff --git a/canister/src/rpc_client/tests.rs b/canister/src/rpc_client/tests.rs index 2d31732f..82aeec5e 100644 --- a/canister/src/rpc_client/tests.rs +++ b/canister/src/rpc_client/tests.rs @@ -1,5 +1,3 @@ -// TODO XC-292: Add more unit tests - mod sol_rpc_client { use crate::rpc_client::SolRpcClient; use assert_matches::assert_matches; @@ -11,7 +9,7 @@ mod sol_rpc_client { #[test] fn should_fail_when_providers_explicitly_set_to_empty() { assert_matches!( - SolRpcClient::new(RpcSources::Custom(vec![]), None), + SolRpcClient::new(RpcSources::Custom(vec![]), None, None), Err(ProviderError::InvalidRpcConfig(_)) ); } @@ -19,7 +17,7 @@ mod sol_rpc_client { #[test] fn should_use_default_providers() { for cluster in [SolanaCluster::Mainnet, SolanaCluster::Devnet] { - let client = SolRpcClient::new(RpcSources::Default(cluster), None).unwrap(); + let client = SolRpcClient::new(RpcSources::Default(cluster), None, None).unwrap(); assert!(!client.providers().is_empty()); } } @@ -35,6 +33,7 @@ mod sol_rpc_client { RpcSource::Supported(provider2), ]), None, + None, ) .unwrap(); diff --git a/canister/src/types/mod.rs b/canister/src/types/mod.rs index 8cfcc081..bf25b919 100644 --- a/canister/src/types/mod.rs +++ b/canister/src/types/mod.rs @@ -2,6 +2,8 @@ mod tests; use crate::{constants::API_KEY_REPLACE_STRING, validate::validate_api_key}; +use derive_more::{From, Into}; +use minicbor::{Decode, Encode}; use serde::{Deserialize, Serialize}; use sol_rpc_types::{RegexSubstitution, RpcEndpoint}; use std::{fmt, fmt::Debug}; @@ -74,3 +76,59 @@ impl OverrideProvider { } } } + +/// This type defines a rounding error to use when fetching the current +/// [slot](https://solana.com/docs/references/terminology#slot) from Solana using the JSON-RPC +/// interface, meaning slots will be rounded down to the nearest multiple of this error when +/// being fetched. +/// +/// This is done to achieve consensus on the HTTP outcalls whose responses contain Solana slots +/// despite Solana's fast blocktime and hence fast-changing slot value. However, this solution +/// does not guarantee consensus on the slot value across nodes and different consensus rates +/// will be achieved depending on the rounding error value used. A higher rounding error will +/// lead to a higher consensus rate, but also means the slot value may differ more from the actual +/// value on the Solana blockchain. This means, for example, that setting a large rounding error +/// and then fetching the corresponding block with the Solana +/// [`getBlock`](https://solana.com/docs/rpc/http/getblock) RPC method can result in obtaining a +/// block whose hash is too old to use in a valid Solana transaction (see more details about using +/// recent blockhashes [here](https://solana.com/developers/guides/advanced/confirmation#how-does-transaction-expiration-work). +/// +/// The default value given by [`RoundingError::default`] +/// has been experimentally shown to achieve a high HTTP outcall consensus rate. +/// +/// See the [`RoundingError::round`] method for more details and examples. +#[derive(Debug, Decode, Encode, Clone, Copy, Eq, PartialEq, From, Into)] +pub struct RoundingError(#[n(0)] u64); + +impl Default for RoundingError { + fn default() -> Self { + Self(20) + } +} + +impl RoundingError { + /// Create a new instance of [`RoundingError`] with the given value. + pub fn new(rounding_error: u64) -> Self { + Self(rounding_error) + } + + /// Round the given value down to the nearest multiple of the rounding error. + /// A rounding error of 0 or 1 leads to this method returning the input unchanged. + /// + /// # Examples + /// + /// ```rust + /// use sol_rpc_canister::types::RoundingError; + /// + /// assert_eq!(RoundingError::new(0).round(19), 19); + /// assert_eq!(RoundingError::new(1).round(19), 19); + /// assert_eq!(RoundingError::new(10).round(19), 10); + /// assert_eq!(RoundingError::new(20).round(19), 0); + /// ``` + pub fn round(&self, slot: u64) -> u64 { + match self.0 { + 0 | 1 => slot, + n => (slot / n) * n, + } + } +} diff --git a/canister/src/types/tests.rs b/canister/src/types/tests.rs index 790d891c..259b87b3 100644 --- a/canister/src/types/tests.rs +++ b/canister/src/types/tests.rs @@ -1,7 +1,7 @@ use crate::{ memory::{init_state, reset_state, State}, providers::{resolve_rpc_provider, PROVIDERS}, - types::{ApiKey, OverrideProvider}, + types::{ApiKey, OverrideProvider, RoundingError}, }; use proptest::{ prelude::{prop, Strategy}, @@ -114,3 +114,36 @@ mod override_provider_tests { ) } } +mod rounding_error_tests { + use super::*; + + #[test] + fn should_round_slot() { + for (rounding_error, slot, rounded) in [ + (0, 0, 0), + (0, 13, 13), + (1, 13, 13), + (10, 13, 10), + (10, 100, 100), + (10, 101, 100), + (10, 102, 100), + (10, 103, 100), + (10, 104, 100), + (10, 105, 100), + (10, 106, 100), + (10, 107, 100), + (10, 108, 100), + (10, 109, 100), + (10, 110, 110), + ] { + assert_eq!(RoundingError::new(rounding_error).round(slot), rounded); + } + } + + proptest! { + #[test] + fn should_not_panic (rounding_error: u64, slot: u64) { + let _result = RoundingError::new(rounding_error).round(slot); + } + } +} diff --git a/integration_tests/src/lib.rs b/integration_tests/src/lib.rs index 661492d6..e7b838e6 100644 --- a/integration_tests/src/lib.rs +++ b/integration_tests/src/lib.rs @@ -25,6 +25,7 @@ use std::{ }; pub mod mock; +use crate::mock::MockOutcallBuilder; use mock::MockOutcall; const DEFAULT_MAX_RESPONSE_BYTES: u64 = 2_000_000; @@ -471,6 +472,11 @@ pub trait SolRpcTestClient { fn mock_http(self, mock: impl Into) -> Self; fn mock_http_once(self, mock: impl Into) -> Self; fn mock_http_sequence(self, mocks: Vec>) -> Self; + fn mock_sequential_json_rpc_responses( + self, + status: u16, + body: serde_json::Value, + ) -> Self; } #[async_trait] @@ -523,6 +529,18 @@ impl SolRpcTestClient> for SolRpcClient> ..self } } + + fn mock_sequential_json_rpc_responses( + self, + status: u16, + body: serde_json::Value, + ) -> Self { + let mocks = json_rpc_sequential_id::(body) + .into_iter() + .map(|response| MockOutcallBuilder::new(status, &response)) + .collect(); + self.mock_http_sequence(mocks) + } } pub fn json_rpc_sequential_id( diff --git a/integration_tests/tests/solana_test_validator.rs b/integration_tests/tests/solana_test_validator.rs index 9a9c3348..c9677987 100644 --- a/integration_tests/tests/solana_test_validator.rs +++ b/integration_tests/tests/solana_test_validator.rs @@ -18,7 +18,7 @@ async fn should_get_slot() { .compare_client( |sol| sol.get_slot().expect("Failed to get slot"), |ic| async move { - match ic.get_slot(None).await { + match ic.get_slot(None, None).await { MultiRpcResult::Consistent(Ok(slot)) => slot, result => panic!("Failed to get slot, received: {:?}", result), } @@ -27,7 +27,7 @@ async fn should_get_slot() { .await; assert!( - sol_res.abs_diff(ic_res) < 10, + sol_res.abs_diff(ic_res) < 20, "Difference is too large between slot {sol_res} from Solana client and slot {ic_res} from the SOL RPC canister" ); diff --git a/integration_tests/tests/tests.rs b/integration_tests/tests/tests.rs index ab62f1a8..51cd73fa 100644 --- a/integration_tests/tests/tests.rs +++ b/integration_tests/tests/tests.rs @@ -5,13 +5,13 @@ use pocket_ic::common::rest::CanisterHttpMethod; use serde_json::json; use sol_rpc_canister::constants::*; use sol_rpc_int_tests::{ - json_rpc_sequential_id, mock::MockOutcallBuilder, Setup, SolRpcTestClient, - DEFAULT_CALLER_TEST_ID, + mock::MockOutcallBuilder, Setup, SolRpcTestClient, DEFAULT_CALLER_TEST_ID, }; use sol_rpc_types::{ InstallArgs, Mode, ProviderError, RpcAccess, RpcAuth, RpcConfig, RpcEndpoint, RpcError, - RpcSource, RpcSources, SolanaCluster, SupportedRpcProvider, SupportedRpcProviderId, + RpcResult, RpcSource, RpcSources, SolanaCluster, SupportedRpcProvider, SupportedRpcProviderId, }; +use std::str::FromStr; const MOCK_REQUEST_URL: &str = "https://api.devnet.solana.com/"; const MOCK_REQUEST_PAYLOAD: &str = r#"{"jsonrpc":"2.0","id":0,"method":"getVersion"}"#; @@ -140,9 +140,128 @@ mod get_provider_tests { } } +mod get_slot_tests { + use super::*; + + #[tokio::test] + async fn should_get_slot_without_rounding() { + for sources in [ + // Use Mainnet providers that do not require API keys + RpcSources::Custom(vec![ + RpcSource::Supported(SupportedRpcProviderId::AlchemyMainnet), + RpcSource::Supported(SupportedRpcProviderId::DrpcMainnet), + RpcSource::Supported(SupportedRpcProviderId::PublicNodeMainnet), + ]), + RpcSources::Default(SolanaCluster::Devnet), + ] { + let setup = Setup::new().await; + let client = setup.client().with_rpc_sources(sources); + + let results = client + .mock_sequential_json_rpc_responses::<3>( + 200, + json!({ + "id": 0, + "jsonrpc": "2.0", + "result": 1234, + }), + ) + .get_slot(None, Some(0)) + .await + .expect_consistent(); + + assert_eq!(results, Ok(1234)); + + setup.drop().await; + } + } + + #[tokio::test] + async fn should_get_consistent_result_with_rounding() { + for sources in [ + // Use Mainnet providers that do not require API keys + RpcSources::Custom(vec![ + RpcSource::Supported(SupportedRpcProviderId::AlchemyMainnet), + RpcSource::Supported(SupportedRpcProviderId::DrpcMainnet), + RpcSource::Supported(SupportedRpcProviderId::PublicNodeMainnet), + ]), + RpcSources::Default(SolanaCluster::Devnet), + ] { + let responses = [1234, 1229, 1237] + .iter() + .enumerate() + .map(|(id, slot)| { + MockOutcallBuilder::new( + 200, + &json!({ + "id": id, + "jsonrpc": "2.0", + "result": slot, + }), + ) + }) + .collect(); + let setup = Setup::new().await; + let client = setup.client().with_rpc_sources(sources); + + let results = client + .mock_http_sequence(responses) + .get_slot(None, None) + .await + .expect_consistent(); + + assert_eq!(results, Ok(1220)); + + setup.drop().await; + } + } + + #[tokio::test] + async fn should_get_inconsistent_result_without_rounding() { + for sources in [ + // Use Mainnet providers that do not require API keys + RpcSources::Custom(vec![ + RpcSource::Supported(SupportedRpcProviderId::AlchemyMainnet), + RpcSource::Supported(SupportedRpcProviderId::DrpcMainnet), + RpcSource::Supported(SupportedRpcProviderId::PublicNodeMainnet), + ]), + RpcSources::Default(SolanaCluster::Devnet), + ] { + let responses = [1234, 1229, 1237] + .iter() + .enumerate() + .map(|(id, slot)| { + MockOutcallBuilder::new( + 200, + &json!({ + "id": id, + "jsonrpc": "2.0", + "result": slot, + }), + ) + }) + .collect(); + let setup = Setup::new().await; + let client = setup.client().with_rpc_sources(sources); + + let results: Vec> = client + .mock_http_sequence(responses) + .get_slot(None, Some(0)) + .await + .expect_inconsistent() + .into_iter() + .map(|(_source, result)| result) + .collect(); + + assert_eq!(results, vec![Ok(1234), Ok(1229), Ok(1237)]); + + setup.drop().await; + } + } +} + mod generic_request_tests { use super::*; - use std::str::FromStr; #[tokio::test] async fn request_should_require_cycles() { @@ -171,11 +290,6 @@ mod generic_request_tests { #[tokio::test] async fn request_should_succeed_in_demo_mode() { - let [response_0, response_1, response_2] = json_rpc_sequential_id(json!({ - "id": 0, - "jsonrpc": "2.0", - "result": serde_json::Value::from_str(MOCK_RESPONSE_RESULT).unwrap() - })); let setup = Setup::with_args(InstallArgs { mode: Some(Mode::Demo), ..Default::default() @@ -184,11 +298,14 @@ mod generic_request_tests { let client = setup.client(); let result = client - .mock_http_sequence(vec![ - MockOutcallBuilder::new(200, &response_0), - MockOutcallBuilder::new(200, &response_1), - MockOutcallBuilder::new(200, &response_2), - ]) + .mock_sequential_json_rpc_responses::<3>( + 200, + json!({ + "id": 0, + "jsonrpc": "2.0", + "result": serde_json::Value::from_str(MOCK_RESPONSE_RESULT).unwrap() + }), + ) .request(MOCK_REQUEST_PAYLOAD, 0) .await .expect_consistent(); diff --git a/libs/client/src/lib.rs b/libs/client/src/lib.rs index d93cb37d..c3e87ee3 100644 --- a/libs/client/src/lib.rs +++ b/libs/client/src/lib.rs @@ -8,7 +8,8 @@ use candid::{utils::ArgumentEncoder, CandidType, Principal}; use ic_cdk::api::call::RejectionCode; use serde::de::DeserializeOwned; use sol_rpc_types::{ - GetSlotParams, RpcConfig, RpcSources, SupportedRpcProvider, SupportedRpcProviderId, + GetSlotParams, GetSlotRpcConfig, RpcConfig, RpcSources, SupportedRpcProvider, + SupportedRpcProviderId, }; use solana_clock::Slot; @@ -123,12 +124,28 @@ impl SolRpcClient { pub async fn get_slot( &self, params: Option, + rounding_error: Option, ) -> sol_rpc_types::MultiRpcResult { + let rpc_config = if self.rpc_config.is_some() || rounding_error.is_some() { + Some(GetSlotRpcConfig { + rounding_error, + response_size_estimate: self + .rpc_config + .as_ref() + .and_then(|c| c.response_size_estimate), + response_consensus: self + .rpc_config + .as_ref() + .and_then(|c| c.response_consensus.clone()), + }) + } else { + None + }; self.runtime .update_call( self.sol_rpc_canister, "getSlot", - (self.rpc_sources.clone(), self.rpc_config.clone(), params), + (self.rpc_sources.clone(), rpc_config, params), 10_000_000_000, ) .await diff --git a/libs/types/src/lib.rs b/libs/types/src/lib.rs index ef72aa00..61a9c901 100644 --- a/libs/types/src/lib.rs +++ b/libs/types/src/lib.rs @@ -11,8 +11,9 @@ mod solana; pub use lifecycle::{InstallArgs, Mode, NumSubnetNodes}; pub use response::MultiRpcResult; pub use rpc_client::{ - ConsensusStrategy, HttpHeader, HttpOutcallError, JsonRpcError, OverrideProvider, ProviderError, - RegexString, RegexSubstitution, RpcAccess, RpcAuth, RpcConfig, RpcEndpoint, RpcError, - RpcResult, RpcSource, RpcSources, SolanaCluster, SupportedRpcProvider, SupportedRpcProviderId, + ConsensusStrategy, GetSlotRpcConfig, HttpHeader, HttpOutcallError, JsonRpcError, + OverrideProvider, ProviderError, RegexString, RegexSubstitution, RpcAccess, RpcAuth, RpcConfig, + RpcEndpoint, RpcError, RpcResult, RpcSource, RpcSources, SolanaCluster, SupportedRpcProvider, + SupportedRpcProviderId, }; pub use solana::{CommitmentLevel, GetSlotParams}; diff --git a/libs/types/src/rpc_client/mod.rs b/libs/types/src/rpc_client/mod.rs index b175d790..d9c96b40 100644 --- a/libs/types/src/rpc_client/mod.rs +++ b/libs/types/src/rpc_client/mod.rs @@ -100,6 +100,35 @@ pub struct RpcConfig { pub response_consensus: Option, } +/// Configures how to perform HTTP calls for the Solana `getSlot` RPC method. +#[derive(Clone, Debug, PartialEq, Eq, Default, CandidType, Deserialize)] +pub struct GetSlotRpcConfig { + /// Describes the expected (90th percentile) number of bytes in the HTTP response body. + /// This number should be less than `MAX_PAYLOAD_SIZE`. + #[serde(rename = "responseSizeEstimate")] + pub response_size_estimate: Option, + + /// Specifies how the responses of the different RPC providers should be aggregated into + /// a single response. + #[serde(rename = "responseConsensus")] + pub response_consensus: Option, + + /// The result of the `getSlot` method will be rounded down to the nearest value within + /// this error threshold. This is done to achieve consensus between nodes on the value + /// of the latest slot despite the fast Solana block time. + #[serde(rename = "roundingError")] + pub rounding_error: Option, +} + +impl From for RpcConfig { + fn from(config: GetSlotRpcConfig) -> Self { + RpcConfig { + response_size_estimate: config.response_size_estimate, + response_consensus: config.response_consensus, + } + } +} + /// Defines a consensus strategy for combining responses from different providers. #[derive(Clone, Debug, PartialEq, Eq, Default, CandidType, Deserialize)] pub enum ConsensusStrategy {