diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 498253ce..a017111b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -66,7 +66,7 @@ jobs: - name: 'Checkout' uses: actions/checkout@v4 - name: 'Run unit tests' - run: cargo test --locked --workspace --exclude sol_rpc_int_tests + run: cargo test --locked --workspace --exclude basic_solana --exclude sol_rpc_int_tests integration-tests: needs: [ reproducible-build ] @@ -107,8 +107,18 @@ jobs: run: | echo "WALLET_WASM_PATH=$GITHUB_WORKSPACE/wallet.wasm.gz" >> "$GITHUB_ENV" - - name: 'Cargo test' - run: cargo test --package sol_rpc_int_tests -- --test-threads 2 --nocapture + - name: 'Set BASIC_SOLANA_WASM_PATH for load_wasm' + run: | + echo "BASIC_SOLANA_WASM_PATH=$GITHUB_WORKSPACE/target/wasm32-unknown-unknown/canister-release/basic_solana.wasm" >> "$GITHUB_ENV" + + - name: 'Test basic_solana' + run: | + cargo build --manifest-path examples/basic_solana/Cargo.toml --target wasm32-unknown-unknown --no-default-features --profile canister-release + cargo test --locked --package basic_solana + + + - name: 'Test sol_rpc_int_tests' + run: cargo test --locked --package sol_rpc_int_tests -- --test-threads 2 --nocapture end-to-end-tests: needs: [ reproducible-build ] diff --git a/Cargo.lock b/Cargo.lock index 51176623..0cef5a3e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -276,21 +276,27 @@ dependencies = [ "bincode", "bs58", "candid", + "candid_parser", "getrandom 0.2.15", "ic-cdk", "ic-ed25519", + "ic-test-utilities-load-wasm", "num 0.4.3", + "pocket-ic", "serde", "serde_json", "sol_rpc_client", "sol_rpc_types", "solana-account-decoder-client-types", + "solana-client", + "solana-commitment-config", "solana-hash", "solana-instruction", "solana-message", "solana-nonce", "solana-program", "solana-pubkey", + "solana-rpc-client-nonce-utils", "solana-signature", "solana-transaction", ] diff --git a/Cargo.toml b/Cargo.toml index aa4eaa17..0ec0adb7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -76,6 +76,7 @@ solana-program = "2.2.0" solana-pubkey = "2.2.0" solana-reward-info = "2.2.0" solana-rpc-client-api = "2.2.0" +solana-rpc-client-nonce-utils = "2.2.0" solana-signature = "2.2.0" solana-signer = "2.2.0" solana-transaction = "2.2.0" @@ -109,6 +110,7 @@ solana-program = { git = "https://github.com/dfinity/agave", tag = "323039e-js-f solana-pubkey = { git = "https://github.com/dfinity/agave", tag = "323039e-js-feature-flag" } solana-reward-info = { git = "https://github.com/dfinity/agave", tag = "323039e-js-feature-flag" } solana-rpc-client-api = { git = "https://github.com/dfinity/agave", tag = "323039e-js-feature-flag" } +solana-rpc-client-nonce-utils = { git = "https://github.com/dfinity/agave", tag = "323039e-js-feature-flag" } solana-signer = { git = "https://github.com/dfinity/agave", tag = "323039e-js-feature-flag" } solana-signature = { git = "https://github.com/dfinity/agave", tag = "323039e-js-feature-flag" } solana-transaction = { git = "https://github.com/dfinity/agave", tag = "323039e-js-feature-flag" } diff --git a/examples/basic_solana/Cargo.toml b/examples/basic_solana/Cargo.toml index b2fe143a..dcbff9de 100644 --- a/examples/basic_solana/Cargo.toml +++ b/examples/basic_solana/Cargo.toml @@ -3,8 +3,9 @@ name = "basic_solana" version = "0.1.0" edition = "2021" -[lib] -crate-type = ["cdylib"] +[[bin]] +name = "basic_solana" +path = "src/main.rs" [dependencies] base64 = "0.22.1" @@ -30,3 +31,12 @@ solana-program = { workspace = true } solana-pubkey = { workspace = true } solana-signature = { workspace = true } solana-transaction = { workspace = true, features = ["bincode"] } + +[dev-dependencies] +candid = { workspace = true } +candid_parser = { workspace = true } +ic-test-utilities-load-wasm = { workspace = true } +pocket-ic = { workspace = true } +solana-client = { workspace = true } +solana-commitment-config = { workspace = true } +solana-rpc-client-nonce-utils = {workspace = true} diff --git a/examples/basic_solana/basic_solana.did b/examples/basic_solana/basic_solana.did index 2fd8bf64..ab857185 100644 --- a/examples/basic_solana/basic_solana.did +++ b/examples/basic_solana/basic_solana.did @@ -37,6 +37,9 @@ type Address = text; // A transaction ID on Solana, i.e. the first signature in a transaction. type Txid = text; +// Hash value used as recent_blockhash field in Transactions. +type Blockhash = text; + service : (InitArg) -> { // Returns the Solana account derived from the owner principal. // @@ -62,7 +65,7 @@ service : (InitArg) -> { // Returns the current blockhash for the given Solana nonce account. // // If no account is provided, the nonce account derived from the caller's principal is used. - get_nonce : (account: opt Address) -> (Lamport); + 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 balance is a floating point value and is formatted diff --git a/examples/basic_solana/src/lib.rs b/examples/basic_solana/src/lib.rs index 4fba7e84..51a9ff92 100644 --- a/examples/basic_solana/src/lib.rs +++ b/examples/basic_solana/src/lib.rs @@ -1,336 +1,19 @@ mod ed25519; -mod solana_wallet; -mod spl; -mod state; -use solana_nonce::{state::State, versions::Versions as NonceVersions}; +pub mod solana_wallet; +pub mod spl; +pub mod state; -use crate::{ - solana_wallet::SolanaWallet, - state::{init_state, read_state}, -}; -use base64::{prelude::BASE64_STANDARD, Engine}; -use candid::{CandidType, Deserialize, Nat, Principal}; -use ic_cdk::{init, post_upgrade, update}; -use num::ToPrimitive; -use serde_json::json; +use crate::state::read_state; +use candid::{CandidType, Deserialize, Principal}; use sol_rpc_client::{IcRuntime, SolRpcClient}; -use sol_rpc_types::{ - GetAccountInfoEncoding, GetAccountInfoParams, MultiRpcResult, RpcSources, SolanaCluster, -}; -use solana_account_decoder_client_types::{UiAccountData, UiAccountEncoding}; +use sol_rpc_types::{MultiRpcResult, RpcSources, SolanaCluster}; use solana_hash::Hash; -use solana_message::Message; -use solana_program::system_instruction; -use solana_pubkey::Pubkey; -use solana_transaction::Transaction; use std::{fmt::Display, str::FromStr}; -#[init] -pub fn init(init_arg: InitArg) { - init_state(init_arg) -} - -#[post_upgrade] -fn post_upgrade(init_arg: Option) { - if let Some(init_arg) = init_arg { - init_state(init_arg) - } -} - -#[update] -pub async fn solana_account(owner: Option) -> String { - let owner = owner.unwrap_or_else(validate_caller_not_anonymous); - let wallet = SolanaWallet::new(owner).await; - wallet.solana_account().to_string() -} - -#[update] -pub async fn nonce_account(owner: Option) -> String { - let owner = owner.unwrap_or_else(validate_caller_not_anonymous); - let wallet = SolanaWallet::new(owner).await; - wallet.derived_nonce_account().to_string() -} - -#[update] -pub async fn associated_token_account(owner: Option, mint_account: String) -> String { - let owner = owner.unwrap_or_else(validate_caller_not_anonymous); - let mint = Pubkey::from_str(&mint_account).unwrap(); - let wallet = SolanaWallet::new(owner).await; - spl::get_associated_token_address(wallet.solana_account().as_ref(), &mint).to_string() -} - -#[update] -pub async fn get_balance(account: Option) -> Nat { - let account = account.unwrap_or(solana_account(None).await); - let public_key = Pubkey::from_str(&account).unwrap(); - let balance = client() - .get_balance(public_key) - .send() - .await - .expect_consistent() - .expect("Call to `getBalance` failed"); - - Nat::from(balance) -} - -#[update] -pub async fn get_nonce(account: Option) -> String { - let account = account.unwrap_or(nonce_account(None).await); - - // Fetch the account info with the data encoded in base64 format - // TODO XC-347: use method from client to retrieve nonce - let mut params = GetAccountInfoParams::from_encoded_pubkey(account); - params.encoding = Some(GetAccountInfoEncoding::Base64); - let account_data = client() - .get_account_info(params) - .send() - .await - .expect_consistent() - .expect("Call to `getAccountInfo` failed") - .expect("Account not found for given pubkey") - .data; - - // Extract the nonce from the account data - let account_data = if let UiAccountData::Binary(blob, UiAccountEncoding::Base64) = account_data - { - BASE64_STANDARD - .decode(blob) - .expect("Unable to base64 decode account data") - } else { - panic!("Invalid response format"); - }; - match bincode::deserialize::(account_data.as_slice()) - .expect("Failed to deserialize nonce account data") - .state() - { - State::Uninitialized => panic!("Nonce account is uninitialized"), - State::Initialized(data) => data.blockhash().to_string(), - } -} - -#[update] -pub async fn get_spl_token_balance(account: Option, mint_account: String) -> String { - let account = account.unwrap_or(associated_token_account(None, mint_account).await); - - // TODO XC-325: use `getTokenAccountBalance` method from client - let response = client() - .json_request(json!({ - "jsonrpc": "2.0", - "id": 1, - "method": "getTokenAccountBalance", - "params": [ account ] - })) - .send() - .await - .expect_consistent() - .expect("Call to `getTokenAccountBalance` failed"); - - // The response to a successful `getTokenAccountBalance` call has the following format: - // { "id": "[ID]", "jsonrpc": "2.0", "result": { "context": { "slot": [SLOT] } }, "value": [ { "uiAmountString": "FORMATTED AMOUNT" } ] }, } - serde_json::to_value(response).expect("`getTokenAccountBalance` response is not a valid JSON") - ["result"]["value"]["uiAmountString"] - .as_str() - .unwrap() - .to_string() -} - -#[update] -pub async fn create_nonce_account(owner: Option) -> String { - let client = client(); - - let owner = owner.unwrap_or_else(validate_caller_not_anonymous); - let wallet = SolanaWallet::new(owner).await; - - let payer = wallet.solana_account(); - let nonce_account = wallet.derived_nonce_account(); - - let instructions = system_instruction::create_nonce_account( - payer.as_ref(), - nonce_account.as_ref(), - payer.as_ref(), - 1_500_000, - ); - - let message = Message::new_with_blockhash( - instructions.as_slice(), - Some(payer.as_ref()), - &get_recent_blockhash(&client).await, - ); - - let signatures = vec![ - wallet.sign_with_ed25519(&message, &payer).await, - wallet.sign_with_ed25519(&message, &nonce_account).await, - ]; - let transaction = Transaction { - message, - signatures, - }; - - client - .send_transaction(transaction) - .send() - .await - .expect_consistent() - .expect("Call to `sendTransaction` failed") - .to_string() -} - -#[update] -pub async fn create_associated_token_account( - owner: Option, - mint_account: String, -) -> String { - let client = client(); - - let owner = owner.unwrap_or_else(validate_caller_not_anonymous); - let wallet = SolanaWallet::new(owner).await; - - let payer = wallet.solana_account(); - let mint = Pubkey::from_str(&mint_account).unwrap(); - - let instruction = - spl::create_associated_token_account_instruction(payer.as_ref(), payer.as_ref(), &mint); - - let message = Message::new_with_blockhash( - &[instruction], - Some(payer.as_ref()), - &get_recent_blockhash(&client).await, - ); - - let signatures = vec![wallet.sign_with_ed25519(&message, &payer).await]; - let transaction = Transaction { - message, - signatures, - }; - - client - .send_transaction(transaction) - .send() - .await - .expect_consistent() - .expect("Call to `sendTransaction` failed") - .to_string() -} - -#[update] -pub async fn send_sol(owner: Option, to: String, amount: Nat) -> String { - let client = client(); - - let owner = owner.unwrap_or_else(validate_caller_not_anonymous); - let wallet = SolanaWallet::new(owner).await; - - let recipient = Pubkey::from_str(&to).unwrap(); - let payer = wallet.solana_account(); - let amount = amount.0.to_u64().unwrap(); - - let instruction = system_instruction::transfer(payer.as_ref(), &recipient, amount); - - let message = Message::new_with_blockhash( - &[instruction], - Some(payer.as_ref()), - &get_recent_blockhash(&client).await, - ); - let signatures = vec![wallet.sign_with_ed25519(&message, &payer).await]; - let transaction = Transaction { - message, - signatures, - }; - - client - .send_transaction(transaction) - .send() - .await - .expect_consistent() - .expect("Call to `sendTransaction` failed") - .to_string() -} - -#[update] -pub async fn send_sol_with_durable_nonce( - owner: Option, - to: String, - amount: Nat, -) -> String { - let client = client(); - - let owner = owner.unwrap_or_else(validate_caller_not_anonymous); - let wallet = SolanaWallet::new(owner).await; - - let recipient = Pubkey::from_str(&to).unwrap(); - let payer = wallet.solana_account(); - let amount = amount.0.to_u64().unwrap(); - let nonce_account = wallet.derived_nonce_account(); - - let instructions = &[ - system_instruction::advance_nonce_account(nonce_account.as_ref(), payer.as_ref()), - system_instruction::transfer(payer.as_ref(), &recipient, amount), - ]; - - let blockhash = Hash::from_str(&get_nonce(Some(nonce_account.to_string())).await) - .expect("Unable to parse nonce as blockhash"); - - let message = Message::new_with_blockhash(instructions, Some(payer.as_ref()), &blockhash); - let signatures = vec![wallet.sign_with_ed25519(&message, &payer).await]; - let transaction = Transaction { - message, - signatures, - }; - - client - .send_transaction(transaction) - .send() - .await - .expect_consistent() - .expect("Call to `sendTransaction` failed") - .to_string() -} - -#[update] -pub async fn send_spl_token( - owner: Option, - mint_account: String, - to: String, - amount: Nat, -) -> String { - let client = client(); - - let owner = owner.unwrap_or_else(validate_caller_not_anonymous); - let wallet = SolanaWallet::new(owner).await; - - let payer = wallet.solana_account(); - let recipient = Pubkey::from_str(&to).unwrap(); - let mint = Pubkey::from_str(&mint_account).unwrap(); - let amount = amount.0.to_u64().unwrap(); - - let from = spl::get_associated_token_address(payer.as_ref(), &mint); - let to = spl::get_associated_token_address(&recipient, &mint); - - let instruction = spl::transfer_instruction(&from, &to, payer.as_ref(), amount); - - let message = Message::new_with_blockhash( - &[instruction], - Some(payer.as_ref()), - &get_recent_blockhash(&client).await, - ); - let signatures = vec![wallet.sign_with_ed25519(&message, &payer).await]; - let transaction = Transaction { - message, - signatures, - }; - - client - .send_transaction(transaction) - .send() - .await - .expect_consistent() - .expect("Call to `sendTransaction` failed") - .to_string() -} - // Fetch a recent blockhash using the Solana `getSlot` and `getBlock` methods. // Since the `getSlot` method might fail due to Solana's fast blocktime, and some slots do not // have blocks, we retry the RPC calls several times in case of failure to find a recent block. -async fn get_recent_blockhash(rpc_client: &SolRpcClient) -> Hash { +pub async fn get_recent_blockhash(rpc_client: &SolRpcClient) -> Hash { let num_tries = 3; let mut errors = Vec::with_capacity(num_tries); loop { @@ -370,7 +53,7 @@ async fn get_recent_blockhash(rpc_client: &SolRpcClient) -> Hash { } } -fn client() -> SolRpcClient { +pub fn client() -> SolRpcClient { read_state(|state| state.sol_rpc_canister_id()) .map(|canister_id| SolRpcClient::builder(IcRuntime, canister_id)) .unwrap_or(SolRpcClient::builder_for_ic()) diff --git a/examples/basic_solana/src/main.rs b/examples/basic_solana/src/main.rs new file mode 100644 index 00000000..65ac8a20 --- /dev/null +++ b/examples/basic_solana/src/main.rs @@ -0,0 +1,375 @@ +use base64::prelude::BASE64_STANDARD; +use base64::Engine; +use basic_solana::solana_wallet::SolanaWallet; +use basic_solana::state::init_state; +use basic_solana::{client, get_recent_blockhash, spl, 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::{CommitmentLevel, GetAccountInfoEncoding, GetAccountInfoParams}; +use solana_account_decoder_client_types::{UiAccountData, UiAccountEncoding}; +use solana_hash::Hash; +use solana_message::Message; +use solana_nonce::{state::State, versions::Versions as NonceVersions}; +use solana_program::system_instruction; +use solana_pubkey::Pubkey; +use solana_transaction::Transaction; +use std::str::FromStr; + +#[init] +pub fn init(init_arg: InitArg) { + init_state(init_arg) +} + +#[post_upgrade] +fn post_upgrade(init_arg: Option) { + if let Some(init_arg) = init_arg { + init_state(init_arg) + } +} + +#[update] +pub async fn solana_account(owner: Option) -> String { + let owner = owner.unwrap_or_else(validate_caller_not_anonymous); + let wallet = SolanaWallet::new(owner).await; + wallet.solana_account().to_string() +} + +#[update] +pub async fn nonce_account(owner: Option) -> String { + let owner = owner.unwrap_or_else(validate_caller_not_anonymous); + let wallet = SolanaWallet::new(owner).await; + wallet.derived_nonce_account().to_string() +} + +#[update] +pub async fn associated_token_account(owner: Option, mint_account: String) -> String { + let owner = owner.unwrap_or_else(validate_caller_not_anonymous); + let mint = Pubkey::from_str(&mint_account).unwrap(); + let wallet = SolanaWallet::new(owner).await; + spl::get_associated_token_address(wallet.solana_account().as_ref(), &mint).to_string() +} + +#[update] +pub async fn get_balance(account: Option) -> Nat { + let account = account.unwrap_or(solana_account(None).await); + let public_key = Pubkey::from_str(&account).unwrap(); + let balance = client() + .get_balance(public_key) + .send() + .await + .expect_consistent() + .expect("Call to `getBalance` failed"); + + Nat::from(balance) +} + +#[update] +pub async fn get_nonce(account: Option) -> String { + let account = account.unwrap_or(nonce_account(None).await); + + // Fetch the account info with the data encoded in base64 format + // TODO XC-347: use method from client to retrieve nonce + let mut params = GetAccountInfoParams::from_encoded_pubkey(account); + params.encoding = Some(GetAccountInfoEncoding::Base64); + let account_data = client() + .get_account_info(params) + // TODO XC-350: use commitment level from client + .modify_params(|params| params.commitment = Some(CommitmentLevel::Confirmed)) + .send() + .await + .expect_consistent() + .expect("Call to `getAccountInfo` failed") + .expect("Account not found for given pubkey") + .data; + + // Extract the nonce from the account data + let account_data = if let UiAccountData::Binary(blob, UiAccountEncoding::Base64) = account_data + { + BASE64_STANDARD + .decode(blob) + .expect("Unable to base64 decode account data") + } else { + panic!("Invalid response format"); + }; + match bincode::deserialize::(account_data.as_slice()) + .expect("Failed to deserialize nonce account data") + .state() + { + State::Uninitialized => panic!("Nonce account is uninitialized"), + State::Initialized(data) => data.blockhash().to_string(), + } +} + +#[update] +pub async fn get_spl_token_balance(account: Option, mint_account: String) -> String { + let account = account.unwrap_or(associated_token_account(None, mint_account).await); + + // TODO XC-325: use `getTokenAccountBalance` method from client + let response = client() + .json_request(json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "getTokenAccountBalance", + "params": [ account ] + })) + .send() + .await + .expect_consistent() + .expect("Call to `getTokenAccountBalance` failed"); + + // The response to a successful `getTokenAccountBalance` call has the following format: + // { "id": "[ID]", "jsonrpc": "2.0", "result": { "context": { "slot": [SLOT] } }, "value": [ { "uiAmountString": "FORMATTED AMOUNT" } ] }, } + serde_json::to_value(response).expect("`getTokenAccountBalance` response is not a valid JSON") + ["result"]["value"]["uiAmountString"] + .as_str() + .unwrap() + .to_string() +} + +#[update] +pub async fn create_nonce_account(owner: Option) -> String { + let client = client(); + + let owner = owner.unwrap_or_else(validate_caller_not_anonymous); + let wallet = SolanaWallet::new(owner).await; + + let payer = wallet.solana_account(); + let nonce_account = wallet.derived_nonce_account(); + + if let Some(_account) = client + .get_account_info(*nonce_account.as_ref()) + // TODO XC-350: use commitment level from client + .modify_params(|params| params.commitment = Some(CommitmentLevel::Confirmed)) + .send() + .await + .expect_consistent() + .unwrap_or_else(|e| { + panic!( + "Call to `getAccountInfo` for {} failed: {e}", + nonce_account.as_ref() + ) + }) + { + ic_cdk::println!( + "[create_nonce_account]: Account {} already exists. Skipping creation of nonce account", + nonce_account.as_ref() + ); + return nonce_account.as_ref().to_string(); + } + + let instructions = system_instruction::create_nonce_account( + payer.as_ref(), + nonce_account.as_ref(), + payer.as_ref(), + 1_500_000, + ); + + let message = Message::new_with_blockhash( + instructions.as_slice(), + Some(payer.as_ref()), + &get_recent_blockhash(&client).await, + ); + + let signatures = vec![ + wallet.sign_with_ed25519(&message, &payer).await, + wallet.sign_with_ed25519(&message, &nonce_account).await, + ]; + let transaction = Transaction { + message, + signatures, + }; + + client + .send_transaction(transaction) + // TODO XC-350: use commitment level from client + .modify_params(|params| params.preflight_commitment = Some(CommitmentLevel::Confirmed)) + .send() + .await + .expect_consistent() + .expect("Call to `sendTransaction` failed"); + + nonce_account.as_ref().to_string() +} + +#[update] +pub async fn create_associated_token_account( + owner: Option, + mint_account: String, +) -> String { + let client = client(); + + let owner = owner.unwrap_or_else(validate_caller_not_anonymous); + let wallet = SolanaWallet::new(owner).await; + + let payer = wallet.solana_account(); + let mint = Pubkey::from_str(&mint_account).unwrap(); + + let instruction = + spl::create_associated_token_account_instruction(payer.as_ref(), payer.as_ref(), &mint); + + let message = Message::new_with_blockhash( + &[instruction], + Some(payer.as_ref()), + &get_recent_blockhash(&client).await, + ); + + let signatures = vec![wallet.sign_with_ed25519(&message, &payer).await]; + let transaction = Transaction { + message, + signatures, + }; + + client + .send_transaction(transaction) + .send() + .await + .expect_consistent() + .expect("Call to `sendTransaction` failed") + .to_string() +} + +#[update] +pub async fn send_sol(owner: Option, to: String, amount: Nat) -> String { + let client = client(); + + let owner = owner.unwrap_or_else(validate_caller_not_anonymous); + let wallet = SolanaWallet::new(owner).await; + + let recipient = Pubkey::from_str(&to).unwrap(); + let payer = wallet.solana_account(); + let amount = amount.0.to_u64().unwrap(); + + ic_cdk::println!( + "Instruction to transfer {amount} lamports from {} to {recipient}", + payer.as_ref() + ); + let instruction = system_instruction::transfer(payer.as_ref(), &recipient, amount); + + let message = Message::new_with_blockhash( + &[instruction], + Some(payer.as_ref()), + &get_recent_blockhash(&client).await, + ); + let signatures = vec![wallet.sign_with_ed25519(&message, &payer).await]; + let transaction = Transaction { + message, + signatures, + }; + + client + .send_transaction(transaction) + // TODO XC-350: use commitment level from client + .modify_params(|params| params.preflight_commitment = Some(CommitmentLevel::Confirmed)) + .send() + .await + .expect_consistent() + .expect("Call to `sendTransaction` failed") + .to_string() +} + +#[update] +pub async fn send_sol_with_durable_nonce( + owner: Option, + to: String, + amount: Nat, +) -> String { + let client = client(); + + let owner = owner.unwrap_or_else(validate_caller_not_anonymous); + let wallet = SolanaWallet::new(owner).await; + + let recipient = Pubkey::from_str(&to).unwrap(); + let payer = wallet.solana_account(); + let amount = amount.0.to_u64().unwrap(); + let nonce_account = wallet.derived_nonce_account(); + + let instructions = &[ + system_instruction::advance_nonce_account(nonce_account.as_ref(), payer.as_ref()), + system_instruction::transfer(payer.as_ref(), &recipient, amount), + ]; + + let blockhash = Hash::from_str(&get_nonce(Some(nonce_account.to_string())).await) + .expect("Unable to parse nonce as blockhash"); + + let message = Message::new_with_blockhash(instructions, Some(payer.as_ref()), &blockhash); + let signatures = vec![wallet.sign_with_ed25519(&message, &payer).await]; + let transaction = Transaction { + message, + signatures, + }; + + client + .send_transaction(transaction) + // TODO XC-350: use commitment level from client + .modify_params(|params| params.preflight_commitment = Some(CommitmentLevel::Confirmed)) + .send() + .await + .expect_consistent() + .expect("Call to `sendTransaction` failed") + .to_string() +} + +#[update] +pub async fn send_spl_token( + owner: Option, + mint_account: String, + to: String, + amount: Nat, +) -> String { + let client = client(); + + let owner = owner.unwrap_or_else(validate_caller_not_anonymous); + let wallet = SolanaWallet::new(owner).await; + + let payer = wallet.solana_account(); + let recipient = Pubkey::from_str(&to).unwrap(); + let mint = Pubkey::from_str(&mint_account).unwrap(); + let amount = amount.0.to_u64().unwrap(); + + let from = spl::get_associated_token_address(payer.as_ref(), &mint); + let to = spl::get_associated_token_address(&recipient, &mint); + + let instruction = spl::transfer_instruction(&from, &to, payer.as_ref(), amount); + + let message = Message::new_with_blockhash( + &[instruction], + Some(payer.as_ref()), + &get_recent_blockhash(&client).await, + ); + let signatures = vec![wallet.sign_with_ed25519(&message, &payer).await]; + let transaction = Transaction { + message, + signatures, + }; + + client + .send_transaction(transaction) + .send() + .await + .expect_consistent() + .expect("Call to `sendTransaction` failed") + .to_string() +} + +fn main() {} + +#[test] +fn check_candid_interface_compatibility() { + use candid_parser::utils::{service_equal, CandidSource}; + + candid::export_service!(); + + let new_interface = __export_service(); + + // check the public interface against the actual one + let old_interface = std::path::PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").unwrap()) + .join("basic_solana.did"); + + service_equal( + CandidSource::Text(dbg!(&new_interface)), + CandidSource::File(old_interface.as_path()), + ) + .unwrap(); +} diff --git a/examples/basic_solana/tests/tests.rs b/examples/basic_solana/tests/tests.rs new file mode 100644 index 00000000..250fdff7 --- /dev/null +++ b/examples/basic_solana/tests/tests.rs @@ -0,0 +1,312 @@ +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 serde::de::DeserializeOwned; +use sol_rpc_types::{ + OverrideProvider, RegexSubstitution, RpcAccess, SupportedRpcProvider, SupportedRpcProviderId, +}; +use solana_client::rpc_client::RpcClient as SolanaRpcClient; +use solana_commitment_config::CommitmentConfig; +use solana_hash::Hash; +use solana_pubkey::{pubkey, Pubkey}; +use solana_signature::Signature; +use std::env::var; +use std::path::PathBuf; +use std::sync::Arc; + +pub const USER: Principal = Principal::from_slice(&[0x9d, 0xf7, 0x42]); +pub const AIRDROP_AMOUNT: u64 = 1_000_000_000; // 1 SOL + +// *NOTE*: Update instructions in README.md if you change this test! +#[test] +fn test_basic_solana() { + let setup = Setup::new().with_mock_api_keys(); + let basic_solana = setup.basic_solana(); + + // ## Step 2: Generating a Solana account + let user_solana_account: Pubkey = basic_solana + .update_call::<_, String>(USER, "solana_account", ()) + .parse() + .expect("Failed to parse public key"); + + // ## Step 3: Receiving SOL + setup.airdrop(&user_solana_account, AIRDROP_AMOUNT); + println!("User solana account {user_solana_account}"); + + let receiver_solana_account = pubkey!("8HNiduWaBanrBv8c2pgGXZWnpKBdEYuQNHnspqto4yyq"); + assert_ne!(user_solana_account, receiver_solana_account); + // The receiver account must be Initialized before receiving SOL, + // which will be done when requesting an airdrop + setup.airdrop(&receiver_solana_account, AIRDROP_AMOUNT); + println!("Receiver solana account {receiver_solana_account}"); + + // ## Step 4: Sending SOL + let receiver_balance_before = setup + .solana_client + .get_balance(&receiver_solana_account) + .unwrap(); + let send_sol_tx: Signature = basic_solana + .update_call::<_, String>( + USER, + "send_sol", + ( + None::, + receiver_solana_account.to_string(), + Nat::from(1_u8), + ), + ) + .parse() + .unwrap(); + let expected_receiver_balance = receiver_balance_before + 1; + assert_eq!( + setup.solana_client.wait_for_balance_with_commitment( + &receiver_solana_account, + Some(expected_receiver_balance), + CommitmentConfig::confirmed() + ), + Some(expected_receiver_balance) + ); + assert!(setup + .solana_client + .confirm_transaction(&send_sol_tx) + .unwrap()); + + // ## Step 5: Sending SOL using durable nonces + let nonce_account: Pubkey = basic_solana + .update_call::<_, String>(USER, "create_nonce_account", ()) + .parse() + .unwrap(); + setup.solana_client.wait_for_balance_with_commitment( + &nonce_account, + Some(1_500_000), + CommitmentConfig::confirmed(), + ); + let nonce_1 = setup.ensure_nonce_consistent(&nonce_account); + + let receiver_balance_before = setup + .solana_client + .get_balance(&receiver_solana_account) + .unwrap(); + let send_sol_tx: Signature = basic_solana + .update_call::<_, String>( + USER, + "send_sol_with_durable_nonce", + ( + None::, + receiver_solana_account.to_string(), + Nat::from(1_u8), + ), + ) + .parse() + .unwrap(); + let expected_receiver_balance = receiver_balance_before + 1; + assert_eq!( + setup.solana_client.wait_for_balance_with_commitment( + &receiver_solana_account, + Some(expected_receiver_balance), + CommitmentConfig::confirmed() + ), + Some(expected_receiver_balance) + ); + assert!(setup + .solana_client + .confirm_transaction(&send_sol_tx) + .unwrap()); + let nonce_2 = setup.ensure_nonce_consistent(&nonce_account); + assert_ne!(nonce_1, nonce_2); + + // ## Step 6: Sending Solana Program Library (SPL) tokens + // TODO: XC-349 test SPL tokens, adding the spl_token_2022 dependency brings a lot of problems + // with conflicting versions of transitive dependencies. +} + +pub struct Setup { + env: Arc, + solana_client: SolanaRpcClient, + sol_rpc_canister_id: CanisterId, + basic_solana_canister_id: CanisterId, +} + +impl Setup { + pub const DEFAULT_CONTROLLER: Principal = Principal::from_slice(&[0x9d, 0xf7, 0x02]); + const SOLANA_VALIDATOR_URL: &'static str = "http://localhost:8899"; + + pub fn new() -> Self { + let env = PocketIcBuilder::new() + .with_nns_subnet() //make_live requires NNS subnet. + .with_fiduciary_subnet() + .build(); + + let sol_rpc_canister_id = env.create_canister_with_settings( + None, + Some(CanisterSettings { + controllers: Some(vec![Self::DEFAULT_CONTROLLER]), + ..CanisterSettings::default() + }), + ); + env.add_cycles(sol_rpc_canister_id, u64::MAX as u128); + let sol_rpc_install_args = sol_rpc_types::InstallArgs { + override_provider: Some(OverrideProvider { + override_url: Some(RegexSubstitution { + pattern: ".*".into(), + replacement: Self::SOLANA_VALIDATOR_URL.to_string(), + }), + }), + ..Default::default() + }; + env.install_canister( + sol_rpc_canister_id, + sol_rpc_wasm(), + Encode!(&sol_rpc_install_args).unwrap(), + Some(Self::DEFAULT_CONTROLLER), + ); + + let basic_solana_canister_id = env.create_canister(); + env.add_cycles(basic_solana_canister_id, u64::MAX as u128); + let basic_solana_install_args = basic_solana::InitArg { + sol_rpc_canister_id: Some(sol_rpc_canister_id), + solana_network: Some(SolanaNetwork::Devnet), + ed25519_key_name: Some(Ed25519KeyName::ProductionKey1), + }; + env.install_canister( + basic_solana_canister_id, + basic_solana_wasm(), + Encode!(&basic_solana_install_args).unwrap(), + None, + ); + + println!("Basic solana canister ID {basic_solana_canister_id}"); + println!("SOL RPC canister id {sol_rpc_canister_id}"); + let mut env = env; + let _endpoint = env.make_live(None); + Self { + env: Arc::new(env), + solana_client: SolanaRpcClient::new_with_commitment( + Self::SOLANA_VALIDATOR_URL, + // Using confirmed commitment in tests provides faster execution while maintaining + // sufficient reliability. + CommitmentConfig::confirmed(), + ), + sol_rpc_canister_id, + basic_solana_canister_id, + } + } + + fn with_mock_api_keys(self) -> Self { + const MOCK_API_KEY: &str = "mock-api-key"; + let sol_rpc = self.sol_rpc(); + let providers: Vec<(SupportedRpcProviderId, SupportedRpcProvider)> = + sol_rpc.update_call(Principal::anonymous(), "getProviders", ()); + let mut api_keys = Vec::new(); + for (id, provider) in providers { + match provider.access { + RpcAccess::Authenticated { .. } => { + api_keys.push((id, Some(MOCK_API_KEY.to_string()))); + } + RpcAccess::Unauthenticated { .. } => {} + } + } + let _res: () = sol_rpc.update_call(Self::DEFAULT_CONTROLLER, "updateApiKeys", (api_keys,)); + self + } + + fn airdrop(&self, account: &Pubkey, amount: u64) { + let balance_before = self.solana_client.get_balance(account).unwrap(); + let _airdrop_tx = self.solana_client.request_airdrop(account, amount).unwrap(); + let expected_balance = balance_before + amount; + assert_eq!( + self.solana_client.wait_for_balance_with_commitment( + account, + Some(expected_balance), + CommitmentConfig::confirmed() + ), + Some(expected_balance) + ); + } + + fn ensure_nonce_consistent(&self, nonce_account: &Pubkey) -> Hash { + let expected_nonce: Hash = self + .basic_solana() + .update_call::<_, String>(USER, "get_nonce", (Some(nonce_account.to_string()),)) + .parse() + .unwrap(); + let actual_nonce = solana_rpc_client_nonce_utils::data_from_account( + &self.solana_client.get_account(nonce_account).unwrap(), + ) + .unwrap() + .blockhash(); + assert_eq!(expected_nonce, actual_nonce); + expected_nonce + } + + fn sol_rpc(&self) -> Canister { + Canister { + env: self.env.clone(), + id: self.sol_rpc_canister_id, + } + } + + fn basic_solana(&self) -> Canister { + Canister { + env: self.env.clone(), + id: self.basic_solana_canister_id, + } + } +} + +impl Default for Setup { + fn default() -> Self { + Self::new() + } +} + +fn sol_rpc_wasm() -> Vec { + ic_test_utilities_load_wasm::load_wasm( + PathBuf::from(var("CARGO_MANIFEST_DIR").unwrap()).join("../../canister"), + "sol_rpc_canister", + &[], + ) +} + +fn basic_solana_wasm() -> Vec { + ic_test_utilities_load_wasm::load_wasm( + PathBuf::from(var("CARGO_MANIFEST_DIR").unwrap()).join("."), + "basic_solana", + &[], + ) +} + +pub struct Canister { + env: Arc, + id: CanisterId, +} + +impl Canister { + pub fn update_call(&self, sender: Principal, method: &str, args: In) -> Out + where + In: ArgumentEncoder + Send, + Out: CandidType + DeserializeOwned, + { + let message_id = self + .env + .submit_call( + self.id, + sender, + method, + encode_args(args).unwrap_or_else(|e| { + panic!("Failed to encode arguments for method {method}: {e}") + }), + ) + .unwrap_or_else(|e| panic!("Failed to call method {method}: {e}")); + let response_bytes = self + .env + .await_call_no_ticks(message_id) + .unwrap_or_else(|e| panic!("Failed to await call for method {method}: {e}")); + let (res,) = decode_args(&response_bytes).unwrap_or_else(|e| { + panic!("Failed to decode canister response for method {method}: {e}") + }); + res + } +}