From 41febf6b0099ccfdc75315e05f4a3dc1e55fc2d7 Mon Sep 17 00:00:00 2001 From: Stavros Vlachakis <89769224+svlachakis@users.noreply.github.com> Date: Tue, 23 Jun 2026 15:09:49 +0200 Subject: [PATCH] stellar: add localnet integration tests Adds a standalone Stellar integration-test crate for exercising the Wormhole Core Soroban contract against localnet. The tests compile independently from the contract unit tests and cover the main localnet contract lifecycle. Changes: - Add stellar/integration-tests/ with helpers for deployment, VAA construction, RPC/event inspection, and localnet test cases. - Add localnet tests for initialization/querying, message posting, guardian set upgrades, contract upgrades, message fees, and fee transfers. - Add .env.localnet and scripts/run-tests.sh for localnet execution. - Register integration-tests in the Stellar Cargo workspace and update Cargo.lock. --- stellar/Cargo.lock | 58 +++- stellar/Cargo.toml | 3 +- stellar/integration-tests/.env.localnet | 8 + stellar/integration-tests/Cargo.toml | 16 + .../integration-tests/scripts/run-tests.sh | 67 ++++ stellar/integration-tests/src/lib.rs | 295 ++++++++++++++++++ .../tests/localnet_contract_upgrade.rs | 87 ++++++ .../tests/localnet_guardian_set_upgrade.rs | 129 ++++++++ .../tests/localnet_initialize_and_query.rs | 56 ++++ .../tests/localnet_message_fee.rs | 165 ++++++++++ .../tests/localnet_post_message.rs | 107 +++++++ .../tests/localnet_transfer_fees.rs | 203 ++++++++++++ 12 files changed, 1191 insertions(+), 3 deletions(-) create mode 100644 stellar/integration-tests/.env.localnet create mode 100644 stellar/integration-tests/Cargo.toml create mode 100755 stellar/integration-tests/scripts/run-tests.sh create mode 100644 stellar/integration-tests/src/lib.rs create mode 100644 stellar/integration-tests/tests/localnet_contract_upgrade.rs create mode 100644 stellar/integration-tests/tests/localnet_guardian_set_upgrade.rs create mode 100644 stellar/integration-tests/tests/localnet_initialize_and_query.rs create mode 100644 stellar/integration-tests/tests/localnet_message_fee.rs create mode 100644 stellar/integration-tests/tests/localnet_post_message.rs create mode 100644 stellar/integration-tests/tests/localnet_transfer_fees.rs diff --git a/stellar/Cargo.lock b/stellar/Cargo.lock index 1036ef8cb69..f00f9b230b8 100644 --- a/stellar/Cargo.lock +++ b/stellar/Cargo.lock @@ -311,6 +311,12 @@ dependencies = [ "serde_json", ] +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + [[package]] name = "crypto-bigint" version = "0.5.5" @@ -826,6 +832,17 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e04e2fd2b8188ea827b32ef11de88377086d690286ab35747ef7f9bf3ccb590" +[[package]] +name = "integration-tests" +version = "0.1.0" +dependencies = [ + "hex", + "secp256k1 0.32.0-beta.2", + "serde_json", + "stellar-strkey 0.0.14", + "tiny-keccak", +] + [[package]] name = "itertools" version = "0.10.5" @@ -1204,7 +1221,16 @@ checksum = "2c3c81b43dc2d8877c216a3fccf76677ee1ebccd429566d3e67447290d0c42b2" dependencies = [ "bitcoin_hashes", "rand 0.9.4", - "secp256k1-sys", + "secp256k1-sys 0.11.0", +] + +[[package]] +name = "secp256k1" +version = "0.32.0-beta.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c5fdc7d6e800869d3fd60ff857c479bf0a83ea7bf44b389e64461e844204994" +dependencies = [ + "secp256k1-sys 0.12.0", ] [[package]] @@ -1216,6 +1242,15 @@ dependencies = [ "cc", ] +[[package]] +name = "secp256k1-sys" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d3be00697c88c00fe102af8dc316038cc2062eab8da646e7463f4c0e70ca9fd" +dependencies = [ + "cc", +] + [[package]] name = "semver" version = "1.0.27" @@ -1571,6 +1606,16 @@ dependencies = [ "data-encoding", ] +[[package]] +name = "stellar-strkey" +version = "0.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4b7f19b1c8a8b98616479c71669633456b567898aef7c5367489c982dcb58a1" +dependencies = [ + "crate-git-revision", + "data-encoding", +] + [[package]] name = "stellar-strkey" version = "0.0.16" @@ -1686,6 +1731,15 @@ dependencies = [ "time-core", ] +[[package]] +name = "tiny-keccak" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" +dependencies = [ + "crunchy", +] + [[package]] name = "typenum" version = "1.18.0" @@ -1895,7 +1949,7 @@ checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" name = "wormhole-contract" version = "0.1.0" dependencies = [ - "secp256k1", + "secp256k1 0.31.1", "soroban-sdk", "wormhole-soroban-client", ] diff --git a/stellar/Cargo.toml b/stellar/Cargo.toml index 42504521dec..be608391ba6 100644 --- a/stellar/Cargo.toml +++ b/stellar/Cargo.toml @@ -2,7 +2,8 @@ resolver = "3" members = [ "contracts/wormhole-soroban-client", - "contracts/wormhole-contract" + "contracts/wormhole-contract", + "integration-tests" ] [workspace.package] diff --git a/stellar/integration-tests/.env.localnet b/stellar/integration-tests/.env.localnet new file mode 100644 index 00000000000..ca67315f8b0 --- /dev/null +++ b/stellar/integration-tests/.env.localnet @@ -0,0 +1,8 @@ +# Stellar Localnet Configuration +STELLAR_NETWORK=local +STELLAR_IDENTITY=local_admin +SOROBAN_RPC_URL=http://localhost:8000/soroban/rpc +STELLAR_FRIENDBOT_URL=http://localhost:8000/friendbot +WORMHOLE_WASM_PATH=target/wasm32v1-none/release/wormhole_contract.wasm +STELLAR_NETWORK_PASSPHRASE="Standalone Network ; February 2017" +WORMHOLE_CONTRACT_ID=CDJ345X4JXGG5PBBIHAW4ZL4X3CGRD7XKFK36XN5CKZZNUEZ3TXLEOQD \ No newline at end of file diff --git a/stellar/integration-tests/Cargo.toml b/stellar/integration-tests/Cargo.toml new file mode 100644 index 00000000000..12f5233277c --- /dev/null +++ b/stellar/integration-tests/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "integration-tests" +version = "0.1.0" +edition = "2024" +publish = false + +autobins = false + +[dependencies] +serde_json = "1.0" +stellar-strkey = "0.0.14" +secp256k1 = { version = "0.32.0-beta.2", default-features = false, features = ["alloc", "recovery"] } +hex = "0.4" +tiny-keccak = { version = "2.0", features = ["keccak"] } + +[dev-dependencies] diff --git a/stellar/integration-tests/scripts/run-tests.sh b/stellar/integration-tests/scripts/run-tests.sh new file mode 100755 index 00000000000..3963cb9a273 --- /dev/null +++ b/stellar/integration-tests/scripts/run-tests.sh @@ -0,0 +1,67 @@ +#!/bin/bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +ENV_FILE="$SCRIPT_DIR/../.env.localnet" + +die() { + echo "$*" >&2 + exit 1 +} + +cleanup() { + if [[ -n "${LIB_RS:-}" && -f "${LIB_RS}.bak" ]]; then + mv "${LIB_RS}.bak" "$LIB_RS" + fi +} + +trap cleanup EXIT + +[ -f "$ENV_FILE" ] || die "Environment file $ENV_FILE not found" + +# Load the localnet configuration consumed by both the shell wrapper and the +# Rust integration test harness. +# shellcheck disable=SC1091 +source "$ENV_FILE" + +: "${STELLAR_NETWORK:?STELLAR_NETWORK not set}" +: "${STELLAR_IDENTITY:?STELLAR_IDENTITY not set}" +: "${SOROBAN_RPC_URL:?SOROBAN_RPC_URL not set}" +: "${WORMHOLE_WASM_PATH:?WORMHOLE_WASM_PATH not set}" + +if [[ "$WORMHOLE_WASM_PATH" != /* ]]; then + # Resolve the configured wasm path relative to the workspace root. + WORMHOLE_WASM_PATH="$PROJECT_ROOT/$WORMHOLE_WASM_PATH" +fi + +# The Rust tests read these values via `std::env`, so they must be exported +# before `cargo test` launches the test binaries. +export STELLAR_NETWORK +export STELLAR_IDENTITY +export SOROBAN_RPC_URL +export WORMHOLE_WASM_PATH + +echo "Building contracts using stellar contract build..." +cd "$PROJECT_ROOT" +rm -f target/wasm32v1-none/release/wormhole_contract.wasm +stellar contract build +# Keep a copy of the original WASM so we can restore it after preparing the +# upgrade artifact used by the contract-upgrade integration test. +cp target/wasm32v1-none/release/wormhole_contract.wasm target/wasm32v1-none/release/wormhole_contract_original.wasm + +# Build a version for upgrade test, with different chain ID +echo "Building upgraded contract version for integration tests..." +LIB_RS="contracts/wormhole-contract/src/lib.rs" +sed -i.bak 's/u32::from(CHAIN_ID_STELLAR)/999u32/' "$LIB_RS" +stellar contract build +cp target/wasm32v1-none/release/wormhole_contract.wasm target/wasm32v1-none/release/wormhole_contract_upgrade.wasm + +# Restore original source and WASM file +touch "$LIB_RS" +cp target/wasm32v1-none/release/wormhole_contract_original.wasm target/wasm32v1-none/release/wormhole_contract.wasm + +export WORMHOLE_UPGRADE_WASM_PATH="$PROJECT_ROOT/target/wasm32v1-none/release/wormhole_contract_upgrade.wasm" + +echo "Running integration tests..." +cargo test -p integration-tests -- --ignored --nocapture diff --git a/stellar/integration-tests/src/lib.rs b/stellar/integration-tests/src/lib.rs new file mode 100644 index 00000000000..009d5eb0af4 --- /dev/null +++ b/stellar/integration-tests/src/lib.rs @@ -0,0 +1,295 @@ +use secp256k1::{Message, SecretKey, ecdsa::RecoverableSignature}; +use serde_json::Value; +use std::{process::Command, thread::sleep, time::Duration}; +use tiny_keccak::{Hasher, Keccak}; + +pub fn run(cmd: &mut Command) -> String { + let out = cmd.output().expect("failed to spawn command"); + if !out.status.success() { + panic!( + "command failed: {:?}\nstdout:\n{}\nstderr:\n{}", + cmd, + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr) + ); + } + String::from_utf8(out.stdout).expect("stdout not utf8") +} + +pub fn rpc_call(rpc_url: &str, body: &str) -> Value { + let out = run(Command::new("curl") + .arg("-s") + .arg("-X") + .arg("POST") + .arg(rpc_url) + .arg("-H") + .arg("Content-Type: application/json") + .arg("-d") + .arg(body)); + let v: Value = serde_json::from_str(&out).expect("rpc response not json"); + if v["error"].is_object() { + panic!("RPC error: {}\nRequest: {}", v["error"], body); + } + v +} + +pub fn keccak256(data: &[u8]) -> [u8; 32] { + let mut hasher = Keccak::v256(); + let mut out = [0u8; 32]; + hasher.update(data); + hasher.finalize(&mut out); + out +} + +pub fn eth_address_from_privkey(privkey: &[u8; 32]) -> [u8; 20] { + let sk = SecretKey::from_secret_bytes(*privkey).unwrap(); + let pk = secp256k1::PublicKey::from_secret_key(&sk); + let pk_serialized = pk.serialize_uncompressed(); + let hash = keccak256(&pk_serialized[1..]); + let mut addr = [0u8; 20]; + addr.copy_from_slice(&hash[12..]); + addr +} + +pub struct TestContext { + pub network: String, + pub admin_identity: String, + pub rpc_url: String, + pub wasm_path: String, +} + +impl Default for TestContext { + fn default() -> Self { + Self::new() + } +} + +impl TestContext { + pub fn new() -> Self { + Self { + network: std::env::var("STELLAR_NETWORK").unwrap_or_else(|_| "local".to_string()), + admin_identity: std::env::var("STELLAR_IDENTITY").expect("STELLAR_IDENTITY not set"), + rpc_url: std::env::var("SOROBAN_RPC_URL").expect("SOROBAN_RPC_URL not set"), + wasm_path: std::env::var("WORMHOLE_WASM_PATH").expect("WORMHOLE_WASM_PATH not set"), + } + } + + /// Deploy the wormhole contract with constructor args (initial_guardians + /// and governance_emitter). The contract is initialized via + /// __constructor at deploy time; there is no separate initialize. + pub fn deploy_contract( + &self, + initial_guardians: &[String], + governance_emitter: &str, + ) -> String { + let guardians_json = format!( + "[{}]", + initial_guardians + .iter() + .map(|g| format!("\"{}\"", g)) + .collect::>() + .join(",") + ); + run(Command::new("stellar").args([ + "contract", + "deploy", + "--network", + &self.network, + "--source", + &self.admin_identity, + "--wasm", + &self.wasm_path, + "--", + "--initial_guardians", + &guardians_json, + "--governance_emitter", + governance_emitter, + ])) + .trim() + .to_string() + } + + pub fn fund_identity(&self, name: &str) { + run(Command::new("stellar").args(["keys", "fund", "--network", &self.network, name])); + } + + pub fn get_identity_address(&self, name: &str) -> String { + run(Command::new("stellar").args(["keys", "address", name])) + .trim() + .to_string() + } + + pub fn setup_identity(&self, name: &str) -> String { + let _ = Command::new("stellar").args(["keys", "rm", name]).output(); + run(Command::new("stellar").args(["keys", "generate", "--network", &self.network, name])); + let addr = run(Command::new("stellar").args(["keys", "address", name])) + .trim() + .to_string(); + self.fund_identity(name); + addr + } + + pub fn deploy_native_asset(&self) { + let _ = Command::new("stellar") + .args([ + "contract", + "asset", + "deploy", + "--asset", + "native", + "--network", + &self.network, + "--source", + &self.admin_identity, + ]) + .output(); + } + + /// Native XLM token contract ID for the current network (differs per + /// network, e.g. local vs testnet). + pub fn get_native_asset_id(&self) -> String { + run(Command::new("stellar").args([ + "contract", + "id", + "asset", + "--asset", + "native", + "--network", + &self.network, + ])) + .trim() + .to_string() + } + + pub fn invoke(&self, source: &str, id: &str, func: &str, args: &[&str]) -> String { + let mut cmd = Command::new("stellar"); + cmd.args([ + "contract", + "invoke", + "--network", + &self.network, + "--source", + source, + "--id", + id, + "--", + func, + ]); + cmd.args(args); + run(&mut cmd) + } + + pub fn get_balance(&self, asset_id: &str, address: &str) -> i128 { + let out = self.invoke( + &self.admin_identity, + asset_id, + "balance", + &["--id", address], + ); + out.trim() + .trim_matches('"') + .parse::() + .expect("failed to parse balance") + } +} + +pub fn craft_governance_payload(action: u8, action_payload: &[u8]) -> Vec { + let mut payload = Vec::new(); + let mut module = [0u8; 32]; + module[28..32].copy_from_slice(b"Core"); + payload.extend_from_slice(&module); + payload.push(action); + payload.extend_from_slice(&61u16.to_be_bytes()); // Chain ID: Stellar + payload.extend_from_slice(action_payload); + payload +} + +pub fn assemble_vaa( + guardian_set_index: u32, + signatures: Vec<(u8, [u8; 64], u8)>, + body: &[u8], +) -> Vec { + let mut vaa = Vec::new(); + vaa.push(1); // Version + vaa.extend_from_slice(&guardian_set_index.to_be_bytes()); + vaa.push(signatures.len() as u8); + for (guardian_index, compact_sig, recovery_id) in signatures { + vaa.push(guardian_index); + vaa.extend_from_slice(&compact_sig); + vaa.push(recovery_id); + } + vaa.extend_from_slice(body); + vaa +} + +pub fn craft_vaa_body( + emitter_chain: u16, + emitter_address: [u8; 32], + nonce: u32, + sequence: u64, + payload: &[u8], +) -> Vec { + let mut body = Vec::new(); + body.extend_from_slice(&0u32.to_be_bytes()); + body.extend_from_slice(&nonce.to_be_bytes()); + body.extend_from_slice(&emitter_chain.to_be_bytes()); + body.extend_from_slice(&emitter_address); + body.extend_from_slice(&sequence.to_be_bytes()); + body.push(1); + body.extend_from_slice(payload); + body +} + +pub fn sign_vaa_body(body: &[u8], privkey: [u8; 32]) -> (u8, [u8; 64]) { + let body_hash = keccak256(&keccak256(body)); + let sk = SecretKey::from_secret_bytes(privkey).unwrap(); + let msg = Message::from_digest(body_hash); + let sig = RecoverableSignature::sign_ecdsa_recoverable(msg, &sk); + let (recid, compact) = sig.serialize_compact(); + (recid.to_u8(), compact) +} + +pub fn find_event(rpc_url: &str, contract_id: &str, topic_filters: &[Vec<&str>]) -> bool { + for _ in 0..15 { + let latest = rpc_call( + rpc_url, + r#"{"jsonrpc":"2.0","id":1,"method":"getLatestLedger","params":{}}"#, + ); + let latest_seq = latest["result"]["sequence"] + .as_u64() + .expect("latest ledger sequence missing"); + let start_ledger = latest_seq.saturating_sub(100).max(1); + + let events = rpc_call( + rpc_url, + &format!( + r#"{{ + "jsonrpc":"2.0","id":1,"method":"getEvents", + "params":{{ + "startLedger": {start}, + "endLedger": {end}, + "filters": [{{"type":"contract","contractIds":["{cid}"]}}] + }} + }}"#, + start = start_ledger, + end = latest_seq, + cid = contract_id + ), + ); + + let records = events["result"]["events"] + .as_array() + .expect("events result missing events array"); + for ev in records { + let ev_str = ev.to_string(); + if topic_filters + .iter() + .all(|alternatives| alternatives.iter().any(|&s| ev_str.contains(s))) + { + return true; + } + } + sleep(Duration::from_secs(1)); + } + false +} diff --git a/stellar/integration-tests/tests/localnet_contract_upgrade.rs b/stellar/integration-tests/tests/localnet_contract_upgrade.rs new file mode 100644 index 00000000000..d959f3d1020 --- /dev/null +++ b/stellar/integration-tests/tests/localnet_contract_upgrade.rs @@ -0,0 +1,87 @@ +use integration_tests::{ + TestContext, assemble_vaa, craft_governance_payload, craft_vaa_body, eth_address_from_privkey, + find_event, sign_vaa_body, +}; + +#[test] +#[ignore] +fn integration_contract_upgrade_flow() { + let ctx = TestContext::new(); + let upgrade_wasm_path = + std::env::var("WORMHOLE_UPGRADE_WASM_PATH").expect("WORMHOLE_UPGRADE_WASM_PATH not set"); + + println!("Deploying initial contract..."); + let guardian_privkey = [1u8; 32]; + let guardian_addr = eth_address_from_privkey(&guardian_privkey); + let guardian_addr_hex = hex::encode(guardian_addr); + + let mut gov_emitter_arr = [0u8; 32]; + gov_emitter_arr[31] = 4; + let gov_emitter_hex = hex::encode(gov_emitter_arr); + + let contract_id = + ctx.deploy_contract(std::slice::from_ref(&guardian_addr_hex), &gov_emitter_hex); + println!("Contract deployed at: {}", contract_id); + + // Verify initial chain ID + let initial_chain_id = ctx.invoke(&ctx.admin_identity, &contract_id, "get_chain_id", &[]); + assert_eq!(initial_chain_id.trim(), "61"); + println!("Initial Chain ID: {}", initial_chain_id.trim()); + + println!("Installing upgrade WASM..."); + let hash_out = integration_tests::run(std::process::Command::new("stellar").args([ + "contract", + "install", + "--network", + &ctx.network, + "--source", + &ctx.admin_identity, + "--wasm", + &upgrade_wasm_path, + ])); + let wasm_hash_hex = hash_out.trim().to_string(); + println!("Upgrade WASM hash: {}", wasm_hash_hex); + let wasm_hash = hex::decode(&wasm_hash_hex).expect("Failed to decode WASM hash"); + + println!("Crafting ContractUpgrade VAA..."); + + // Payload + let payload = craft_governance_payload(1, &wasm_hash); // Action: ContractUpgrade + + let body = craft_vaa_body(1, gov_emitter_arr, 0, 1, &payload); + let (recid, compact) = sign_vaa_body(&body, guardian_privkey); + + let vaa = assemble_vaa(0, vec![(0, compact, recid)], &body); + let vaa_hex = hex::encode(vaa); + + println!("Submitting ContractUpgrade VAA..."); + ctx.invoke( + &ctx.admin_identity, + &contract_id, + "submit_contract_upgrade", + &["--vaa_bytes", &vaa_hex], + ); + println!("ContractUpgrade VAA submitted."); + + println!("Verifying event through RPC..."); + let found_event = find_event( + &ctx.rpc_url, + &contract_id, + &[ + vec!["upgrade", "AAAADwAAAAd1cGdyYWRl"], + vec!["wormhole_core", "AAAADwAAAA13b3JtaG9sZV9jb3JlAAAA"], + ], + ); + assert!(found_event, "Contract upgrade event not found"); + println!("Contract upgrade event verified."); + + // Verify contract behavior HAS CHANGED + let new_chain_id_out = ctx.invoke(&ctx.admin_identity, &contract_id, "get_chain_id", &[]); + assert_eq!(new_chain_id_out.trim(), "999"); + println!( + "Contract behavior changed after upgrade! New Chain ID: {}", + new_chain_id_out.trim() + ); + + println!("Integration test passed!"); +} diff --git a/stellar/integration-tests/tests/localnet_guardian_set_upgrade.rs b/stellar/integration-tests/tests/localnet_guardian_set_upgrade.rs new file mode 100644 index 00000000000..36b7570bdb8 --- /dev/null +++ b/stellar/integration-tests/tests/localnet_guardian_set_upgrade.rs @@ -0,0 +1,129 @@ +pub(crate) use integration_tests::{ + TestContext, assemble_vaa, craft_governance_payload, craft_vaa_body, eth_address_from_privkey, + find_event, sign_vaa_body, +}; + +#[test] +#[ignore] +fn integration_guardian_set_upgrade_flow() { + let ctx = TestContext::new(); + + println!("Deploying contract..."); + let guardian_privkey = [1u8; 32]; + let guardian_addr = eth_address_from_privkey(&guardian_privkey); + let guardian_addr_hex = hex::encode(guardian_addr); + + let mut gov_emitter_arr = [0u8; 32]; + gov_emitter_arr[31] = 4; + let gov_emitter_hex = hex::encode(gov_emitter_arr); + + let contract_id = + ctx.deploy_contract(std::slice::from_ref(&guardian_addr_hex), &gov_emitter_hex); + println!("Contract deployed at: {}", contract_id); + println!("Contract initialized with guardian: {}", guardian_addr_hex); + + println!("Crafting GuardianSetUpgrade VAA..."); + let new_guardian_privkey = [2u8; 32]; + let new_guardian_addr = eth_address_from_privkey(&new_guardian_privkey); + let new_guardian_addr_hex = hex::encode(new_guardian_addr); + + // Payload + let mut action_payload = Vec::new(); + action_payload.extend_from_slice(&1u32.to_be_bytes()); // New Guardian Set Index: 1 + action_payload.push(1); // Guardian count: 1 + action_payload.extend_from_slice(&new_guardian_addr); + + let payload = craft_governance_payload(2, &action_payload); // Action: GuardianSetUpgrade + + let body = craft_vaa_body(1, gov_emitter_arr, 0, 1, &payload); + let (recid, compact) = sign_vaa_body(&body, guardian_privkey); + + let vaa = assemble_vaa(0, vec![(0, compact, recid)], &body); + let vaa_hex = hex::encode(vaa); + println!("VAA crafted: {}", vaa_hex); + + println!("Submitting GuardianSetUpgrade VAA..."); + ctx.invoke( + &ctx.admin_identity, + &contract_id, + "submit_guardian_set_upgrade", + &["--vaa_bytes", &vaa_hex], + ); + + println!("Verifying state updated..."); + let index_out = ctx.invoke( + &ctx.admin_identity, + &contract_id, + "get_current_guardian_set_index", + &[], + ); + let current_index = index_out + .trim() + .parse::() + .expect("Failed to parse index"); + assert_eq!(current_index, 1, "Guardian set index should be 1"); + + let gset_out = ctx.invoke( + &ctx.admin_identity, + &contract_id, + "get_guardian_set", + &["--index", "1"], + ); + assert!( + gset_out.contains(&new_guardian_addr_hex), + "New guardian set should contain the new guardian address" + ); + println!("New guardian set verified."); + + let expiry_out = ctx.invoke( + &ctx.admin_identity, + &contract_id, + "get_guardian_set_expiry", + &["--index", "0"], + ); + assert!( + !expiry_out.contains("void") && !expiry_out.trim().is_empty(), + "Old guardian set should have an expiry set" + ); + println!("Old set expiry: {}", expiry_out.trim()); + + println!("Verifying event via RPC..."); + let found_event = find_event( + &ctx.rpc_url, + &contract_id, + &[ + vec![ + "guardian_set_upgrade", + "AAAADwAAABRndWFyZGlhbl9zZXRfdXBncmFkZQ==", + ], + vec!["wormhole_core", "AAAADwAAAA13b3JtaG9sZV9jb3JlAAAA"], + ], + ); + assert!(found_event, "GuardianSetUpgrade event not found"); + println!("GuardianSetUpgrade event verified."); + + println!("Verifying a message using the NEW guardian set..."); + let mut msg_body = Vec::new(); + msg_body.extend_from_slice(&0u32.to_be_bytes()); // Timestamp + msg_body.extend_from_slice(&123u32.to_be_bytes()); // Nonce + msg_body.extend_from_slice(&61u16.to_be_bytes()); // Emitter Chain + msg_body.extend_from_slice(&[0u8; 32]); // Emitter Address + msg_body.extend_from_slice(&0u64.to_be_bytes()); // Sequence + msg_body.push(1); // Consistency Level + msg_body.extend_from_slice(b"Hello Wormhole"); // Payload + + let (msg_recid, msg_compact) = sign_vaa_body(&msg_body, new_guardian_privkey); + + let msg_vaa = assemble_vaa(1, vec![(0, msg_compact, msg_recid)], &msg_body); + let msg_vaa_hex = hex::encode(msg_vaa); + + ctx.invoke( + &ctx.admin_identity, + &contract_id, + "verify_vaa", + &["--vaa_bytes", &msg_vaa_hex], + ); + println!("VAA verification with new guardian set succeeded."); + + println!("Integration test passed!"); +} diff --git a/stellar/integration-tests/tests/localnet_initialize_and_query.rs b/stellar/integration-tests/tests/localnet_initialize_and_query.rs new file mode 100644 index 00000000000..02eb997c7aa --- /dev/null +++ b/stellar/integration-tests/tests/localnet_initialize_and_query.rs @@ -0,0 +1,56 @@ +use integration_tests::{TestContext, find_event}; + +#[test] +#[ignore] +fn testnet_initialize_and_query() { + let ctx = TestContext::new(); + + ctx.fund_identity(&ctx.admin_identity); + + // Deploy with constructor (initializes in one step) + let guardian1 = "0101010101010101010101010101010101010101"; + let guardian2 = "0202020202020202020202020202020202020202"; + let emitter = "0404040404040404040404040404040404040404040404040404040404040404"; + let contract_id = ctx.deploy_contract(&[guardian1.to_string(), guardian2.to_string()], emitter); + + // Query state (no is_initialized; verify via guardian set and gov emitter) + assert!( + ctx.invoke( + &ctx.admin_identity, + &contract_id, + "get_current_guardian_set_index", + &[] + ) + .trim() + .contains("0") + ); + + let gset = ctx.invoke( + &ctx.admin_identity, + &contract_id, + "get_guardian_set", + &["--index", "0"], + ); + assert!(gset.contains("010101")); + assert!(gset.contains("020202")); + + let gov = ctx.invoke( + &ctx.admin_identity, + &contract_id, + "get_governance_emitter", + &[], + ); + assert!(gov.contains("040404")); + + // Fetch events through RPC + let found_init = find_event( + &ctx.rpc_url, + &contract_id, + &[ + vec!["wormhole_core", "AAAADwAAAA13b3JtaG9sZV9jb3JlAAAA"], + vec!["init", "AAAADwAAAARpbml0"], + ], + ); + + assert!(found_init, "Expected init event not found"); +} diff --git a/stellar/integration-tests/tests/localnet_message_fee.rs b/stellar/integration-tests/tests/localnet_message_fee.rs new file mode 100644 index 00000000000..e2587d91f39 --- /dev/null +++ b/stellar/integration-tests/tests/localnet_message_fee.rs @@ -0,0 +1,165 @@ +//! Message fee integration test. +//! +//! Note: On localnet this test will fail at "Posting message with fee paid" +//! because the wormhole contract uses a hardcoded native token address +//! (testnet/mainnet). The native asset contract ID differs per network +//! (localnet has a different ID). To pass on localnet the contract would need a +//! configurable native token address at deployment. + +use integration_tests::{ + TestContext, assemble_vaa, craft_governance_payload, craft_vaa_body, eth_address_from_privkey, + rpc_call, sign_vaa_body, +}; + +#[test] +#[ignore] +fn integration_set_message_fee_and_required_fee() { + let ctx = TestContext::new(); + + println!("Deploying contract..."); + ctx.deploy_native_asset(); + + let guardian_privkey = [1u8; 32]; + let guardian_addr = eth_address_from_privkey(&guardian_privkey); + let guardian_addr_hex = hex::encode(guardian_addr); + + let mut gov_emitter_arr = [0u8; 32]; + gov_emitter_arr[31] = 4; + let gov_emitter_hex = hex::encode(gov_emitter_arr); + + let contract_id = + ctx.deploy_contract(std::slice::from_ref(&guardian_addr_hex), &gov_emitter_hex); + println!("Contract deployed at: {}", contract_id); + println!("Contract initialized with guardian: {}", guardian_addr_hex); + + println!("Crafting SetMessageFee VAA..."); + let new_fee: u64 = 1000; + + // Payload + let mut action_payload = Vec::new(); + action_payload.extend_from_slice(&[0u8; 24]); + action_payload.extend_from_slice(&new_fee.to_be_bytes()); + + let payload = craft_governance_payload(3, &action_payload); // Action: SetMessageFee + + let body = craft_vaa_body(1, gov_emitter_arr, 0, 0, &payload); + let (recid, compact) = sign_vaa_body(&body, guardian_privkey); + + let vaa = assemble_vaa(0, vec![(0, compact, recid)], &body); + let vaa_hex = hex::encode(vaa); + println!("VAA crafted: {}", vaa_hex); + + println!("Submitting SetMessageFee VAA..."); + ctx.invoke( + &ctx.admin_identity, + &contract_id, + "submit_set_message_fee", + &["--vaa_bytes", &vaa_hex], + ); + + // Verify fee updated + let fee_out = ctx.invoke(&ctx.admin_identity, &contract_id, "get_message_fee", &[]); + let current_fee = fee_out.trim().parse::().expect("Failed to parse fee"); + assert_eq!(current_fee, new_fee, "Fee should be updated to {}", new_fee); + println!("Fee updated successfully."); + + // Create emitter_identity + let emitter_identity = "fee_emitter"; + let emitter_addr = ctx.setup_identity(emitter_identity); + + println!("Funding emitter address: {}", emitter_addr); + + println!("Attempting post_message without fee..."); + let post_fail_out = std::process::Command::new("stellar") + .args([ + "contract", + "invoke", + "--network", + &ctx.network, + "--source", + emitter_identity, + "--id", + &contract_id, + "--", + "post_message", + "--emitter", + &emitter_addr, + "--nonce", + "1", + "--payload", + "00", + "--consistency_level", + "1", + ]) + .output() + .expect("Failed to execute post_message"); + + assert!( + !post_fail_out.status.success(), + "post_message should fail without fee" + ); + let stderr = String::from_utf8_lossy(&post_fail_out.stderr); + println!("Stderr from failed post: {}", stderr); + assert!( + stderr.contains("InsufficientFeePaid") || stderr.contains("Error(Contract, #50)"), + "Error should be InsufficientFeePaid (50)" + ); + println!("post_message failed as expected without fee."); + + println!("Approving fee payment..."); + let native_token = ctx.get_native_asset_id(); + let latest = rpc_call( + &ctx.rpc_url, + r#"{"jsonrpc":"2.0","id":1,"method":"getLatestLedger","params":{}}"#, + ); + let latest_ledger = latest["result"]["sequence"].as_u64().unwrap() as u32; + let expiration = latest_ledger + 100; + + ctx.invoke( + emitter_identity, + &native_token, + "approve", + &[ + "--from", + &emitter_addr, + "--spender", + &contract_id, + "--amount", + &new_fee.to_string(), + "--expiration_ledger", + &expiration.to_string(), + ], + ); + println!("Fee payment approved."); + + println!("Posting message with fee paid..."); + let post_success_out = ctx.invoke( + emitter_identity, + &contract_id, + "post_message", + &[ + "--emitter", + &emitter_addr, + "--nonce", + "2", + "--payload", + "00", + "--consistency_level", + "1", + ], + ); + println!( + "post_message succeeded with fee. Result: {}", + post_success_out.trim() + ); + + println!("Verifying balance changes..."); + let contract_balance = ctx.get_balance(&native_token, &contract_id); + assert!( + contract_balance >= new_fee as i128, + "Contract balance should have increased" + ); + println!("Contract balance: {}", contract_balance); + + println!("Integration test passed!"); +} diff --git a/stellar/integration-tests/tests/localnet_post_message.rs b/stellar/integration-tests/tests/localnet_post_message.rs new file mode 100644 index 00000000000..50db01a70f1 --- /dev/null +++ b/stellar/integration-tests/tests/localnet_post_message.rs @@ -0,0 +1,107 @@ +use integration_tests::{TestContext, find_event}; + +#[test] +#[ignore] +fn integration_post_message_no_fee_flow() { + let ctx = TestContext::new(); + + println!("Deploying contract..."); + let guardian = "0101010101010101010101010101010101010101"; + let gov_emitter = "0404040404040404040404040404040404040404040404040404040404040404"; + let contract_id = ctx.deploy_contract(&[guardian.to_string()], gov_emitter); + println!("Contract deployed at: {}", contract_id); + + let emitter_identity = "test_emitter"; + println!("Setting up emitter identity: {}", emitter_identity); + let emitter_addr = ctx.setup_identity(emitter_identity); + println!("Emitter address: {}", emitter_addr); + + println!("Posting first message..."); + let seq_out = ctx.invoke( + emitter_identity, + &contract_id, + "post_message", + &[ + "--emitter", + &emitter_addr, + "--nonce", + "123", + "--payload", + "abcdef", + "--consistency_level", + "1", + ], + ); + let sequence = seq_out + .trim() + .parse::() + .expect("Failed to parse sequence number"); + assert_eq!(sequence, 0, "First sequence should be 0"); + println!("First message posted. Sequence: {}", sequence); + + println!("Verifying first message events and state..."); + let next_seq_out = ctx.invoke( + &ctx.admin_identity, + &contract_id, + "get_emitter_sequence", + &["--emitter", &emitter_addr], + ); + let next_sequence = next_seq_out + .trim() + .parse::() + .expect("Failed to parse next sequence"); + assert_eq!(next_sequence, 1, "Next sequence should be 1"); + + // Fetch events via RPC + let found_event = find_event( + &ctx.rpc_url, + &contract_id, + &[ + vec![ + "message_published", + "AAAADwAAABFtZXNzYWdlX3B1Ymxpc2hlZAAAAA==", + ], + vec!["wormhole", "AAAADwAAAAh3b3JtaG9sZQ=="], + ], + ); + assert!(found_event, "MessagePublished event not found"); + + println!("Posting second message..."); + let seq_out2 = ctx.invoke( + emitter_identity, + &contract_id, + "post_message", + &[ + "--emitter", + &emitter_addr, + "--nonce", + "124", + "--payload", + "010203", + "--consistency_level", + "1", + ], + ); + let sequence2 = seq_out2 + .trim() + .parse::() + .expect("Failed to parse second sequence number"); + assert_eq!(sequence2, 1, "Second sequence should be 1"); + println!("Second message posted. Sequence: {}", sequence2); + + let next_seq_out2 = ctx.invoke( + &ctx.admin_identity, + &contract_id, + "get_emitter_sequence", + &["--emitter", &emitter_addr], + ); + let next_sequence2 = next_seq_out2 + .trim() + .parse::() + .expect("Failed to parse next sequence 2"); + assert_eq!( + next_sequence2, 2, + "Next sequence should be 2 after second post" + ); + println!("Integration test passed!"); +} diff --git a/stellar/integration-tests/tests/localnet_transfer_fees.rs b/stellar/integration-tests/tests/localnet_transfer_fees.rs new file mode 100644 index 00000000000..c2c1bcab804 --- /dev/null +++ b/stellar/integration-tests/tests/localnet_transfer_fees.rs @@ -0,0 +1,203 @@ +use integration_tests::{ + TestContext, assemble_vaa, craft_governance_payload, craft_vaa_body, eth_address_from_privkey, + find_event, sign_vaa_body, +}; +use stellar_strkey::Strkey; + +#[test] +#[ignore] +fn integration_transfer_fees_flow() { + let ctx = TestContext::new(); + + println!("Deploying contract..."); + ctx.deploy_native_asset(); + + let guardian_privkey = [1u8; 32]; + let guardian_addr = eth_address_from_privkey(&guardian_privkey); + let guardian_addr_hex = hex::encode(guardian_addr); + + let mut gov_emitter_arr = [0u8; 32]; + gov_emitter_arr[31] = 4; + let gov_emitter_hex = hex::encode(gov_emitter_arr); + + let contract_id = + ctx.deploy_contract(std::slice::from_ref(&guardian_addr_hex), &gov_emitter_hex); + println!("Contract deployed at: {}", contract_id); + + println!("Funding contract with XLM..."); + let native_token = ctx.get_native_asset_id(); + let admin_addr = ctx.get_identity_address(&ctx.admin_identity); + + let fund_amount: i128 = 500_000_000; + ctx.invoke( + &ctx.admin_identity, + &native_token, + "transfer", + &[ + "--from", + &admin_addr, + "--to", + &contract_id, + "--amount", + &fund_amount.to_string(), + ], + ); + + let contract_balance = ctx.get_balance(&native_token, &contract_id); + assert!(contract_balance >= fund_amount, "Contract should be funded"); + println!("Contract balance: {} stroops", contract_balance); + + let recipient_identity = "fee_recipient"; + let recipient_addr = ctx.setup_identity(recipient_identity); + + println!("Funding recipient address: {}", recipient_addr); + + // Get recipient's initial balance + let initial_recipient_balance = ctx.get_balance(&native_token, &recipient_addr); + println!( + "Initial recipient balance: {} stroops", + initial_recipient_balance + ); + + println!("Crafting TransferFees VAA..."); + let transfer_amount: u64 = 200_000_000; // 20 XLM + let strkey = Strkey::from_string(&recipient_addr).expect("Invalid recipient address"); + let recipient_pk = match strkey { + Strkey::PublicKeyEd25519(pk) => pk.0, + _ => panic!("Expected ED25519 public key"), + }; + + // Payload + let mut action_payload = Vec::new(); + action_payload.extend_from_slice(&[0u8; 24]); + action_payload.extend_from_slice(&transfer_amount.to_be_bytes()); + action_payload.extend_from_slice(&recipient_pk); + + let payload = craft_governance_payload(4, &action_payload); // Action: TransferFees + + let body = craft_vaa_body(1, gov_emitter_arr, 0, 0, &payload); + let (recid, compact) = sign_vaa_body(&body, guardian_privkey); + + let vaa = assemble_vaa(0, vec![(0, compact, recid)], &body); + let vaa_hex = hex::encode(vaa); + + println!("Submitting TransferFees VAA..."); + ctx.invoke( + &ctx.admin_identity, + &contract_id, + "submit_transfer_fees", + &["--vaa_bytes", &vaa_hex], + ); + + println!("Verifying balance changes..."); + let new_contract_balance = ctx.get_balance(&native_token, &contract_id); + assert_eq!( + new_contract_balance, + contract_balance - transfer_amount as i128, + "Contract balance should have decreased" + ); + + let new_recipient_balance = ctx.get_balance(&native_token, &recipient_addr); + assert_eq!( + new_recipient_balance, + initial_recipient_balance + transfer_amount as i128, + "Recipient balance should have increased" + ); + println!("Balances verified successfully."); + + println!("Verifying event via RPC..."); + let found_event = find_event( + &ctx.rpc_url, + &contract_id, + &[ + vec!["fee_transfer", "AAAADwAAAAxmZWVfdHJhbnNmZXI="], + vec!["wormhole_core", "AAAADwAAAA13b3JtaG9sZV9jb3JlAAAA"], + ], + ); + assert!(found_event, "FeeTransfer event not found"); + println!("FeeTransfer event verified."); + + println!("Testing failure case: Transfer more than available..."); + let too_much_amount: u64 = 1_000_000_000; + let mut too_much_action_payload = action_payload.clone(); + too_much_action_payload[24..32].copy_from_slice(&too_much_amount.to_be_bytes()); + + let too_much_payload = craft_governance_payload(4, &too_much_action_payload); + let too_much_body = craft_vaa_body(1, gov_emitter_arr, 0, 1, &too_much_payload); + let (tm_recid, tm_compact) = sign_vaa_body(&too_much_body, guardian_privkey); + + let too_much_vaa = assemble_vaa(0, vec![(0, tm_compact, tm_recid)], &too_much_body); + let too_much_vaa_hex = hex::encode(too_much_vaa); + + let tm_res = std::process::Command::new("stellar") + .args([ + "contract", + "invoke", + "--network", + &ctx.network, + "--source", + &ctx.admin_identity, + "--id", + &contract_id, + "--", + "submit_transfer_fees", + "--vaa_bytes", + &too_much_vaa_hex, + ]) + .output() + .expect("Failed to execute submit_transfer_fees"); + + assert!( + !tm_res.status.success(), + "Transfer should fail when amount is too high" + ); + let tm_stderr = String::from_utf8_lossy(&tm_res.stderr); + assert!( + tm_stderr.contains("InsufficientFees") || tm_stderr.contains("Error(Contract, #51)"), + "Error should be InsufficientFees (51)" + ); + println!("Transfer failed as expected for high amount."); + + println!("Testing failure case: Leaving less than 1 XLM..."); + let leave_too_little_amount: u64 = 495_000_000; // Contract has 500, needs to keep 10. 500-495 = 5 (too little) + let mut little_action_payload = action_payload.clone(); + little_action_payload[24..32].copy_from_slice(&leave_too_little_amount.to_be_bytes()); + + let little_payload = craft_governance_payload(4, &little_action_payload); + let little_body = craft_vaa_body(1, gov_emitter_arr, 0, 2, &little_payload); + let (l_recid, l_compact) = sign_vaa_body(&little_body, guardian_privkey); + + let little_vaa = assemble_vaa(0, vec![(0, l_compact, l_recid)], &little_body); + let little_vaa_hex = hex::encode(little_vaa); + + let l_res = std::process::Command::new("stellar") + .args([ + "contract", + "invoke", + "--network", + &ctx.network, + "--source", + &ctx.admin_identity, + "--id", + &contract_id, + "--", + "submit_transfer_fees", + "--vaa_bytes", + &little_vaa_hex, + ]) + .output() + .expect("Failed to execute submit_transfer_fees"); + + assert!( + !l_res.status.success(), + "Transfer should fail when leaving too little balance" + ); + let l_stderr = String::from_utf8_lossy(&l_res.stderr); + assert!( + l_stderr.contains("InsufficientFees") || l_stderr.contains("Error(Contract, #51)"), + "Error should be InsufficientFees (51)" + ); + println!("Transfer failed as expected when leaving too little balance."); + + println!("Integration test passed!"); +}