diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a017111b..33231e06 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 basic_solana --exclude sol_rpc_int_tests + run: cargo test --locked --workspace --exclude basic_solana --exclude sol_rpc_int_tests --exclude sol_rpc_e2e_tests integration-tests: needs: [ reproducible-build ] @@ -116,7 +116,6 @@ jobs: 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 @@ -175,3 +174,10 @@ jobs: - name: "Detect Inconsistent Results" working-directory: canister/ci run: cat e2e_examples.log | grep -q -e Inconsistent && exit 1 || exit 0 + + - name: "Run end-to-end tests" + env: + SOLANA_SENDER_PRIVATE_KEY_BYTES: ${{ secrets.SOLANA_SENDER_PRIVATE_KEY_BYTES }} + SOLANA_RECEIVER_PRIVATE_KEY_BYTES: ${{ secrets.SOLANA_RECEIVER_PRIVATE_KEY_BYTES }} + DFX_DEPLOY_KEY: ${{ secrets.DFX_DEPLOY_KEY }} + run: cargo test --locked --package sol_rpc_e2e_tests -- --test-threads 2 --nocapture diff --git a/Cargo.lock b/Cargo.lock index 20fe9492..72708aca 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -54,6 +54,12 @@ dependencies = [ "alloc-no-stdlib", ] +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + [[package]] name = "android-tzdata" version = "0.1.1" @@ -81,6 +87,12 @@ version = "1.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dde20b3d026af13f561bdd0f15edf01fc734f0dafcedbaf42bba506a9517f223" +[[package]] +name = "arc-swap" +version = "1.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69f7f8c3906b62b754cd5326047894316021dcfe5a194c8ea52bdd94934a3457" + [[package]] name = "arrayref" version = "0.3.9" @@ -200,6 +212,15 @@ dependencies = [ "syn 2.0.101", ] +[[package]] +name = "async-watch" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a078faf4e27c0c6cc0efb20e5da59dcccc04968ebf2801d8e0b2195124cdcdb2" +dependencies = [ + "event-listener 2.5.3", +] + [[package]] name = "atomic-waker" version = "1.1.2" @@ -238,6 +259,12 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + [[package]] name = "base64" version = "0.12.3" @@ -560,6 +587,19 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" +[[package]] +name = "cached" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8466736fe5dbcaf8b8ee24f9bbefe43c884dc3e9ff7178da70f55bffca1133c" +dependencies = [ + "ahash", + "hashbrown 0.14.5", + "instant", + "once_cell", + "thiserror 1.0.69", +] + [[package]] name = "camino" version = "1.1.9" @@ -970,6 +1010,18 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "43da5946c66ffcc7745f48db692ffbb10a83bfe0afd96235c5c2a4fb23994929" +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "subtle", + "zeroize", +] + [[package]] name = "crypto-common" version = "0.1.6" @@ -1022,6 +1074,19 @@ dependencies = [ "syn 2.0.101", ] +[[package]] +name = "curve25519-dalek-ng" +version = "4.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c359b7249347e46fb28804470d071c921156ad62b3eef5d34e2ba867533dec8" +dependencies = [ + "byteorder", + "digest 0.9.0", + "rand_core 0.6.4", + "subtle-ng", + "zeroize", +] + [[package]] name = "darling" version = "0.20.11" @@ -1147,6 +1212,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer 0.10.4", + "const-oid", "crypto-common", "subtle", ] @@ -1212,6 +1278,20 @@ version = "1.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1c7a8fb8a9fbf66c1f703fe16184d10ca0ee9d23be5b4436400408ba54a95005" +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der", + "digest 0.10.7", + "elliptic-curve", + "rfc6979", + "signature 2.2.0", + "spki", +] + [[package]] name = "ed25519" version = "1.5.3" @@ -1231,6 +1311,21 @@ dependencies = [ "signature 2.2.0", ] +[[package]] +name = "ed25519-consensus" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c8465edc8ee7436ffea81d21a019b16676ee3db267aa8d5a8d729581ecf998b" +dependencies = [ + "curve25519-dalek-ng", + "hex", + "rand_core 0.6.4", + "serde", + "sha2 0.9.9", + "thiserror 1.0.69", + "zeroize", +] + [[package]] name = "ed25519-dalek" version = "1.0.1" @@ -1268,6 +1363,26 @@ version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest 0.10.7", + "ff", + "generic-array", + "group", + "pem-rfc7468", + "pkcs8", + "rand_core 0.6.4", + "sec1", + "subtle", + "zeroize", +] + [[package]] name = "ena" version = "0.14.3" @@ -1533,6 +1648,7 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ "typenum", "version_check", + "zeroize", ] [[package]] @@ -1694,6 +1810,10 @@ name = "hashbrown" version = "0.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", + "allocator-api2", +] [[package]] name = "hashbrown" @@ -1935,6 +2055,54 @@ dependencies = [ "cc", ] +[[package]] +name = "ic-agent" +version = "0.40.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe81c75d117a496296e2a896733ae7a33ba838a7b2d79dae93e4df233940a9a8" +dependencies = [ + "arc-swap", + "async-channel", + "async-lock", + "async-trait", + "async-watch", + "backoff", + "cached", + "candid", + "der", + "ecdsa", + "ed25519-consensus", + "elliptic-curve", + "futures-util", + "hex", + "http 1.3.1", + "http-body 1.0.1", + "ic-certification", + "ic-transport-types 0.40.0", + "ic-verify-bls-signature", + "k256", + "leb128", + "p256", + "pem 3.0.5", + "pkcs8", + "rand 0.8.5", + "rangemap", + "reqwest 0.12.15", + "sec1", + "serde", + "serde_bytes", + "serde_cbor", + "serde_repr", + "sha2 0.10.8", + "simple_asn1", + "stop-token", + "thiserror 2.0.12", + "time", + "tokio", + "tower-service", + "url", +] + [[package]] name = "ic-canister-log" version = "0.2.0" @@ -1992,7 +2160,7 @@ dependencies = [ "curve25519-dalek 4.1.3", "ed25519-dalek 2.1.1", "hkdf", - "pem", + "pem 1.1.1", "rand 0.8.5", "thiserror 2.0.12", "zeroize", @@ -2051,12 +2219,58 @@ dependencies = [ "thiserror 2.0.12", ] +[[package]] +name = "ic-transport-types" +version = "0.40.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fc75ff050a629a7fed83c5cff2eab9509cde334e777621b826d764160e75393" +dependencies = [ + "candid", + "hex", + "ic-certification", + "leb128", + "serde", + "serde_bytes", + "serde_cbor", + "serde_repr", + "sha2 0.10.8", + "thiserror 2.0.12", +] + +[[package]] +name = "ic-verify-bls-signature" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d420b25c0091059f6c3c23a21427a81915e6e0aca3b79e0d403ed767f286a3b9" +dependencies = [ + "hex", + "ic_bls12_381", + "lazy_static", + "pairing", + "rand 0.8.5", + "sha2 0.10.8", +] + [[package]] name = "ic0" version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8de254dd67bbd58073e23dc1c8553ba12fa1dc610a19de94ad2bbcd0460c067f" +[[package]] +name = "ic_bls12_381" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1e828f9e804ccefe4b9b15b2195f474c60fd4f95ccd14fcb554eb6d7dfafde3" +dependencies = [ + "digest 0.10.7", + "ff", + "group", + "pairing", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "ic_principal" version = "0.1.1" @@ -2347,6 +2561,20 @@ dependencies = [ "serde_json", ] +[[package]] +name = "k256" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6e3919bbaa2945715f0bb6d3934a173d1e9a59ac23767fbaaef277265a7411b" +dependencies = [ + "cfg-if", + "ecdsa", + "elliptic-curve", + "once_cell", + "sha2 0.10.8", + "signature 2.2.0", +] + [[package]] name = "keccak" version = "0.1.5" @@ -2872,6 +3100,27 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2 0.10.8", +] + +[[package]] +name = "pairing" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81fec4625e73cf41ef4bb6846cafa6d44736525f442ba45e407c4a000a13996f" +dependencies = [ + "group", +] + [[package]] name = "parking" version = "2.2.1" @@ -2925,6 +3174,16 @@ dependencies = [ "base64 0.13.1", ] +[[package]] +name = "pem" +version = "3.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38af38e8470ac9dee3ce1bae1af9c1671fffc44ddfd8bd1d0a3445bf349a8ef3" +dependencies = [ + "base64 0.22.1", + "serde", +] + [[package]] name = "pem-rfc7468" version = "0.7.0" @@ -3034,7 +3293,7 @@ dependencies = [ "flate2", "hex", "ic-certification", - "ic-transport-types", + "ic-transport-types 0.39.3", "reqwest 0.12.15", "schemars", "serde", @@ -3091,6 +3350,15 @@ dependencies = [ "unicode-width 0.1.14", ] +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", +] + [[package]] name = "proc-macro-crate" version = "0.1.5" @@ -3347,6 +3615,12 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "rangemap" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60fcc7d6849342eff22c4350c8b9a989ee8ceabc4b481253e8946b9fe83d684" + [[package]] name = "raw-cpuid" version = "11.5.0" @@ -3549,6 +3823,16 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac", + "subtle", +] + [[package]] name = "ring" version = "0.17.14" @@ -3801,6 +4085,20 @@ dependencies = [ "untrusted", ] +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array", + "pkcs8", + "subtle", + "zeroize", +] + [[package]] name = "security-framework" version = "3.2.0" @@ -4054,6 +4352,18 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "simple_asn1" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "297f631f50729c8c99b84667867963997ec0b50f32b2a7dbcab828ef0541e8bb" +dependencies = [ + "num-bigint 0.4.6", + "num-traits", + "thiserror 2.0.12", + "time", +] + [[package]] name = "siphasher" version = "1.0.1" @@ -4162,6 +4472,31 @@ dependencies = [ "tokio", ] +[[package]] +name = "sol_rpc_e2e_tests" +version = "0.1.0" +dependencies = [ + "async-trait", + "candid", + "ic-agent", + "ic-cdk", + "serde", + "serde_json", + "sol_rpc_client", + "sol_rpc_int_tests", + "sol_rpc_types", + "solana-commitment-config", + "solana-compute-budget-interface", + "solana-hash", + "solana-keypair", + "solana-program", + "solana-pubkey", + "solana-signature", + "solana-signer", + "solana-transaction", + "tokio", +] + [[package]] name = "sol_rpc_int_tests" version = "0.1.0" @@ -5355,7 +5690,7 @@ dependencies = [ "libc", "log", "nix", - "pem", + "pem 1.1.1", "percentage", "quinn", "quinn-proto", @@ -5690,6 +6025,18 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "stop-token" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af91f480ee899ab2d9f8435bfdfc14d08a5754bd9d3fef1f1a1c23336aad6c8b" +dependencies = [ + "async-channel", + "cfg-if", + "futures-core", + "pin-project-lite", +] + [[package]] name = "string_cache" version = "0.8.9" @@ -5758,6 +6105,12 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +[[package]] +name = "subtle-ng" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "734676eb262c623cec13c3155096e08d1f8f29adce39ba17948b18dad1e54142" + [[package]] name = "syn" version = "1.0.109" diff --git a/Cargo.toml b/Cargo.toml index 2c08d39b..45d4f6b2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,10 +1,13 @@ [workspace] resolver = "2" members = [ - "canister", "integration_tests", + "canister", + "end_to_end_tests", + "examples/basic_solana", + "integration_tests", "libs/client", "libs/types", - "examples/basic_solana"] +] [workspace.package] authors = ["DFINITY Stiftung"] @@ -43,6 +46,7 @@ futures = "0.3.31" getrandom = { version = "*", default-features = false, features = ["custom"] } hex = "0.4.3" http = "1.2.0" +ic-agent = "0.40.0" ic-canister-log = "0.2.0" ic-cdk = "0.17.1" ic-http-types = "0.1.0" diff --git a/canister/scripts/examples.sh b/canister/scripts/examples.sh index 24c4d3e6..d28aaf37 100755 --- a/canister/scripts/examples.sh +++ b/canister/scripts/examples.sh @@ -100,8 +100,6 @@ GET_TRANSACTION_PARAMS="( CYCLES=$(dfx canister call sol_rpc getTransactionCyclesCost "$GET_TRANSACTION_PARAMS" $FLAGS --output json | jq '.Ok' --raw-output || exit 1) dfx canister call sol_rpc getTransaction "$GET_TRANSACTION_PARAMS" $FLAGS --with-cycles "$CYCLES" || exit 1 -# TODO XC-339: Add end-to-end test for `sendTransaction` using `getSlot` and `getBlock` - # Get the USDC mint account info on Mainnet with a 2-out-of-3 strategy GET_ACCOUNT_INFO_PARAMS="( variant { Default = variant { Mainnet } }, diff --git a/end_to_end_tests/Cargo.toml b/end_to_end_tests/Cargo.toml new file mode 100644 index 00000000..33ec86db --- /dev/null +++ b/end_to_end_tests/Cargo.toml @@ -0,0 +1,31 @@ +[package] +name = "sol_rpc_e2e_tests" +version = "0.1.0" +authors.workspace = true +edition.workspace = true +repository.workspace = true +homepage.workspace = true +license.workspace = true + +[dependencies] +async-trait = { workspace = true } +candid = { workspace = true } +ic-agent = { workspace = true } +ic-cdk = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +sol_rpc_client = { path = "../libs/client" } +sol_rpc_types = { path = "../libs/types" } +sol_rpc_int_tests = { path = "../integration_tests" } +solana-commitment-config = { workspace = true } +solana-hash = { workspace = true } +solana-pubkey = { workspace = true } +solana-signature = { workspace = true } +solana-signer = { workspace = true } +solana-transaction = { workspace = true } +tokio = { workspace = true } + +[dev-dependencies] +solana-compute-budget-interface = { workspace = true } +solana-keypair = { workspace = true } +solana-program = { workspace = true } diff --git a/end_to_end_tests/LICENSE b/end_to_end_tests/LICENSE new file mode 120000 index 00000000..ea5b6064 --- /dev/null +++ b/end_to_end_tests/LICENSE @@ -0,0 +1 @@ +../LICENSE \ No newline at end of file diff --git a/end_to_end_tests/NOTICE b/end_to_end_tests/NOTICE new file mode 120000 index 00000000..7e1b82f6 --- /dev/null +++ b/end_to_end_tests/NOTICE @@ -0,0 +1 @@ +../NOTICE \ No newline at end of file diff --git a/end_to_end_tests/src/lib.rs b/end_to_end_tests/src/lib.rs new file mode 100644 index 00000000..61c9507f --- /dev/null +++ b/end_to_end_tests/src/lib.rs @@ -0,0 +1,215 @@ +use async_trait::async_trait; +use candid::{utils::ArgumentEncoder, CandidType, Encode, Principal}; +use ic_agent::{identity::Secp256k1Identity, Agent}; +use ic_cdk::api::call::RejectionCode; +use serde::de::DeserializeOwned; +use serde_json::json; +use sol_rpc_client::{ClientBuilder, Runtime, SolRpcClient}; +use sol_rpc_int_tests::{ + decode_call_response, encode_args, + wallet::{decode_cycles_wallet_response, CallCanisterArgs}, +}; +use sol_rpc_types::{ + CommitmentLevel, ConsensusStrategy, MultiRpcResult, RpcConfig, RpcSource, RpcSources, + SupportedRpcProviderId, +}; +use solana_commitment_config::CommitmentConfig; +use solana_pubkey::Pubkey; +use solana_signature::Signature; +use std::{env, time::Duration}; + +const DEFAULT_IC_GATEWAY: &str = "https://icp0.io"; + +pub struct Setup { + agent: Agent, + sol_rpc_canister_id: Principal, + wallet_canister_id: Principal, +} + +impl Setup { + pub fn new() -> Self { + Self { + agent: Agent::builder() + .with_url(DEFAULT_IC_GATEWAY) + .with_identity({ + Secp256k1Identity::from_pem(env("DFX_DEPLOY_KEY").as_bytes()) + .expect("Unable to import identity from PEM file") + }) + .build() + .expect("Could not build agent"), + sol_rpc_canister_id: Principal::from_text(env("sol_rpc_canister_id")).unwrap(), + wallet_canister_id: Principal::from_text(env("wallet_canister_id")).unwrap(), + } + } + + pub fn new_ic_agent_runtime(&self) -> IcAgentRuntime { + IcAgentRuntime { + agent: &self.agent, + wallet_canister_id: self.wallet_canister_id, + } + } + + pub fn client_builder(&self) -> ClientBuilder { + SolRpcClient::builder(self.new_ic_agent_runtime(), self.sol_rpc_canister_id) + } + + pub fn client(&self) -> SolRpcClient { + self.client_builder() + .with_rpc_sources(RpcSources::Custom(vec![ + RpcSource::Supported(SupportedRpcProviderId::AnkrDevnet), + RpcSource::Supported(SupportedRpcProviderId::DrpcDevnet), + RpcSource::Supported(SupportedRpcProviderId::HeliusDevnet), + ])) + .with_rpc_config(RpcConfig { + response_consensus: Some(ConsensusStrategy::Threshold { + min: 2, + total: None, + }), + ..RpcConfig::default() + }) + .with_default_commitment_level(CommitmentLevel::Confirmed) + .build() + } + + pub async fn confirm_transaction(&self, transaction_id: &Signature) { + let mut num_trials = 0; + loop { + num_trials += 1; + if num_trials > 20 { + panic!("Failed to confirm transaction {transaction_id}"); + } + let statuses = self + .client() + .get_signature_statuses([transaction_id]) + .unwrap() + .send() + .await; + if let MultiRpcResult::Consistent(Ok(statuses)) = statuses { + if let Some(Some(status)) = statuses.first() { + if let Some(err) = &status.err { + panic!("Transaction failed with error {:?}", err); + } + if status.satisfies_commitment(CommitmentConfig::confirmed()) { + return; + } + } + } + tokio::time::sleep(Duration::from_millis(400)).await; + } + } + + pub async fn airdrop(&self, account: &Pubkey, amount: u64) -> u64 { + let balance_before = self.get_account_balance(account).await; + let _airdrop_tx = self + .client() + .json_request(json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "requestAirdrop", + "params": [account.to_string(), amount] + })) + .send() + .await; + let expected_balance = balance_before + amount; + let mut num_trials = 0; + loop { + num_trials += 1; + if num_trials > 20 { + panic!("Failed to airdrop funds to account {account}"); + } + let balance = self.get_account_balance(account).await; + if balance >= expected_balance { + return balance; + }; + tokio::time::sleep(Duration::from_millis(400)).await; + } + } + + pub async fn fund_account(&self, account: &Pubkey, amount: u64) -> u64 { + let balance = self.get_account_balance(account).await; + if balance < amount { + self.airdrop(account, amount).await + } else { + balance + } + } + + pub async fn get_account_balance(&self, pubkey: &Pubkey) -> u64 { + self.client() + .get_balance(*pubkey) + .send() + .await + .expect_consistent() + .unwrap_or_else(|_| panic!("Failed to fetch account balance for account {pubkey}")) + } +} + +impl Default for Setup { + fn default() -> Self { + Self::new() + } +} + +pub fn env(key: &str) -> String { + env::var(key).unwrap_or_else(|_| panic!("Environment variable '{key}' is not set!")) +} + +#[derive(Clone, Debug)] +pub struct IcAgentRuntime<'a> { + pub agent: &'a Agent, + pub wallet_canister_id: Principal, +} + +impl<'a> IcAgentRuntime<'a> { + pub fn new(agent: &'a Agent, wallet_canister_id: Principal) -> Self { + Self { + agent, + wallet_canister_id, + } + } +} + +#[async_trait] +impl Runtime for IcAgentRuntime<'_> { + async fn update_call( + &self, + id: Principal, + method: &str, + args: In, + cycles: u128, + ) -> Result + where + In: ArgumentEncoder + Send, + Out: CandidType + DeserializeOwned, + { + // Forward the call through the wallet canister + let result = self + .agent + .update(&self.wallet_canister_id, "wallet_call128") + .with_arg(Encode!(&CallCanisterArgs::new(id, method, args, cycles)).unwrap()) + .call_and_wait() + .await + .map_err(|e| (RejectionCode::Unknown, e.to_string()))?; + decode_cycles_wallet_response(result) + } + + async fn query_call( + &self, + id: Principal, + method: &str, + args: In, + ) -> Result + where + In: ArgumentEncoder + Send, + Out: CandidType + DeserializeOwned, + { + let result = self + .agent + .query(&id, method) + .with_arg(encode_args(args)) + .call() + .await + .map_err(|e| (RejectionCode::Unknown, e.to_string()))?; + decode_call_response(result) + } +} diff --git a/end_to_end_tests/tests/end_to_end.rs b/end_to_end_tests/tests/end_to_end.rs new file mode 100644 index 00000000..513cb405 --- /dev/null +++ b/end_to_end_tests/tests/end_to_end.rs @@ -0,0 +1,101 @@ +use sol_rpc_e2e_tests::{env, Setup}; +use solana_compute_budget_interface::ComputeBudgetInstruction; +use solana_hash::Hash; +use solana_keypair::Keypair; +use solana_program::system_instruction; +use solana_signer::Signer; +use solana_transaction::Transaction; +use std::str::FromStr; + +#[tokio::test(flavor = "multi_thread")] +async fn should_send_transaction() { + let setup = Setup::new(); + + fn load_keypair(key: &str) -> Keypair { + fn try_load_keypair(key: &str) -> Result { + let value = env(key); + let bytes = serde_json::from_str::>(&value).map_err(|e| e.to_string())?; + Keypair::from_bytes(bytes.as_ref()).map_err(|e| e.to_string()) + } + try_load_keypair(key).unwrap_or_else(|e| panic!("Unable to parse bytes stored in environment variable '{key}' as a valid keypair: {e}")) + } + + let sender = load_keypair("SOLANA_SENDER_PRIVATE_KEY_BYTES"); + let recipient = load_keypair("SOLANA_RECEIVER_PRIVATE_KEY_BYTES"); + + let sender_balance_before = setup.fund_account(&sender.pubkey(), 1_000_000_000).await; + let recipient_balance_before = setup.fund_account(&recipient.pubkey(), 1_000_000_000).await; + + let prioritization_fees: Vec<_> = setup + .client() + .get_recent_prioritization_fees(&[sender.pubkey(), recipient.pubkey()]) + .unwrap() + .send() + .await + .expect_consistent() + .expect("Call to `getRecentPrioritizationFees` failed") + .into_iter() + .map(|fee| fee.prioritization_fee) + .collect(); + + // Set the compute unit (CU) price to the median of the recent prioritization fees + let priority_fee = if !prioritization_fees.is_empty() { + prioritization_fees[prioritization_fees.len() / 2] + } else { + 0 + }; + let add_priority_fee_ix = ComputeBudgetInstruction::set_compute_unit_price(priority_fee); + + // Set a CU limit based for a simple SOL transfer + instructions to set the CU price and CU limit + let set_cu_limit_ix = ComputeBudgetInstruction::set_compute_unit_limit(500); + + // Send some SOL from sender to recipient + let transaction_amount = 1_000; + let transfer_ix = + system_instruction::transfer(&sender.pubkey(), &recipient.pubkey(), transaction_amount); + + let slot = setup + .client() + .get_slot() + .send() + .await + .expect_consistent() + .expect("Call to get slot failed"); + let block = setup + .client() + .get_block(slot) + .send() + .await + .expect_consistent() + .expect("Call to `getBlock` failed") + .expect("Block not found"); + let blockhash = Hash::from_str(&block.blockhash).expect("Failed to parse blockhash"); + + let transaction = Transaction::new_signed_with_payer( + &[set_cu_limit_ix, add_priority_fee_ix, transfer_ix], + Some(&sender.pubkey()), + &[&sender], + blockhash, + ); + + let transaction_id = setup + .client() + .send_transaction(transaction) + .send() + .await + .expect_consistent() + .unwrap(); + + // Wait until the transaction is successfully executed and confirmed. + setup.confirm_transaction(&transaction_id).await; + + // Make sure the funds were sent from the sender to the recipient + let sender_balance_after = setup.get_account_balance(&sender.pubkey()).await; + let recipient_balance_after = setup.get_account_balance(&recipient.pubkey()).await; + + assert_eq!( + recipient_balance_after, + recipient_balance_before + transaction_amount + ); + assert!(sender_balance_after + transaction_amount <= sender_balance_before); +} diff --git a/integration_tests/Cargo.toml b/integration_tests/Cargo.toml index fdd6d412..eea53ebd 100644 --- a/integration_tests/Cargo.toml +++ b/integration_tests/Cargo.toml @@ -28,12 +28,9 @@ sol_rpc_types = { path = "../libs/types" } solana-account = { workspace = true } solana-account-decoder-client-types = { workspace = true } solana-commitment-config = { workspace = true } -solana-compute-budget-interface = {workspace = true} solana-hash = { workspace = true } solana-instruction = { workspace = true } -solana-keypair = { workspace = true } solana-pubkey = { workspace = true } -solana-program = { workspace = true } solana-rpc-client-api = { workspace = true } solana-signature = { workspace = true } solana-signer = { workspace = true } @@ -44,5 +41,8 @@ solana-transaction-status-client-types = { workspace = true } assert_matches = { workspace = true } futures = { workspace = true } solana-client = { workspace = true } +solana-compute-budget-interface = { workspace = true } +solana-keypair = { workspace = true } +solana-program = { workspace = true } strum = { workspace = true } tokio = { workspace = true } diff --git a/integration_tests/src/lib.rs b/integration_tests/src/lib.rs index aff97244..c1aa9ea4 100644 --- a/integration_tests/src/lib.rs +++ b/integration_tests/src/lib.rs @@ -1,5 +1,5 @@ use async_trait::async_trait; -use candid::{decode_args, encode_args, utils::ArgumentEncoder, CandidType, Encode, Principal}; +use candid::{decode_args, utils::ArgumentEncoder, CandidType, Encode, Principal}; use canhttp::http::json::ConstantSizeId; use canlog::{Log, LogEntry}; use ic_cdk::api::call::RejectionCode; @@ -13,8 +13,7 @@ use pocket_ic::{ nonblocking::PocketIc, PocketIcBuilder, RejectCode, RejectResponse, }; -use regex::Regex; -use serde::{de::DeserializeOwned, Deserialize}; +use serde::de::DeserializeOwned; use sol_rpc_canister::logs::Priority; use sol_rpc_client::{ClientBuilder, Runtime, SolRpcClient}; use sol_rpc_types::{InstallArgs, RpcAccess, SupportedRpcProviderId}; @@ -26,8 +25,10 @@ use std::{ pub mod mock; pub mod spl; +pub mod wallet; use mock::{MockOutcall, MockOutcallBuilder}; +use wallet::CallCanisterArgs; const DEFAULT_MAX_RESPONSE_BYTES: u64 = 2_000_000; const MAX_TICKS: usize = 10; @@ -135,12 +136,13 @@ impl Setup { RpcAccess::Unauthenticated { .. } => {} } } + let args = (api_keys,); self.env .update_call( self.sol_rpc_canister_id, self.controller, "updateApiKeys", - PocketIcRuntime::encode_args((api_keys,)), + encode_args(args), ) .await .expect("BUG: Failed to call updateApiKeys"); @@ -289,18 +291,17 @@ impl Runtime for PocketIcRuntime<'_> { self.wallet, self.controller, "wallet_call128", - Encode!(&CallCanisterArgs { - canister: id, - method_name: method.to_string(), - args: PocketIcRuntime::encode_args(args), - cycles, - }) - .unwrap(), + Encode!(&CallCanisterArgs::new(id, method, args, cycles)).unwrap(), ) .await .unwrap(); self.execute_mock().await; - PocketIcRuntime::decode_forwarded_result(self.env.await_call(message_id).await) + wallet::decode_cycles_wallet_response( + self.env + .await_call(message_id) + .await + .map_err(PocketIcRuntime::parse_reject_response)?, + ) } async fn query_call( @@ -313,44 +314,16 @@ impl Runtime for PocketIcRuntime<'_> { In: ArgumentEncoder + Send, Out: CandidType + DeserializeOwned, { - PocketIcRuntime::decode_call_result( - self.env - .query_call(id, self.caller, method, PocketIcRuntime::encode_args(args)) - .await, - ) + let result = self + .env + .query_call(id, self.caller, method, encode_args(args)) + .await + .map_err(PocketIcRuntime::parse_reject_response); + decode_call_response(result?) } } impl PocketIcRuntime<'_> { - fn encode_args(args: In) -> Vec - where - In: ArgumentEncoder, - { - encode_args(args).expect("Failed to encode arguments.") - } - - fn decode_call_result( - result: Result, RejectResponse>, - ) -> Result - where - Out: CandidType + DeserializeOwned, - { - match result { - Ok(bytes) => Self::decode_call_response(bytes), - Err(e) => { - let rejection_code = match e.reject_code { - RejectCode::SysFatal => RejectionCode::SysFatal, - RejectCode::SysTransient => RejectionCode::SysTransient, - RejectCode::DestinationInvalid => RejectionCode::DestinationInvalid, - RejectCode::CanisterReject => RejectionCode::CanisterReject, - RejectCode::CanisterError => RejectionCode::CanisterError, - RejectCode::SysUnknown => RejectionCode::Unknown, - }; - Err((rejection_code, e.reject_message)) - } - } - } - fn with_strategy(self, strategy: MockStrategy) -> Self { Self { mock_strategy: Some(strategy), @@ -422,45 +395,16 @@ impl PocketIcRuntime<'_> { true } - fn decode_call_response(bytes: Vec) -> Result - where - Out: CandidType + DeserializeOwned, - { - decode_args(&bytes).map(|(res,)| res).map_err(|e| { - ( - RejectionCode::CanisterError, - format!( - "failed to decode canister response as {}: {}", - std::any::type_name::(), - e - ), - ) - }) - } - - fn decode_forwarded_result( - call_result: Result, RejectResponse>, - ) -> Result - where - Out: CandidType + DeserializeOwned, - { - match PocketIcRuntime::decode_call_result::>(call_result)? { - Ok(CallResult { bytes }) => PocketIcRuntime::decode_call_response(bytes), - Err(message) => { - // The wallet canister formats the rejection code and error message from the target - // canister into a single string. Extract them back from the formatted string. - match Regex::new(r"^An error happened during the call: (\d+): (.*)$") - .unwrap() - .captures(&message) - { - Some(captures) => { - let (_, [code, message]) = captures.extract(); - Err((code.parse::().unwrap().into(), message.to_string())) - } - None => Err((RejectionCode::Unknown, message)), - } - } - } + fn parse_reject_response(response: RejectResponse) -> (RejectionCode, String) { + let rejection_code = match response.reject_code { + RejectCode::SysFatal => RejectionCode::SysFatal, + RejectCode::SysTransient => RejectionCode::SysTransient, + RejectCode::DestinationInvalid => RejectionCode::DestinationInvalid, + RejectCode::CanisterReject => RejectionCode::CanisterReject, + RejectCode::CanisterError => RejectionCode::CanisterError, + RejectCode::SysUnknown => RejectionCode::Unknown, + }; + (rejection_code, response.reject_message) } } @@ -497,18 +441,16 @@ impl Runtime for PocketIcLiveModeRuntime<'_> { self.wallet, self.controller, "wallet_call128", - Encode!(&CallCanisterArgs { - canister: id, - method_name: method.to_string(), - args: PocketIcRuntime::encode_args(args), - cycles, - }) - .unwrap(), + Encode!(&CallCanisterArgs::new(id, method, args, cycles)).unwrap(), ) .await .unwrap(); - - PocketIcRuntime::decode_forwarded_result(self.env.await_call_no_ticks(message_id).await) + wallet::decode_cycles_wallet_response( + self.env + .await_call_no_ticks(message_id) + .await + .map_err(PocketIcRuntime::parse_reject_response)?, + ) } async fn query_call( @@ -521,14 +463,35 @@ impl Runtime for PocketIcLiveModeRuntime<'_> { In: ArgumentEncoder + Send, Out: CandidType + DeserializeOwned, { - PocketIcRuntime::decode_call_result( - self.env - .query_call(id, self.caller, method, PocketIcRuntime::encode_args(args)) - .await, - ) + let result = self + .env + .query_call(id, self.caller, method, encode_args(args)) + .await + .map_err(PocketIcRuntime::parse_reject_response); + decode_call_response(result?) } } +pub fn encode_args(args: In) -> Vec { + candid::encode_args(args).expect("Failed to encode arguments.") +} + +pub fn decode_call_response(bytes: Vec) -> Result +where + Out: CandidType + DeserializeOwned, +{ + decode_args(&bytes).map(|(res,)| res).map_err(|e| { + ( + RejectionCode::CanisterError, + format!( + "failed to decode canister response as {}: {}", + std::any::type_name::(), + e + ), + ) + }) +} + #[async_trait] pub trait SolRpcTestClient { fn mock_http(self, mock: impl Into) -> Self; @@ -596,22 +559,3 @@ enum MockStrategy { MockOnce(MockOutcall), MockSequence(Vec), } - -/// Argument to the wallet canister `wallet_call128` method. -/// See the [cycles wallet repository](https://github.com/dfinity/cycles-wallet). -#[derive(CandidType, Deserialize)] -struct CallCanisterArgs { - canister: Principal, - method_name: String, - #[serde(with = "serde_bytes")] - args: Vec, - cycles: u128, -} - -/// Return type of the wallet canister `wallet_call128` method. -/// See the [cycles wallet repository](https://github.com/dfinity/cycles-wallet) -#[derive(CandidType, Deserialize)] -struct CallResult { - #[serde(with = "serde_bytes", rename = "return")] - bytes: Vec, -} diff --git a/integration_tests/src/wallet.rs b/integration_tests/src/wallet.rs new file mode 100644 index 00000000..4d20bb71 --- /dev/null +++ b/integration_tests/src/wallet.rs @@ -0,0 +1,64 @@ +//! Module to interact with a [cycles wallet](https://github.com/dfinity/cycles-wallet) canister. + +use crate::{decode_call_response, encode_args}; +use candid::{utils::ArgumentEncoder, CandidType, Principal}; +use ic_cdk::api::call::RejectionCode; +use pocket_ic::management_canister::CanisterId; +use regex::Regex; +use serde::{de::DeserializeOwned, Deserialize}; + +/// Argument to the cycles wallet canister `wallet_call128` method. +#[derive(CandidType, Deserialize)] +pub struct CallCanisterArgs { + canister: Principal, + method_name: String, + #[serde(with = "serde_bytes")] + args: Vec, + cycles: u128, +} + +impl CallCanisterArgs { + pub fn new( + canister_id: CanisterId, + method: impl ToString, + args: In, + cycles: u128, + ) -> Self { + Self { + canister: canister_id, + method_name: method.to_string(), + args: encode_args(args), + cycles, + } + } +} + +/// Return type of the cycles wallet canister `wallet_call128` method. +#[derive(CandidType, Deserialize)] +pub struct CallResult { + #[serde(with = "serde_bytes", rename = "return")] + pub bytes: Vec, +} + +/// The cycles wallet canister formats the rejection code and error message from the target +/// canister into a single string. Extract them back from the formatted string. +pub fn decode_cycles_wallet_response(response: Vec) -> Result +where + Out: CandidType + DeserializeOwned, +{ + match decode_call_response::>(response)? { + Ok(CallResult { bytes }) => decode_call_response(bytes), + Err(message) => { + match Regex::new(r"^An error happened during the call: (\d+): (.*)$") + .unwrap() + .captures(&message) + { + Some(captures) => { + let (_, [code, message]) = captures.extract(); + Err((code.parse::().unwrap().into(), message.to_string())) + } + None => Err((RejectionCode::Unknown, message)), + } + } + } +} diff --git a/integration_tests/tests/solana_test_validator.rs b/integration_tests/tests/solana_test_validator.rs index 5451475d..dd71add3 100644 --- a/integration_tests/tests/solana_test_validator.rs +++ b/integration_tests/tests/solana_test_validator.rs @@ -12,7 +12,7 @@ use sol_rpc_types::{ OverrideProvider, PrioritizationFee, RegexSubstitution, TransactionDetails, TransactionStatus, }; use solana_account_decoder_client_types::{token::UiTokenAmount, UiAccount}; -use solana_client::rpc_client::{RpcClient as SolanaRpcClient, RpcClient}; +use solana_client::rpc_client::RpcClient as SolanaRpcClient; use solana_commitment_config::CommitmentConfig; use solana_compute_budget_interface::ComputeBudgetInstruction; use solana_hash::Hash; @@ -508,7 +508,7 @@ async fn should_get_signature_statuses() { fn solana_rpc_client_get_account( pubkey: &Pubkey, - sol: &RpcClient, + sol: &SolanaRpcClient, config: Option, ) -> Option { sol.get_account_with_config(pubkey, config.unwrap_or_default()) @@ -538,7 +538,7 @@ impl Setup { .await; let _endpoint = pic.make_live(None).await; Setup { - solana_client: RpcClient::new_with_commitment( + solana_client: SolanaRpcClient::new_with_commitment( Self::SOLANA_VALIDATOR_URL, // Using confirmed commitment in tests provides faster execution while maintaining // sufficient reliability. diff --git a/libs/types/src/lib.rs b/libs/types/src/lib.rs index 11e1106c..d1c6bb45 100644 --- a/libs/types/src/lib.rs +++ b/libs/types/src/lib.rs @@ -39,7 +39,8 @@ pub use solana::{ TransactionConfirmationStatus, TransactionInfo, TransactionReturnData, TransactionStatus, TransactionStatusMeta, TransactionTokenBalance, TransactionVersion, }, - ConfirmedBlock, Hash, Lamport, PrioritizationFee, Pubkey, Signature, Slot, Timestamp, + ConfirmedBlock, Hash, Lamport, MicroLamport, PrioritizationFee, Pubkey, Signature, Slot, + Timestamp, }; /// A vector with a maximum capacity. diff --git a/release-plz.toml b/release-plz.toml index ddcf4c75..70917024 100644 --- a/release-plz.toml +++ b/release-plz.toml @@ -54,4 +54,8 @@ release = false # don't process this package [[package]] name = "sol_rpc_int_tests" +release = false # don't process this package + +[[package]] +name = "sol_rpc_e2e_tests" release = false # don't process this package \ No newline at end of file