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],