diff --git a/crates/e2e-tests/src/external_sui_network.rs b/crates/e2e-tests/src/external_sui_network.rs new file mode 100644 index 000000000..a43f6e640 --- /dev/null +++ b/crates/e2e-tests/src/external_sui_network.rs @@ -0,0 +1,259 @@ +// Copyright (c) Mysten Labs, Inc. +// SPDX-License-Identifier: Apache-2.0 + +//! A lightweight Sui network handle that connects to an already-running node +//! (e.g. devnet, testnet). Unlike [`crate::SuiNetworkHandle`], this does NOT +//! spawn or manage the Sui process. + +use anyhow::Context; +use anyhow::Result; +use std::collections::BTreeMap; +use std::path::Path; +use sui_crypto::SuiSigner; +use sui_crypto::ed25519::Ed25519PrivateKey; +use sui_rpc::Client; +use sui_rpc::field::FieldMask; +use sui_rpc::field::FieldMaskUtil; +use sui_rpc::proto::sui::rpc::v2::ExecuteTransactionRequest; +use sui_sdk_types::Address; +use sui_sdk_types::Argument; +use sui_sdk_types::GasPayment; +use sui_sdk_types::Input; +use sui_sdk_types::ProgrammableTransaction; +use sui_sdk_types::StructTag; +use sui_sdk_types::Transaction; +use sui_sdk_types::TransactionExpiration; +use sui_sdk_types::TransactionKind; +use sui_sdk_types::TransferObjects; +use sui_sdk_types::bcs::ToBcs; +use tracing::info; + +use crate::hashi_network::SuiNetworkInfo; +use crate::sui_network::keypair_from_base64; + +pub struct ExternalSuiNetwork { + rpc_url: String, + client: Client, + operator_key: Ed25519PrivateKey, + validator_keys: BTreeMap, +} + +impl ExternalSuiNetwork { + /// Connect to an already-running Sui network, generate validator keys, and fund them. + /// + /// # Arguments + /// - `rpc_url`: Sui RPC URL (e.g. `https://fullnode.devnet.sui.io:443`) + /// - `operator_key`: Ed25519 key with sufficient SUI balance for funding validators + /// - `num_validators`: Number of validator keypairs to generate and fund + pub async fn new( + rpc_url: &str, + operator_key: Ed25519PrivateKey, + num_validators: usize, + ) -> Result { + let mut client = Client::new(rpc_url)?; + + crate::sui_network::wait_for_ready(&mut client) + .await + .with_context(|| { + format!( + "Failed to connect to Sui network at {}. Ensure the node is reachable.", + rpc_url + ) + })?; + + let operator_addr = operator_key.public_key().derive_address(); + info!( + "Connected to external Sui network at {}, operator: {}", + rpc_url, operator_addr + ); + + // Generate fresh Ed25519 keypairs for hashi validator identities + let mut validator_keys = BTreeMap::new(); + for _ in 0..num_validators { + let seed: [u8; 32] = rand::random(); + let key = Ed25519PrivateKey::new(seed); + let addr = key.public_key().derive_address(); + validator_keys.insert(addr, key); + } + + let mut network = Self { + rpc_url: rpc_url.to_string(), + client, + operator_key, + validator_keys, + }; + + // Fund validator accounts from operator. + // External networks have limited funds, so use 5 SUI per validator + // (enough for registration + gas during operations). + let fund_requests: Vec<(Address, u64)> = network + .validator_keys + .keys() + .map(|addr| (*addr, 5 * 1_000_000_000)) + .collect(); + network.fund(&fund_requests).await?; + + Ok(network) + } + + /// Load an Ed25519 private key from a Sui keystore file by address. + /// + /// The keystore file is a JSON array of base64-encoded keys, each prefixed + /// with a scheme byte (as written by `sui keytool import`). + pub fn load_key_from_keystore( + keystore_path: &Path, + target_address: &Address, + ) -> Result { + let contents = std::fs::read_to_string(keystore_path) + .with_context(|| format!("Failed to read keystore at {}", keystore_path.display()))?; + let keys: Vec = serde_json::from_str(&contents) + .with_context(|| format!("Failed to parse keystore at {}", keystore_path.display()))?; + + for b64_key in &keys { + if let Ok(key) = keypair_from_base64(b64_key) { + let addr = key.public_key().derive_address(); + if addr == *target_address { + return Ok(key); + } + } + } + + anyhow::bail!( + "No Ed25519 key found for address {} in keystore at {}", + target_address, + keystore_path.display() + ) + } + + /// Fund Sui addresses from the operator account. + pub async fn fund(&mut self, requests: &[(Address, u64)]) -> Result<()> { + let sender = self.operator_key.public_key().derive_address(); + let price = self.client.get_reference_gas_price().await?; + + let gas_objects = self + .client + .select_coins( + &sender, + &StructTag::sui().into(), + requests.iter().map(|r| r.1).sum(), + &[], + ) + .await?; + + let (inputs, transfers): (Vec, Vec) = requests + .iter() + .enumerate() + .map(|(i, request)| { + ( + Input::Pure(request.0.to_bcs().unwrap()), + sui_sdk_types::Command::TransferObjects(TransferObjects { + objects: vec![Argument::NestedResult(0, i as u16)], + address: Argument::Input(i as u16), + }), + ) + }) + .unzip(); + + let (input_amounts, argument_amounts) = requests + .iter() + .enumerate() + .map(|(i, request)| { + ( + Input::Pure(request.1.to_bcs().unwrap()), + Argument::Input((i + inputs.len()) as u16), + ) + }) + .unzip(); + + let pt = ProgrammableTransaction { + inputs: [inputs, input_amounts].concat(), + commands: [ + vec![sui_sdk_types::Command::SplitCoins( + sui_sdk_types::SplitCoins { + coin: Argument::Gas, + amounts: argument_amounts, + }, + )], + transfers, + ] + .concat(), + }; + + let gas_payment_objects = gas_objects + .iter() + .map(|o| -> anyhow::Result<_> { Ok((&o.object_reference()).try_into()?) }) + .collect::>>()?; + + let transaction = Transaction { + kind: TransactionKind::ProgrammableTransaction(pt), + sender, + gas_payment: GasPayment { + objects: gas_payment_objects, + owner: sender, + price, + budget: 1_000_000_000, + }, + expiration: TransactionExpiration::None, + }; + + let signature = self.operator_key.sign_transaction(&transaction)?; + + let response = self + .client + .execute_transaction_and_wait_for_checkpoint( + ExecuteTransactionRequest::new(transaction.into()) + .with_signatures(vec![signature.into()]) + .with_read_mask(FieldMask::from_str("*")), + std::time::Duration::from_secs(10), + ) + .await? + .into_inner(); + + anyhow::ensure!( + response.transaction().effects().status().success(), + "fund failed" + ); + + info!("Funded {} validator accounts from operator", requests.len()); + Ok(()) + } + + /// Write a minimal `client.yaml` + keystore so `sui move build` can resolve + /// framework dependencies. Call this before publishing. + pub fn write_sui_config(&self, sui_dir: &Path) -> Result<()> { + std::fs::create_dir_all(sui_dir)?; + + // Write a minimal keystore (empty array is valid) + let keystore_path = sui_dir.join("sui.keystore"); + std::fs::write(&keystore_path, "[]")?; + + // Write client.yaml pointing to our RPC + let client_yaml = format!( + "---\nkeystore:\n File: {keystore}\nenvs:\n - alias: external\n rpc: \"{rpc}\"\n ws: ~\nactive_env: external\nactive_address: \"{addr}\"\n", + keystore = keystore_path.display(), + rpc = self.rpc_url, + addr = self.operator_key.public_key().derive_address(), + ); + std::fs::write(sui_dir.join("client.yaml"), client_yaml)?; + + Ok(()) + } +} + +impl SuiNetworkInfo for ExternalSuiNetwork { + fn rpc_url(&self) -> &str { + &self.rpc_url + } + + fn client(&self) -> Client { + self.client.clone() + } + + fn validator_keys(&self) -> &BTreeMap { + &self.validator_keys + } + + fn funding_key(&self) -> &Ed25519PrivateKey { + &self.operator_key + } +} diff --git a/crates/e2e-tests/src/hashi_network.rs b/crates/e2e-tests/src/hashi_network.rs index d6ce1bf6d..69ed20bb8 100644 --- a/crates/e2e-tests/src/hashi_network.rs +++ b/crates/e2e-tests/src/hashi_network.rs @@ -6,9 +6,11 @@ use hashi::Hashi; use hashi::ServerVersion; use hashi::config::Config as HashiConfig; use hashi::config::HashiIds; +use std::collections::BTreeMap; use std::net::SocketAddr; use std::path::Path; use std::sync::Arc; +use sui_crypto::ed25519::Ed25519PrivateKey; use sui_futures::service::Service; use sui_rpc::proto::sui::rpc::v2::GetServiceInfoRequest; use sui_sdk_types::Identifier; @@ -17,8 +19,6 @@ use sui_transaction_builder::ObjectInput; use sui_transaction_builder::TransactionBuilder; use tracing::debug; -use crate::SuiNetworkHandle; - const POLL_INTERVAL: std::time::Duration = std::time::Duration::from_millis(500); const TEST_WEIGHT_DIVISOR: u16 = 100; @@ -37,6 +37,29 @@ impl BitcoinNodeInfo for crate::BitcoinNodeHandle { } } +/// Trait for Sui network connectivity used by the hashi network builder. +pub trait SuiNetworkInfo { + fn rpc_url(&self) -> &str; + fn client(&self) -> sui_rpc::Client; + fn validator_keys(&self) -> &BTreeMap; + fn funding_key(&self) -> &Ed25519PrivateKey; +} + +impl SuiNetworkInfo for crate::SuiNetworkHandle { + fn rpc_url(&self) -> &str { + &self.rpc_url + } + fn client(&self) -> sui_rpc::Client { + self.client.clone() + } + fn validator_keys(&self) -> &BTreeMap { + &self.validator_keys + } + fn funding_key(&self) -> &Ed25519PrivateKey { + self.user_keys.first().expect("no funded user keys") + } +} + pub struct HashiNodeHandle { config: HashiConfig, /// The running service and Hashi instance. Both are dropped together on shutdown @@ -253,6 +276,12 @@ pub struct HashiNetworkBuilder { pub bitcoin_chain_id: String, /// Optional override for bitcoin RPC auth credentials. pub bitcoin_rpc_auth: Option<(String, String)>, + /// Optional override for bitcoin start height (for compact block filter scanning). + pub bitcoin_start_height: Option, + /// Timeout in seconds for the initial committee to form. Default: 120s. + pub genesis_timeout_secs: u64, + /// Test-only: validator addresses for test_start_reconfig on external networks. + pub test_reconfig_addresses: Option>, } impl HashiNetworkBuilder { @@ -268,6 +297,9 @@ impl HashiNetworkBuilder { test_corrupt_shares_target: None, bitcoin_chain_id: hashi::constants::BITCOIN_REGTEST_CHAIN_ID.to_string(), bitcoin_rpc_auth: None, + bitcoin_start_height: None, + genesis_timeout_secs: 120, + test_reconfig_addresses: None, } } @@ -281,6 +313,21 @@ impl HashiNetworkBuilder { self } + pub fn with_bitcoin_start_height(mut self, height: u32) -> Self { + self.bitcoin_start_height = Some(height); + self + } + + pub fn with_genesis_timeout_secs(mut self, secs: u64) -> Self { + self.genesis_timeout_secs = secs; + self + } + + pub fn with_test_reconfig_addresses(mut self, addrs: Vec) -> Self { + self.test_reconfig_addresses = Some(addrs); + self + } + pub fn with_num_nodes(mut self, num_nodes: usize) -> Self { self.num_nodes = num_nodes; self @@ -324,7 +371,7 @@ impl HashiNetworkBuilder { pub async fn build( self, dir: &Path, - sui: &SuiNetworkHandle, + sui: &impl SuiNetworkInfo, bitcoin: &impl BitcoinNodeInfo, hashi_ids: HashiIds, ) -> Result { @@ -334,10 +381,9 @@ impl HashiNetworkBuilder { let screener_endpoint = format!("http://{}", screener_addr); let bitcoin_rpc = bitcoin.rpc_url().to_owned(); - let sui_rpc = sui.rpc_url.clone(); + let sui_rpc = sui.rpc_url().to_owned(); let service_info = sui - .client - .clone() + .client() .ledger_client() .get_service_info(GetServiceInfoRequest::default()) .await? @@ -345,7 +391,7 @@ impl HashiNetworkBuilder { // Resolve the corrupt shares target index to a validator address. let corrupt_target_address = self.test_corrupt_shares_target.map(|idx| { - *sui.validator_keys + *sui.validator_keys() .keys() .nth(idx) .expect("corrupt target index out of range") @@ -353,7 +399,7 @@ impl HashiNetworkBuilder { let mut configs = Vec::with_capacity(self.num_nodes); for (i, (validator_address, private_key)) in - sui.validator_keys.iter().take(self.num_nodes).enumerate() + sui.validator_keys().iter().take(self.num_nodes).enumerate() { let mut config = HashiConfig::new_for_testing(); config.test_weight_divisor = self.test_weight_divisor; @@ -383,6 +429,8 @@ impl HashiNetworkBuilder { )); config.bitcoin_trusted_peers = Some(vec![bitcoin.p2p_address()]); config.bitcoin_chain_id = Some(self.bitcoin_chain_id.clone()); + config.bitcoin_start_height = self.bitcoin_start_height; + config.test_reconfig_addresses = self.test_reconfig_addresses.clone(); config.sui_chain_id = service_info.chain_id.clone(); config.screener_endpoint = Some(screener_endpoint.clone()); config.db = Some(dir.join(validator_address.to_string())); @@ -426,7 +474,7 @@ impl HashiNetworkBuilder { // Wait for the initial committee to appear on-chain, which indicates // that the genesis bootstrap (start_reconfig → DKG → end_reconfig) // has completed. - let genesis_timeout = std::time::Duration::from_secs(120); + let genesis_timeout = std::time::Duration::from_secs(self.genesis_timeout_secs); tokio::time::timeout(genesis_timeout, async { loop { if let Some(onchain) = nodes[0].hashi().onchain_state_opt() diff --git a/crates/e2e-tests/src/lib.rs b/crates/e2e-tests/src/lib.rs index 2beb7035a..ce012673c 100644 --- a/crates/e2e-tests/src/lib.rs +++ b/crates/e2e-tests/src/lib.rs @@ -23,6 +23,7 @@ use corepc_client::client_sync::v29::Client; pub mod bitcoin_node; pub mod e2e_flow; pub mod external_bitcoin_node; +pub mod external_sui_network; pub mod hashi_network; pub mod publish; pub mod sui_network; @@ -46,6 +47,7 @@ pub use bitcoin_node::BitcoinNodeHandle; pub use hashi_network::HashiNetwork; pub use hashi_network::HashiNetworkBuilder; pub use hashi_network::HashiNodeHandle; +pub use hashi_network::SuiNetworkInfo; pub use sui_network::SuiNetworkBuilder; pub use sui_network::SuiNetworkHandle; use tempfile::TempDir; diff --git a/crates/e2e-tests/src/main.rs b/crates/e2e-tests/src/main.rs index 7d70db5a1..c4a193e95 100644 --- a/crates/e2e-tests/src/main.rs +++ b/crates/e2e-tests/src/main.rs @@ -82,18 +82,37 @@ struct ExternalBitcoinOpts { btc_p2p_address: String, } +/// Options for connecting to an external Sui network (devnet, testnet, etc). +/// These are only used when `--sui-network` is not `local`. +#[derive(Args)] +struct ExternalSuiOpts { + /// Sui RPC URL (defaults based on --sui-network) + #[clap(long)] + sui_rpc_url: Option, + + /// Sui address whose key will be loaded from the keystore as the operator + #[clap(long)] + sui_address: Option, + + /// Path to Sui keystore file + #[clap(long, default_value = "~/.sui/sui_config/sui.keystore")] + sui_keystore_path: String, +} + #[derive(Subcommand)] enum Commands { - /// Start a local development environment (Sui localnet + Hashi validators + Bitcoin) + /// Start a development environment (Sui + Hashi validators + Bitcoin) /// /// With --bitcoin-network regtest (default), a local bitcoind is spawned. /// With --bitcoin-network signet, connects to an external node (see --btc-rpc-url). + /// With --sui-network local (default), a local Sui network is spawned. + /// With --sui-network devnet/testnet, connects to an external Sui node. Start { /// Number of Hashi validators to run #[clap(long, default_value = "4")] num_validators: usize, - /// Sui fullnode RPC port + /// Sui fullnode RPC port (only used when --sui-network is local) #[clap(long, default_value = "9000")] sui_rpc_port: u16, @@ -105,8 +124,15 @@ enum Commands { #[clap(long, default_value = "regtest")] bitcoin_network: String, + /// Sui network: "local" spawns a local node, others connect externally + #[clap(long, default_value = "local")] + sui_network: String, + #[command(flatten)] - btc_opts: ExternalBitcoinOpts, + btc_opts: Box, + + #[command(flatten)] + sui_opts: ExternalSuiOpts, /// Enable verbose tracing output #[clap(long, short)] @@ -212,12 +238,19 @@ struct LocalnetState { /// Bitcoin wallet name for RPC calls (e.g. "mining", "test") #[serde(default)] btc_wallet: Option, + /// Sui network: "local", "devnet", "testnet", or custom + #[serde(default = "default_sui_network")] + sui_network: String, } fn default_bitcoin_network() -> String { "regtest".to_string() } +fn default_sui_network() -> String { + "local".to_string() +} + impl LocalnetState { fn state_file_path(data_dir: &Path) -> std::path::PathBuf { data_dir.join("state.json") @@ -266,7 +299,9 @@ async fn main() -> Result<()> { sui_rpc_port, btc_rpc_port, bitcoin_network, + sui_network, btc_opts, + sui_opts, verbose, opts, } => { @@ -275,7 +310,9 @@ async fn main() -> Result<()> { sui_rpc_port, btc_rpc_port, bitcoin_network, - btc_opts, + sui_network, + btc_opts: *btc_opts, + sui_opts, verbose, data_dir: opts.data_dir, }) @@ -309,7 +346,9 @@ struct StartConfig { sui_rpc_port: u16, btc_rpc_port: u16, bitcoin_network: String, + sui_network: String, btc_opts: ExternalBitcoinOpts, + sui_opts: ExternalSuiOpts, verbose: bool, data_dir: std::path::PathBuf, } @@ -331,10 +370,153 @@ impl StartConfig { fn is_regtest(&self) -> bool { self.bitcoin_network == "regtest" } + + fn is_local_sui(&self) -> bool { + self.sui_network == "local" + } + + fn sui_rpc_url(&self) -> Result { + if let Some(url) = &self.sui_opts.sui_rpc_url { + return Ok(url.clone()); + } + match self.sui_network.as_str() { + "devnet" => Ok("https://fullnode.devnet.sui.io:443".to_string()), + "testnet" => Ok("https://fullnode.testnet.sui.io:443".to_string()), + other => anyhow::bail!( + "Unknown sui network '{}'. Use local, devnet, testnet, or provide --sui-rpc-url", + other + ), + } + } + + fn load_operator_key(&self) -> Result { + let address_str = self + .sui_opts + .sui_address + .as_ref() + .context("--sui-address is required when using an external Sui network")?; + let address = sui_sdk_types::Address::from_hex(address_str) + .or_else(|_| address_str.parse::()) + .with_context(|| format!("Invalid Sui address: {}", address_str))?; + + let keystore_path = expand_tilde(&self.sui_opts.sui_keystore_path); + e2e_tests::external_sui_network::ExternalSuiNetwork::load_key_from_keystore( + &keystore_path, + &address, + ) + } +} + +/// Patch the copied Move source for external Sui network testing. +/// +/// The hashi contract ties validator registration and committee formation to the +/// Sui validator set. On external networks our generated keys are NOT validators. +/// +/// We patch: +/// 1. `new_member`: remove active-validator assertion +/// 2. Add `test_start_reconfig` entry function that accepts member addresses +/// and builds a committee with equal weight (avoids struct layout changes) +/// +/// We do NOT change the CommitteeSet struct layout to avoid breaking BCS parsing. +fn patch_validator_check(dir: &Path) -> Result<()> { + let cs_path = dir.join("packages/hashi/sources/core/committee/committee_set.move"); + let cs_source = std::fs::read_to_string(&cs_path) + .with_context(|| format!("Failed to read {}", cs_path.display()))?; + + // Patch 1: Remove validator assertion in new_member + let cs_patched = cs_source.replacen( + " // Only allow Sui Validators to register as Hashi members\n assert!(sui_system.active_validator_addresses_ref().contains(&validator_address));", + " // [patched] validator check removed for external network testing", + 1, + ); + + // Patch 2: Add test_start_reconfig_from_addresses — builds committee from given addresses + let test_fn = r#" + +/// [patched] Build a committee from explicit addresses with equal weight. +/// Used for external network testing where test keys are not Sui validators. +public(package) fun test_start_reconfig_from_addresses( + self: &mut CommitteeSet, + sui_system: &sui_system::sui_system::SuiSystemState, + member_addresses: vector
, + ctx: &TxContext, +): u64 { + assert!(!self.is_reconfiguring()); + assert!(!self.has_committee(ctx.epoch())); + assert!(self.epoch == 0 || self.epoch != ctx.epoch()); + + let epoch = ctx.epoch(); + let g1_identity = g1_to_uncompressed_g1(&sui::bls12381::g1_identity()); + let mut committee_members = vector[]; + let mut i = 0; + while (i < member_addresses.length()) { + let addr = member_addresses[i]; + i = i + 1; + if (!self.has_member(addr)) { continue }; + let member = self.member(addr); + if (sui::group_ops::equal(&member.next_epoch_public_key, &g1_identity)) { continue }; + if (member.next_epoch_encryption_public_key.is_empty()) { continue }; + committee_members.push_back(committee::new_committee_member( + addr, member.next_epoch_public_key, member.next_epoch_encryption_public_key, 1000, + )); + }; + let committee = committee::new_committee(epoch, committee_members); + let epoch = committee.epoch(); + self.pending_epoch_change = option::some(epoch); + self.insert_committee(committee); + epoch +}"#; + + // Append the test function at the end of the module (Move 2024 modules have no closing brace) + let cs_patched = format!("{}{}\n", cs_patched, test_fn); + + if cs_source == cs_patched { + anyhow::bail!("Failed to patch committee_set.move"); + } + std::fs::write(&cs_path, &cs_patched)?; + + // Patch 3: Add test_start_reconfig entry function in reconfig.move + let reconfig_path = dir.join("packages/hashi/sources/core/reconfig.move"); + let reconfig_source = std::fs::read_to_string(&reconfig_path)?; + + let test_entry = r#" + +/// [patched] Test version of start_reconfig that accepts member addresses. +entry fun test_start_reconfig( + self: &mut Hashi, + sui_system: &sui_system::sui_system::SuiSystemState, + member_addresses: vector
, + ctx: &TxContext, +) { + self.config().assert_version_enabled(); + let epoch = self + .committee_set_mut() + .test_start_reconfig_from_addresses(sui_system, member_addresses, ctx); + sui::event::emit(StartReconfigEvent { epoch }); +}"#; + + let reconfig_patched = format!("{}{}\n", reconfig_source, test_entry); + + std::fs::write(&reconfig_path, &reconfig_patched)?; + + tracing::info!("Patched Move source for external network testing"); + Ok(()) +} + +fn expand_tilde(path: &str) -> std::path::PathBuf { + if let Some(rest) = path.strip_prefix("~/") + && let Ok(home) = std::env::var("HOME") + { + return std::path::PathBuf::from(home).join(rest); + } + std::path::PathBuf::from(path) } async fn cmd_start(cfg: StartConfig) -> Result<()> { - cfg.chain_id()?; // Validate early + cfg.chain_id()?; // Validate BTC network early + if !cfg.is_local_sui() { + cfg.sui_rpc_url()?; // Validate Sui network early + } // Check for existing running instance if let Ok(state) = LocalnetState::load(&cfg.data_dir) { @@ -351,22 +533,23 @@ async fn cmd_start(cfg: StartConfig) -> Result<()> { use std::io::Write; print!( - "{} Starting localnet with {} validators (btc: {})...", + "{} Starting localnet with {} validators (sui: {}, btc: {})...", "ℹ".blue().bold(), cfg.num_validators, + cfg.sui_network, cfg.bitcoin_network, ); std::io::stdout().flush().ok(); - if cfg.is_regtest() { - start_regtest(&cfg).await + if cfg.is_local_sui() && cfg.is_regtest() { + start_all_local(&cfg).await } else { - start_external(&cfg).await + start_custom(&cfg).await } } -/// Regtest mode: spawn bitcoind, Sui localnet, and Hashi validators. -async fn start_regtest(cfg: &StartConfig) -> Result<()> { +/// Fully local mode: spawn bitcoind, Sui localnet, and Hashi validators. +async fn start_all_local(cfg: &StartConfig) -> Result<()> { let test_networks = TestNetworksBuilder::new() .with_nodes(cfg.num_validators) .with_sui_rpc_port(cfg.sui_rpc_port) @@ -391,75 +574,157 @@ async fn start_regtest(cfg: &StartConfig) -> Result<()> { Ok(()) } -/// External node mode: connect to an existing Bitcoin node (signet, testnet4, etc). -async fn start_external(cfg: &StartConfig) -> Result<()> { - let btc = &cfg.btc_opts; - let external_node = e2e_tests::external_bitcoin_node::ExternalBitcoinNode::new( - &btc.btc_rpc_url, - &btc.btc_rpc_user, - &btc.btc_rpc_pass, - btc.btc_wallet.as_deref(), - &btc.btc_p2p_address, - )?; - - let dir = tempfile::Builder::new() - .prefix("hashi-test-env-") - .tempdir()?; - tracing::info!("test env: {}", dir.path().display()); +/// Custom mode: at least one of Sui or Bitcoin is external. +/// Sets up Sui first (local or external), then dispatches to a generic helper. +async fn start_custom(cfg: &StartConfig) -> Result<()> { + if cfg.is_local_sui() { + let dir = tempfile::Builder::new() + .prefix("hashi-test-env-") + .tempdir()?; + tracing::info!("test env: {}", dir.path().display()); + + let sui_network = e2e_tests::SuiNetworkBuilder::default() + .with_num_validators(cfg.num_validators) + .with_rpc_port(cfg.sui_rpc_port) + .dir(&dir.path().join("sui")) + .build() + .await?; - let mut sui_network = e2e_tests::SuiNetworkBuilder::default() - .with_num_validators(cfg.num_validators) - .with_rpc_port(cfg.sui_rpc_port) - .dir(&dir.path().join("sui")) - .build() + start_with_sui(cfg, &sui_network, dir).await + } else { + let dir = tempfile::Builder::new() + .prefix("hashi-test-env-") + .tempdir()?; + tracing::info!("test env: {}", dir.path().display()); + + let operator_key = cfg.load_operator_key()?; + let ext_sui = e2e_tests::external_sui_network::ExternalSuiNetwork::new( + &cfg.sui_rpc_url()?, + operator_key, + cfg.num_validators, + ) .await?; + // Write a minimal sui config so `sui move build` can resolve dependencies + ext_sui.write_sui_config(&dir.path().join("sui"))?; + + start_with_sui(cfg, &ext_sui, dir).await + } +} + +/// Given a Sui network (local or external), set up Bitcoin and Hashi. +async fn start_with_sui( + cfg: &StartConfig, + sui: &impl e2e_tests::SuiNetworkInfo, + dir: tempfile::TempDir, +) -> Result<()> { TestNetworksBuilder::cp_packages(dir.as_ref())?; + + // When deploying to an external Sui network, the generated hashi validator + // keys are NOT actual Sui validators. Patch the copied Move source to skip + // the active-validator-set assertion so non-validators can register. + if !cfg.is_local_sui() { + patch_validator_check(dir.as_ref())?; + } + let chain_id = cfg.chain_id()?; - let hashi_ids = e2e_tests::publish::publish( - dir.as_ref(), - &mut sui_network.client, - sui_network.user_keys.first().unwrap(), - chain_id, - ) - .await?; - - let hashi_network = e2e_tests::HashiNetworkBuilder::new() - .with_num_nodes(cfg.num_validators) - .with_bitcoin_chain_id(chain_id) - .with_bitcoin_rpc_auth(btc.btc_rpc_user.clone(), btc.btc_rpc_pass.clone()) - .build( - &dir.path().join("hashi"), - &sui_network, - &external_node, + let mut client = sui.client(); + let hashi_ids = + e2e_tests::publish::publish(dir.as_ref(), &mut client, sui.funding_key(), chain_id).await?; + + // On external Sui, pass validator addresses for test_start_reconfig + let test_reconfig_addrs: Option> = if !cfg.is_local_sui() { + Some(sui.validator_keys().keys().copied().collect()) + } else { + None + }; + + if cfg.is_regtest() { + let btc_node = e2e_tests::BitcoinNodeBuilder::new() + .with_rpc_port(cfg.btc_rpc_port) + .dir(dir.as_ref()) + .build() + .await?; + + let mut builder = e2e_tests::HashiNetworkBuilder::new() + .with_num_nodes(cfg.num_validators) + .with_bitcoin_chain_id(chain_id); + if let Some(addrs) = test_reconfig_addrs.clone() { + builder = builder.with_test_reconfig_addresses(addrs); + } + let hashi_network = builder + .build(&dir.path().join("hashi"), sui, &btc_node, hashi_ids) + .await?; + + let state = persist_localnet_state( + &cfg.data_dir, + sui, + btc_node.rpc_url(), + e2e_tests::bitcoin_node::RPC_USER, + e2e_tests::bitcoin_node::RPC_PASSWORD, hashi_ids, - ) - .await?; + cfg, + )?; + print_ready(&state); + + tokio::signal::ctrl_c().await?; + cleanup_state_files(&cfg.data_dir); + drop(hashi_network); + drop(btc_node); + drop(dir); + } else { + let btc = &cfg.btc_opts; + let external_node = e2e_tests::external_bitcoin_node::ExternalBitcoinNode::new( + &btc.btc_rpc_url, + &btc.btc_rpc_user, + &btc.btc_rpc_pass, + btc.btc_wallet.as_deref(), + &btc.btc_p2p_address, + )?; + + // Set start height near the current tip so Kyoto only scans recent blocks + let current_height = external_node.get_block_count().await? as u32; + let start_height = current_height.saturating_sub(10); + + // External BTC networks need longer genesis timeout for Kyoto header sync + let mut builder = e2e_tests::HashiNetworkBuilder::new() + .with_num_nodes(cfg.num_validators) + .with_bitcoin_chain_id(chain_id) + .with_bitcoin_rpc_auth(btc.btc_rpc_user.clone(), btc.btc_rpc_pass.clone()) + .with_bitcoin_start_height(start_height) + .with_genesis_timeout_secs(600); + if let Some(addrs) = test_reconfig_addrs { + builder = builder.with_test_reconfig_addresses(addrs); + } + let hashi_network = builder + .build(&dir.path().join("hashi"), sui, &external_node, hashi_ids) + .await?; - let state = persist_localnet_state( - &cfg.data_dir, - &sui_network, - &btc.btc_rpc_url, - &btc.btc_rpc_user, - &btc.btc_rpc_pass, - hashi_ids, - cfg, - )?; - print_ready(&state); + let state = persist_localnet_state( + &cfg.data_dir, + sui, + &btc.btc_rpc_url, + &btc.btc_rpc_user, + &btc.btc_rpc_pass, + hashi_ids, + cfg, + )?; + print_ready(&state); + + tokio::signal::ctrl_c().await?; + cleanup_state_files(&cfg.data_dir); + drop(hashi_network); + drop(external_node); + drop(dir); + } - tokio::signal::ctrl_c().await?; - cleanup_state_files(&cfg.data_dir); - drop(hashi_network); - drop(external_node); - drop(sui_network); - drop(dir); Ok(()) } -/// Write the funded genesis key and localnet state to disk. +/// Write the funded key and localnet state to disk. fn persist_localnet_state( data_dir: &Path, - sui_network: &e2e_tests::SuiNetworkHandle, + sui: &impl e2e_tests::SuiNetworkInfo, btc_rpc_url: &str, btc_rpc_user: &str, btc_rpc_pass: &str, @@ -468,17 +733,14 @@ fn persist_localnet_state( ) -> Result { std::fs::create_dir_all(data_dir)?; - // Write the funded genesis key to disk so deposit/faucet commands can use it + // Write the funded key to disk so deposit/faucet commands can use it let funded_key_path = data_dir.join("funded_keypair.pem"); - let funded_key = sui_network - .user_keys - .first() - .context("No funded user keys in localnet genesis")?; + let funded_key = sui.funding_key(); write_pem_key(&funded_key_path, &funded_key.to_pem()?)?; let state = LocalnetState { pid: std::process::id(), - sui_rpc_url: sui_network.rpc_url.clone(), + sui_rpc_url: sui.rpc_url().to_string(), btc_rpc_url: btc_rpc_url.to_string(), btc_rpc_user: btc_rpc_user.to_string(), btc_rpc_password: btc_rpc_pass.to_string(), @@ -493,6 +755,7 @@ fn persist_localnet_state( } else { cfg.btc_opts.btc_wallet.clone() }, + sui_network: cfg.sui_network.clone(), }; state.save(data_dir)?; write_cli_config(data_dir, &state)?; @@ -532,9 +795,10 @@ fn init_tracing(verbose: bool) { fn print_ready(state: &LocalnetState) { print!("\r{}", " ".repeat(80)); println!( - "\r{} Localnet started with {} validators (btc: {})", + "\r{} Localnet started with {} validators (sui: {}, btc: {})", "✓".green().bold(), state.num_validators, + state.sui_network, state.bitcoin_network, ); println!(); @@ -1081,14 +1345,10 @@ fn print_connection_details(state: &LocalnetState) { println!("{}", "━".repeat(50)); println!("{}", " Localnet Connection Details".bold()); println!("{}", "━".repeat(50)); + println!(" {} {}", "Sui Network:".bold(), state.sui_network); println!(" {} {}", "Sui RPC:".bold(), state.sui_rpc_url); + println!(" {} {}", "BTC Network:".bold(), state.bitcoin_network); println!(" {} {}", "BTC RPC:".bold(), state.btc_rpc_url); - println!( - " {} {}:{}", - "BTC RPC Auth:".bold(), - state.btc_rpc_user, - state.btc_rpc_password - ); println!(" {} {}", "Package ID:".bold(), state.package_id); println!(" {} {}", "Hashi Object:".bold(), state.hashi_object_id); println!(" {} {}", "Validators:".bold(), state.num_validators); diff --git a/crates/e2e-tests/src/sui_network.rs b/crates/e2e-tests/src/sui_network.rs index 515f3be9b..a09ca39b8 100644 --- a/crates/e2e-tests/src/sui_network.rs +++ b/crates/e2e-tests/src/sui_network.rs @@ -60,7 +60,7 @@ pub fn sui_binary() -> &'static Path { .as_path() } -async fn wait_for_ready(client: &mut Client) -> Result<()> { +pub async fn wait_for_ready(client: &mut Client) -> Result<()> { // Wait till the network has started up and at least one checkpoint has been produced for _ in 0..NETWORK_STARTUP_TIMEOUT_SECS { if let Ok(resp) = client @@ -238,7 +238,7 @@ impl SuiNetworkBuilder { } } -fn keypair_from_base64(b64: &str) -> Result { +pub fn keypair_from_base64(b64: &str) -> Result { let bytes = ::decode_vec(b64)?; let keypair = diff --git a/crates/hashi/src/config.rs b/crates/hashi/src/config.rs index a679bf2be..aed1a3ba7 100644 --- a/crates/hashi/src/config.rs +++ b/crates/hashi/src/config.rs @@ -174,6 +174,11 @@ pub struct Config { /// complaint recovery flow. Must not be set on mainnet or testnet. #[serde(skip_serializing_if = "Option::is_none")] pub test_corrupt_shares_for: Option
, + + /// Test-only: validator addresses to pass to test_start_reconfig on + /// external networks where test keys are not actual Sui validators. + #[serde(skip_serializing_if = "Option::is_none")] + pub test_reconfig_addresses: Option>, } #[derive(Clone, Debug, Default, serde_derive::Deserialize, serde_derive::Serialize)] diff --git a/crates/hashi/src/mpc/service.rs b/crates/hashi/src/mpc/service.rs index 3457064a3..18aca15f5 100644 --- a/crates/hashi/src/mpc/service.rs +++ b/crates/hashi/src/mpc/service.rs @@ -216,10 +216,16 @@ impl MpcService { } // Attempt to submit start_reconfig. This will fail on-chain if // not enough validators have registered (95% stake threshold). + // On external networks, use test_start_reconfig with explicit addresses. + let test_addrs = self.inner.config.test_reconfig_addresses.clone(); let result = async { let mut executor = crate::sui_tx_executor::SuiTxExecutor::from_hashi(self.inner.clone())?; - executor.execute_start_reconfig().await + if let Some(ref addrs) = test_addrs { + executor.execute_test_start_reconfig(addrs).await + } else { + executor.execute_start_reconfig().await + } }; match result.await { Ok(()) => { @@ -493,11 +499,17 @@ impl MpcService { if hashi_epoch >= sui_epoch { return; } + let test_addrs = self.inner.config.test_reconfig_addresses.clone(); for attempt in 1..=MAX_PROTOCOL_ATTEMPTS { + let test_addrs = test_addrs.clone(); let result = async { let mut executor = crate::sui_tx_executor::SuiTxExecutor::from_hashi(self.inner.clone())?; - executor.execute_start_reconfig().await + if let Some(ref addrs) = test_addrs { + executor.execute_test_start_reconfig(addrs).await + } else { + executor.execute_start_reconfig().await + } }; match result.await { Ok(()) => { diff --git a/crates/hashi/src/sui_tx_executor.rs b/crates/hashi/src/sui_tx_executor.rs index 170aeff4a..4da481963 100644 --- a/crates/hashi/src/sui_tx_executor.rs +++ b/crates/hashi/src/sui_tx_executor.rs @@ -579,6 +579,42 @@ impl SuiTxExecutor { Ok(()) } + /// Test version of start_reconfig that passes member addresses explicitly. + /// Used for external network testing where test keys are not Sui validators. + pub async fn execute_test_start_reconfig( + &mut self, + member_addresses: &Vec
, + ) -> anyhow::Result<()> { + let mut builder = TransactionBuilder::new(); + let hashi_arg = builder.object( + ObjectInput::new(self.hashi_ids.hashi_object_id) + .as_shared() + .with_mutable(true), + ); + let sui_system_arg = builder.object( + ObjectInput::new(SUI_SYSTEM_STATE_OBJECT_ID) + .as_shared() + .with_mutable(false), + ); + let addresses_arg = builder.pure(member_addresses); + builder.move_call( + Function::new( + self.hashi_ids.package_id, + Identifier::from_static("reconfig"), + Identifier::from_static("test_start_reconfig"), + ), + vec![hashi_arg, sui_system_arg, addresses_arg], + ); + let response = self.execute(builder).await?; + if !response.transaction().effects().status().success() { + anyhow::bail!( + "test_start_reconfig transaction failed: {:?}", + response.transaction().effects().status() + ); + } + Ok(()) + } + pub async fn execute_end_reconfig( &mut self, mpc_public_key: &[u8],