diff --git a/canister/src/rpc_client/cbor/mod.rs b/canister/src/rpc_client/cbor/mod.rs new file mode 100644 index 00000000..0af49908 --- /dev/null +++ b/canister/src/rpc_client/cbor/mod.rs @@ -0,0 +1,3 @@ +pub mod rounding_error; +#[cfg(test)] +mod tests; diff --git a/canister/src/rpc_client/cbor/rounding_error.rs b/canister/src/rpc_client/cbor/rounding_error.rs new file mode 100644 index 00000000..030e0149 --- /dev/null +++ b/canister/src/rpc_client/cbor/rounding_error.rs @@ -0,0 +1,18 @@ +use minicbor::decode::Decoder; +use minicbor::encode::{Encoder, Write}; +use sol_rpc_types::RoundingError; + +pub fn decode( + d: &mut Decoder<'_>, + _ctx: &mut Ctx, +) -> Result { + d.u64().map(RoundingError::from) +} + +pub fn encode( + v: &RoundingError, + e: &mut Encoder, + _ctx: &mut Ctx, +) -> Result<(), minicbor::encode::Error> { + e.u64(*v.as_ref())?.ok() +} diff --git a/canister/src/rpc_client/cbor/tests.rs b/canister/src/rpc_client/cbor/tests.rs new file mode 100644 index 00000000..9c624aea --- /dev/null +++ b/canister/src/rpc_client/cbor/tests.rs @@ -0,0 +1,31 @@ +use minicbor::{Decode, Encode}; +use proptest::prelude::{any, TestCaseError}; +use proptest::{prop_assert_eq, proptest}; +use sol_rpc_types::RoundingError; + +proptest! { + #[test] + fn should_encode_decode_rounding_error(v in any::()) { + check_roundtrip(&RoundingErrorContainer { + value: RoundingError::from(v), + }) + .unwrap(); + } +} + +#[derive(Eq, PartialEq, Debug, Decode, Encode)] +struct RoundingErrorContainer { + #[cbor(n(0), with = "crate::rpc_client::cbor::rounding_error")] + pub value: RoundingError, +} + +pub fn check_roundtrip(v: &T) -> Result<(), TestCaseError> +where + for<'a> T: PartialEq + std::fmt::Debug + Encode<()> + Decode<'a, ()>, +{ + let mut buf = vec![]; + minicbor::encode(v, &mut buf).expect("encoding should succeed"); + let decoded = minicbor::decode(&buf).expect("decoding should succeed"); + prop_assert_eq!(v, &decoded); + Ok(()) +} diff --git a/canister/src/rpc_client/mod.rs b/canister/src/rpc_client/mod.rs index 312ba579..c7a40a6e 100644 --- a/canister/src/rpc_client/mod.rs +++ b/canister/src/rpc_client/mod.rs @@ -1,3 +1,4 @@ +pub mod cbor; pub mod json; mod sol_rpc; #[cfg(test)] @@ -11,7 +12,6 @@ use crate::{ metrics::MetricRpcMethod, providers::{request_builder, resolve_rpc_provider, Providers}, rpc_client::sol_rpc::ResponseTransform, - types::RoundingError, }; use canhttp::{ http::json::JsonRpcRequest, @@ -179,10 +179,7 @@ impl GetSlotRequest { 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(); + let rounding_error = config.rounding_error.unwrap_or_default(); Ok(MultiRpcRequest::new( providers, @@ -214,10 +211,7 @@ impl GetRecentPrioritizationFeesRequest { JsonRpcRequest::new("getRecentPrioritizationFees", params.into()), max_response_bytes, ResponseTransform::GetRecentPrioritizationFees { - max_slot_rounding_error: config - .max_slot_rounding_error - .map(RoundingError::new) - .unwrap_or_default(), + max_slot_rounding_error: config.max_slot_rounding_error.unwrap_or_default(), max_length: config.max_length.unwrap_or(100), }, ReductionStrategy::from(consensus_strategy), diff --git a/canister/src/rpc_client/sol_rpc/mod.rs b/canister/src/rpc_client/sol_rpc/mod.rs index 071d2753..8257675b 100644 --- a/canister/src/rpc_client/sol_rpc/mod.rs +++ b/canister/src/rpc_client/sol_rpc/mod.rs @@ -1,7 +1,6 @@ #[cfg(test)] mod tests; -use crate::types::RoundingError; use candid::candid_method; use canhttp::http::json::JsonRpcResponse; use ic_cdk::{ @@ -11,7 +10,7 @@ use ic_cdk::{ use minicbor::{Decode, Encode}; use serde::{de::DeserializeOwned, Serialize}; use serde_json::{from_slice, Value}; -use sol_rpc_types::PrioritizationFee; +use sol_rpc_types::{PrioritizationFee, RoundingError}; use solana_clock::Slot; use std::fmt::Debug; use strum::EnumIter; @@ -29,13 +28,13 @@ pub enum ResponseTransform { GetBlock, #[n(3)] GetRecentPrioritizationFees { - #[n(0)] + #[cbor(n(0), with = "crate::rpc_client::cbor::rounding_error")] max_slot_rounding_error: RoundingError, #[n(1)] max_length: u8, }, #[n(4)] - GetSlot(#[n(0)] RoundingError), + GetSlot(#[cbor(n(0), with = "crate::rpc_client::cbor::rounding_error")] RoundingError), #[n(5)] GetTokenAccountBalance, #[n(6)] diff --git a/canister/src/rpc_client/sol_rpc/tests.rs b/canister/src/rpc_client/sol_rpc/tests.rs index 3a7cada7..96108e6d 100644 --- a/canister/src/rpc_client/sol_rpc/tests.rs +++ b/canister/src/rpc_client/sol_rpc/tests.rs @@ -1,10 +1,11 @@ -use crate::{rpc_client::sol_rpc::ResponseTransform, types::RoundingError}; +use crate::rpc_client::sol_rpc::ResponseTransform; use canhttp::http::json::{Id, JsonRpcResponse}; use proptest::proptest; use serde_json::{from_slice, json, to_vec, Value}; mod normalization_tests { use super::*; + use sol_rpc_types::RoundingError; use strum::IntoEnumIterator; #[test] @@ -338,7 +339,6 @@ mod normalization_tests { mod get_recent_prioritization_fees { use crate::rpc_client::sol_rpc::ResponseTransform; - use crate::types::RoundingError; use proptest::arbitrary::any; use proptest::array::uniform32; use proptest::prelude::{prop, Strategy}; @@ -348,7 +348,7 @@ mod get_recent_prioritization_fees { use rand_chacha::ChaCha20Rng; use serde::Serialize; use serde_json::json; - use sol_rpc_types::{PrioritizationFee, Slot}; + use sol_rpc_types::{PrioritizationFee, RoundingError, Slot}; use std::ops::RangeInclusive; #[test] diff --git a/canister/src/types/mod.rs b/canister/src/types/mod.rs index bf25b919..8cfcc081 100644 --- a/canister/src/types/mod.rs +++ b/canister/src/types/mod.rs @@ -2,8 +2,6 @@ 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}; @@ -76,59 +74,3 @@ 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 259b87b3..790d891c 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, RoundingError}, + types::{ApiKey, OverrideProvider}, }; use proptest::{ prelude::{prop, Strategy}, @@ -114,36 +114,3 @@ 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/libs/client/src/request/mod.rs b/libs/client/src/request/mod.rs index fab56d51..5aab34bd 100644 --- a/libs/client/src/request/mod.rs +++ b/libs/client/src/request/mod.rs @@ -8,8 +8,9 @@ use sol_rpc_types::{ AccountInfo, CommitmentLevel, ConfirmedBlock, GetAccountInfoParams, GetBalanceParams, GetBlockCommitmentLevel, GetBlockParams, GetRecentPrioritizationFeesParams, GetRecentPrioritizationFeesRpcConfig, GetSlotParams, GetSlotRpcConfig, - GetTokenAccountBalanceParams, GetTransactionParams, Lamport, PrioritizationFee, RpcConfig, - RpcResult, RpcSources, SendTransactionParams, Signature, TokenAmount, TransactionInfo, + GetTokenAccountBalanceParams, GetTransactionParams, Lamport, PrioritizationFee, RoundingError, + RpcConfig, RpcResult, RpcSources, SendTransactionParams, Signature, TokenAmount, + TransactionInfo, }; use solana_account_decoder_client_types::token::UiTokenAmount; use solana_clock::Slot; @@ -479,9 +480,12 @@ impl RequestBuilder { /// Change the rounding error for the maximum slot value for a `getRecentPrioritizationFees` request. - pub fn with_max_slot_rounding_error(mut self, rounding_error: u64) -> Self { + pub fn with_max_slot_rounding_error>( + mut self, + rounding_error: T, + ) -> Self { let config = self.request.rpc_config_mut().get_or_insert_default(); - config.max_slot_rounding_error = Some(rounding_error); + config.max_slot_rounding_error = Some(rounding_error.into()); self } @@ -497,9 +501,9 @@ impl RequestBuilder { /// Change the rounding error for `getSlot` request. - pub fn with_rounding_error(mut self, rounding_error: u64) -> Self { + pub fn with_rounding_error>(mut self, rounding_error: T) -> Self { let config = self.request.rpc_config_mut().get_or_insert_default(); - config.rounding_error = Some(rounding_error); + config.rounding_error = Some(rounding_error.into()); self } } diff --git a/libs/types/src/lib.rs b/libs/types/src/lib.rs index fa694f11..eeb56f8e 100644 --- a/libs/types/src/lib.rs +++ b/libs/types/src/lib.rs @@ -13,8 +13,8 @@ pub use response::MultiRpcResult; pub use rpc_client::{ ConsensusStrategy, GetRecentPrioritizationFeesRpcConfig, GetSlotRpcConfig, HttpHeader, HttpOutcallError, JsonRpcError, OverrideProvider, ProviderError, RegexString, - RegexSubstitution, RpcAccess, RpcAuth, RpcConfig, RpcEndpoint, RpcError, RpcResult, RpcSource, - RpcSources, SolanaCluster, SupportedRpcProvider, SupportedRpcProviderId, + RegexSubstitution, RoundingError, RpcAccess, RpcAuth, RpcConfig, RpcEndpoint, RpcError, + RpcResult, RpcSource, RpcSources, SolanaCluster, SupportedRpcProvider, SupportedRpcProviderId, }; pub use solana::{ account::{AccountData, AccountEncoding, AccountInfo, ParsedAccount}, diff --git a/libs/types/src/rpc_client/mod.rs b/libs/types/src/rpc_client/mod.rs index 0d9db28d..dc68e13e 100644 --- a/libs/types/src/rpc_client/mod.rs +++ b/libs/types/src/rpc_client/mod.rs @@ -2,7 +2,7 @@ mod tests; use candid::CandidType; -use derive_more::From; +use derive_more::{From, Into}; use ic_cdk::api::call::RejectionCode; pub use ic_cdk::api::management_canister::http_request::HttpHeader; use regex::Regex; @@ -135,7 +135,7 @@ pub struct GetSlotRpcConfig { /// 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, + pub rounding_error: Option, } impl From for RpcConfig { @@ -204,7 +204,7 @@ pub struct GetRecentPrioritizationFeesRpcConfig { /// Increasing that value will reduce the freshness of the returned prioritization fees /// but increase the likelihood of nodes reaching consensus. #[serde(rename = "maxSlotRoundingError")] - pub max_slot_rounding_error: Option, + pub max_slot_rounding_error: Option, /// Limit the number of returned priority fees. /// @@ -455,3 +455,79 @@ pub struct OverrideProvider { #[serde(rename = "overrideUrl")] pub override_url: Option, } + +/// 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, + Clone, + Copy, + Eq, + Ord, + PartialEq, + PartialOrd, + CandidType, + From, + Into, + Serialize, + Deserialize, +)] +#[serde(transparent)] +pub struct RoundingError(u64); + +impl Default for RoundingError { + fn default() -> Self { + Self(20) + } +} + +impl AsRef for RoundingError { + fn as_ref(&self) -> &u64 { + &self.0 + } +} + +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_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/libs/types/src/rpc_client/tests.rs b/libs/types/src/rpc_client/tests.rs index 0c54fb4e..1f5eddb4 100644 --- a/libs/types/src/rpc_client/tests.rs +++ b/libs/types/src/rpc_client/tests.rs @@ -26,3 +26,38 @@ fn should_contain_host_without_sensitive_information() { ); } } + +mod rounding_error_tests { + use crate::RoundingError; + use proptest::proptest; + + #[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); + } + } +}