diff --git a/canister/scripts/examples.sh b/canister/scripts/examples.sh index 2c3d0f58..767860de 100755 --- a/canister/scripts/examples.sh +++ b/canister/scripts/examples.sh @@ -86,6 +86,7 @@ GET_ACCOUNT_INFO_PARAMS="( CYCLES=$(dfx canister call sol_rpc getAccountInfoCyclesCost "$GET_ACCOUNT_INFO_PARAMS" $FLAGS --output json | jq '.Ok' --raw-output || exit 1) dfx canister call sol_rpc getAccountInfo "$GET_ACCOUNT_INFO_PARAMS" $FLAGS --with-cycles "$CYCLES" || exit 1 +# Get the USDC mint account balance on Mainnet with a 2-out-of-3 strategy GET_BALANCE_PARAMS="( variant { Default = variant { Mainnet } }, opt record { @@ -102,3 +103,20 @@ GET_BALANCE_PARAMS="( )" CYCLES=$(dfx canister call sol_rpc getBalanceCyclesCost "$GET_BALANCE_PARAMS" $FLAGS --output json | jq '.Ok' --raw-output || exit 1) dfx canister call sol_rpc getBalance "$GET_BALANCE_PARAMS" $FLAGS --with-cycles "$CYCLES" || exit 1 + +# Get the USDC issuer (Circle) token account balance on Mainnet with a 2-out-of-3 strategy +GET_TOKEN_ACCOUNT_BALANCE_PARAMS="( + variant { Default = variant { Mainnet } }, + opt record { + responseConsensus = opt variant { + Threshold = record { min = 2 : nat8; total = opt (3 : nat8) } + }; + responseSizeEstimate = null; + }, + record { + pubkey = \"3emsAVdmGKERbHjmGfQ6oZ1e35dkf5iYcS6U4CPKFVaa\"; + commitment = null; + }, +)" +CYCLES=$(dfx canister call sol_rpc getTokenAccountBalanceCyclesCost "$GET_TOKEN_ACCOUNT_BALANCE_PARAMS" $FLAGS --output json | jq '.Ok' --raw-output || exit 1) +dfx canister call sol_rpc getTokenAccountBalance "$GET_TOKEN_ACCOUNT_BALANCE_PARAMS" $FLAGS --with-cycles "$CYCLES" || exit 1 \ No newline at end of file diff --git a/canister/sol_rpc_canister.did b/canister/sol_rpc_canister.did index e61fb600..16e057d2 100644 --- a/canister/sol_rpc_canister.did +++ b/canister/sol_rpc_canister.did @@ -579,6 +579,23 @@ type MultiGetSlotResult = variant { Inconsistent : vec record { RpcSource; GetSlotResult }; }; +// The parameters for a Solana `getTokenAccountBalance` RPC method call. +type GetTokenAccountBalanceParams = record { + // Pubkey of token account to query, as base-58 encoded string. + pubkey: Pubkey; + // The commitment describes how finalized a block is at that point in time. + commitment: opt CommitmentLevel; +}; + +// Represents the result of a call to the `getTokenAccountBalance` Solana RPC method. +type GetTokenAccountBalanceResult = variant { Ok : TokenAmount; Err : RpcError }; + +// Represents an aggregated result from multiple RPC calls to the `getTokenAccountBalance` Solana RPC method. +type MultiGetTokenAccountBalanceResult = variant { + Consistent : GetTokenAccountBalanceResult; + Inconsistent : vec record { RpcSource; GetTokenAccountBalanceResult }; +}; + // Represents the result of a call to the `sendTransaction` Solana RPC method. type SendTransactionResult = variant { Ok : Signature; Err : RpcError }; @@ -663,11 +680,11 @@ service : (InstallArgs,) -> { // The caller is the controller or a principal specified in `InstallArgs::manage_api_keys`. updateApiKeys : (vec record { SupportedProvider; opt text }) -> (); - // Call the Solana `getAccountInfo` RPC method and return the resulting slot. + // Call the Solana `getAccountInfo` RPC method and return the resulting info. getAccountInfo : (RpcSources, opt RpcConfig, GetAccountInfoParams) -> (MultiGetAccountInfoResult); getAccountInfoCyclesCost : (RpcSources, opt RpcConfig, GetAccountInfoParams) -> (RequestCostResult) query; - // Call the Solana `getBalance` RPC method and return the resulting block. + // Call the Solana `getBalance` RPC method and return the resulting balance. getBalance : (RpcSources, opt RpcConfig, GetBalanceParams) -> (MultiGetBalanceResult); getBalanceCyclesCost : (RpcSources, opt RpcConfig, GetBalanceParams) -> (RequestCostResult) query; @@ -679,7 +696,12 @@ service : (InstallArgs,) -> { getSlot : (RpcSources, opt GetSlotRpcConfig, opt GetSlotParams) -> (MultiGetSlotResult); getSlotCyclesCost : (RpcSources, opt GetSlotRpcConfig, opt GetSlotParams) -> (RequestCostResult) query; - // Call the Solana `getTransaction` RPC method and return the resulting slot. + // Call the Solana `getTokenAccountBalance` RPC method and return the resulting balance. + // If the account does not exist, this method will return a JSON-RPC error. + getTokenAccountBalance : (RpcSources, opt RpcConfig, GetTokenAccountBalanceParams) -> (MultiGetTokenAccountBalanceResult); + getTokenAccountBalanceCyclesCost : (RpcSources, opt RpcConfig, GetTokenAccountBalanceParams) -> (RequestCostResult) query; + + // Call the Solana `getTransaction` RPC method and return the resulting transaction. getTransaction : (RpcSources, opt RpcConfig, GetTransactionParams) -> (MultiGetTransactionResult); getTransactionCyclesCost : (RpcSources, opt RpcConfig, GetTransactionParams) -> (RequestCostResult) query; diff --git a/canister/src/main.rs b/canister/src/main.rs index 54ea8ca6..616a90c9 100644 --- a/canister/src/main.rs +++ b/canister/src/main.rs @@ -14,9 +14,9 @@ use sol_rpc_canister::{ }; use sol_rpc_types::{ AccountInfo, ConfirmedBlock, GetAccountInfoParams, GetBalanceParams, GetBlockParams, - GetSlotParams, GetSlotRpcConfig, GetTransactionParams, Lamport, MultiRpcResult, RpcAccess, - RpcConfig, RpcResult, RpcSources, SendTransactionParams, Signature, Slot, SupportedRpcProvider, - SupportedRpcProviderId, TransactionInfo, + GetSlotParams, GetSlotRpcConfig, GetTokenAccountBalanceParams, GetTransactionParams, Lamport, + MultiRpcResult, RpcAccess, RpcConfig, RpcResult, RpcSources, SendTransactionParams, Signature, + Slot, SupportedRpcProvider, SupportedRpcProviderId, TokenAmount, TransactionInfo, }; use std::str::FromStr; @@ -188,6 +188,33 @@ async fn get_slot_cycles_cost( .await } +#[update(name = "getTokenAccountBalance")] +#[candid_method(rename = "getTokenAccountBalance")] +async fn get_token_account_balance( + source: RpcSources, + config: Option, + params: GetTokenAccountBalanceParams, +) -> MultiRpcResult { + let request = + MultiRpcRequest::get_token_account_balance(source, config.unwrap_or_default(), params); + send_multi(request).await.into() +} + +#[query(name = "getTokenAccountBalanceCyclesCost")] +#[candid_method(query, rename = "getTokenAccountBalanceCyclesCost")] +async fn get_token_account_balance_cycles_cost( + source: RpcSources, + config: Option, + params: GetTokenAccountBalanceParams, +) -> RpcResult { + if read_state(State::is_demo_mode_active) { + return Ok(0); + } + MultiRpcRequest::get_token_account_balance(source, config.unwrap_or_default(), params)? + .cycles_cost() + .await +} + #[update(name = "getTransaction")] #[candid_method(rename = "getTransaction")] async fn get_transaction( diff --git a/canister/src/rpc_client/json/mod.rs b/canister/src/rpc_client/json/mod.rs index 8d317ed6..88615df7 100644 --- a/canister/src/rpc_client/json/mod.rs +++ b/canister/src/rpc_client/json/mod.rs @@ -163,6 +163,36 @@ pub struct GetBlockConfig { pub max_supported_transaction_version: Option, } +#[skip_serializing_none] +#[derive(Serialize, Deserialize, Clone, Debug)] +#[serde(into = "(String, Option)")] +pub struct GetTokenAccountBalanceParams(String, Option); + +#[skip_serializing_none] +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct GetTokenAccountBalanceConfig { + pub commitment: Option, +} + +impl From for GetTokenAccountBalanceParams { + fn from(params: sol_rpc_types::GetTokenAccountBalanceParams) -> Self { + Self( + params.pubkey, + params + .commitment + .map(|commitment| GetTokenAccountBalanceConfig { + commitment: Some(commitment), + }), + ) + } +} + +impl From for (String, Option) { + fn from(value: GetTokenAccountBalanceParams) -> Self { + (value.0, value.1) + } +} + #[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 8ef9d43e..ed049c62 100644 --- a/canister/src/rpc_client/mod.rs +++ b/canister/src/rpc_client/mod.rs @@ -193,6 +193,33 @@ impl GetSlotRequest { } } +pub type GetTokenAccountBalanceRequest = MultiRpcRequest< + json::GetTokenAccountBalanceParams, + solana_account_decoder_client_types::token::UiTokenAmount, +>; + +impl GetTokenAccountBalanceRequest { + pub fn get_token_account_balance>( + rpc_sources: RpcSources, + config: RpcConfig, + 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(1024 + HEADER_SIZE_LIMIT); + + Ok(MultiRpcRequest::new( + providers, + JsonRpcRequest::new("getTokenAccountBalance", params.into()), + max_response_bytes, + ResponseTransform::GetTokenAccountBalance, + ReductionStrategy::from(consensus_strategy), + )) + } +} + pub type GetTransactionRequest = MultiRpcRequest< json::GetTransactionParams, Option, diff --git a/canister/src/rpc_client/sol_rpc/mod.rs b/canister/src/rpc_client/sol_rpc/mod.rs index d2c1e4fc..3ec3fe21 100644 --- a/canister/src/rpc_client/sol_rpc/mod.rs +++ b/canister/src/rpc_client/sol_rpc/mod.rs @@ -28,10 +28,12 @@ pub enum ResponseTransform { #[n(3)] GetSlot(#[n(0)] RoundingError), #[n(4)] - GetTransaction, + GetTokenAccountBalance, #[n(5)] - SendTransaction, + GetTransaction, #[n(6)] + SendTransaction, + #[n(7)] Raw, } @@ -76,6 +78,9 @@ impl ResponseTransform { value => Some(value), }); } + Self::GetTokenAccountBalance => { + canonicalize_response::(body_bytes, |result| result["value"].clone()); + } Self::SendTransaction => { canonicalize_response::(body_bytes, std::convert::identity); } diff --git a/canister/src/rpc_client/tests.rs b/canister/src/rpc_client/tests.rs index c956d117..e48495c4 100644 --- a/canister/src/rpc_client/tests.rs +++ b/canister/src/rpc_client/tests.rs @@ -5,19 +5,18 @@ use crate::rpc_client::{ use serde::Serialize; use serde_json::json; use sol_rpc_types::{ - CommitmentLevel, DataSlice, GetAccountInfoEncoding, GetAccountInfoParams, + CommitmentLevel, DataSlice, GetAccountInfoEncoding, GetAccountInfoParams, GetBalanceParams, GetBlockCommitmentLevel, GetBlockParams, GetSlotParams, GetSlotRpcConfig, - GetTransactionEncoding, GetTransactionParams, RpcConfig, RpcSources, SendTransactionEncoding, - SendTransactionParams, SolanaCluster, TransactionDetails, + GetTokenAccountBalanceParams, GetTransactionEncoding, GetTransactionParams, RpcConfig, + RpcSources, SendTransactionEncoding, SendTransactionParams, SolanaCluster, TransactionDetails, }; mod request_serialization_tests { use super::*; - use sol_rpc_types::GetBalanceParams; #[test] fn should_serialize_get_account_info_request() { - assert_serialized( + assert_params_eq( GetAccountInfoRequest::get_account_info( RpcSources::Default(SolanaCluster::Mainnet), RpcConfig::default(), @@ -26,7 +25,7 @@ mod request_serialization_tests { .unwrap(), json!(["11111111111111111111111111111111", null]), ); - assert_serialized( + assert_params_eq( GetAccountInfoRequest::get_account_info( RpcSources::Default(SolanaCluster::Mainnet), RpcConfig::default(), @@ -55,7 +54,7 @@ mod request_serialization_tests { #[test] fn should_serialize_get_slot_request() { - assert_serialized( + assert_params_eq( GetSlotRequest::get_slot( RpcSources::Default(SolanaCluster::Mainnet), GetSlotRpcConfig::default(), @@ -64,7 +63,7 @@ mod request_serialization_tests { .unwrap(), json!([null]), ); - assert_serialized( + assert_params_eq( GetSlotRequest::get_slot( RpcSources::Default(SolanaCluster::Mainnet), GetSlotRpcConfig::default(), @@ -86,7 +85,7 @@ mod request_serialization_tests { #[test] fn should_serialize_get_transaction_request() { let signature = solana_signature::Signature::default().to_string(); - assert_serialized( + assert_params_eq( GetTransactionRequest::get_transaction( RpcSources::Default(SolanaCluster::Mainnet), RpcConfig::default(), @@ -95,7 +94,7 @@ mod request_serialization_tests { .unwrap(), json!([signature, null]), ); - assert_serialized( + assert_params_eq( GetTransactionRequest::get_transaction( RpcSources::Default(SolanaCluster::Mainnet), RpcConfig::default(), @@ -121,7 +120,7 @@ mod request_serialization_tests { #[test] fn should_serialize_get_balance_request() { let pubkey = solana_pubkey::Pubkey::default(); - assert_serialized( + assert_params_eq( MultiRpcRequest::get_balance( RpcSources::Default(SolanaCluster::Mainnet), RpcConfig::default(), @@ -131,7 +130,7 @@ mod request_serialization_tests { json!([pubkey.to_string(), null]), ); - assert_serialized( + assert_params_eq( MultiRpcRequest::get_balance( RpcSources::Default(SolanaCluster::Mainnet), RpcConfig::default(), @@ -154,9 +153,39 @@ mod request_serialization_tests { ); } + #[test] + fn should_serialize_get_token_account_balance_request() { + let pubkey = solana_pubkey::Pubkey::default(); + assert_params_eq( + MultiRpcRequest::get_token_account_balance( + RpcSources::Default(SolanaCluster::Mainnet), + RpcConfig::default(), + GetTokenAccountBalanceParams::from(pubkey), + ) + .unwrap(), + json!([pubkey.to_string(), null]), + ); + + assert_params_eq( + MultiRpcRequest::get_token_account_balance( + RpcSources::Default(SolanaCluster::Mainnet), + RpcConfig::default(), + GetTokenAccountBalanceParams { + pubkey: pubkey.to_string(), + commitment: Some(CommitmentLevel::Confirmed), + }, + ) + .unwrap(), + json!([ + pubkey.to_string(), + {"commitment": "confirmed"} + ]), + ); + } + #[test] fn should_serialize_get_block_request() { - assert_serialized( + assert_params_eq( GetBlockRequest::get_block( RpcSources::Default(SolanaCluster::Mainnet), RpcConfig::default(), @@ -168,7 +197,7 @@ mod request_serialization_tests { {"rewards": false, "transactionDetails": "none"} ]), ); - assert_serialized( + assert_params_eq( GetBlockRequest::get_block( RpcSources::Default(SolanaCluster::Mainnet), RpcConfig::default(), @@ -195,7 +224,7 @@ mod request_serialization_tests { #[test] fn should_serialize_send_transaction_request() { let transaction = "4F9ksKhLSgn9e7ugVnAmRpRXL9kjke4TT96FNDxMiUNc5KVDz8p1yuv"; - assert_serialized( + assert_params_eq( SendTransactionRequest::send_transaction( RpcSources::Default(SolanaCluster::Mainnet), RpcConfig::default(), @@ -215,7 +244,7 @@ mod request_serialization_tests { params.skip_preflight = Some(true); params.preflight_commitment = Some(CommitmentLevel::Processed); params.min_context_slot = Some(456); - assert_serialized( + assert_params_eq( SendTransactionRequest::send_transaction( RpcSources::Default(SolanaCluster::Mainnet), RpcConfig::default(), @@ -235,7 +264,7 @@ mod request_serialization_tests { ); } - fn assert_serialized( + fn assert_params_eq( request: MultiRpcRequest, serialized: serde_json::Value, ) { diff --git a/examples/basic_solana/basic_solana.did b/examples/basic_solana/basic_solana.did index 4d672060..bd00af70 100644 --- a/examples/basic_solana/basic_solana.did +++ b/examples/basic_solana/basic_solana.did @@ -51,6 +51,18 @@ type Txid = text; // Hash value used as recent_blockhash field in Transactions. type Blockhash = text; +// A human-readable representation of a token amount, as returned by the Solana `getTokenAccountBalance` RPC method. +type TokenAmount = record { + // The raw balance without decimals, a string representation of a nat64. + amount : text; + // Number of base 10 digits to the right of the decimal place. + decimals : nat8; + // DEPRECATED: The balance, using mint-prescribed decimals. + uiAmount : opt float64; + // The balance as a string, using mint-prescribed decimals. + uiAmountString : text; +}; + service : (InitArg) -> { // Returns the Solana account derived from the owner principal. // @@ -79,10 +91,10 @@ service : (InitArg) -> { get_nonce : (account: opt Address) -> (Blockhash); // Returns the balance of the given Solana account for the SPL token associated with - // the given token mint account. + // the given token mint account formatted as a string. // // If no account is provided, the account derived from the caller's principal is used. - get_spl_token_balance : (account: opt Address, mint_account: Address) -> (nat); + get_spl_token_balance : (account: opt Address, mint_account: Address) -> (TokenAmount); // Creates a nonce account with the given Solana account as nonce authority. Returns the // resulting nonce account address. diff --git a/examples/basic_solana/src/main.rs b/examples/basic_solana/src/main.rs index 254c97ec..560d4fe2 100644 --- a/examples/basic_solana/src/main.rs +++ b/examples/basic_solana/src/main.rs @@ -1,13 +1,12 @@ -use base64::prelude::BASE64_STANDARD; -use base64::Engine; -use basic_solana::solana_wallet::SolanaWallet; -use basic_solana::state::{init_state, read_state, State}; -use basic_solana::{client, get_recent_blockhash, spl, validate_caller_not_anonymous, InitArg}; +use base64::{prelude::BASE64_STANDARD, Engine}; +use basic_solana::{ + client, get_recent_blockhash, solana_wallet::SolanaWallet, spl, state::init_state, + validate_caller_not_anonymous, InitArg, +}; use candid::{Nat, Principal}; use ic_cdk::{init, post_upgrade, update}; use num::ToPrimitive; -use serde_json::json; -use sol_rpc_types::{GetAccountInfoEncoding, GetAccountInfoParams}; +use sol_rpc_types::{GetAccountInfoEncoding, GetAccountInfoParams, TokenAmount}; use solana_account_decoder_client_types::{UiAccountData, UiAccountEncoding}; use solana_hash::Hash; use solana_message::Message; @@ -61,7 +60,6 @@ pub async fn get_balance(account: Option) -> Nat { .await .expect_consistent() .expect("Call to `getBalance` failed"); - Nat::from(balance) } @@ -101,45 +99,16 @@ pub async fn get_nonce(account: Option) -> String { } #[update] -pub async fn get_spl_token_balance(account: Option, mint_account: String) -> Nat { +pub async fn get_spl_token_balance(account: Option, mint_account: String) -> TokenAmount { let account = account.unwrap_or(associated_token_account(None, mint_account).await); - - let commitment = read_state(State::solana_commitment_level); - // TODO XC-325: use `getTokenAccountBalance` method from client - let response = client() - .json_request(json!({ - "jsonrpc": "2.0", - "id": 1, - "method": "getTokenAccountBalance", - "params": [ account , { - "commitment": commitment - }] - })) + let public_key = Pubkey::from_str(&account).unwrap(); + client() + .get_token_account_balance(public_key) .send() .await .expect_consistent() - .expect("Call to `getTokenAccountBalance` failed"); - - // The response to a json_request for the `getTokenAccountBalance` endpoint has the following format: - //{ - // "context": { - // "apiVersion": "2.0.22", - // "slot": 63 - // }, - // "value": { - // "amount": "999999000", - // "decimals": 9, - // "uiAmount": 0.999999, - // "uiAmountString": "0.999999" - // } - //} - let response: serde_json::Value = serde_json::from_str(&response) - .expect("`getTokenAccountBalance` response is not a valid JSON"); - response["value"]["amount"] - .as_str() - .unwrap_or_else(|| panic!("Failed to parse getTokenAccountBalance response {response}")) - .parse() - .unwrap() + .expect("Call to `getTokenAccountBalance` failed") + .into() } #[update] diff --git a/examples/basic_solana/tests/tests.rs b/examples/basic_solana/tests/tests.rs index 6b7f12f2..4b1d12c3 100644 --- a/examples/basic_solana/tests/tests.rs +++ b/examples/basic_solana/tests/tests.rs @@ -1,15 +1,17 @@ use basic_solana::{Ed25519KeyName, SolanaNetwork}; -use candid::utils::ArgumentEncoder; -use candid::{decode_args, encode_args, CandidType, Encode, Nat, Principal}; -use pocket_ic::management_canister::{CanisterId, CanisterSettings}; -use pocket_ic::{PocketIc, PocketIcBuilder}; +use candid::{ + decode_args, encode_args, utils::ArgumentEncoder, CandidType, Encode, Nat, Principal, +}; +use pocket_ic::{ + management_canister::{CanisterId, CanisterSettings}, + PocketIc, PocketIcBuilder, +}; use serde::de::DeserializeOwned; use sol_rpc_types::{ CommitmentLevel, OverrideProvider, RegexSubstitution, RpcAccess, SupportedRpcProvider, - SupportedRpcProviderId, + SupportedRpcProviderId, TokenAmount, }; -use solana_client::rpc_client::RpcClient as SolanaRpcClient; -use solana_client::rpc_config::RpcTransactionConfig; +use solana_client::{rpc_client::RpcClient as SolanaRpcClient, rpc_config::RpcTransactionConfig}; use solana_commitment_config::CommitmentConfig; use solana_hash::Hash; use solana_instruction::{AccountMeta, Instruction}; @@ -19,11 +21,7 @@ use solana_pubkey::{pubkey, Pubkey}; use solana_signature::Signature; use solana_signer::Signer; use solana_transaction::Transaction; -use std::env::var; -use std::path::PathBuf; -use std::sync::Arc; -use std::thread; -use std::time::Duration; +use std::{env::var, path::PathBuf, sync::Arc, thread, time::Duration}; pub const SENDER: Principal = Principal::from_slice(&[0x9d, 0xf7, 0x42]); pub const RECEIVER: Principal = Principal::from_slice(&[0x9d, 0xf7, 0x43]); @@ -206,7 +204,7 @@ fn test_basic_solana() { .unwrap() .expect("Missing user's associated token account"); assert_eq!(token_account.token_amount.amount, "999999000"); - let sender_spl_balance: Nat = basic_solana.update_call( + let sender_spl_balance: TokenAmount = basic_solana.update_call( SENDER, "get_spl_token_balance", ( @@ -214,14 +212,22 @@ fn test_basic_solana() { mint_account.to_string(), ), ); - assert_eq!(sender_spl_balance, Nat::from(999_999_000_u64)); + assert_eq!( + sender_spl_balance, + TokenAmount { + ui_amount: Some(0.999999), + decimals: 9, + amount: "999999000".to_string(), + ui_amount_string: "0.999999".to_string(), + } + ); let token_account = setup .solana_client .get_token_account(&receiver_associated_token_account) .unwrap() .expect("Missing receiver's associated token account"); assert_eq!(token_account.token_amount.amount, "1000"); - let receiver_spl_balance: Nat = basic_solana.update_call( + let receiver_spl_balance: TokenAmount = basic_solana.update_call( RECEIVER, "get_spl_token_balance", ( @@ -229,7 +235,15 @@ fn test_basic_solana() { mint_account.to_string(), ), ); - assert_eq!(receiver_spl_balance, Nat::from(1_000_u64)); + assert_eq!( + receiver_spl_balance, + TokenAmount { + ui_amount: Some(0.000001), + decimals: 9, + amount: "1000".to_string(), + ui_amount_string: "0.000001".to_string(), + } + ); } pub struct Setup { diff --git a/integration_tests/tests/solana_test_validator.rs b/integration_tests/tests/solana_test_validator.rs index a741879a..d58f9f87 100644 --- a/integration_tests/tests/solana_test_validator.rs +++ b/integration_tests/tests/solana_test_validator.rs @@ -309,6 +309,11 @@ async fn should_get_balance() { assert_eq!(compare_balances(&setup, publickey).await, 10_000_000_000); } +#[tokio::test(flavor = "multi_thread")] +async fn should_get_token_account_balance() { + // TODO XC-325: Add test for `getTokenAccountBalance` (requires some SPL test infrastructure) +} + 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 a0284f58..fcab44d1 100644 --- a/integration_tests/tests/tests.rs +++ b/integration_tests/tests/tests.rs @@ -16,7 +16,9 @@ use sol_rpc_types::{ RpcConfig, RpcEndpoint, RpcError, RpcResult, RpcSource, RpcSources, Slot, SolanaCluster, SupportedRpcProvider, SupportedRpcProviderId, }; -use solana_account_decoder_client_types::{UiAccount, UiAccountData, UiAccountEncoding}; +use solana_account_decoder_client_types::{ + token::UiTokenAmount, UiAccount, UiAccountData, UiAccountEncoding, +}; use solana_pubkey::pubkey; use solana_signer::Signer; use std::{fmt::Debug, iter::zip, str::FromStr}; @@ -816,6 +818,9 @@ mod cycles_cost_tests { SolRpcEndpoint::GetTransaction => { check(client.get_transaction(some_signature())).await; } + SolRpcEndpoint::GetTokenAccountBalance => { + check(client.get_token_account_balance(USDC_PUBLIC_KEY)).await; + } SolRpcEndpoint::SendTransaction => { check(client.send_transaction(some_transaction())).await; } @@ -860,6 +865,9 @@ mod cycles_cost_tests { SolRpcEndpoint::GetBlock => { check(client.get_block(577996)).await; } + SolRpcEndpoint::GetTokenAccountBalance => { + check(client.get_token_account_balance(USDC_PUBLIC_KEY)).await; + } SolRpcEndpoint::GetTransaction => { check(client.get_transaction(some_signature())).await; } @@ -919,7 +927,7 @@ mod cycles_cost_tests { "BUG: not enough cycles requested. Requested {cycles_cost} cycles, but consumed {cycles_consumed} cycles" ); - // Same request with less cycles should fail. + // Same request with fewer cycles should fail. let results = request .with_cycles(cycles_cost - 1) .send() @@ -973,6 +981,14 @@ mod cycles_cost_tests { ) .await; } + SolRpcEndpoint::GetTokenAccountBalance => { + check( + &setup, + client.get_token_account_balance(USDC_PUBLIC_KEY), + 1_732_259_200, + ) + .await; + } SolRpcEndpoint::GetTransaction => { check( &setup, @@ -1005,13 +1021,7 @@ mod cycles_cost_tests { } mod get_balance_tests { - use crate::{rpc_sources, USDC_PUBLIC_KEY}; - use canhttp::http::json::{ConstantSizeId, Id}; - use serde_json::json; - use sol_rpc_int_tests::mock::MockOutcallBuilder; - use sol_rpc_int_tests::{Setup, SolRpcTestClient}; - use sol_rpc_types::CommitmentLevel; - use std::iter::zip; + use super::*; #[tokio::test] async fn should_get_balance() { @@ -1072,6 +1082,79 @@ mod get_balance_tests { } } +mod get_token_account_balance_tests { + use super::*; + + #[tokio::test] + async fn should_get_token_account_balance() { + fn request_body(id: u8) -> serde_json::Value { + json!({ + "jsonrpc": "2.0", + "id": Id::from(ConstantSizeId::from(id)), + "method": "getTokenAccountBalance", + "params": [ + USDC_PUBLIC_KEY.to_string(), + { + "commitment": "confirmed", + } + ] + }) + } + + 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": { + "amount": "9864", + "decimals": 2, + "uiAmount": 98.64, + "uiAmountString": "98.64", + } + }, + }) + } + 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_token_account_balance(USDC_PUBLIC_KEY) + .modify_params(|params| { + params.commitment = Some(CommitmentLevel::Confirmed); + }) + .send() + .await + .expect_consistent(); + + assert_eq!( + results, + Ok(UiTokenAmount { + amount: "9864".to_string(), + decimals: 2, + ui_amount: Some(98.64), + ui_amount_string: "98.64".to_string(), + }) + ); + } + + 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; diff --git a/libs/client/src/lib.rs b/libs/client/src/lib.rs index 76fcb51d..826d30ab 100644 --- a/libs/client/src/lib.rs +++ b/libs/client/src/lib.rs @@ -127,7 +127,7 @@ use std::fmt::Debug; use crate::request::{ GetAccountInfoRequest, GetBalanceRequest, GetBlockRequest, GetSlotRequest, - GetTransactionRequest, JsonRequest, SendTransactionRequest, + GetTokenAccountBalanceRequest, GetTransactionRequest, JsonRequest, SendTransactionRequest, }; use async_trait::async_trait; use candid::{utils::ArgumentEncoder, CandidType, Principal}; @@ -135,10 +135,11 @@ use ic_cdk::api::call::RejectionCode; use serde::de::DeserializeOwned; use sol_rpc_types::{ CommitmentLevel, GetAccountInfoParams, GetBalanceParams, GetBlockParams, GetSlotParams, - GetSlotRpcConfig, GetTransactionParams, Lamport, RpcConfig, RpcSources, SendTransactionParams, - Signature, SolanaCluster, SupportedRpcProvider, SupportedRpcProviderId, TransactionDetails, - TransactionInfo, + GetSlotRpcConfig, GetTokenAccountBalanceParams, GetTransactionParams, Lamport, RpcConfig, + RpcSources, SendTransactionParams, Signature, 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; @@ -396,6 +397,61 @@ impl SolRpcClient { RequestBuilder::new(self.clone(), GetBlockRequest::new(params), cycles) } + /// Call `getTokenAccountBalance` on the SOL RPC canister. + /// + /// # Examples + /// + /// ```rust + /// use sol_rpc_client::SolRpcClient; + /// use sol_rpc_types::{RpcSources, SolanaCluster}; + /// use solana_pubkey::pubkey; + /// use solana_account_decoder_client_types::token::UiTokenAmount; + /// + /// # #[tokio::main] + /// # async fn main() -> Result<(), Box> { + /// # use sol_rpc_types::{MultiRpcResult, TokenAmount}; + /// let client = SolRpcClient::builder_for_ic() + /// # .with_mocked_response(MultiRpcResult::Consistent(Ok(TokenAmount { + /// # ui_amount: Some(251153323.575906), + /// # decimals: 6, + /// # amount: "251153323575906".to_string(), + /// # ui_amount_string: "251153323.575906".to_string(), + /// # }))) + /// .with_rpc_sources(RpcSources::Default(SolanaCluster::Mainnet)) + /// .build(); + /// + /// let balance = client + /// .get_token_account_balance(pubkey!("3emsAVdmGKERbHjmGfQ6oZ1e35dkf5iYcS6U4CPKFVaa")) + /// .send() + /// .await + /// .expect_consistent(); + /// + /// assert_eq!(balance, Ok(UiTokenAmount { + /// ui_amount: Some(251153323.575906), + /// decimals: 6, + /// amount: "251153323575906".to_string(), + /// ui_amount_string: "251153323.575906".to_string(), + /// })); + /// # Ok(()) + /// # } + /// ``` + pub fn get_token_account_balance( + &self, + params: impl Into, + ) -> RequestBuilder< + R, + RpcConfig, + GetTokenAccountBalanceParams, + sol_rpc_types::MultiRpcResult, + sol_rpc_types::MultiRpcResult, + > { + RequestBuilder::new( + self.clone(), + GetTokenAccountBalanceRequest::new(params.into()), + 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 1871a375..2490e192 100644 --- a/libs/client/src/request/mod.rs +++ b/libs/client/src/request/mod.rs @@ -6,9 +6,11 @@ use candid::CandidType; use serde::de::DeserializeOwned; use sol_rpc_types::{ AccountInfo, CommitmentLevel, ConfirmedBlock, GetAccountInfoParams, GetBalanceParams, - GetBlockCommitmentLevel, GetBlockParams, GetSlotParams, GetSlotRpcConfig, GetTransactionParams, - Lamport, RpcConfig, RpcResult, RpcSources, SendTransactionParams, Signature, TransactionInfo, + GetBlockCommitmentLevel, GetBlockParams, GetSlotParams, GetSlotRpcConfig, + GetTokenAccountBalanceParams, GetTransactionParams, Lamport, RpcConfig, RpcResult, RpcSources, + SendTransactionParams, Signature, TokenAmount, TransactionInfo, }; +use solana_account_decoder_client_types::token::UiTokenAmount; use solana_clock::Slot; use solana_transaction_status_client_types::EncodedConfirmedTransactionWithStatusMeta; use std::fmt::{Debug, Formatter}; @@ -43,6 +45,8 @@ pub enum SolRpcEndpoint { GetBlock, /// `getSlot` endpoint. GetSlot, + /// `getTokenAccountBalance` endpoint. + GetTokenAccountBalance, /// `getTransaction` endpoint. GetTransaction, /// `jsonRequest` endpoint. @@ -59,6 +63,7 @@ impl SolRpcEndpoint { SolRpcEndpoint::GetBalance => "getBalance", SolRpcEndpoint::GetBlock => "getBlock", SolRpcEndpoint::GetSlot => "getSlot", + SolRpcEndpoint::GetTokenAccountBalance => "getTokenAccountBalance", SolRpcEndpoint::GetTransaction => "getTransaction", SolRpcEndpoint::JsonRequest => "jsonRequest", SolRpcEndpoint::SendTransaction => "sendTransaction", @@ -73,6 +78,7 @@ impl SolRpcEndpoint { SolRpcEndpoint::GetBlock => "getBlockCyclesCost", SolRpcEndpoint::GetSlot => "getSlotCyclesCost", SolRpcEndpoint::GetTransaction => "getTransactionCyclesCost", + SolRpcEndpoint::GetTokenAccountBalance => "getTokenAccountBalanceCyclesCost", SolRpcEndpoint::JsonRequest => "jsonRequestCyclesCost", SolRpcEndpoint::SendTransaction => "sendTransactionCyclesCost", } @@ -202,6 +208,32 @@ impl SolRpcRequest for GetSlotRequest { } } +#[derive(Debug, Clone)] +pub struct GetTokenAccountBalanceRequest(GetTokenAccountBalanceParams); + +impl GetTokenAccountBalanceRequest { + pub fn new(params: GetTokenAccountBalanceParams) -> Self { + Self(params) + } +} + +impl SolRpcRequest for GetTokenAccountBalanceRequest { + type Config = RpcConfig; + type Params = GetTokenAccountBalanceParams; + type CandidOutput = sol_rpc_types::MultiRpcResult; + type Output = sol_rpc_types::MultiRpcResult; + + fn endpoint(&self) -> SolRpcEndpoint { + SolRpcEndpoint::GetTokenAccountBalance + } + + fn params(self, default_commitment_level: Option) -> Self::Params { + let mut params = self.0; + set_default(default_commitment_level, &mut params.commitment); + params + } +} + #[derive(Debug, Clone)] pub struct GetTransactionRequest(GetTransactionParams); diff --git a/libs/client/src/request/tests.rs b/libs/client/src/request/tests.rs index 78858e5e..6a338e00 100644 --- a/libs/client/src/request/tests.rs +++ b/libs/client/src/request/tests.rs @@ -45,6 +45,13 @@ fn should_set_correct_commitment_level() { Some(CommitmentLevel::Confirmed) ); } + SolRpcEndpoint::GetTokenAccountBalance => { + let builder = client_with_commitment_level.get_token_account_balance(pubkey); + assert_eq!( + builder.request.params.commitment, + Some(CommitmentLevel::Confirmed) + ); + } SolRpcEndpoint::GetTransaction => { let builder = client_with_commitment_level.get_transaction("tspfR5p1PFphquz4WzDb7qM4UhJdgQXkEZtW88BykVEdX2zL2kBT9kidwQBviKwQuA3b6GMCR1gknHvzQ3r623T".parse::().unwrap()); assert_eq!( diff --git a/libs/types/src/lib.rs b/libs/types/src/lib.rs index 593c7b90..e4c05270 100644 --- a/libs/types/src/lib.rs +++ b/libs/types/src/lib.rs @@ -20,15 +20,17 @@ pub use solana::{ account::{AccountData, AccountEncoding, AccountInfo, ParsedAccount}, request::{ CommitmentLevel, DataSlice, GetAccountInfoEncoding, GetAccountInfoParams, GetBalanceParams, - GetBlockCommitmentLevel, GetBlockParams, GetSlotParams, GetTransactionEncoding, - GetTransactionParams, SendTransactionEncoding, SendTransactionParams, TransactionDetails, + GetBlockCommitmentLevel, GetBlockParams, GetSlotParams, GetTokenAccountBalanceParams, + GetTransactionEncoding, GetTransactionParams, SendTransactionEncoding, + SendTransactionParams, TransactionDetails, }, transaction::{ error::{InstructionError, TransactionError}, instruction::{CompiledInstruction, InnerInstructions, Instruction}, reward::{Reward, RewardType}, - EncodedTransaction, LoadedAddresses, TransactionBinaryEncoding, TransactionInfo, - TransactionReturnData, TransactionStatusMeta, TransactionTokenBalance, TransactionVersion, + EncodedTransaction, LoadedAddresses, TokenAmount, TransactionBinaryEncoding, + TransactionInfo, TransactionReturnData, TransactionStatusMeta, TransactionTokenBalance, + TransactionVersion, }, Blockhash, ConfirmedBlock, Lamport, Pubkey, Signature, Slot, Timestamp, }; diff --git a/libs/types/src/response/mod.rs b/libs/types/src/response/mod.rs index c0182bdb..dd7d2af3 100644 --- a/libs/types/src/response/mod.rs +++ b/libs/types/src/response/mod.rs @@ -1,9 +1,10 @@ use crate::{ - solana::account::AccountInfo, ConfirmedBlock, RpcResult, RpcSource, Signature, TransactionInfo, + solana::account::AccountInfo, ConfirmedBlock, RpcResult, RpcSource, Signature, TokenAmount, + TransactionInfo, }; use candid::CandidType; use serde::Deserialize; -use solana_account_decoder_client_types::UiAccount; +use solana_account_decoder_client_types::{token::UiTokenAmount, UiAccount}; use solana_transaction_status_client_types::{ EncodedConfirmedTransactionWithStatusMeta, UiConfirmedBlock, }; @@ -137,3 +138,15 @@ impl From>> result.map(|maybe_transaction| maybe_transaction.map(|transaction| transaction.into())) } } + +impl From> for MultiRpcResult { + fn from(result: MultiRpcResult) -> Self { + result.map(UiTokenAmount::from) + } +} + +impl From> for MultiRpcResult { + fn from(result: MultiRpcResult) -> Self { + result.map(TokenAmount::from) + } +} diff --git a/libs/types/src/solana/request/mod.rs b/libs/types/src/solana/request/mod.rs index eb4dd112..e862d2c6 100644 --- a/libs/types/src/solana/request/mod.rs +++ b/libs/types/src/solana/request/mod.rs @@ -8,7 +8,7 @@ use serde::Serialize; pub struct GetAccountInfoParams { /// The public key of the account whose info to fetch formatted as a base-58 string. pub pubkey: Pubkey, - /// The request returns the slot that has reached this or the default commitment level. + /// The commitment describes how finalized a block is at that point in time. pub commitment: Option, /// Encoding format for Account data. pub encoding: Option, @@ -194,6 +194,24 @@ impl GetSlotParams { } } +/// The parameters for a Solana [`getTokenAccountBalance`](https://solana.com/docs/rpc/http/gettokenaccountbalance) RPC method call. +#[derive(Debug, Clone, Default, Deserialize, Serialize, CandidType)] +pub struct GetTokenAccountBalanceParams { + /// The public key of the token account to query formatted as a base-58 string. + pub pubkey: Pubkey, + /// The commitment describes how finalized a block is at that point in time. + pub commitment: Option, +} + +impl From for GetTokenAccountBalanceParams { + fn from(pubkey: solana_pubkey::Pubkey) -> Self { + Self { + pubkey: pubkey.to_string(), + commitment: None, + } + } +} + /// The parameters for a Solana [`getTransaction`](https://solana.com/docs/rpc/http/gettransaction) RPC method call. #[derive(Debug, Clone, Deserialize, Serialize, CandidType)] pub struct GetTransactionParams {