diff --git a/Cargo.lock b/Cargo.lock index 67a4ecae..e81827d8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4116,6 +4116,8 @@ dependencies = [ "minicbor", "num-traits", "proptest", + "rand 0.9.1", + "rand_chacha 0.9.0", "regex", "serde", "serde_bytes", @@ -4128,6 +4130,7 @@ dependencies = [ "solana-pubkey", "solana-signature", "solana-transaction-status-client-types", + "strum 0.27.1", "thiserror 2.0.12", "tower", "tower-http", @@ -4139,6 +4142,7 @@ dependencies = [ name = "sol_rpc_client" version = "0.1.0" dependencies = [ + "assert_matches", "async-trait", "candid", "ic-cdk", @@ -4182,6 +4186,7 @@ dependencies = [ "solana-account-decoder-client-types", "solana-client", "solana-commitment-config", + "solana-compute-budget-interface", "solana-hash", "solana-instruction", "solana-keypair", @@ -4428,6 +4433,15 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "solana-compute-budget-interface" +version = "2.2.0" +source = "git+https://github.com/dfinity/agave?tag=323039e-js-feature-flag#552a076307b8e68cde986d4b2d7740a88f15fb8b" +dependencies = [ + "solana-instruction", + "solana-sdk-ids", +] + [[package]] name = "solana-connection-cache" version = "2.2.0" diff --git a/Cargo.toml b/Cargo.toml index 8ded8f8c..d29412be 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -56,6 +56,8 @@ num = "0.4.3" num-traits = "0.2.19" pocket-ic = "7.0.0" proptest = "1.6.0" +rand = { version = "0.9.1", default-features = false } +rand_chacha = { version = "0.9.0", default-features = false } regex = "1.11.1" serde = { version = "1.0.219", features = ["derive"] } serde_bytes = "0.11.17" @@ -66,6 +68,7 @@ solana-account-decoder-client-types = "2.2.0" solana-client = "2.2.0" solana-clock = "2.2.0" solana-commitment-config = "2.2.0" +solana-compute-budget-interface = "2.2.0" solana-hash = "2.2.0" solana-instruction = "2.2.0" solana-keypair = "2.2.0" @@ -100,6 +103,7 @@ solana-account-decoder-client-types = { git = "https://github.com/dfinity/agave" solana-client = { git = "https://github.com/dfinity/agave", tag = "323039e-js-feature-flag" } solana-clock = { git = "https://github.com/dfinity/agave", tag = "323039e-js-feature-flag" } solana-commitment-config = { git = "https://github.com/dfinity/agave", tag = "323039e-js-feature-flag" } +solana-compute-budget-interface = { git = "https://github.com/dfinity/agave", tag = "323039e-js-feature-flag" } solana-hash = { git = "https://github.com/dfinity/agave", tag = "323039e-js-feature-flag" } solana-instruction = { git = "https://github.com/dfinity/agave", tag = "323039e-js-feature-flag" } solana-keypair = { git = "https://github.com/dfinity/agave", tag = "323039e-js-feature-flag" } diff --git a/README.md b/README.md index 6356f854..938bd87e 100644 --- a/README.md +++ b/README.md @@ -117,6 +117,8 @@ The SOL RPC canister reaches the Solana JSON-RPC providers using [HTTPS outcalls 1. Use a [durable nonce](https://solana.com/de/developers/guides/advanced/introduction-to-durable-nonces) instead of a blockhash. 2. Retrieve a recent blockhash by first retrieving a recent slot with `getSlot` and then getting the block (which includes the blockhash) with `getBlock`. +[//]: # (TODO: XC-326: mention also `getRecenPrioritizationFees`) + ## Reproducible Build The SOL RPC canister supports [reproducible builds](https://internetcomputer.org/docs/current/developer-docs/smart-contracts/test/reproducible-builds): diff --git a/canister/Cargo.toml b/canister/Cargo.toml index 104df330..7e060af2 100644 --- a/canister/Cargo.toml +++ b/canister/Cargo.toml @@ -16,7 +16,7 @@ path = "src/main.rs" assert_matches = { workspace = true } candid = { workspace = true } canhttp = { workspace = true, features = ["json", "multi"] } -canlog = {workspace = true} +canlog = { workspace = true } ciborium = { workspace = true } const_format = { workspace = true } derive_more = { workspace = true } @@ -38,6 +38,7 @@ serde_bytes = { workspace = true } solana-account = { workspace = true, features = ["serde"] } solana-account-decoder-client-types = { workspace = true } solana-transaction-status-client-types = { workspace = true } +strum = {workspace = true} tower = { workspace = true } tower-http = { workspace = true, features = ["set-header", "util"] } url = { workspace = true } @@ -48,6 +49,8 @@ serde_with = { workspace = true } [dev-dependencies] candid_parser = { workspace = true } proptest = { workspace = true } +rand = { workspace = true } +rand_chacha = { workspace = true } solana-pubkey = { workspace = true } solana-signature = { workspace = true } diff --git a/canister/scripts/examples.sh b/canister/scripts/examples.sh index 767860de..55ae37d5 100755 --- a/canister/scripts/examples.sh +++ b/canister/scripts/examples.sh @@ -25,6 +25,24 @@ CYCLES=$(dfx canister call sol_rpc getSlotCyclesCost "$GET_SLOT_PARAMS" $FLAGS - GET_SLOT_OUTPUT=$(dfx canister call sol_rpc getSlot "$GET_SLOT_PARAMS" $FLAGS --output json --with-cycles "$CYCLES" || exit 1) SLOT=$(jq --raw-output '.Consistent.Ok' <<< "$GET_SLOT_OUTPUT") + +# Get the recent prioritization fees on Mainnet with a 2-out-of-3 strategy for USDC +GET_RECENT_PRIORITIZATION_FEES_PARAMS="( + variant { Default = variant { Mainnet } }, + opt record { + responseConsensus = opt variant { + Threshold = record { min = 2 : nat8; total = opt (3 : nat8) } + }; + responseSizeEstimate = null; + maxSlotRoundingError = opt (20 : nat64); + maxLength = opt (100 : nat8); + }, + opt vec { \"EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v\" }, +)" +CYCLES=$(dfx canister call sol_rpc getRecentPrioritizationFeesCyclesCost "$GET_RECENT_PRIORITIZATION_FEES_PARAMS" $FLAGS --output json | jq '.Ok' --raw-output || exit 1) +GET_RECENT_PRIORITIZATION_FEES_OUTPUT=$(dfx canister call sol_rpc getRecentPrioritizationFees "$GET_RECENT_PRIORITIZATION_FEES_PARAMS" $FLAGS --output json --with-cycles "$CYCLES" || exit 1) +GET_RECENT_PRIORITIZATION_FEES=$(jq --raw-output '.Consistent.Ok' <<< "$GET_RECENT_PRIORITIZATION_FEES_OUTPUT") + # Fetch the latest finalized block GET_BLOCK_PARAMS="( variant { Default = variant { Mainnet } }, diff --git a/canister/sol_rpc_canister.did b/canister/sol_rpc_canister.did index 16e057d2..03c65e7d 100644 --- a/canister/sol_rpc_canister.did +++ b/canister/sol_rpc_canister.did @@ -57,6 +57,30 @@ type GetSlotRpcConfig = record { roundingError : opt RoundingError; }; +// Configures how to perform `getRecentPrioritizationFees` RPC HTTP calls. +// +// The response to `getRecentPrioritizationFees` corresponds to a (non-necessarily continuous) range of slots associated +// with the priority fee for that slot and may include `processed` slots (a new `processed` slot is produced every ca. 400ms). +// Similarly to the necessary rounding used for `getSlot`, +// achieving consensus for `getRecentPrioritizationFees` requires to select a subset of those slots +// that can be seen my a super-majority of the nodes, which is done as follows: +// 1) `maxSlotRoundingError`: round down the slot with the maximum value. +// The selected subset will only contain priority fees for slots that are smaller or equal to the rounded down slot. +// 2) `maxLength`: limit the size of the selected subset by removing priority fees for the older slots (lower values). +type GetRecentPrioritizationFeesRpcConfig = record { + responseSizeEstimate : opt nat64; + responseConsensus : opt ConsensusStrategy; + // Round down the slot with the maximum value. + // Increasing that value will reduce the freshness of the returned prioritization fees + // but increase the likelihood of nodes reaching consensus. + maxSlotRoundingError : opt RoundingError; + // Limit the number of returned priority fees. + // Valid numbers are 1-150, default is 100. + // Increasing that value can help in estimating the current priority fee + // but will reduce the likelihood of nodes reaching consensus. + maxLength : opt nat8; +}; + // Defines a consensus strategy for combining responses from different providers. type ConsensusStrategy = variant { Equality; @@ -315,6 +339,10 @@ type GetBalanceParams = record { // 1_000_000_000 Lamports is 1 SOL type Lamport = nat64; +// Micro-lamports are used for the calculation of prioritization fees. +// 1_000_000 MicroLamport == 1 Lamport +type MicroLamport = nat64; + // Represents an aggregated result from multiple RPC calls to the `getBalance` Solana RPC method. type MultiGetBalanceResult = variant { Consistent : GetBalanceResult; @@ -567,6 +595,24 @@ type MultiGetTransactionResult = variant { Inconsistent : vec record { RpcSource; GetTransactionResult }; }; +// Prioritization fee returned by `getRecentPrioritizationFees`. +type PrioritizationFee = record { + // Slot in which the fee was observed. + slot: Slot; + // The per-compute-unit fee paid by at least one successfully landed transaction, + // specified in increments of micro-lamports. + prioritizationFee: MicroLamport +}; + +// Represents the result of a call to the `getSlot` Solana RPC method. +type GetRecentPrioritizationFeesResult = variant { Ok : vec PrioritizationFee; Err : RpcError }; + +// Represents an aggregated result from multiple RPC calls to the `getRecentPrioritizationFeesResult` Solana RPC method. +type MultiGetRecentPrioritizationFeesResult = variant { + Consistent : GetRecentPrioritizationFeesResult; + Inconsistent : vec record { RpcSource; GetRecentPrioritizationFeesResult }; +}; + // Represents a Solana slot type Slot = nat64; @@ -612,6 +658,10 @@ type CommitmentLevel = variant { finalized; }; +// The parameters for a call to the `getRecentPrioritizationFees` Solana RPC method. +// An array of Account addresses (up to a maximum of 128 addresses), as base-58 encoded strings. +type GetRecentPrioritizationFeesParams = vec Pubkey; + // The parameters for a call to the `getSlot` Solana RPC method. type GetSlotParams = record { commitment: opt CommitmentLevel; @@ -691,6 +741,10 @@ service : (InstallArgs,) -> { // Call the Solana `getBlock` RPC method and return the resulting block. getBlock : (RpcSources, opt RpcConfig, GetBlockParams) -> (MultiGetBlockResult); getBlockCyclesCost : (RpcSources, opt RpcConfig, GetBlockParams) -> (RequestCostResult) query; + + // Call the Solana `getRecentPrioritizationFees` RPC method and return the resulting slot. + getRecentPrioritizationFees : (RpcSources, opt GetRecentPrioritizationFeesRpcConfig, opt GetRecentPrioritizationFeesParams) -> (MultiGetRecentPrioritizationFeesResult); + getRecentPrioritizationFeesCyclesCost : (RpcSources, opt GetRecentPrioritizationFeesRpcConfig, opt GetRecentPrioritizationFeesParams) -> (RequestCostResult) query; // Call the Solana `getSlot` RPC method and return the resulting slot. getSlot : (RpcSources, opt GetSlotRpcConfig, opt GetSlotParams) -> (MultiGetSlotResult); diff --git a/canister/src/main.rs b/canister/src/main.rs index 616a90c9..fb1fa427 100644 --- a/canister/src/main.rs +++ b/canister/src/main.rs @@ -14,9 +14,10 @@ use sol_rpc_canister::{ }; use sol_rpc_types::{ AccountInfo, ConfirmedBlock, GetAccountInfoParams, GetBalanceParams, GetBlockParams, - GetSlotParams, GetSlotRpcConfig, GetTokenAccountBalanceParams, GetTransactionParams, Lamport, - MultiRpcResult, RpcAccess, RpcConfig, RpcResult, RpcSources, SendTransactionParams, Signature, - Slot, SupportedRpcProvider, SupportedRpcProviderId, TokenAmount, TransactionInfo, + GetRecentPrioritizationFeesParams, GetRecentPrioritizationFeesRpcConfig, GetSlotParams, + GetSlotRpcConfig, GetTokenAccountBalanceParams, GetTransactionParams, Lamport, MultiRpcResult, + PrioritizationFee, RpcAccess, RpcConfig, RpcResult, RpcSources, SendTransactionParams, + Signature, Slot, SupportedRpcProvider, SupportedRpcProviderId, TokenAmount, TransactionInfo, }; use std::str::FromStr; @@ -154,6 +155,40 @@ async fn get_block_cycles_cost( .await } +#[update(name = "getRecentPrioritizationFees")] +#[candid_method(rename = "getRecentPrioritizationFees")] +async fn get_recent_prioritization_fees( + source: RpcSources, + config: Option, + params: Option, +) -> MultiRpcResult> { + let request = MultiRpcRequest::get_recent_prioritization_fees( + source, + config.unwrap_or_default(), + params.unwrap_or_default(), + ); + send_multi(request).await +} + +#[query(name = "getRecentPrioritizationFeesCyclesCost")] +#[candid_method(query, rename = "getRecentPrioritizationFeesCyclesCost")] +async fn get_recent_prioritization_fees_cycles_cost( + source: RpcSources, + config: Option, + params: Option, +) -> RpcResult { + if read_state(State::is_demo_mode_active) { + return Ok(0); + } + MultiRpcRequest::get_recent_prioritization_fees( + source, + config.unwrap_or_default(), + params.unwrap_or_default(), + )? + .cycles_cost() + .await +} + #[update(name = "getSlot")] #[candid_method(rename = "getSlot")] async fn get_slot( diff --git a/canister/src/rpc_client/json/mod.rs b/canister/src/rpc_client/json/mod.rs index 88615df7..f03819a3 100644 --- a/canister/src/rpc_client/json/mod.rs +++ b/canister/src/rpc_client/json/mod.rs @@ -2,7 +2,7 @@ use serde::{Deserialize, Serialize}; use serde_with::skip_serializing_none; use sol_rpc_types::{ CommitmentLevel, DataSlice, GetAccountInfoEncoding, GetBlockCommitmentLevel, - GetTransactionEncoding, SendTransactionEncoding, Slot, TransactionDetails, + GetTransactionEncoding, Pubkey, SendTransactionEncoding, Slot, TransactionDetails, }; use solana_transaction_status_client_types::UiTransactionEncoding; @@ -163,6 +163,23 @@ pub struct GetBlockConfig { pub max_supported_transaction_version: Option, } +#[skip_serializing_none] +#[derive(Serialize, Clone, Debug)] +#[serde(into = "(Vec,)")] +pub struct GetRecentPrioritizationFeesParams(Vec); + +impl From for (Vec,) { + fn from(value: GetRecentPrioritizationFeesParams) -> Self { + (value.0,) + } +} + +impl From for GetRecentPrioritizationFeesParams { + fn from(value: sol_rpc_types::GetRecentPrioritizationFeesParams) -> Self { + Self(value.into()) + } +} + #[skip_serializing_none] #[derive(Serialize, Deserialize, Clone, Debug)] #[serde(into = "(String, Option)")] diff --git a/canister/src/rpc_client/mod.rs b/canister/src/rpc_client/mod.rs index ed049c62..312ba579 100644 --- a/canister/src/rpc_client/mod.rs +++ b/canister/src/rpc_client/mod.rs @@ -25,8 +25,9 @@ use ic_cdk::api::management_canister::http_request::{ }; use serde::{de::DeserializeOwned, Serialize}; use sol_rpc_types::{ - ConsensusStrategy, GetSlotRpcConfig, Lamport, ProviderError, RpcConfig, RpcError, RpcResult, - RpcSource, RpcSources, Signature, TransactionDetails, + ConsensusStrategy, GetRecentPrioritizationFeesRpcConfig, GetSlotRpcConfig, Lamport, + PrioritizationFee, ProviderError, RpcConfig, RpcError, RpcResult, RpcSource, RpcSources, + Signature, TransactionDetails, }; use solana_clock::Slot; use std::{fmt::Debug, marker::PhantomData}; @@ -193,6 +194,37 @@ impl GetSlotRequest { } } +pub type GetRecentPrioritizationFeesRequest = + MultiRpcRequest>; + +impl GetRecentPrioritizationFeesRequest { + pub fn get_recent_prioritization_fees>( + rpc_sources: RpcSources, + config: GetRecentPrioritizationFeesRpcConfig, + params: Params, + ) -> 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(8 * 1024 + HEADER_SIZE_LIMIT); + + Ok(MultiRpcRequest::new( + providers, + 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_length: config.max_length.unwrap_or(100), + }, + ReductionStrategy::from(consensus_strategy), + )) + } +} + pub type GetTokenAccountBalanceRequest = MultiRpcRequest< json::GetTokenAccountBalanceParams, solana_account_decoder_client_types::token::UiTokenAmount, diff --git a/canister/src/rpc_client/sol_rpc/mod.rs b/canister/src/rpc_client/sol_rpc/mod.rs index 3ec3fe21..071d2753 100644 --- a/canister/src/rpc_client/sol_rpc/mod.rs +++ b/canister/src/rpc_client/sol_rpc/mod.rs @@ -10,14 +10,16 @@ use ic_cdk::{ }; use minicbor::{Decode, Encode}; use serde::{de::DeserializeOwned, Serialize}; -use serde_json::{from_slice, to_vec, Value}; +use serde_json::{from_slice, Value}; +use sol_rpc_types::PrioritizationFee; use solana_clock::Slot; use std::fmt::Debug; +use strum::EnumIter; /// 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(Clone, Debug, Decode, Encode)] +#[derive(Clone, Debug, Decode, Encode, EnumIter)] pub enum ResponseTransform { #[n(0)] GetAccountInfo, @@ -26,14 +28,21 @@ pub enum ResponseTransform { #[n(2)] GetBlock, #[n(3)] - GetSlot(#[n(0)] RoundingError), + GetRecentPrioritizationFees { + #[n(0)] + max_slot_rounding_error: RoundingError, + #[n(1)] + max_length: u8, + }, #[n(4)] - GetTokenAccountBalance, + GetSlot(#[n(0)] RoundingError), #[n(5)] - GetTransaction, + GetTokenAccountBalance, #[n(6)] - SendTransaction, + GetTransaction, #[n(7)] + SendTransaction, + #[n(8)] Raw, } @@ -45,9 +54,14 @@ impl ResponseTransform { R: Serialize + DeserializeOwned, { if let Ok(response) = from_slice::>(body_bytes) { - if let Ok(bytes) = to_vec(&response.map(f)) { + if let Ok(bytes) = serde_json::to_vec(&response.map(f)) { *body_bytes = bytes } + // If the serialization fails, this would typically be the sign of a bug, + // since deserialization was successfully done before calling that method. + // However, since this code path is called in a query method as part of the HTTPs transform, + // we prefer avoiding panicking since this would be hard to debug and could theoretically affect + // all calls. } } @@ -69,6 +83,46 @@ impl ResponseTransform { value => Some(value), }); } + Self::GetRecentPrioritizationFees { + max_slot_rounding_error, + max_length, + } => { + canonicalize_response::, Vec>( + body_bytes, + |mut fees| { + // actual processing here + // The exact number of elements for the returned priority fees is not really specified in the + // [API](https://solana.com/de/docs/rpc/http/getrecentprioritizationfees), + // which simply mentions + // "Currently, a node's prioritization-fee cache stores data from up to 150 blocks." + // Manual testing shows that the result seems to always contain 150 elements on mainnet (also for not used addresses) + // but not necessarily when using a local validator. + if fees.is_empty() || max_length == &0 { + return Vec::default(); + } + // The order of the prioritization fees in the response is not specified in the + // [API](https://solana.com/de/docs/rpc/http/getrecentprioritizationfees), + // although examples and manual testing show that the response is sorted by increasing number of slot. + // To avoid any problem, we enforce the sorting. + fees.sort_unstable_by(|fee, other_fee| { + other_fee.slot.cmp(&fee.slot) //sort by decreasing order of slot + }); + let max_rounded_slot = max_slot_rounding_error.round( + fees.first() + .expect("BUG: recent prioritization fees should be non-empty") + .slot, + ); + + fees.into_iter() + .skip_while(|fee| fee.slot > max_rounded_slot) + .take(*max_length as usize) + .collect::>() + .into_iter() + .rev() + .collect() + }, + ); + } Self::GetSlot(rounding_error) => { canonicalize_response::(body_bytes, |slot| rounding_error.round(slot)); } diff --git a/canister/src/rpc_client/sol_rpc/tests.rs b/canister/src/rpc_client/sol_rpc/tests.rs index 07dae7cc..3a7cada7 100644 --- a/canister/src/rpc_client/sol_rpc/tests.rs +++ b/canister/src/rpc_client/sol_rpc/tests.rs @@ -5,6 +5,7 @@ use serde_json::{from_slice, json, to_vec, Value}; mod normalization_tests { use super::*; + use strum::IntoEnumIterator; #[test] fn should_normalize_raw_response() { @@ -271,6 +272,30 @@ mod normalization_tests { ); } + #[test] + fn should_normalize_json_rpc_error() { + fn normalize_json(transform: &ResponseTransform, response: &str) -> Vec { + let mut bytes = response.bytes().collect(); + transform.apply(&mut bytes); + bytes + } + + for transform in ResponseTransform::iter() { + let left = r#"{ "jsonrpc": "2.0", "error": { "code": -32602, "message": "Invalid param: could not find account" }, "id": 1 }"#; + let right = r#"{ "error": { "message": "Invalid param: could not find account", "code": -32602 }, "id": 1, "jsonrpc": "2.0" }"#; + let normalized_left = normalize_json(&transform, left); + let normalized_right = normalize_json(&transform, right); + + assert_eq!(normalized_left, normalized_right); + assert_eq!( + serde_json::from_slice::(&normalized_left).unwrap(), + json!( + { "jsonrpc": "2.0", "error": { "code": -32602, "message": "Invalid param: could not find account" }, "id": 1 } + ) + ); + } + } + fn assert_normalized(transform: &ResponseTransform, result: &str, expected: Value) { let expected_response = to_vec(&JsonRpcResponse::from_ok(Id::Number(1), expected)).unwrap(); let normalized_response = normalize_result(transform, result); @@ -310,3 +335,251 @@ 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}; + use proptest::{prop_assert_eq, proptest}; + use rand::prelude::SliceRandom; + use rand_chacha::rand_core::SeedableRng; + use rand_chacha::ChaCha20Rng; + use serde::Serialize; + use serde_json::json; + use sol_rpc_types::{PrioritizationFee, Slot}; + use std::ops::RangeInclusive; + + #[test] + fn should_normalize_response_with_less_than_150_entries() { + fn prioritization_fees(slots: Vec) -> Vec { + slots + .into_iter() + .map(|slot| { + json!({ + "prioritizationFee": slot, + "slot": slot + }) + }) + .collect() + } + let raw_response = json_response(&prioritization_fees(vec![1, 2, 3, 4, 5])); + + for (transform, expected_fees) in [ + ( + ResponseTransform::GetRecentPrioritizationFees { + max_slot_rounding_error: RoundingError::new(2), + max_length: 2, + }, + prioritization_fees(vec![3, 4]), + ), + ( + ResponseTransform::GetRecentPrioritizationFees { + max_slot_rounding_error: RoundingError::new(2), + max_length: 0, + }, + prioritization_fees(vec![]), + ), + ( + ResponseTransform::GetRecentPrioritizationFees { + max_slot_rounding_error: RoundingError::new(2), + max_length: u8::MAX, + }, + prioritization_fees(vec![1, 2, 3, 4]), + ), + ( + ResponseTransform::GetRecentPrioritizationFees { + max_slot_rounding_error: RoundingError::new(10), + max_length: 2, + }, + prioritization_fees(vec![]), + ), + ] { + let mut raw_bytes = serde_json::to_vec(&raw_response).unwrap(); + transform.apply(&mut raw_bytes); + let transformed_response: serde_json::Value = + serde_json::from_slice(&raw_bytes).unwrap(); + + assert_eq!(transformed_response, json_response(&expected_fees)); + } + } + + #[test] + fn should_normalize_response_with_no_fees() { + let raw_response = json_response::(&[]); + let transform = ResponseTransform::GetRecentPrioritizationFees { + max_slot_rounding_error: RoundingError::new(2), + max_length: 2, + }; + let original_bytes = serde_json::to_vec(&raw_response).unwrap(); + let mut transformed_bytes = original_bytes.clone(); + transform.apply(&mut transformed_bytes); + let transformed_response: serde_json::Value = + serde_json::from_slice(&transformed_bytes).unwrap(); + + assert_eq!(raw_response, transformed_response); + } + + // The API of [getRecentPrioritizationFees](https://solana.com/de/docs/rpc/http/getrecentprioritizationfees) + // does not specify whether the array of prioritization fees includes a range of continuous slots. + // The following was observed: + // 1) On mainnet: the range seems most of the time continuous (e.g., for slots 337346483..=337346632), also for not used addresses + // 2) Locally with solana-test-validator, the range is often not continuous, e.g. + // RpcPrioritizationFee { slot: 5183, prioritization_fee: 150 }, RpcPrioritizationFee { slot: 5321, prioritization_fee: 0 } + // + // The non-continuity is probably because + // [not all slots have a block](https://docs.chainstack.com/docs/understanding-the-difference-between-blocks-and-slots-on-solana). + #[test] + fn should_normalize_response_with_non_contiguous_slots() { + let range_1 = [PrioritizationFee { + slot: 150, + prioritization_fee: 150, + }]; + let range_2 = [PrioritizationFee { + slot: 500, + prioritization_fee: 500, + }]; + let fees = [&range_1[..], &range_2[..]].concat(); + + let transform = ResponseTransform::GetRecentPrioritizationFees { + max_slot_rounding_error: RoundingError::new(10), + max_length: 100, + }; + let mut raw_bytes = serde_json::to_vec(&json_response(&fees)).unwrap(); + transform.apply(&mut raw_bytes); + let transformed_response: serde_json::Value = serde_json::from_slice(&raw_bytes).unwrap(); + + assert_eq!(transformed_response, json_response(&fees)); + } + + #[test] + fn should_normalize_response_when_rounded_slot_not_in_range() { + let fees = [ + PrioritizationFee { + slot: 100, + prioritization_fee: 100, + }, + PrioritizationFee { + slot: 200, + prioritization_fee: 200, + }, + PrioritizationFee { + slot: 301, + prioritization_fee: 300, + }, + ]; + + let transform = ResponseTransform::GetRecentPrioritizationFees { + max_slot_rounding_error: RoundingError::new(10), + max_length: 100, + }; + let mut raw_bytes = serde_json::to_vec(&json_response(&fees)).unwrap(); + transform.apply(&mut raw_bytes); + let transformed_response: serde_json::Value = serde_json::from_slice(&raw_bytes).unwrap(); + + assert_eq!(transformed_response, json_response(&fees[0..2])); + } + + proptest! { + #[test] + fn should_be_nop_when_failed_to_deserialize(original_bytes in prop::collection::vec(any::(), 0..1000)) { + let transform = ResponseTransform::GetRecentPrioritizationFees { + max_slot_rounding_error: RoundingError::new(2), + max_length: 2, + }; + let mut transformed_bytes = original_bytes.clone(); + transform.apply(&mut transformed_bytes); + + assert_eq!(original_bytes, transformed_bytes); + } + + #[test] + fn should_normalize_get_recent_prioritization_fees_response(fees in arb_prioritization_fees(337346483..=337346632)) { + let raw_response = json_response(&fees); + let transform = ResponseTransform::GetRecentPrioritizationFees { + max_slot_rounding_error: RoundingError::new(20), + max_length: 100, + }; + let mut raw_bytes = serde_json::to_vec(&raw_response).unwrap(); + transform.apply(&mut raw_bytes); + let transformed_response: serde_json::Value = serde_json::from_slice(&raw_bytes).unwrap(); + + let mut expected_fees = fees; + // last slot is 337346632 and has index 150. + // Last slot rounded by 20 is 337346620, which has index 138. + expected_fees.drain(138..); + expected_fees.drain(..38); + prop_assert_eq!(expected_fees.len(), 100); + + prop_assert_eq!( + transformed_response, + json_response(&expected_fees) + ) + } + + #[test] + fn should_normalize_unsorted_prioritization_fees( + seed in uniform32(any::()), + fees in arb_prioritization_fees(337346483..=337346632) + ) { + let mut rng = ChaCha20Rng::from_seed(seed); + let shuffled_fees = { + let mut f = fees.clone(); + f.shuffle(&mut rng); + f + }; + let transform = ResponseTransform::GetRecentPrioritizationFees { + max_slot_rounding_error: RoundingError::new(20), + max_length: 100, + }; + + let sorted_fees_bytes = { + let raw_response = json_response(&fees); + let mut raw_bytes = serde_json::to_vec(&raw_response).unwrap(); + transform.apply(&mut raw_bytes); + raw_bytes + }; + + let shuffled_fees_bytes = { + let raw_response = json_response(&shuffled_fees); + let mut raw_bytes = serde_json::to_vec(&raw_response).unwrap(); + transform.apply(&mut raw_bytes); + raw_bytes + }; + + assert_eq!(sorted_fees_bytes, shuffled_fees_bytes); + } + } + + fn arb_prioritization_fees( + slots: RangeInclusive, + ) -> impl Strategy> { + let len = if slots.is_empty() { + 0 + } else { + slots.end() - slots.start() + 1 + }; + prop::collection::vec(any::(), len as usize).prop_map(move |fees| { + fees.into_iter() + .enumerate() + .map(|(index, prioritization_fee)| { + let slot = slots.start() + index as u64; + assert!(slots.contains(&slot)); + PrioritizationFee { + slot, + prioritization_fee, + } + }) + .collect::>() + }) + } + + fn json_response(fees: &[T]) -> serde_json::Value { + json!({ + "jsonrpc": "2.0", + "result": fees, + "id": 1 + }) + } +} diff --git a/integration_tests/Cargo.toml b/integration_tests/Cargo.toml index 85ef8c07..fdd6d412 100644 --- a/integration_tests/Cargo.toml +++ b/integration_tests/Cargo.toml @@ -28,6 +28,7 @@ sol_rpc_types = { path = "../libs/types" } solana-account = { workspace = true } solana-account-decoder-client-types = { workspace = true } solana-commitment-config = { workspace = true } +solana-compute-budget-interface = {workspace = true} solana-hash = { workspace = true } solana-instruction = { workspace = true } solana-keypair = { workspace = true } diff --git a/integration_tests/tests/solana_test_validator.rs b/integration_tests/tests/solana_test_validator.rs index 9d7a67c7..18b0b329 100644 --- a/integration_tests/tests/solana_test_validator.rs +++ b/integration_tests/tests/solana_test_validator.rs @@ -9,11 +9,12 @@ use sol_rpc_int_tests::{spl, PocketIcLiveModeRuntime}; use sol_rpc_types::{ CommitmentLevel, GetAccountInfoEncoding, GetAccountInfoParams, GetBlockCommitmentLevel, GetBlockParams, GetTransactionEncoding, GetTransactionParams, InstallArgs, Lamport, - OverrideProvider, RegexSubstitution, TransactionDetails, + OverrideProvider, PrioritizationFee, RegexSubstitution, TransactionDetails, }; use solana_account_decoder_client_types::{token::UiTokenAmount, UiAccount}; use solana_client::rpc_client::{RpcClient as SolanaRpcClient, RpcClient}; use solana_commitment_config::CommitmentConfig; +use solana_compute_budget_interface::ComputeBudgetInstruction; use solana_hash::Hash; use solana_keypair::Keypair; use solana_program::{ @@ -22,10 +23,12 @@ use solana_program::{ }; use solana_pubkey::{pubkey, Pubkey}; use solana_rpc_client_api::config::{RpcBlockConfig, RpcTransactionConfig}; +use solana_rpc_client_api::response::RpcPrioritizationFee; use solana_signature::Signature; use solana_signer::Signer; use solana_transaction::Transaction; use solana_transaction_status_client_types::UiTransactionEncoding; +use std::iter::zip; use std::{ future::Future, str::FromStr, @@ -61,6 +64,95 @@ async fn should_get_slot() { setup.setup.drop().await; } +#[tokio::test(flavor = "multi_thread")] +async fn should_get_recent_prioritization_fees() { + const BASE_FEE_PER_SIGNATURE_LAMPORTS: u64 = 5000; + const NUM_TRANSACTIONS: u64 = 10; + + let setup = Setup::new().await; + + let (sender, sender_balance_before) = setup.generate_keypair_and_fund_account(); + let (recipient, _recipient_balance_before) = setup.generate_keypair_and_fund_account(); + + // Generate some transactions with priority fees to ensure that + // 1) There are some slots + // 2) Priority fee is not always 0 + let mut transactions = Vec::with_capacity(NUM_TRANSACTIONS as usize); + let transaction_amount = 1; + for micro_lamports in 1..=NUM_TRANSACTIONS { + let modify_cu_ix = ComputeBudgetInstruction::set_compute_unit_limit(1_000_000); + let add_priority_fee_ix = ComputeBudgetInstruction::set_compute_unit_price(micro_lamports); + let transfer_ix = + system_instruction::transfer(&sender.pubkey(), &recipient.pubkey(), transaction_amount); + let blockhash = setup.solana_client.get_latest_blockhash().unwrap(); + let transaction = Transaction::new_signed_with_payer( + &[modify_cu_ix, add_priority_fee_ix, transfer_ix], + Some(&sender.pubkey()), + &[&sender], + blockhash, + ); + let signature = setup + .solana_client + .send_and_confirm_transaction(&transaction) + .unwrap(); + println!("Sent transaction {micro_lamports}: {signature}"); + transactions.push(signature); + } + + let spent_lamports = NUM_TRANSACTIONS * transaction_amount //amount sent + + NUM_TRANSACTIONS * BASE_FEE_PER_SIGNATURE_LAMPORTS //base fee + + NUM_TRANSACTIONS * (NUM_TRANSACTIONS+1) / 2; //prioritization_fee = 1 µL * CUL / 1_000_000 + .. + NUM_TRANSACTIONS * CUL / 1_000_000 and compute unit limit was set to 1 million. + assert_eq!( + sender_balance_before - setup.solana_client.get_balance(&sender.pubkey()).unwrap(), + spent_lamports + ); + + setup.confirm_transaction(transactions.last().unwrap()); + + let account = sender.pubkey(); + let (sol_res, ic_res) = setup + .compare_client( + |sol| { + sol.get_recent_prioritization_fees(&[account]) + .expect("Failed to get recent prioritization fees") + }, + |ic| async move { + ic.get_recent_prioritization_fees(&[account]) + .unwrap() + .with_max_length(150) + .with_max_slot_rounding_error(1) + .send() + .await + .expect_consistent() + .unwrap_or_else(|e| panic!("`getRecentPrioritizationFees` call failed: {e}")) + }, + ) + .await; + + assert_eq!( + sol_res.len(), + ic_res.len(), + "SOL results {:?}, ICP results {:?}", + sol_res, + ic_res + ); + for (fees_sol, fees_ic) in zip(sol_res, ic_res) { + let RpcPrioritizationFee { + slot: slot_sol, + prioritization_fee: prioritization_fee_sol, + } = fees_sol; + let PrioritizationFee { + slot: slot_ic, + prioritization_fee: prioritization_fee_ic, + } = fees_ic; + + assert_eq!(slot_sol, slot_ic); + assert_eq!(prioritization_fee_sol, prioritization_fee_ic) + } + + setup.setup.drop().await; +} + #[tokio::test(flavor = "multi_thread")] async fn should_get_account_info() { let setup = Setup::new().await; diff --git a/integration_tests/tests/tests.rs b/integration_tests/tests/tests.rs index fcab44d1..799a7a9e 100644 --- a/integration_tests/tests/tests.rs +++ b/integration_tests/tests/tests.rs @@ -1,3 +1,4 @@ +#![recursion_limit = "512"] use assert_matches::*; use candid::CandidType; use canhttp::http::json::{ConstantSizeId, Id}; @@ -467,6 +468,678 @@ mod get_slot_tests { setup.drop().await; } } +mod get_recent_prioritization_fees_tests { + use crate::USDC_PUBLIC_KEY; + use canhttp::http::json::ConstantSizeId; + use serde_json::json; + use sol_rpc_int_tests::mock::MockOutcallBuilder; + use sol_rpc_int_tests::{Setup, SolRpcTestClient}; + use sol_rpc_types::PrioritizationFee; + + #[tokio::test] + async fn should_get_fees_with_rounding() { + fn request_body(id: u8) -> serde_json::Value { + let id = ConstantSizeId::from(id).to_string(); + json!( { "jsonrpc": "2.0", "id": id, "method": "getRecentPrioritizationFees", "params": [ [ USDC_PUBLIC_KEY.to_string() ] ] } ) + } + + fn response_body(id: u8) -> serde_json::Value { + let id = ConstantSizeId::from(id).to_string(); + json!({ + "jsonrpc": "2.0", + "result": [ + { + "prioritizationFee": 0, + "slot": 338225766 + }, + { + "prioritizationFee": 203228, + "slot": 338225767 + }, + { + "prioritizationFee": 110788, + "slot": 338225768 + }, + { + "prioritizationFee": 395962, + "slot": 338225769 + }, + { + "prioritizationFee": 0, + "slot": 338225770 + }, + { + "prioritizationFee": 395477, + "slot": 338225771 + }, + { + "prioritizationFee": 202136, + "slot": 338225772 + }, + { + "prioritizationFee": 0, + "slot": 338225773 + }, + { + "prioritizationFee": 0, + "slot": 338225774 + }, + { + "prioritizationFee": 0, + "slot": 338225775 + }, + { + "prioritizationFee": 2894338, + "slot": 338225776 + }, + { + "prioritizationFee": 0, + "slot": 338225777 + }, + { + "prioritizationFee": 162918, + "slot": 338225778 + }, + { + "prioritizationFee": 238785, + "slot": 338225779 + }, + { + "prioritizationFee": 10714, + "slot": 338225780 + }, + { + "prioritizationFee": 81000, + "slot": 338225781 + }, + { + "prioritizationFee": 0, + "slot": 338225782 + }, + { + "prioritizationFee": 0, + "slot": 338225783 + }, + { + "prioritizationFee": 202136, + "slot": 338225784 + }, + { + "prioritizationFee": 166667, + "slot": 338225785 + }, + { + "prioritizationFee": 166667, + "slot": 338225786 + }, + { + "prioritizationFee": 0, + "slot": 338225787 + }, + { + "prioritizationFee": 0, + "slot": 338225788 + }, + { + "prioritizationFee": 0, + "slot": 338225789 + }, + { + "prioritizationFee": 0, + "slot": 338225790 + }, + { + "prioritizationFee": 0, + "slot": 338225791 + }, + { + "prioritizationFee": 0, + "slot": 338225792 + }, + { + "prioritizationFee": 0, + "slot": 338225793 + }, + { + "prioritizationFee": 494120, + "slot": 338225794 + }, + { + "prioritizationFee": 0, + "slot": 338225795 + }, + { + "prioritizationFee": 0, + "slot": 338225796 + }, + { + "prioritizationFee": 202136, + "slot": 338225797 + }, + { + "prioritizationFee": 0, + "slot": 338225798 + }, + { + "prioritizationFee": 0, + "slot": 338225799 + }, + { + "prioritizationFee": 202136, + "slot": 338225800 + }, + { + "prioritizationFee": 0, + "slot": 338225801 + }, + { + "prioritizationFee": 0, + "slot": 338225802 + }, + { + "prioritizationFee": 10001, + "slot": 338225803 + }, + { + "prioritizationFee": 0, + "slot": 338225804 + }, + { + "prioritizationFee": 0, + "slot": 338225805 + }, + { + "prioritizationFee": 0, + "slot": 338225806 + }, + { + "prioritizationFee": 0, + "slot": 338225807 + }, + { + "prioritizationFee": 202136, + "slot": 338225808 + }, + { + "prioritizationFee": 0, + "slot": 338225809 + }, + { + "prioritizationFee": 202136, + "slot": 338225810 + }, + { + "prioritizationFee": 0, + "slot": 338225811 + }, + { + "prioritizationFee": 0, + "slot": 338225812 + }, + { + "prioritizationFee": 0, + "slot": 338225813 + }, + { + "prioritizationFee": 0, + "slot": 338225814 + }, + { + "prioritizationFee": 6064097, + "slot": 338225815 + }, + { + "prioritizationFee": 0, + "slot": 338225816 + }, + { + "prioritizationFee": 0, + "slot": 338225817 + }, + { + "prioritizationFee": 0, + "slot": 338225818 + }, + { + "prioritizationFee": 517927, + "slot": 338225819 + }, + { + "prioritizationFee": 0, + "slot": 338225820 + }, + { + "prioritizationFee": 0, + "slot": 338225821 + }, + { + "prioritizationFee": 0, + "slot": 338225822 + }, + { + "prioritizationFee": 602011, + "slot": 338225823 + }, + { + "prioritizationFee": 187015, + "slot": 338225824 + }, + { + "prioritizationFee": 50000, + "slot": 338225825 + }, + { + "prioritizationFee": 0, + "slot": 338225826 + }, + { + "prioritizationFee": 0, + "slot": 338225827 + }, + { + "prioritizationFee": 0, + "slot": 338225828 + }, + { + "prioritizationFee": 0, + "slot": 338225829 + }, + { + "prioritizationFee": 0, + "slot": 338225830 + }, + { + "prioritizationFee": 0, + "slot": 338225831 + }, + { + "prioritizationFee": 0, + "slot": 338225832 + }, + { + "prioritizationFee": 0, + "slot": 338225833 + }, + { + "prioritizationFee": 0, + "slot": 338225834 + }, + { + "prioritizationFee": 0, + "slot": 338225835 + }, + { + "prioritizationFee": 0, + "slot": 338225836 + }, + { + "prioritizationFee": 0, + "slot": 338225837 + }, + { + "prioritizationFee": 0, + "slot": 338225838 + }, + { + "prioritizationFee": 487330, + "slot": 338225839 + }, + { + "prioritizationFee": 149432, + "slot": 338225840 + }, + { + "prioritizationFee": 0, + "slot": 338225841 + }, + { + "prioritizationFee": 0, + "slot": 338225842 + }, + { + "prioritizationFee": 68526, + "slot": 338225843 + }, + { + "prioritizationFee": 0, + "slot": 338225844 + }, + { + "prioritizationFee": 310090, + "slot": 338225845 + }, + { + "prioritizationFee": 0, + "slot": 338225846 + }, + { + "prioritizationFee": 2173913, + "slot": 338225847 + }, + { + "prioritizationFee": 99725, + "slot": 338225848 + }, + { + "prioritizationFee": 0, + "slot": 338225849 + }, + { + "prioritizationFee": 88441, + "slot": 338225850 + }, + { + "prioritizationFee": 0, + "slot": 338225851 + }, + { + "prioritizationFee": 400000, + "slot": 338225852 + }, + { + "prioritizationFee": 0, + "slot": 338225853 + }, + { + "prioritizationFee": 0, + "slot": 338225854 + }, + { + "prioritizationFee": 164507, + "slot": 338225855 + }, + { + "prioritizationFee": 0, + "slot": 338225856 + }, + { + "prioritizationFee": 4898, + "slot": 338225857 + }, + { + "prioritizationFee": 0, + "slot": 338225858 + }, + { + "prioritizationFee": 0, + "slot": 338225859 + }, + { + "prioritizationFee": 142369, + "slot": 338225860 + }, + { + "prioritizationFee": 84566, + "slot": 338225861 + }, + { + "prioritizationFee": 0, + "slot": 338225862 + }, + { + "prioritizationFee": 10001, + "slot": 338225863 + }, + { + "prioritizationFee": 187015, + "slot": 338225864 + }, + { + "prioritizationFee": 8902, + "slot": 338225865 + }, + { + "prioritizationFee": 0, + "slot": 338225866 + }, + { + "prioritizationFee": 75000, + "slot": 338225867 + }, + { + "prioritizationFee": 0, + "slot": 338225868 + }, + { + "prioritizationFee": 0, + "slot": 338225869 + }, + { + "prioritizationFee": 1771477, + "slot": 338225870 + }, + { + "prioritizationFee": 1110536, + "slot": 338225871 + }, + { + "prioritizationFee": 215920, + "slot": 338225872 + }, + { + "prioritizationFee": 68408, + "slot": 338225873 + }, + { + "prioritizationFee": 0, + "slot": 338225874 + }, + { + "prioritizationFee": 260520, + "slot": 338225875 + }, + { + "prioritizationFee": 2143332, + "slot": 338225876 + }, + { + "prioritizationFee": 0, + "slot": 338225877 + }, + { + "prioritizationFee": 84168, + "slot": 338225878 + }, + { + "prioritizationFee": 0, + "slot": 338225879 + }, + { + "prioritizationFee": 0, + "slot": 338225880 + }, + { + "prioritizationFee": 501111, + "slot": 338225881 + }, + { + "prioritizationFee": 88060, + "slot": 338225882 + }, + { + "prioritizationFee": 10001, + "slot": 338225883 + }, + { + "prioritizationFee": 171521, + "slot": 338225884 + }, + { + "prioritizationFee": 0, + "slot": 338225885 + }, + { + "prioritizationFee": 6064097, + "slot": 338225886 + }, + { + "prioritizationFee": 6064097, + "slot": 338225887 + }, + { + "prioritizationFee": 0, + "slot": 338225888 + }, + { + "prioritizationFee": 7578, + "slot": 338225889 + }, + { + "prioritizationFee": 0, + "slot": 338225890 + }, + { + "prioritizationFee": 0, + "slot": 338225891 + }, + { + "prioritizationFee": 202136, + "slot": 338225892 + }, + { + "prioritizationFee": 106090, + "slot": 338225893 + }, + { + "prioritizationFee": 80776, + "slot": 338225894 + }, + { + "prioritizationFee": 111939, + "slot": 338225895 + }, + { + "prioritizationFee": 75000, + "slot": 338225896 + }, + { + "prioritizationFee": 0, + "slot": 338225897 + }, + { + "prioritizationFee": 0, + "slot": 338225898 + }, + { + "prioritizationFee": 0, + "slot": 338225899 + }, + { + "prioritizationFee": 0, + "slot": 338225900 + }, + { + "prioritizationFee": 0, + "slot": 338225901 + }, + { + "prioritizationFee": 183582, + "slot": 338225902 + }, + { + "prioritizationFee": 0, + "slot": 338225903 + }, + { + "prioritizationFee": 0, + "slot": 338225904 + }, + { + "prioritizationFee": 0, + "slot": 338225905 + }, + { + "prioritizationFee": 535775, + "slot": 338225906 + }, + { + "prioritizationFee": 65038, + "slot": 338225907 + }, + { + "prioritizationFee": 0, + "slot": 338225908 + }, + { + "prioritizationFee": 0, + "slot": 338225909 + }, + { + "prioritizationFee": 0, + "slot": 338225910 + }, + { + "prioritizationFee": 0, + "slot": 338225911 + }, + { + "prioritizationFee": 0, + "slot": 338225912 + }, + { + "prioritizationFee": 0, + "slot": 338225913 + }, + { + "prioritizationFee": 0, + "slot": 338225914 + }, + { + "prioritizationFee": 0, + "slot": 338225915 + } + ], + "id": id + } + ) + } + + let setup = Setup::new().await.with_mock_api_keys().await; + let client = setup.client(); + let fees = client + .mock_http_sequence(vec![ + MockOutcallBuilder::new(200, response_body(0)).with_request_body(request_body(0)), + MockOutcallBuilder::new(200, response_body(1)).with_request_body(request_body(1)), + MockOutcallBuilder::new(200, response_body(2)).with_request_body(request_body(2)), + ]) + .build() + .get_recent_prioritization_fees(&[USDC_PUBLIC_KEY]) + .unwrap() + .with_max_slot_rounding_error(10) + .with_max_length(5) + .send() + .await + .expect_consistent(); + + assert_eq!( + fees, + Ok(vec![ + PrioritizationFee { + prioritization_fee: 535775, + slot: 338225906 + }, + PrioritizationFee { + prioritization_fee: 65038, + slot: 338225907 + }, + PrioritizationFee { + prioritization_fee: 0, + slot: 338225908 + }, + PrioritizationFee { + prioritization_fee: 0, + slot: 338225909 + }, + PrioritizationFee { + prioritization_fee: 0, + slot: 338225910 + }, + ]) + ); + + setup.drop().await; + } +} mod send_transaction_tests { use super::*; @@ -815,6 +1488,9 @@ mod cycles_cost_tests { SolRpcEndpoint::GetBlock => { check(client.get_block(577996)).await; } + SolRpcEndpoint::GetRecentPrioritizationFees => { + check(client.get_recent_prioritization_fees(&[]).unwrap()).await + } SolRpcEndpoint::GetTransaction => { check(client.get_transaction(some_signature())).await; } @@ -865,6 +1541,9 @@ mod cycles_cost_tests { SolRpcEndpoint::GetBlock => { check(client.get_block(577996)).await; } + SolRpcEndpoint::GetRecentPrioritizationFees => { + check(client.get_recent_prioritization_fees(&[]).unwrap()).await; + } SolRpcEndpoint::GetTokenAccountBalance => { check(client.get_token_account_balance(USDC_PUBLIC_KEY)).await; } @@ -973,6 +1652,15 @@ mod cycles_cost_tests { SolRpcEndpoint::GetBlock => { check(&setup, client.get_block(577996), 1_791_868_000).await; } + + SolRpcEndpoint::GetRecentPrioritizationFees => { + check( + &setup, + client.get_recent_prioritization_fees(&[]).unwrap(), + 2_378_204_800, + ) + .await; + } SolRpcEndpoint::GetSlot => { check( &setup, diff --git a/libs/client/Cargo.toml b/libs/client/Cargo.toml index e0e63f1d..ceaa06f9 100644 --- a/libs/client/Cargo.toml +++ b/libs/client/Cargo.toml @@ -23,7 +23,8 @@ solana-pubkey = { workspace = true } solana-signature = { workspace = true } solana-transaction-status-client-types = { workspace = true } sol_rpc_types = { version = "0.1.0", path = "../types" } -strum = {workspace = true} +strum = { workspace = true } [dev-dependencies] -tokio = {workspace = true, features = ["full"]} +assert_matches = { workspace = true } +tokio = { workspace = true, features = ["full"] } diff --git a/libs/client/src/lib.rs b/libs/client/src/lib.rs index 826d30ab..11eb97cd 100644 --- a/libs/client/src/lib.rs +++ b/libs/client/src/lib.rs @@ -126,18 +126,20 @@ pub use request::{Request, RequestBuilder, SolRpcEndpoint, SolRpcRequest}; use std::fmt::Debug; use crate::request::{ - GetAccountInfoRequest, GetBalanceRequest, GetBlockRequest, GetSlotRequest, - GetTokenAccountBalanceRequest, GetTransactionRequest, JsonRequest, SendTransactionRequest, + GetAccountInfoRequest, GetBalanceRequest, GetBlockRequest, GetRecentPrioritizationFeesRequest, + GetRecentPrioritizationFeesRequestBuilder, GetSlotRequest, GetTokenAccountBalanceRequest, + GetTransactionRequest, JsonRequest, SendTransactionRequest, }; use async_trait::async_trait; use candid::{utils::ArgumentEncoder, CandidType, Principal}; use ic_cdk::api::call::RejectionCode; use serde::de::DeserializeOwned; use sol_rpc_types::{ - CommitmentLevel, GetAccountInfoParams, GetBalanceParams, GetBlockParams, GetSlotParams, - GetSlotRpcConfig, GetTokenAccountBalanceParams, GetTransactionParams, Lamport, RpcConfig, - RpcSources, SendTransactionParams, Signature, SolanaCluster, SupportedRpcProvider, - SupportedRpcProviderId, TokenAmount, TransactionDetails, TransactionInfo, + CommitmentLevel, GetAccountInfoParams, GetBalanceParams, GetBlockParams, + GetRecentPrioritizationFeesParams, GetSlotParams, GetSlotRpcConfig, + GetTokenAccountBalanceParams, GetTransactionParams, Lamport, RpcConfig, RpcError, RpcSources, + SendTransactionParams, Signature, SolanaCluster, SupportedRpcProvider, SupportedRpcProviderId, + TokenAmount, TransactionDetails, TransactionInfo, }; use solana_account_decoder_client_types::token::UiTokenAmount; use solana_clock::Slot; @@ -185,6 +187,7 @@ pub trait Runtime { } /// Client to interact with the SOL RPC canister. +#[derive(Debug)] pub struct SolRpcClient { config: Arc>, } @@ -452,6 +455,86 @@ impl SolRpcClient { ) } + /// Call `getRecentPrioritizationFees` on the SOL RPC canister. + /// + /// # Examples + /// + /// ```rust + /// use sol_rpc_client::SolRpcClient; + /// use sol_rpc_types::{RpcSources, SolanaCluster}; + /// use solana_pubkey::pubkey; + /// + /// # #[tokio::main] + /// # async fn main() -> Result<(), Box> { + /// # use sol_rpc_types::{MultiRpcResult, PrioritizationFee, TokenAmount}; + /// let client = SolRpcClient::builder_for_ic() + /// # .with_mocked_response(MultiRpcResult::Consistent(Ok(vec![PrioritizationFee{slot: 338637772, prioritization_fee: 166667}]))) + /// .with_rpc_sources(RpcSources::Default(SolanaCluster::Mainnet)) + /// .build(); + /// + /// let fees = client + /// .get_recent_prioritization_fees(&[pubkey!("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v")]) + /// .unwrap() + /// .with_max_length(1) + /// .send() + /// .await + /// .expect_consistent(); + /// + /// assert_eq! + /// (fees, + /// Ok(vec![ PrioritizationFee { + /// slot: 338637772, + /// prioritization_fee: 166667 + /// }])); + /// # Ok(()) + /// # } + /// ``` + /// + /// # Errors + /// + /// The number of account addresses that can be passed to + /// [`getRecentPrioritizationFees`](https://solana.com/de/docs/rpc/http/getrecentprioritizationfees) + /// is limited to 128. More accounts result in an error. + /// + /// ```rust + /// use std::collections::BTreeSet; + /// use assert_matches::assert_matches; + /// use solana_pubkey::Pubkey; + /// use sol_rpc_client::SolRpcClient; + /// use sol_rpc_types::{RpcSources, SolanaCluster, RpcError}; + /// + /// let client = SolRpcClient::builder_for_ic() + /// .with_rpc_sources(RpcSources::Default(SolanaCluster::Mainnet)) + /// .build(); + /// + /// let too_many_accounts: BTreeSet = (0..129_u8) + /// .map(|i| Pubkey::from([i; 32])) + /// .collect(); + /// assert_eq!(too_many_accounts.len(), 129); + /// + /// let err = client.get_recent_prioritization_fees(&too_many_accounts).unwrap_err(); + /// assert_matches!(err, RpcError::ValidationError(_)); + /// ``` + pub fn get_recent_prioritization_fees<'a, I>( + &self, + addresses: I, + ) -> Result, RpcError> + where + I: IntoIterator, + { + let params = GetRecentPrioritizationFeesParams::try_from( + addresses + .into_iter() + .map(|a| a.to_string()) + .collect::>(), + )?; + Ok(RequestBuilder::new( + self.clone(), + GetRecentPrioritizationFeesRequest::from(params), + 10_000_000_000, + )) + } + /// Call `getSlot` on the SOL RPC canister. /// /// # Examples diff --git a/libs/client/src/request/mod.rs b/libs/client/src/request/mod.rs index 2490e192..fab56d51 100644 --- a/libs/client/src/request/mod.rs +++ b/libs/client/src/request/mod.rs @@ -6,9 +6,10 @@ use candid::CandidType; use serde::de::DeserializeOwned; use sol_rpc_types::{ AccountInfo, CommitmentLevel, ConfirmedBlock, GetAccountInfoParams, GetBalanceParams, - GetBlockCommitmentLevel, GetBlockParams, GetSlotParams, GetSlotRpcConfig, - GetTokenAccountBalanceParams, GetTransactionParams, Lamport, RpcConfig, RpcResult, RpcSources, - SendTransactionParams, Signature, TokenAmount, TransactionInfo, + GetBlockCommitmentLevel, GetBlockParams, GetRecentPrioritizationFeesParams, + GetRecentPrioritizationFeesRpcConfig, GetSlotParams, GetSlotRpcConfig, + GetTokenAccountBalanceParams, GetTransactionParams, Lamport, PrioritizationFee, RpcConfig, + RpcResult, RpcSources, SendTransactionParams, Signature, TokenAmount, TransactionInfo, }; use solana_account_decoder_client_types::token::UiTokenAmount; use solana_clock::Slot; @@ -43,6 +44,8 @@ pub enum SolRpcEndpoint { GetBalance, /// `getBlock` endpoint. GetBlock, + /// `getRecentPrioritizationFees` endpoint. + GetRecentPrioritizationFees, /// `getSlot` endpoint. GetSlot, /// `getTokenAccountBalance` endpoint. @@ -62,6 +65,7 @@ impl SolRpcEndpoint { SolRpcEndpoint::GetAccountInfo => "getAccountInfo", SolRpcEndpoint::GetBalance => "getBalance", SolRpcEndpoint::GetBlock => "getBlock", + SolRpcEndpoint::GetRecentPrioritizationFees => "getRecentPrioritizationFees", SolRpcEndpoint::GetSlot => "getSlot", SolRpcEndpoint::GetTokenAccountBalance => "getTokenAccountBalance", SolRpcEndpoint::GetTransaction => "getTransaction", @@ -76,6 +80,7 @@ impl SolRpcEndpoint { SolRpcEndpoint::GetAccountInfo => "getAccountInfoCyclesCost", SolRpcEndpoint::GetBalance => "getBalanceCyclesCost", SolRpcEndpoint::GetBlock => "getBlockCyclesCost", + SolRpcEndpoint::GetRecentPrioritizationFees => "getRecentPrioritizationFeesCyclesCost", SolRpcEndpoint::GetSlot => "getSlotCyclesCost", SolRpcEndpoint::GetTransaction => "getTransactionCyclesCost", SolRpcEndpoint::GetTokenAccountBalance => "getTokenAccountBalanceCyclesCost", @@ -179,6 +184,32 @@ impl SolRpcRequest for GetBlockRequest { } } +#[derive(Debug, Clone, Default)] +pub struct GetRecentPrioritizationFeesRequest(GetRecentPrioritizationFeesParams); + +impl SolRpcRequest for GetRecentPrioritizationFeesRequest { + type Config = GetRecentPrioritizationFeesRpcConfig; + type Params = GetRecentPrioritizationFeesParams; + type CandidOutput = sol_rpc_types::MultiRpcResult>; + type Output = Self::CandidOutput; + + fn endpoint(&self) -> SolRpcEndpoint { + SolRpcEndpoint::GetRecentPrioritizationFees + } + + fn params(self, _default_commitment_level: Option) -> Self::Params { + // [getRecentPrioritizationFees](https://solana.com/de/docs/rpc/http/getrecentprioritizationfees) + // does not use commitment levels + self.0 + } +} + +impl From for GetRecentPrioritizationFeesRequest { + fn from(value: GetRecentPrioritizationFeesParams) -> Self { + Self(value) + } +} + #[derive(Debug, Clone, Default)] pub struct GetSlotRequest(Option); @@ -322,6 +353,14 @@ pub struct RequestBuilder { request: Request, } +pub type GetRecentPrioritizationFeesRequestBuilder = RequestBuilder< + R, + GetRecentPrioritizationFeesRpcConfig, + GetRecentPrioritizationFeesParams, + sol_rpc_types::MultiRpcResult>, + sol_rpc_types::MultiRpcResult>, +>; + impl Clone for RequestBuilder { @@ -333,6 +372,18 @@ impl Clone } } +impl Debug + for RequestBuilder +{ + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + let RequestBuilder { client, request } = &self; + f.debug_struct("RequestBuilder") + .field("client", client) + .field("request", request) + .finish() + } +} + impl RequestBuilder { @@ -424,19 +475,32 @@ impl } } +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 { + let config = self.request.rpc_config_mut().get_or_insert_default(); + config.max_slot_rounding_error = Some(rounding_error); + self + } + + /// Change the maximum number of entries for a `getRecentPrioritizationFees` response. + pub fn with_max_length(mut self, len: u8) -> Self { + let config = self.request.rpc_config_mut().get_or_insert_default(); + config.max_length = Some(len); + self + } +} + impl RequestBuilder { /// Change the rounding error for `getSlot` request. pub fn with_rounding_error(mut self, rounding_error: u64) -> Self { - if let Some(config) = self.request.rpc_config_mut() { - config.rounding_error = Some(rounding_error); - return self; - } - self.with_rpc_config(GetSlotRpcConfig { - rounding_error: Some(rounding_error), - ..Default::default() - }) + let config = self.request.rpc_config_mut().get_or_insert_default(); + config.rounding_error = Some(rounding_error); + self } } diff --git a/libs/client/src/request/tests.rs b/libs/client/src/request/tests.rs index 6a338e00..b39e5578 100644 --- a/libs/client/src/request/tests.rs +++ b/libs/client/src/request/tests.rs @@ -38,6 +38,9 @@ fn should_set_correct_commitment_level() { Some(GetBlockCommitmentLevel::Confirmed) ); } + SolRpcEndpoint::GetRecentPrioritizationFees => { + //no op, GetRecentPrioritizationFees does not use commitment level + } SolRpcEndpoint::GetSlot => { let builder = client_with_commitment_level.get_slot(); assert_eq!( diff --git a/libs/types/src/lib.rs b/libs/types/src/lib.rs index e4c05270..3ac347cf 100644 --- a/libs/types/src/lib.rs +++ b/libs/types/src/lib.rs @@ -11,18 +11,18 @@ mod solana; pub use lifecycle::{InstallArgs, Mode, NumSubnetNodes}; pub use response::MultiRpcResult; pub use rpc_client::{ - ConsensusStrategy, GetSlotRpcConfig, HttpHeader, HttpOutcallError, JsonRpcError, - OverrideProvider, ProviderError, RegexString, RegexSubstitution, RpcAccess, RpcAuth, RpcConfig, - RpcEndpoint, RpcError, RpcResult, RpcSource, RpcSources, SolanaCluster, SupportedRpcProvider, - SupportedRpcProviderId, + ConsensusStrategy, GetRecentPrioritizationFeesRpcConfig, GetSlotRpcConfig, HttpHeader, + HttpOutcallError, JsonRpcError, OverrideProvider, ProviderError, RegexString, + RegexSubstitution, RpcAccess, RpcAuth, RpcConfig, RpcEndpoint, RpcError, RpcResult, RpcSource, + RpcSources, SolanaCluster, SupportedRpcProvider, SupportedRpcProviderId, }; pub use solana::{ account::{AccountData, AccountEncoding, AccountInfo, ParsedAccount}, request::{ CommitmentLevel, DataSlice, GetAccountInfoEncoding, GetAccountInfoParams, GetBalanceParams, - GetBlockCommitmentLevel, GetBlockParams, GetSlotParams, GetTokenAccountBalanceParams, - GetTransactionEncoding, GetTransactionParams, SendTransactionEncoding, - SendTransactionParams, TransactionDetails, + GetBlockCommitmentLevel, GetBlockParams, GetRecentPrioritizationFeesParams, GetSlotParams, + GetTokenAccountBalanceParams, GetTransactionEncoding, GetTransactionParams, + SendTransactionEncoding, SendTransactionParams, TransactionDetails, }, transaction::{ error::{InstructionError, TransactionError}, @@ -32,5 +32,5 @@ pub use solana::{ TransactionInfo, TransactionReturnData, TransactionStatusMeta, TransactionTokenBalance, TransactionVersion, }, - Blockhash, ConfirmedBlock, Lamport, Pubkey, Signature, Slot, Timestamp, + Blockhash, ConfirmedBlock, Lamport, PrioritizationFee, Pubkey, Signature, Slot, Timestamp, }; diff --git a/libs/types/src/rpc_client/mod.rs b/libs/types/src/rpc_client/mod.rs index f236b4e9..00f84aa1 100644 --- a/libs/types/src/rpc_client/mod.rs +++ b/libs/types/src/rpc_client/mod.rs @@ -139,6 +139,77 @@ impl From for GetSlotRpcConfig { } } +/// Configures how to perform HTTP calls for the Solana `getRecentPrioritizationFees` RPC method. +/// +/// The response to `getRecentPrioritizationFees` corresponds to a (non-necessarily continuous) range of slots associated +/// with the priority fee for that slot and may include `processed` slots (a new `processed` slot is produced every ca. 400ms); e.g. +/// ```json +/// ... +/// { +/// "prioritizationFee": 166667, +/// "slot": 338637772 +///}, +///{ +/// "prioritizationFee": 0, +/// "slot": 338637773 +///}, +///{ +/// "prioritizationFee": 0, +/// "slot": 338637774 +///}, +///{ +/// "prioritizationFee": 50000, +/// "slot": 338637775 +///}, +/// ... +/// ``` +/// Similarly to the necessary rounding used for `getSlot`, +/// achieving consensus for `getRecentPrioritizationFees` requires selecting a subset of those slots +/// that can be seen by a super-majority of the nodes, which is done as follows: +/// 1. `max_slot_rounding_error`: round down the slot with the maximum value. +/// The selected subset will only contain priority fees for slots that are smaller or equal to the rounded-down slot. +/// 2. `max_length`: limit the size of the selected subset by removing priority fees for the older slots. +#[derive(Clone, Debug, PartialEq, Eq, Default, CandidType, Deserialize)] +pub struct GetRecentPrioritizationFeesRpcConfig { + /// 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, + + /// Round down the slot with the maximum value. + /// Increasing that value will reduce the freshness of the returned prioritization fees + /// but increase the likelihood of nodes reaching consensus. + #[serde(rename = "responseConsensus")] + pub response_consensus: Option, + + /// Round down the slot with the maximum value. + /// 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, + + /// Limit the number of returned priority fees. + /// + /// A Solana validator returns at most 150 entries, so that bigger values are possible but not useful. + /// MUST be non-zero to avoid useless call. + /// Default value is 100. + /// Increasing that value can help in estimating the current priority fee + /// but will reduce the likelihood of nodes reaching consensus. + #[serde(rename = "maxLength")] + // TODO XC-326: Use a wrapper type to implement Candid on `NonZeroU8` to prohibit the value 0. + pub max_length: Option, +} + +impl From for GetRecentPrioritizationFeesRpcConfig { + fn from(value: RpcConfig) -> Self { + GetRecentPrioritizationFeesRpcConfig { + response_size_estimate: value.response_size_estimate, + response_consensus: value.response_consensus, + ..Default::default() + } + } +} + /// Defines a consensus strategy for combining responses from different providers. #[derive(Clone, Debug, PartialEq, Eq, Default, CandidType, Deserialize)] pub enum ConsensusStrategy { diff --git a/libs/types/src/solana/mod.rs b/libs/types/src/solana/mod.rs index 95aa9a78..36432b93 100644 --- a/libs/types/src/solana/mod.rs +++ b/libs/types/src/solana/mod.rs @@ -12,6 +12,10 @@ pub type Slot = u64; /// A Solana [Lamport](https://solana.com/de/docs/references/terminology#lamport). pub type Lamport = u64; +/// Within the compute budget, a quantity of micro-lamports is used in the calculation of prioritization fees. +/// `1_000_000 MicroLamport == 1 Lamport` +pub type MicroLamport = u64; + /// A Solana base58-encoded [blockhash](https://solana.com/de/docs/references/terminology#blockhash). pub type Blockhash = String; @@ -80,3 +84,14 @@ impl From for solana_transaction_status_client_types::UiConfirme } } } + +/// An entry in the result of a Solana `getRecentPrioritizationFees` RPC method call. +#[derive(Debug, Clone, Deserialize, Serialize, CandidType, PartialEq)] +pub struct PrioritizationFee { + /// Slot in which the fee was observed. + pub slot: Slot, + /// The per-compute-unit fee paid by at least one successfully landed transaction, + /// specified in increments of micro-lamports (0.000001 lamports) + #[serde(rename = "prioritizationFee")] + pub prioritization_fee: MicroLamport, +} diff --git a/libs/types/src/solana/request/mod.rs b/libs/types/src/solana/request/mod.rs index e862d2c6..6f030adf 100644 --- a/libs/types/src/solana/request/mod.rs +++ b/libs/types/src/solana/request/mod.rs @@ -173,6 +173,38 @@ pub enum TransactionDetails { None, } +/// The parameters for a Solana [`getRecentPrioritizationFees`](https://solana.com/de/docs/rpc/http/getrecentprioritizationfees) RPC method call. +#[derive(Debug, Clone, Default, Deserialize, Serialize, CandidType)] +#[serde(try_from = "Vec")] +pub struct GetRecentPrioritizationFeesParams(Vec); + +impl TryFrom> for GetRecentPrioritizationFeesParams { + type Error = RpcError; + + fn try_from(accounts: Vec) -> Result { + const MAX_NUM_ACCOUNTS: usize = 128; + if accounts.len() > MAX_NUM_ACCOUNTS { + return Err(RpcError::ValidationError(format!( + "Expected at most {MAX_NUM_ACCOUNTS} account addresses, but got {}", + accounts.len() + ))); + } + Ok(Self(accounts)) + } +} + +impl From for GetRecentPrioritizationFeesParams { + fn from(value: solana_pubkey::Pubkey) -> Self { + Self(vec![value.to_string()]) + } +} + +impl From for Vec { + fn from(value: GetRecentPrioritizationFeesParams) -> Self { + value.0 + } +} + /// The parameters for a Solana [`getSlot`](https://solana.com/docs/rpc/http/getslot) RPC method call. #[derive(Debug, Clone, Default, Deserialize, Serialize, CandidType)] pub struct GetSlotParams {