diff --git a/Cargo.lock b/Cargo.lock index 29ccbc1b..20fe9492 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4145,6 +4145,7 @@ dependencies = [ "assert_matches", "async-trait", "candid", + "derive_more", "ic-cdk", "serde", "serde_json", @@ -4152,8 +4153,10 @@ dependencies = [ "solana-account", "solana-account-decoder-client-types", "solana-clock", + "solana-instruction", "solana-pubkey", "solana-signature", + "solana-transaction-error", "solana-transaction-status-client-types", "strum 0.27.1", "tokio", diff --git a/Cargo.toml b/Cargo.toml index d29412be..2c08d39b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -82,7 +82,6 @@ solana-rpc-client-nonce-utils = "2.2.0" solana-signature = "2.2.0" solana-signer = "2.2.0" solana-transaction = "2.2.0" -solana-transaction-context = "2.2.0" solana-transaction-error = "2.2.0" solana-transaction-status-client-types = "2.2.0" strum = { version = "0.27.0", features = ["derive"] } diff --git a/README.md b/README.md index 938bd87e..cd832c2e 100644 --- a/README.md +++ b/README.md @@ -118,6 +118,7 @@ The SOL RPC canister reaches the Solana JSON-RPC providers using [HTTPS outcalls 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`) +[//]: # (TODO: XC-291: mention also `getSignatureStatuses#confirmations`) ## Reproducible Build diff --git a/canister/scripts/examples.sh b/canister/scripts/examples.sh index 55ae37d5..24c4d3e6 100755 --- a/canister/scripts/examples.sh +++ b/canister/scripts/examples.sh @@ -61,7 +61,25 @@ GET_BLOCK_PARAMS="( )" CYCLES=$(dfx canister call sol_rpc getBlockCyclesCost "$GET_BLOCK_PARAMS" $FLAGS --output json | jq '.Ok' --raw-output || exit 1) GET_BLOCK_OUTPUT=$(dfx canister call sol_rpc getBlock "$GET_BLOCK_PARAMS" $FLAGS --output json --with-cycles "$CYCLES" || exit 1) -SIGNATURE=$(jq --raw-output '.Consistent.Ok[0].signatures[0][0]' <<< "$GET_BLOCK_OUTPUT") +FIRST_SIGNATURE=$(jq --raw-output '.Consistent.Ok[0].signatures[0][0]' <<< "$GET_BLOCK_OUTPUT") +SECOND_SIGNATURE=$(jq --raw-output '.Consistent.Ok[0].signatures[0][1]' <<< "$GET_BLOCK_OUTPUT") + +# Fetch the statuses of the first two transactions in the received block +GET_SIGNATURE_STATUSES_PARAMS="( + variant { Default = variant { Mainnet } }, + opt record { + responseConsensus = opt variant { + Threshold = record { min = 2 : nat8; total = opt (3 : nat8) } + }; + responseSizeEstimate = null; + }, + record { + signatures = vec { \"${FIRST_SIGNATURE}\"; \"${SECOND_SIGNATURE}\" }; + searchTransactionHistory = null; + }, +)" +CYCLES=$(dfx canister call sol_rpc getSignatureStatusesCyclesCost "$GET_SIGNATURE_STATUSES_PARAMS" $FLAGS --output json | jq '.Ok' --raw-output || exit 1) +dfx canister call sol_rpc getSignatureStatuses "$GET_SIGNATURE_STATUSES_PARAMS" $FLAGS --with-cycles "$CYCLES" || exit 1 # Fetch the first transaction in the retrieved block GET_TRANSACTION_PARAMS="( @@ -73,7 +91,7 @@ GET_TRANSACTION_PARAMS="( responseSizeEstimate = null; }, record { - signature = \"${SIGNATURE}\"; + signature = \"${FIRST_SIGNATURE}\"; commitment = opt variant { finalized }; encoding = opt variant{ base64 }; maxSupportedTransactionVersion = opt (0 : nat8); diff --git a/canister/sol_rpc_canister.did b/canister/sol_rpc_canister.did index 02936841..22db8eb7 100644 --- a/canister/sol_rpc_canister.did +++ b/canister/sol_rpc_canister.did @@ -66,7 +66,7 @@ type GetSlotRpcConfig = record { // 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). +// 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; @@ -420,19 +420,23 @@ type CompiledInstruction = record { programIdIndex : nat8; stackHeight : opt nat32; }; + // Encoding of a Solana transaction. type EncodedTransaction = variant { binary : record { text; variant { base58; base64 } }; legacyBinary : text; }; + // Specific operation executed by a Solana transaction. type Instruction = variant { compiled : CompiledInstruction }; + // List of inner instructions executed by a Solana transaction. // See the [Solana documentation](https://solana.com/de/docs/rpc/json-structures#inner-instructions) for more details. type InnerInstructions = record { instructions : vec Instruction; index : nat8; }; + // Errors that can occur during the execution of a specific instruction within a Solana transaction. // See the [Solana documentation](https://github.com/solana-labs/solana/blob/7700cb3128c1f19820de67b81aa45d18f73d2ac0/sdk/program/src/instruction.rs#L33) for more details. type InstructionError = variant { @@ -491,8 +495,10 @@ type InstructionError = variant { ReadonlyLamportChange; InsufficientFunds; }; + // Transaction addresses loaded from address lookup tables. type LoadedAddresses = record { writable : vec Pubkey; readonly : vec Pubkey }; + // Reward or penalty applied to an account for fees, rent, voting, or staking activity. type Reward = record { lamports : int64; @@ -501,6 +507,7 @@ type Reward = record { rewardType : opt variant { fee; rent; voting; staking }; postBalance : nat64; }; + // A human-readable representation of a token amount. type TokenAmount = record { decimals : nat8; @@ -555,6 +562,7 @@ type TransactionError = variant { CommitCancelled; BlockhashNotFound; }; + // The result of a Solana `getTransaction` RPC method call. type TransactionInfo = record { // Estimated production time, as Unix timestamp (seconds since the Unix epoch) of when the transaction was processed. @@ -570,6 +578,7 @@ type TransactionInfo = record { // Undefined if `maxSupportedTransactionVersion` is not set in request params. version : opt variant { legacy; number : nat8 }; }; + // Transaction status metadata. // See the [Solana documentation](https://solana.com/docs/rpc/json-structures#transaction-status-metadata) for more details. type TransactionStatusMeta = record { @@ -586,6 +595,7 @@ type TransactionStatusMeta = record { preTokenBalances : opt vec TransactionTokenBalance; computeUnitsConsumed : opt nat64; }; + // Balance of a specific SPL token account. type TransactionTokenBalance = record { owner : opt Pubkey; @@ -595,6 +605,43 @@ type TransactionTokenBalance = record { uiTokenAmount : TokenAmount; }; +// Solana transaction status as returned by the `getSignatureStatuses` RPC method. +// +// *WARNING*: The optional `confirmations` field in the `getSignatureStatuses` response is not +// included in this type. This value is ignored when processing the RPC response because it +// changes with every Solana block (approximately every 400ms) which is too quick to achieve +// consensus between the different nodes performing the request. +type TransactionStatus = record { + // The slot the transaction was processed. + slot: Slot; + // *DEPRECATED*: Transaction status: + // * Ok - Transaction was successful + // * Err - Transaction failed with `TransactionError` + status: variant { Ok; Err : TransactionError }; + // Error if transaction failed, null if transaction succeeded. + err: opt TransactionError; + // The transaction's cluster confirmation status; Either `processed`, `confirmed`, or `finalized`. + // See [Commitment](https://solana.com/docs/rpc#configuring-state-commitment) for more on + // optimistic confirmation. + confirmationStatus: opt TransactionConfirmationStatus; +}; + +// A Solana transaction confirmation status. +type TransactionConfirmationStatus = variant { + processed; + confirmed; + finalized; +}; + +// Represents the result of a call to the `getSignatureStatuses` Solana RPC method. +type GetSignatureStatusesResult = variant { Ok : vec opt TransactionStatus; Err : RpcError }; + +// Represents an aggregated result from multiple RPC calls to the `getSignatureStatuses` Solana RPC method. +type MultiGetSignatureStatusesResult = variant { + Consistent : GetSignatureStatusesResult; + Inconsistent : vec record { RpcSource; GetSignatureStatusesResult }; +}; + // Represents the result of a call to the `getTransaction` Solana RPC method. type GetTransactionResult = variant { Ok : opt TransactionInfo; Err : RpcError }; @@ -671,6 +718,14 @@ type CommitmentLevel = variant { // 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 `getSignatureStatuses` Solana RPC method. +type GetSignatureStatusesParams = record { + // An array of transaction signatures to confirm, as base-58 encoded strings (up to a maximum of 256) + signatures: vec Signature; + // If set to true, a Solana node will search its ledger cache for any signatures not found in the recent status cache. + searchTransactionHistory: opt bool; +}; + // The parameters for a call to the `getSlot` Solana RPC method. type GetSlotParams = record { commitment: opt CommitmentLevel; @@ -750,11 +805,15 @@ 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 `getSignatureStatuses` RPC method and return the resulting statuses. + getSignatureStatuses : (RpcSources, opt RpcConfig, GetSignatureStatusesParams) -> (MultiGetSignatureStatusesResult); + getSignatureStatusesCyclesCost : (RpcSources, opt RpcConfig, GetSignatureStatusesParams) -> (RequestCostResult) query; + // Call the Solana `getSlot` RPC method and return the resulting slot. getSlot : (RpcSources, opt GetSlotRpcConfig, opt GetSlotParams) -> (MultiGetSlotResult); getSlotCyclesCost : (RpcSources, opt GetSlotRpcConfig, opt GetSlotParams) -> (RequestCostResult) query; diff --git a/canister/src/main.rs b/canister/src/main.rs index fb1fa427..50f99718 100644 --- a/canister/src/main.rs +++ b/canister/src/main.rs @@ -14,10 +14,11 @@ use sol_rpc_canister::{ }; use sol_rpc_types::{ AccountInfo, ConfirmedBlock, GetAccountInfoParams, GetBalanceParams, GetBlockParams, - GetRecentPrioritizationFeesParams, GetRecentPrioritizationFeesRpcConfig, GetSlotParams, - GetSlotRpcConfig, GetTokenAccountBalanceParams, GetTransactionParams, Lamport, MultiRpcResult, - PrioritizationFee, RpcAccess, RpcConfig, RpcResult, RpcSources, SendTransactionParams, - Signature, Slot, SupportedRpcProvider, SupportedRpcProviderId, TokenAmount, TransactionInfo, + GetRecentPrioritizationFeesParams, GetRecentPrioritizationFeesRpcConfig, + GetSignatureStatusesParams, GetSlotParams, GetSlotRpcConfig, GetTokenAccountBalanceParams, + GetTransactionParams, Lamport, MultiRpcResult, PrioritizationFee, RpcAccess, RpcConfig, + RpcResult, RpcSources, SendTransactionParams, Signature, Slot, SupportedRpcProvider, + SupportedRpcProviderId, TokenAmount, TransactionInfo, TransactionStatus, }; use std::str::FromStr; @@ -189,6 +190,33 @@ async fn get_recent_prioritization_fees_cycles_cost( .await } +#[update(name = "getSignatureStatuses")] +#[candid_method(rename = "getSignatureStatuses")] +async fn get_signature_statuses( + source: RpcSources, + config: Option, + params: GetSignatureStatusesParams, +) -> MultiRpcResult>> { + let request = + MultiRpcRequest::get_signature_statuses(source, config.unwrap_or_default(), params); + send_multi(request).await.into() +} + +#[query(name = "getSignatureStatusesCyclesCost")] +#[candid_method(query, rename = "getSignatureStatusesCyclesCost")] +async fn get_signature_statuses_cycles_cost( + source: RpcSources, + config: Option, + params: GetSignatureStatusesParams, +) -> RpcResult { + if read_state(State::is_demo_mode_active) { + return Ok(0); + } + MultiRpcRequest::get_signature_statuses(source, config.unwrap_or_default(), params)? + .cycles_cost() + .await +} + #[update(name = "getSlot")] #[candid_method(rename = "getSlot")] async fn get_slot( diff --git a/canister/src/rpc_client/cbor/rounding_error.rs b/canister/src/rpc_client/cbor/rounding_error.rs index 030e0149..5290c63d 100644 --- a/canister/src/rpc_client/cbor/rounding_error.rs +++ b/canister/src/rpc_client/cbor/rounding_error.rs @@ -1,5 +1,7 @@ -use minicbor::decode::Decoder; -use minicbor::encode::{Encoder, Write}; +use minicbor::{ + decode::Decoder, + encode::{Encoder, Write}, +}; use sol_rpc_types::RoundingError; pub fn decode( diff --git a/canister/src/rpc_client/cbor/tests.rs b/canister/src/rpc_client/cbor/tests.rs index 9c624aea..2524dc42 100644 --- a/canister/src/rpc_client/cbor/tests.rs +++ b/canister/src/rpc_client/cbor/tests.rs @@ -1,6 +1,8 @@ use minicbor::{Decode, Encode}; -use proptest::prelude::{any, TestCaseError}; -use proptest::{prop_assert_eq, proptest}; +use proptest::{ + prelude::{any, TestCaseError}, + prop_assert_eq, proptest, +}; use sol_rpc_types::RoundingError; proptest! { diff --git a/canister/src/rpc_client/json/mod.rs b/canister/src/rpc_client/json/mod.rs index c6cb0c3b..2055c80b 100644 --- a/canister/src/rpc_client/json/mod.rs +++ b/canister/src/rpc_client/json/mod.rs @@ -1,8 +1,9 @@ +use derive_more::From; use serde::{Deserialize, Serialize}; use serde_with::skip_serializing_none; use sol_rpc_types::{ CommitmentLevel, DataSlice, GetAccountInfoEncoding, GetBlockCommitmentLevel, - GetTransactionEncoding, Pubkey, SendTransactionEncoding, Slot, TransactionDetails, + GetTransactionEncoding, Pubkey, SendTransactionEncoding, Signature, Slot, TransactionDetails, }; use solana_transaction_status_client_types::UiTransactionEncoding; @@ -180,6 +181,41 @@ impl From for GetRecentPriorit } } +#[skip_serializing_none] +#[derive(Serialize, Deserialize, Clone, Debug)] +#[serde(into = "(Vec, Option)")] +pub struct GetSignatureStatusesParams(Vec, Option); + +impl GetSignatureStatusesParams { + pub fn num_signatures(&self) -> usize { + self.0.len() + } +} + +impl From for GetSignatureStatusesParams { + fn from(params: sol_rpc_types::GetSignatureStatusesParams) -> Self { + Self( + params.signatures.into(), + params + .search_transaction_history + .map(GetSignatureStatusesConfig::from), + ) + } +} + +impl From for (Vec, Option) { + fn from(params: GetSignatureStatusesParams) -> Self { + (params.0, params.1) + } +} + +#[skip_serializing_none] +#[derive(Serialize, Deserialize, Clone, Debug, From)] +pub struct GetSignatureStatusesConfig { + #[serde(rename = "searchTransactionHistory")] + pub search_transaction_history: bool, +} + #[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 c7a40a6e..63bff7c4 100644 --- a/canister/src/rpc_client/mod.rs +++ b/canister/src/rpc_client/mod.rs @@ -166,6 +166,34 @@ impl GetBlockRequest { } } +pub type GetSignatureStatusesRequest = MultiRpcRequest< + json::GetSignatureStatusesParams, + Vec>, +>; + +impl GetSignatureStatusesRequest { + pub fn get_signature_statuses>( + rpc_sources: RpcSources, + config: RpcConfig, + params: Params, + ) -> Result { + let params = params.into(); + 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(128 + (params.num_signatures() as u64 * 256) + HEADER_SIZE_LIMIT); + + Ok(MultiRpcRequest::new( + providers, + JsonRpcRequest::new("getSignatureStatuses", params), + max_response_bytes, + ResponseTransform::GetSignatureStatuses, + ReductionStrategy::from(consensus_strategy), + )) + } +} + pub type GetSlotRequest = MultiRpcRequest; impl GetSlotRequest { diff --git a/canister/src/rpc_client/sol_rpc/mod.rs b/canister/src/rpc_client/sol_rpc/mod.rs index 8257675b..b4dfbadd 100644 --- a/canister/src/rpc_client/sol_rpc/mod.rs +++ b/canister/src/rpc_client/sol_rpc/mod.rs @@ -8,10 +8,11 @@ use ic_cdk::{ query, }; use minicbor::{Decode, Encode}; -use serde::{de::DeserializeOwned, Serialize}; +use serde::{de::DeserializeOwned, Deserialize, Serialize}; use serde_json::{from_slice, Value}; use sol_rpc_types::{PrioritizationFee, RoundingError}; use solana_clock::Slot; +use solana_transaction_status_client_types::TransactionStatus; use std::fmt::Debug; use strum::EnumIter; @@ -34,22 +35,36 @@ pub enum ResponseTransform { max_length: u8, }, #[n(4)] - GetSlot(#[cbor(n(0), with = "crate::rpc_client::cbor::rounding_error")] RoundingError), + GetSignatureStatuses, #[n(5)] - GetTokenAccountBalance, + GetSlot(#[cbor(n(0), with = "crate::rpc_client::cbor::rounding_error")] RoundingError), #[n(6)] - GetTransaction, + GetTokenAccountBalance, #[n(7)] - SendTransaction, + GetTransaction, #[n(8)] + SendTransaction, + #[n(9)] Raw, } impl ResponseTransform { fn apply(&self, body_bytes: &mut Vec) { + #[derive(Clone, Debug, Deserialize, Serialize)] + pub struct SolanaRpcResult { + // This field is always ignored since it contains the fast-changing current + // slot value for which consensus cannot generally be reached across nodes. + context: Value, + value: T, + } + + fn ignore_context(value: SolanaRpcResult) -> T { + value.value + } + fn canonicalize_response(body_bytes: &mut Vec, f: impl FnOnce(T) -> R) where - T: Serialize + DeserializeOwned, + T: Serialize + DeserializeOwned + Debug, R: Serialize + DeserializeOwned, { if let Ok(response) = from_slice::>(body_bytes) { @@ -66,15 +81,13 @@ impl ResponseTransform { match self { Self::GetAccountInfo => { - canonicalize_response::>(body_bytes, |result| { - match result["value"].clone() { - Value::Null => None, - value => Some(value), - } - }); + canonicalize_response::>, Option>( + body_bytes, + ignore_context, + ); } Self::GetBalance => { - canonicalize_response::(body_bytes, |result| result["value"].clone()); + canonicalize_response::, Value>(body_bytes, ignore_context); } Self::GetBlock => { canonicalize_response::>(body_bytes, |result| match result { @@ -89,7 +102,6 @@ impl ResponseTransform { 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 @@ -122,6 +134,22 @@ impl ResponseTransform { }, ); } + Self::GetSignatureStatuses => { + canonicalize_response::< + SolanaRpcResult>>, + Vec>, + >(body_bytes, |statuses| { + ignore_context(statuses) + .into_iter() + .map(|maybe_status| { + maybe_status.map(|mut status| { + status.confirmations = None; + status + }) + }) + .collect() + }); + } Self::GetSlot(rounding_error) => { canonicalize_response::(body_bytes, |slot| rounding_error.round(slot)); } @@ -132,7 +160,7 @@ impl ResponseTransform { }); } Self::GetTokenAccountBalance => { - canonicalize_response::(body_bytes, |result| result["value"].clone()); + canonicalize_response::, Value>(body_bytes, ignore_context); } Self::SendTransaction => { canonicalize_response::(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 96108e6d..9c43733c 100644 --- a/canister/src/rpc_client/sol_rpc/tests.rs +++ b/canister/src/rpc_client/sol_rpc/tests.rs @@ -1,12 +1,20 @@ use crate::rpc_client::sol_rpc::ResponseTransform; use canhttp::http::json::{Id, JsonRpcResponse}; -use proptest::proptest; +use proptest::{ + array::uniform32, + prelude::{any, prop, Strategy}, + prop_assert_eq, proptest, +}; +use rand::prelude::SliceRandom; +use rand_chacha::{rand_core::SeedableRng, ChaCha20Rng}; +use serde::Serialize; use serde_json::{from_slice, json, to_vec, Value}; +use sol_rpc_types::{PrioritizationFee, RoundingError, Slot}; +use std::ops::RangeInclusive; +use strum::IntoEnumIterator; mod normalization_tests { use super::*; - use sol_rpc_types::RoundingError; - use strum::IntoEnumIterator; #[test] fn should_normalize_raw_response() { @@ -273,6 +281,92 @@ mod normalization_tests { ); } + #[test] + fn should_normalize_get_signature_statuses_response() { + assert_normalized_equal( + &ResponseTransform::GetSignatureStatuses, + r#"{ + "context": { "apiVersion": "2.0.15", "slot": 341197053 }, + "value": [ + { + "err": null, + "confirmations": 27, + "status": { "Ok": null }, + "slot": 48, + "confirmationStatus": "finalized" + }, + { + "slot": 987, + "err": "AccountInUse", + "confirmations": null, + "confirmationStatus": "processed", + "status": { "Err": "AccountInUse" } + }, + null + ] + }"#, + r#"{ + "context": { "apiVersion": "2.0.15", "slot": 341197053 }, + "value": [ + { + "slot": 48, + "confirmations": null, + "err": null, + "status": { "Ok": null }, + "confirmationStatus": "finalized" + }, + { + "slot": 987, + "confirmations": null, + "err": "AccountInUse", + "status": { "Err": "AccountInUse" }, + "confirmationStatus": "processed" + }, + null + ] + }"#, + ); + } + + proptest! { + #[test] + fn should_ignore_get_signature_statuses_context_and_confirmations(slot1: u64, slot2: u64, confirmations1: usize, confirmations2: usize) { + assert_normalized_equal( + &ResponseTransform::GetSignatureStatuses, + json!({ + "context": { "apiVersion": "2.0.15", "slot": slot1 }, + "value": [ + { + "slot": 48, + "confirmations": confirmations1, + "err": null, + "status": { + "Ok": null + }, + "confirmationStatus": "finalized" + }, + null + ] + }).to_string(), + json!({ + "context": { "apiVersion": "2.0.15", "slot": slot2 }, + "value": [ + { + "slot": 48, + "confirmations": confirmations2, + "err": null, + "status": { + "Ok": null + }, + "confirmationStatus": "finalized" + }, + null + ] + }).to_string(), + ); + } + } + #[test] fn should_normalize_json_rpc_error() { fn normalize_json(transform: &ResponseTransform, response: &str) -> Vec { @@ -303,7 +397,7 @@ mod normalization_tests { assert_eq!( expected_response, normalized_response, - "expected {:?}, actual: {:?}", + "expected: {:?}, actual: {:?}", from_slice::(&expected_response), from_slice::(&normalized_response), ); @@ -323,9 +417,14 @@ mod normalization_tests { left: impl AsRef, right: impl AsRef, ) { + let normalized_left = normalize_result(transform, left.as_ref()); + let normalized_right = normalize_result(transform, right.as_ref()); assert_eq!( - normalize_result(transform, left.as_ref()), - normalize_result(transform, right.as_ref()) + normalized_left, + normalized_right, + "Normalized values are not equal:\n left: {:?}\n right: {:?}", + from_slice::(&normalized_left), + from_slice::(&normalized_right), ); } @@ -338,18 +437,7 @@ mod normalization_tests { } mod get_recent_prioritization_fees { - use crate::rpc_client::sol_rpc::ResponseTransform; - 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, RoundingError, Slot}; - use std::ops::RangeInclusive; + use super::*; #[test] fn should_normalize_response_with_less_than_150_entries() { @@ -474,9 +562,9 @@ mod get_recent_prioritization_fees { max_slot_rounding_error: RoundingError::new(10), max_length: 100, }; - let mut raw_bytes = serde_json::to_vec(&json_response(&fees)).unwrap(); + let mut raw_bytes = to_vec(&json_response(&fees)).unwrap(); transform.apply(&mut raw_bytes); - let transformed_response: serde_json::Value = serde_json::from_slice(&raw_bytes).unwrap(); + let transformed_response: Value = from_slice(&raw_bytes).unwrap(); assert_eq!(transformed_response, json_response(&fees[0..2])); } diff --git a/canister/src/rpc_client/tests.rs b/canister/src/rpc_client/tests.rs index 7b4b5e52..ebb9fb15 100644 --- a/canister/src/rpc_client/tests.rs +++ b/canister/src/rpc_client/tests.rs @@ -1,20 +1,21 @@ use crate::rpc_client::{ - GetAccountInfoRequest, GetBlockRequest, GetSlotRequest, GetTransactionRequest, MultiRpcRequest, - SendTransactionRequest, + GetAccountInfoRequest, GetBlockRequest, GetSignatureStatusesRequest, GetSlotRequest, + GetTransactionRequest, MultiRpcRequest, SendTransactionRequest, }; use serde::Serialize; use serde_json::json; use sol_rpc_types::{ CommitmentLevel, DataSlice, GetAccountInfoEncoding, GetAccountInfoParams, GetBalanceParams, - GetBlockCommitmentLevel, GetBlockParams, GetSlotParams, GetSlotRpcConfig, - GetTokenAccountBalanceParams, GetTransactionEncoding, GetTransactionParams, RpcConfig, - RpcSources, SendTransactionEncoding, SendTransactionParams, SolanaCluster, TransactionDetails, + GetBlockCommitmentLevel, GetBlockParams, GetSignatureStatusesParams, GetSlotParams, + GetSlotRpcConfig, GetTokenAccountBalanceParams, GetTransactionEncoding, GetTransactionParams, + RpcConfig, RpcSources, SendTransactionEncoding, SendTransactionParams, Signature, + SolanaCluster, TransactionDetails, }; +use solana_pubkey::pubkey; +use std::str::FromStr; mod request_serialization_tests { use super::*; - use sol_rpc_types::Signature; - use solana_pubkey::pubkey; #[test] fn should_serialize_get_account_info_request() { @@ -84,6 +85,45 @@ mod request_serialization_tests { ); } + #[test] + fn should_serialize_get_signature_statuses_request() { + assert_params_eq( + GetSignatureStatusesRequest::get_signature_statuses( + RpcSources::Default(SolanaCluster::Mainnet), + RpcConfig::default(), + GetSignatureStatusesParams { + signatures: vec![].try_into().unwrap(), + search_transaction_history: None, + }, + ) + .unwrap(), + json!([[], null]), + ); + assert_params_eq( + GetSignatureStatusesRequest::get_signature_statuses( + RpcSources::Default(SolanaCluster::Mainnet), + RpcConfig::default(), + GetSignatureStatusesParams { + signatures: vec![ + Signature::from_str("5iBbqBJzgqafuQn93Np8ztWyXeYe2ReGPzUB1zXP2suZ8b5EaxSwe74ZUhg5pZQuDQkNGW7XApgfXX91YLYUuo5y").unwrap(), + Signature::from_str("FAAHyQpENs991w9BR7jpwzyXk74jhQWzbsSbjs4NJWkYeL6nggNfT5baWy6eBNLSuqfiiYRGfEC5bhwxUVBZamB").unwrap() + ].try_into().unwrap(), + search_transaction_history: Some(true), + }, + ) + .unwrap(), + json!([ + [ + "5iBbqBJzgqafuQn93Np8ztWyXeYe2ReGPzUB1zXP2suZ8b5EaxSwe74ZUhg5pZQuDQkNGW7XApgfXX91YLYUuo5y", + "FAAHyQpENs991w9BR7jpwzyXk74jhQWzbsSbjs4NJWkYeL6nggNfT5baWy6eBNLSuqfiiYRGfEC5bhwxUVBZamB" + ], + { + "searchTransactionHistory": true, + } + ]), + ); + } + #[test] fn should_serialize_get_transaction_request() { let signature = solana_signature::Signature::default().to_string(); diff --git a/integration_tests/tests/solana_test_validator.rs b/integration_tests/tests/solana_test_validator.rs index 0baa9efd..5451475d 100644 --- a/integration_tests/tests/solana_test_validator.rs +++ b/integration_tests/tests/solana_test_validator.rs @@ -9,7 +9,7 @@ use sol_rpc_int_tests::{spl, PocketIcLiveModeRuntime}; use sol_rpc_types::{ CommitmentLevel, GetAccountInfoEncoding, GetAccountInfoParams, GetBlockCommitmentLevel, GetBlockParams, GetTransactionEncoding, GetTransactionParams, InstallArgs, Lamport, - OverrideProvider, PrioritizationFee, RegexSubstitution, TransactionDetails, + OverrideProvider, PrioritizationFee, RegexSubstitution, TransactionDetails, TransactionStatus, }; use solana_account_decoder_client_types::{token::UiTokenAmount, UiAccount}; use solana_client::rpc_client::{RpcClient as SolanaRpcClient, RpcClient}; @@ -22,15 +22,17 @@ use solana_program::{ system_instruction, sysvar, }; use solana_pubkey::{pubkey, Pubkey}; -use solana_rpc_client_api::config::{RpcBlockConfig, RpcTransactionConfig}; -use solana_rpc_client_api::response::RpcPrioritizationFee; +use solana_rpc_client_api::{ + config::{RpcBlockConfig, RpcTransactionConfig}, + 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, + iter::zip, str::FromStr, thread, thread::sleep, @@ -394,6 +396,8 @@ async fn should_get_balance() { setup.confirm_transaction(&tx); assert_eq!(compare_balances(&setup, publickey).await, 10_000_000_000); + + setup.setup.drop().await; } #[tokio::test(flavor = "multi_thread")] @@ -452,6 +456,56 @@ async fn should_get_token_account_balance() { ); } +#[tokio::test(flavor = "multi_thread")] +async fn should_get_signature_statuses() { + let setup = Setup::new().await; + + let signatures = { + // Generate a transaction and get the signature + let sig_1 = setup + .solana_client + .request_airdrop(&Keypair::new().pubkey(), 10_000_000_000) + .expect("Error while requesting airdrop"); + setup.confirm_transaction(&sig_1); + // An arbitrary signature not corresponding to any transaction + let sig_2 = Signature::from([57u8; 64]); + &vec![sig_1, sig_2] + }; + + let (sol_res, ic_res) = setup + .compare_client( + |sol| { + sol.get_signature_statuses(signatures) + .expect("Failed to get signature statuses") + .value + }, + |ic| async move { + ic.get_signature_statuses(signatures) + .unwrap() + .send() + .await + .expect_consistent() + .unwrap_or_else(|e| panic!("`getSignatureStatuses` call failed: {e}")) + }, + ) + .await; + + // Convert to sol_rpc_type::TransactionStatus to avoid comparing TransactionStatus#confirmations + // which changes fast and hence is usually different for both calls + assert_eq!( + sol_res + .into_iter() + .map(|maybe_status| maybe_status.map(TransactionStatus::from)) + .collect::>(), + ic_res + .into_iter() + .map(|maybe_status| maybe_status.map(TransactionStatus::from)) + .collect::>() + ); + + setup.setup.drop().await; +} + fn solana_rpc_client_get_account( pubkey: &Pubkey, sol: &RpcClient, diff --git a/integration_tests/tests/tests.rs b/integration_tests/tests/tests.rs index 799a7a9e..e856bbb2 100644 --- a/integration_tests/tests/tests.rs +++ b/integration_tests/tests/tests.rs @@ -22,6 +22,7 @@ use solana_account_decoder_client_types::{ }; use solana_pubkey::pubkey; use solana_signer::Signer; +use solana_transaction_status_client_types::{TransactionConfirmationStatus, TransactionStatus}; use std::{fmt::Debug, iter::zip, str::FromStr}; use strum::IntoEnumIterator; @@ -472,8 +473,7 @@ 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_int_tests::{mock::MockOutcallBuilder, Setup, SolRpcTestClient}; use sol_rpc_types::PrioritizationFee; #[tokio::test] @@ -1491,6 +1491,9 @@ mod cycles_cost_tests { SolRpcEndpoint::GetRecentPrioritizationFees => { check(client.get_recent_prioritization_fees(&[]).unwrap()).await } + SolRpcEndpoint::GetSignatureStatuses => { + check(client.get_signature_statuses(&[some_signature()]).unwrap()).await; + } SolRpcEndpoint::GetTransaction => { check(client.get_transaction(some_signature())).await; } @@ -1544,6 +1547,9 @@ mod cycles_cost_tests { SolRpcEndpoint::GetRecentPrioritizationFees => { check(client.get_recent_prioritization_fees(&[]).unwrap()).await; } + SolRpcEndpoint::GetSignatureStatuses => { + check(client.get_signature_statuses(&[some_signature()]).unwrap()).await; + } SolRpcEndpoint::GetTokenAccountBalance => { check(client.get_token_account_balance(USDC_PUBLIC_KEY)).await; } @@ -1661,6 +1667,14 @@ mod cycles_cost_tests { ) .await; } + SolRpcEndpoint::GetSignatureStatuses => { + check( + &setup, + client.get_signature_statuses(&[some_signature()]).unwrap(), + 1_744_458_400, + ) + .await; + } SolRpcEndpoint::GetSlot => { check( &setup, @@ -1843,6 +1857,88 @@ mod get_token_account_balance_tests { } } +mod get_signature_statuses_tests { + use super::*; + + #[tokio::test] + async fn should_get_signature_statuses() { + fn request_body(id: u8) -> serde_json::Value { + json!({ + "jsonrpc": "2.0", + "id": Id::from(ConstantSizeId::from(id)), + "method": "getSignatureStatuses", + "params": [ + [some_signature().to_string(), another_signature().to_string()], + { + "searchTransactionHistory": true + } + ], + }) + } + + fn response_body(id: u8) -> serde_json::Value { + json!({ + "id": Id::from(ConstantSizeId::from(id)), + "jsonrpc": "2.0", + "result": { + // context should be filtered out by transform + "context": { "slot": 334048531 + id as u64, "apiVersion": "2.1.9" }, + "value": [ + { + "slot": 48, + // confirmations should be filtered out by transform + "confirmations": id, + "err": null, + "status": { "Ok": null }, + "confirmationStatus": "finalized" + }, + null + ] + }, + }) + } + + let setup = Setup::new().await.with_mock_api_keys().await; + + for (sources, first_id) in zip(rpc_sources(), vec![0_u8, 3, 6]) { + let client = setup.client().with_rpc_sources(sources); + + let results = client + .mock_http_sequence(vec![ + MockOutcallBuilder::new(200, response_body(first_id)) + .with_request_body(request_body(first_id)), + MockOutcallBuilder::new(200, response_body(first_id + 1)) + .with_request_body(request_body(first_id + 1)), + MockOutcallBuilder::new(200, response_body(first_id + 2)) + .with_request_body(request_body(first_id + 2)), + ]) + .build() + .get_signature_statuses(&[some_signature(), another_signature()]) + .unwrap() + .with_search_transaction_history(true) + .send() + .await + .expect_consistent(); + + assert_eq!( + results, + Ok(vec![ + Some(TransactionStatus { + slot: 48, + confirmations: None, + status: Ok(()), + err: None, + confirmation_status: Some(TransactionConfirmationStatus::Finalized), + }), + None, + ]) + ); + } + + setup.drop().await; + } +} + fn assert_within(actual: u128, expected: u128, percentage_error: u8) { assert!(percentage_error <= 100); let error_margin = expected.saturating_mul(percentage_error as u128) / 100; @@ -1873,3 +1969,10 @@ fn some_signature() -> solana_signature::Signature { ) .unwrap() } + +fn another_signature() -> solana_signature::Signature { + solana_signature::Signature::from_str( + "4XLJdFbdYYzzBMqvji9bq6ZgzRx5G9edjkJQGprMoAarJSbNbbHt1DTCZqcA7mYk4bJPgC6w7tFjYEtw1jJJSdyw", + ) + .unwrap() +} diff --git a/libs/client/Cargo.toml b/libs/client/Cargo.toml index ceaa06f9..909bd8aa 100644 --- a/libs/client/Cargo.toml +++ b/libs/client/Cargo.toml @@ -13,14 +13,17 @@ include = ["src", "Cargo.toml", "CHANGELOG.md", "LICENSE", "README.md"] [dependencies] async-trait = { workspace = true } candid = { workspace = true } +derive_more = { workspace = true } ic-cdk = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } solana-account = { workspace = true } solana-account-decoder-client-types = { workspace = true } solana-clock = { workspace = true } +solana-instruction = { workspace = true } solana-pubkey = { workspace = true } solana-signature = { workspace = true } +solana-transaction-error = { workspace = true } solana-transaction-status-client-types = { workspace = true } sol_rpc_types = { version = "0.1.0", path = "../types" } strum = { workspace = true } diff --git a/libs/client/src/lib.rs b/libs/client/src/lib.rs index 0c1212d8..42117a72 100644 --- a/libs/client/src/lib.rs +++ b/libs/client/src/lib.rs @@ -122,29 +122,27 @@ pub mod fixtures; mod request; -pub use request::{Request, RequestBuilder, SolRpcEndpoint, SolRpcRequest}; -use std::fmt::Debug; - use crate::request::{ GetAccountInfoRequest, GetBalanceRequest, GetBlockRequest, GetRecentPrioritizationFeesRequest, - GetRecentPrioritizationFeesRequestBuilder, GetSlotRequest, GetTokenAccountBalanceRequest, + GetRecentPrioritizationFeesRequestBuilder, GetSignatureStatusesRequest, + GetSignatureStatusesRequestBuilder, GetSlotRequest, GetTokenAccountBalanceRequest, GetTransactionRequest, JsonRequest, SendTransactionRequest, }; use async_trait::async_trait; use candid::{utils::ArgumentEncoder, CandidType, Principal}; use ic_cdk::api::call::RejectionCode; +pub use request::{Request, RequestBuilder, SolRpcEndpoint, SolRpcRequest}; use serde::de::DeserializeOwned; use sol_rpc_types::{ CommitmentLevel, GetAccountInfoParams, GetBalanceParams, GetBlockParams, - GetRecentPrioritizationFeesParams, GetSlotParams, GetSlotRpcConfig, - GetTokenAccountBalanceParams, GetTransactionParams, Lamport, Pubkey, RpcConfig, RpcError, - RpcSources, SendTransactionParams, Signature, SolanaCluster, SupportedRpcProvider, + GetRecentPrioritizationFeesParams, GetSignatureStatusesParams, GetSlotParams, GetSlotRpcConfig, + GetTokenAccountBalanceParams, GetTransactionParams, Lamport, Pubkey, RpcConfig, RpcResult, + RpcSources, SendTransactionParams, Signature, Slot, SolanaCluster, SupportedRpcProvider, SupportedRpcProviderId, TokenAmount, TransactionDetails, TransactionInfo, }; use solana_account_decoder_client_types::token::UiTokenAmount; -use solana_clock::Slot; use solana_transaction_status_client_types::EncodedConfirmedTransactionWithStatusMeta; -use std::sync::Arc; +use std::{fmt::Debug, sync::Arc}; /// The principal identifying the productive Solana RPC canister under NNS control. /// @@ -518,7 +516,7 @@ impl SolRpcClient { pub fn get_recent_prioritization_fees<'a, I>( &self, addresses: I, - ) -> Result, RpcError> + ) -> RpcResult> where I: IntoIterator, { @@ -532,6 +530,116 @@ impl SolRpcClient { )) } + /// Call `getSignatureStatuses` on the SOL RPC canister. + /// + /// # Examples + /// + /// ```rust + /// use sol_rpc_client::SolRpcClient; + /// use sol_rpc_types::{RpcSources, SolanaCluster}; + /// use solana_instruction::error::InstructionError; + /// use solana_signature::Signature; + /// use solana_transaction_error::TransactionError; + /// use solana_transaction_status_client_types::{TransactionConfirmationStatus, TransactionStatus}; + /// + /// # #[tokio::main] + /// # async fn main() -> Result<(), Box> { + /// # use std::str::FromStr; + /// # use sol_rpc_types::MultiRpcResult; + /// let client = SolRpcClient::builder_for_ic() + /// # .with_mocked_response(MultiRpcResult::Consistent(Ok(vec![ + /// # Some(sol_rpc_types::TransactionStatus { + /// # slot: 338837593, + /// # status: Ok(()), + /// # err: None, + /// # confirmation_status: Some(sol_rpc_types::TransactionConfirmationStatus::Finalized), + /// # }), + /// # Some(sol_rpc_types::TransactionStatus { + /// # slot: 338838881, + /// # status: Err(sol_rpc_types::TransactionError::InstructionError(2, sol_rpc_types::InstructionError::GenericError)), + /// # err: Some(sol_rpc_types::TransactionError::InstructionError(2, sol_rpc_types::InstructionError::GenericError)), + /// # confirmation_status: Some(sol_rpc_types::TransactionConfirmationStatus::Finalized), + /// # }), + /// # ]))) + /// .with_rpc_sources(RpcSources::Default(SolanaCluster::Mainnet)) + /// .build(); + /// + /// let statuses = client + /// .get_signature_statuses(&[ + /// Signature::from_str("5iBbqBJzgqafuQn93Np8ztWyXeYe2ReGPzUB1zXP2suZ8b5EaxSwe74ZUhg5pZQuDQkNGW7XApgfXX91YLYUuo5y").unwrap(), + /// Signature::from_str("FAAHyQpENs991w9BR7jpwzyXk74jhQWzbsSbjs4NJWkYeL6nggNfT5baWy6eBNLSuqfiiYRGfEC5bhwxUVBZamB").unwrap() + /// ]) + /// .expect("Invalid `getSignatureStatuses` request parameters") + /// .send() + /// .await + /// .expect_consistent(); + /// + /// assert_eq!(statuses, Ok(vec![ + /// Some(TransactionStatus { + /// slot: 338837593, + /// confirmations: None, + /// status: Ok(()), + /// err: None, + /// confirmation_status: Some(TransactionConfirmationStatus::Finalized), + /// }), + /// Some(TransactionStatus { + /// slot: 338838881, + /// confirmations: None, + /// status: Err(TransactionError::InstructionError(2, InstructionError::GenericError)), + /// err: Some(TransactionError::InstructionError(2, InstructionError::GenericError)), + /// confirmation_status: Some(TransactionConfirmationStatus::Finalized), + /// }), + /// ])); + /// # Ok(()) + /// # } + /// ``` + /// + /// # Errors + /// + /// The number of signatures that can be passed to + /// [`getSignatureStatuses`](https://solana.com/de/docs/rpc/http/getsignaturestatuses) + /// is limited to 256. More signatures result in an error. + /// + /// ```rust + /// use std::{str::FromStr, collections::BTreeSet}; + /// use assert_matches::assert_matches; + /// use solana_signature::Signature; + /// 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_signatures: BTreeSet = (0..257_u16) + /// .map(|i| { + /// let mut bytes = [0; 64]; + /// bytes[0] = (i >> 8) as u8; + /// bytes[1] = (i & 0xff) as u8; + /// Signature::from(bytes) + /// }) + /// .collect(); + /// assert_eq!(too_many_signatures.len(), 257); + /// + /// let err = client.get_signature_statuses(&too_many_signatures).unwrap_err(); + /// assert_matches!(err, RpcError::ValidationError(_)); + /// ``` + pub fn get_signature_statuses<'a, I>( + &self, + signatures: I, + ) -> RpcResult> + where + I: IntoIterator, + { + let signatures = signatures.into_iter().collect::>(); + let num_signatures = signatures.len(); + Ok(RequestBuilder::new( + self.clone(), + GetSignatureStatusesRequest::from(GetSignatureStatusesParams::try_from(signatures)?), + 2_000_000_000 + num_signatures as u128 * 1_000_000, // TODO XC-338: Check heuristic + )) + } + /// 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 5aab34bd..b931d152 100644 --- a/libs/client/src/request/mod.rs +++ b/libs/client/src/request/mod.rs @@ -3,17 +3,17 @@ mod tests; use crate::{Runtime, SolRpcClient}; use candid::CandidType; +use derive_more::From; use serde::de::DeserializeOwned; use sol_rpc_types::{ AccountInfo, CommitmentLevel, ConfirmedBlock, GetAccountInfoParams, GetBalanceParams, GetBlockCommitmentLevel, GetBlockParams, GetRecentPrioritizationFeesParams, - GetRecentPrioritizationFeesRpcConfig, GetSlotParams, GetSlotRpcConfig, - GetTokenAccountBalanceParams, GetTransactionParams, Lamport, PrioritizationFee, RoundingError, - RpcConfig, RpcResult, RpcSources, SendTransactionParams, Signature, TokenAmount, - TransactionInfo, + GetRecentPrioritizationFeesRpcConfig, GetSignatureStatusesParams, GetSlotParams, + GetSlotRpcConfig, GetTokenAccountBalanceParams, GetTransactionParams, Lamport, + PrioritizationFee, RoundingError, RpcConfig, RpcResult, RpcSources, SendTransactionParams, + Signature, Slot, TokenAmount, TransactionInfo, TransactionStatus, }; use solana_account_decoder_client_types::token::UiTokenAmount; -use solana_clock::Slot; use solana_transaction_status_client_types::EncodedConfirmedTransactionWithStatusMeta; use std::fmt::{Debug, Formatter}; use strum::EnumIter; @@ -47,6 +47,8 @@ pub enum SolRpcEndpoint { GetBlock, /// `getRecentPrioritizationFees` endpoint. GetRecentPrioritizationFees, + /// `getSignatureStatuses` endpoint. + GetSignatureStatuses, /// `getSlot` endpoint. GetSlot, /// `getTokenAccountBalance` endpoint. @@ -67,6 +69,7 @@ impl SolRpcEndpoint { SolRpcEndpoint::GetBalance => "getBalance", SolRpcEndpoint::GetBlock => "getBlock", SolRpcEndpoint::GetRecentPrioritizationFees => "getRecentPrioritizationFees", + SolRpcEndpoint::GetSignatureStatuses => "getSignatureStatuses", SolRpcEndpoint::GetSlot => "getSlot", SolRpcEndpoint::GetTokenAccountBalance => "getTokenAccountBalance", SolRpcEndpoint::GetTransaction => "getTransaction", @@ -82,6 +85,7 @@ impl SolRpcEndpoint { SolRpcEndpoint::GetBalance => "getBalanceCyclesCost", SolRpcEndpoint::GetBlock => "getBlockCyclesCost", SolRpcEndpoint::GetRecentPrioritizationFees => "getRecentPrioritizationFeesCyclesCost", + SolRpcEndpoint::GetSignatureStatuses => "getSignatureStatusesCyclesCost", SolRpcEndpoint::GetSlot => "getSlotCyclesCost", SolRpcEndpoint::GetTransaction => "getTransactionCyclesCost", SolRpcEndpoint::GetTokenAccountBalance => "getTokenAccountBalanceCyclesCost", @@ -211,6 +215,44 @@ impl From for GetRecentPrioritizationFeesRequ } } +#[derive(Debug, Clone, Default, From)] +pub struct GetSignatureStatusesRequest(GetSignatureStatusesParams); + +impl SolRpcRequest for GetSignatureStatusesRequest { + type Config = RpcConfig; + type Params = GetSignatureStatusesParams; + type CandidOutput = sol_rpc_types::MultiRpcResult>>; + type Output = sol_rpc_types::MultiRpcResult< + Vec>, + >; + + fn endpoint(&self) -> SolRpcEndpoint { + SolRpcEndpoint::GetSignatureStatuses + } + + fn params(self, _default_commitment_level: Option) -> Self::Params { + self.0 + } +} + +pub type GetSignatureStatusesRequestBuilder = RequestBuilder< + R, + RpcConfig, + GetSignatureStatusesParams, + sol_rpc_types::MultiRpcResult>>, + sol_rpc_types::MultiRpcResult< + Vec>, + >, +>; + +impl GetSignatureStatusesRequestBuilder { + /// Change the `searchTransactionHistory` parameter for a `getSignatureStatuses` request. + pub fn with_search_transaction_history(mut self, search_transaction_history: bool) -> Self { + self.request.params.search_transaction_history = Some(search_transaction_history); + self + } +} + #[derive(Debug, Clone, Default)] pub struct GetSlotRequest(Option); diff --git a/libs/client/src/request/tests.rs b/libs/client/src/request/tests.rs index b39e5578..54f17cd7 100644 --- a/libs/client/src/request/tests.rs +++ b/libs/client/src/request/tests.rs @@ -39,7 +39,10 @@ fn should_set_correct_commitment_level() { ); } SolRpcEndpoint::GetRecentPrioritizationFees => { - //no op, GetRecentPrioritizationFees does not use commitment level + // no op, GetRecentPrioritizationFees does not use commitment level + } + SolRpcEndpoint::GetSignatureStatuses => { + // no op, GetSignatureStatuses does not use commitment level } SolRpcEndpoint::GetSlot => { let builder = client_with_commitment_level.get_slot(); diff --git a/libs/types/Cargo.toml b/libs/types/Cargo.toml index 324c859c..251cb342 100644 --- a/libs/types/Cargo.toml +++ b/libs/types/Cargo.toml @@ -14,7 +14,7 @@ include = ["src", "Cargo.toml", "CHANGELOG.md", "LICENSE", "README.md"] bincode = { workspace = true } base64 = { workspace = true } candid = { workspace = true } -canlog = {workspace = true} +canlog = { workspace = true } derive_more = { workspace = true } ic-cdk = { workspace = true } regex = { workspace = true } @@ -35,5 +35,5 @@ thiserror = { workspace = true } url = { workspace = true } [dev-dependencies] -bs58 = {workspace = true} -proptest = {workspace = true} \ No newline at end of file +bs58 = { workspace = true } +proptest = { workspace = true } \ No newline at end of file diff --git a/libs/types/src/lib.rs b/libs/types/src/lib.rs index eeb56f8e..11e1106c 100644 --- a/libs/types/src/lib.rs +++ b/libs/types/src/lib.rs @@ -3,11 +3,16 @@ #![forbid(unsafe_code)] #![forbid(missing_docs)] +#[cfg(test)] +mod tests; + mod lifecycle; mod response; mod rpc_client; mod solana; +use candid::{CandidType, Deserialize}; +use derive_more::Into; pub use lifecycle::{InstallArgs, Mode, NumSubnetNodes}; pub use response::MultiRpcResult; pub use rpc_client::{ @@ -16,21 +21,42 @@ pub use rpc_client::{ RegexSubstitution, RoundingError, RpcAccess, RpcAuth, RpcConfig, RpcEndpoint, RpcError, RpcResult, RpcSource, RpcSources, SolanaCluster, SupportedRpcProvider, SupportedRpcProviderId, }; +use serde::Serialize; pub use solana::{ account::{AccountData, AccountEncoding, AccountInfo, ParsedAccount}, request::{ CommitmentLevel, DataSlice, GetAccountInfoEncoding, GetAccountInfoParams, GetBalanceParams, - GetBlockCommitmentLevel, GetBlockParams, GetRecentPrioritizationFeesParams, GetSlotParams, - GetTokenAccountBalanceParams, GetTransactionEncoding, GetTransactionParams, - SendTransactionEncoding, SendTransactionParams, TransactionDetails, + GetBlockCommitmentLevel, GetBlockParams, GetRecentPrioritizationFeesParams, + GetSignatureStatusesParams, GetSlotParams, GetTokenAccountBalanceParams, + GetTransactionEncoding, GetTransactionParams, SendTransactionEncoding, + SendTransactionParams, TransactionDetails, }, transaction::{ error::{InstructionError, TransactionError}, instruction::{CompiledInstruction, InnerInstructions, Instruction}, reward::{Reward, RewardType}, EncodedTransaction, LoadedAddresses, TokenAmount, TransactionBinaryEncoding, - TransactionInfo, TransactionReturnData, TransactionStatusMeta, TransactionTokenBalance, - TransactionVersion, + TransactionConfirmationStatus, TransactionInfo, TransactionReturnData, TransactionStatus, + TransactionStatusMeta, TransactionTokenBalance, TransactionVersion, }, ConfirmedBlock, Hash, Lamport, PrioritizationFee, Pubkey, Signature, Slot, Timestamp, }; + +/// A vector with a maximum capacity. +#[derive(Debug, Clone, Deserialize, Serialize, CandidType, PartialEq, Default, Into)] +#[serde(try_from = "Vec")] +pub struct VecWithMaxLen(Vec); + +impl TryFrom> for VecWithMaxLen { + type Error = RpcError; + + fn try_from(value: Vec) -> Result { + if value.len() > CAPACITY { + return Err(RpcError::ValidationError(format!( + "Expected at most {CAPACITY} items, but got {}", + value.len() + ))); + } + Ok(Self(value)) + } +} diff --git a/libs/types/src/response/mod.rs b/libs/types/src/response/mod.rs index 53b304be..1395c2e4 100644 --- a/libs/types/src/response/mod.rs +++ b/libs/types/src/response/mod.rs @@ -1,6 +1,6 @@ use crate::{ solana::account::AccountInfo, ConfirmedBlock, RpcResult, RpcSource, Signature, TokenAmount, - TransactionInfo, + TransactionInfo, TransactionStatus, }; use candid::CandidType; use serde::Deserialize; @@ -147,3 +147,36 @@ impl From> for MultiRpcResult { result.map(TokenAmount::from) } } + +impl From>>> + for MultiRpcResult>> +{ + fn from(result: MultiRpcResult>>) -> Self { + result.map(|statuses| { + statuses + .into_iter() + .map(|maybe_status| { + maybe_status + .map(solana_transaction_status_client_types::TransactionStatus::from) + }) + .collect() + }) + } +} + +impl From>>> + for MultiRpcResult>> +{ + fn from( + result: MultiRpcResult< + Vec>, + >, + ) -> Self { + result.map(|statuses| { + statuses + .into_iter() + .map(|maybe_status| maybe_status.map(TransactionStatus::from)) + .collect() + }) + } +} diff --git a/libs/types/src/solana/mod.rs b/libs/types/src/solana/mod.rs index 8bdfbbd9..081af6c3 100644 --- a/libs/types/src/solana/mod.rs +++ b/libs/types/src/solana/mod.rs @@ -8,8 +8,7 @@ pub mod transaction; use crate::RpcError; use candid::CandidType; use serde::{Deserialize, Serialize}; -use std::fmt::Debug; -use std::str::FromStr; +use std::{fmt::Debug, str::FromStr}; /// A Solana [slot](https://solana.com/docs/references/terminology#slot). pub type Slot = u64; diff --git a/libs/types/src/solana/request/mod.rs b/libs/types/src/solana/request/mod.rs index 4abc70b2..edac1e3e 100644 --- a/libs/types/src/solana/request/mod.rs +++ b/libs/types/src/solana/request/mod.rs @@ -1,4 +1,7 @@ -use crate::{solana::Pubkey, RpcError, Signature, Slot}; +#[cfg(test)] +mod tests; + +use crate::{solana::Pubkey, RpcError, Signature, Slot, VecWithMaxLen}; use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine}; use candid::{CandidType, Deserialize}; use serde::Serialize; @@ -182,33 +185,56 @@ pub enum TransactionDetails { /// 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); +pub struct GetRecentPrioritizationFeesParams(VecWithMaxLen); -impl TryFrom> for GetRecentPrioritizationFeesParams { +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)) + fn try_from(pubkeys: Vec

) -> Result { + Ok(Self( + pubkeys + .into_iter() + .map(Into::into) + .collect::>() + .try_into()?, + )) } } impl From for GetRecentPrioritizationFeesParams { fn from(value: solana_pubkey::Pubkey) -> Self { - Self(vec![Pubkey::from(value)]) + Self(VecWithMaxLen::try_from(vec![Pubkey::from(value)]).unwrap()) } } impl From for Vec { fn from(value: GetRecentPrioritizationFeesParams) -> Self { - value.0 + value.0.into() + } +} + +/// The parameters for a Solana [`getSignatureStatuses`](https://solana.com/docs/rpc/http/getsignaturestatuses) RPC method call. +#[derive(Debug, Clone, Default, Deserialize, Serialize, CandidType)] +pub struct GetSignatureStatusesParams { + /// An array of transaction signatures to confirm, as base-58 encoded strings (up to a maximum of 256) + pub signatures: VecWithMaxLen, + /// If set to true, a Solana node will search its ledger cache for any signatures not found in the recent status cache. + #[serde(rename = "searchTransactionHistory")] + pub search_transaction_history: Option, +} + +impl> TryFrom> for GetSignatureStatusesParams { + type Error = RpcError; + + fn try_from(signatures: Vec) -> Result { + Ok(Self { + signatures: signatures + .into_iter() + .map(Into::into) + .collect::>() + .try_into()?, + search_transaction_history: None, + }) } } diff --git a/libs/types/src/solana/request/tests.rs b/libs/types/src/solana/request/tests.rs new file mode 100644 index 00000000..568486e6 --- /dev/null +++ b/libs/types/src/solana/request/tests.rs @@ -0,0 +1,62 @@ +use crate::{GetRecentPrioritizationFeesParams, GetSignatureStatusesParams}; +use serde::Deserialize; +use serde_json::json; + +mod get_signature_statuses_params_tests { + use super::*; + + #[test] + fn should_deserialize() { + let params = json!({ + "signatures": vec!["5iBbqBJzgqafuQn93Np8ztWyXeYe2ReGPzUB1zXP2suZ8b5EaxSwe74ZUhg5pZQuDQkNGW7XApgfXX91YLYUuo5y"; 256] + }); + + let result = GetSignatureStatusesParams::deserialize(¶ms); + + assert!(result.is_ok()); + } + + #[test] + fn should_not_deserialize() { + let params = json!({ + "signatures": vec!["5iBbqBJzgqafuQn93Np8ztWyXeYe2ReGPzUB1zXP2suZ8b5EaxSwe74ZUhg5pZQuDQkNGW7XApgfXX91YLYUuo5y"; 256 + 1] + }); + + let result = GetSignatureStatusesParams::deserialize(¶ms); + + assert!(result.is_err()); + assert_eq!( + result.err().unwrap().to_string().as_str(), + "Validation error: Expected at most 256 items, but got 257" + ); + } +} + +mod get_recent_prioritization_fees_params_tests { + use super::*; + + #[test] + fn should_deserialize() { + let params = json!(vec!["EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"; 128]); + + let result = GetRecentPrioritizationFeesParams::deserialize(¶ms); + + assert!(result.is_ok()); + } + + #[test] + fn should_not_deserialize() { + let params = json!(vec![ + "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"; + 128 + 1 + ]); + + let result = GetRecentPrioritizationFeesParams::deserialize(¶ms); + + assert!(result.is_err()); + assert_eq!( + result.err().unwrap().to_string().as_str(), + "Validation error: Expected at most 128 items, but got 129" + ); + } +} diff --git a/libs/types/src/solana/tests.rs b/libs/types/src/solana/tests.rs index 7a90991c..1df4464a 100644 --- a/libs/types/src/solana/tests.rs +++ b/libs/types/src/solana/tests.rs @@ -1,9 +1,11 @@ mod impl_candid { use crate::{Hash, Pubkey, Signature}; use candid::{CandidType, Decode, Encode}; - use proptest::collection::SizeRange; - use proptest::prelude::{any, Strategy, TestCaseError}; - use proptest::{prop_assert, prop_assert_eq, proptest}; + use proptest::{ + collection::SizeRange, + prelude::{any, Strategy, TestCaseError}, + prop_assert, prop_assert_eq, proptest, + }; use serde::de::DeserializeOwned; use std::str::FromStr; diff --git a/libs/types/src/solana/transaction/mod.rs b/libs/types/src/solana/transaction/mod.rs index 8fcfd364..be018b6f 100644 --- a/libs/types/src/solana/transaction/mod.rs +++ b/libs/types/src/solana/transaction/mod.rs @@ -2,8 +2,10 @@ pub mod error; pub mod instruction; pub mod reward; -use crate::solana::{parse_opt, parse_vec, try_from_vec}; -use crate::{Pubkey, RpcError, Slot, Timestamp}; +use crate::{ + solana::{parse_opt, parse_vec, try_from_vec}, + Pubkey, RpcError, Slot, Timestamp, +}; use candid::{CandidType, Deserialize}; use error::TransactionError; use instruction::InnerInstructions; @@ -70,6 +72,100 @@ impl From for EncodedConfirmedTransactionWithStatusMeta { } } +/// Solana transaction status as returned by the [`getSignatureStatuses`](https://solana.com/de/docs/rpc/http/getsignaturestatuses) +/// RPC method. +/// +/// *WARNING*: The optional `confirmations` field in the `getSignatureStatuses` response is not +/// included in this type since it is ignored when processing the RPC response. +#[derive(Debug, Clone, Deserialize, Serialize, CandidType, PartialEq)] +pub struct TransactionStatus { + /// The slot the transaction was processed. + pub slot: u64, + /// *DEPRECATED*: Transaction status: + /// * [`Ok(())`] - Transaction was successful + /// * [`Err(err)`] - Transaction failed with [`TransactionError`] `err` + pub status: Result<(), TransactionError>, + /// Error if transaction failed, [`None`] if transaction succeeded. + pub err: Option, + /// The transaction's cluster confirmation status; Either [`TransactionConfirmationStatus::Processed`], + /// [`TransactionConfirmationStatus::Confirmed`], or [`TransactionConfirmationStatus::Finalized`]. + /// See [Commitment](https://solana.com/docs/rpc#configuring-state-commitment) for more on + /// optimistic confirmation. + #[serde(rename = "confirmationStatus")] + pub confirmation_status: Option, +} + +impl From for TransactionStatus { + fn from(status: solana_transaction_status_client_types::TransactionStatus) -> Self { + Self { + slot: status.slot, + status: status.status.map_err(TransactionError::from), + err: status.err.map(TransactionError::from), + confirmation_status: status + .confirmation_status + .map(TransactionConfirmationStatus::from), + } + } +} + +impl From for solana_transaction_status_client_types::TransactionStatus { + fn from(status: TransactionStatus) -> Self { + Self { + slot: status.slot, + confirmations: None, + status: status + .status + .map_err(solana_transaction_error::TransactionError::from), + err: status + .err + .map(solana_transaction_error::TransactionError::from), + confirmation_status: status + .confirmation_status + .map(solana_transaction_status_client_types::TransactionConfirmationStatus::from), + } + } +} + +/// A Solana transaction confirmation status. See [Commitment](https://solana.com/docs/rpc#configuring-state-commitment) +/// for more on optimistic confirmation. +#[derive(Debug, Clone, Deserialize, Serialize, CandidType, PartialEq)] +pub enum TransactionConfirmationStatus { + /// See [`crate::CommitmentLevel::Processed`]. + #[serde(rename = "processed")] + Processed, + /// See [`crate::CommitmentLevel::Confirmed`]. + #[serde(rename = "confirmed")] + Confirmed, + /// See [`crate::CommitmentLevel::Finalized`]. + #[serde(rename = "finalized")] + Finalized, +} + +impl From + for TransactionConfirmationStatus +{ + fn from(status: solana_transaction_status_client_types::TransactionConfirmationStatus) -> Self { + use solana_transaction_status_client_types::TransactionConfirmationStatus; + match status { + TransactionConfirmationStatus::Processed => Self::Processed, + TransactionConfirmationStatus::Confirmed => Self::Confirmed, + TransactionConfirmationStatus::Finalized => Self::Finalized, + } + } +} + +impl From + for solana_transaction_status_client_types::TransactionConfirmationStatus +{ + fn from(status: TransactionConfirmationStatus) -> Self { + match status { + TransactionConfirmationStatus::Processed => Self::Processed, + TransactionConfirmationStatus::Confirmed => Self::Confirmed, + TransactionConfirmationStatus::Finalized => Self::Finalized, + } + } +} + /// Transaction status [metadata](https://solana.com/de/docs/rpc/json-structures#transaction-status-metadata) object. #[derive(Debug, Clone, Deserialize, Serialize, CandidType, PartialEq)] pub struct TransactionStatusMeta { diff --git a/libs/types/src/tests.rs b/libs/types/src/tests.rs new file mode 100644 index 00000000..da7ba3e7 --- /dev/null +++ b/libs/types/src/tests.rs @@ -0,0 +1,48 @@ +use crate::VecWithMaxLen; +use candid::{Decode, Encode}; +use proptest::{ + arbitrary::any, + prelude::{prop, Strategy}, + proptest, +}; +use serde::Deserialize; +use serde_json::json; + +proptest! { + #[test] + fn should_encode_decode (values in arb_vec_with_capacity()) { + let encoded = Encode!(&values).unwrap(); + let decoded = Decode!(&encoded, VecWithMaxLen::).unwrap(); + + assert_eq!(decoded, values); + } + + #[test] + fn should_deserialize(values in prop::collection::vec(any::(), 0..=100)) { + let serialized = json!(values); + + let result = VecWithMaxLen::::deserialize(&serialized); + + assert!(result.is_ok()); + assert_eq!(Vec::from(result.unwrap()), values); + } + + #[test] + fn should_not_deserialize(values in prop::collection::vec(any::(), 101..1000)) { + let serialized = json!(values); + + let result = VecWithMaxLen::::deserialize(&serialized); + + assert!(result.is_err()); + assert_eq!( + result.err().unwrap().to_string(), + format!("Validation error: Expected at most 100 items, but got {}", values.len()) + ); + } +} + +fn arb_vec_with_capacity( +) -> impl Strategy> { + prop::collection::vec(any::(), 0..=CAPACITY) + .prop_map(|values| values.try_into().unwrap()) +}