diff --git a/crates/e2e-tests/src/e2e_flow.rs b/crates/e2e-tests/src/e2e_flow.rs index ab952f221..bcbf629cf 100644 --- a/crates/e2e-tests/src/e2e_flow.rs +++ b/crates/e2e-tests/src/e2e_flow.rs @@ -10,20 +10,14 @@ mod tests { use futures::StreamExt; use hashi::sui_tx_executor::SuiTxExecutor; - use hashi_types::move_types::DepositConfirmedEvent; use hashi_types::move_types::WithdrawalConfirmedEvent; use hashi_types::move_types::WithdrawalPickedForProcessingEvent; - use std::sync::Arc; - use std::sync::atomic::AtomicBool; - use std::sync::atomic::Ordering; use std::time::Duration; use sui_rpc::field::FieldMask; use sui_rpc::field::FieldMaskUtil; use sui_rpc::proto::sui::rpc::v2::Checkpoint; - use sui_rpc::proto::sui::rpc::v2::GetBalanceRequest; use sui_rpc::proto::sui::rpc::v2::SubscribeCheckpointsRequest; use sui_sdk_types::Address; - use sui_sdk_types::StructTag; use sui_sdk_types::bcs::FromBcs; use tracing::debug; use tracing::info; @@ -31,16 +25,12 @@ mod tests { use crate::TestNetworks; use crate::TestNetworksBuilder; - fn init_test_logging() { - tracing_subscriber::fmt() - .with_test_writer() - .with_env_filter( - tracing_subscriber::EnvFilter::from_default_env() - .add_directive(tracing::Level::INFO.into()), - ) - .try_init() - .ok(); - } + use crate::test_helpers::BackgroundMiner; + use crate::test_helpers::create_deposit_and_wait; + use crate::test_helpers::get_hbtc_balance; + use crate::test_helpers::init_test_logging; + use crate::test_helpers::lookup_vout; + use crate::test_helpers::txid_to_address; async fn setup_test_networks() -> Result { info!("Setting up test networks..."); @@ -60,84 +50,6 @@ mod tests { Ok(networks) } - fn txid_to_address(txid: &Txid) -> Address { - hashi_types::bitcoin_txid::BitcoinTxid::from(*txid).into() - } - - async fn wait_for_deposit_confirmation( - sui_client: &mut sui_rpc::Client, - request_id: Address, - timeout: Duration, - ) -> Result<()> { - info!( - "Waiting for deposit confirmation for request_id: {}", - request_id - ); - - let start = std::time::Instant::now(); - let subscription_read_mask = FieldMask::from_paths([Checkpoint::path_builder() - .transactions() - .events() - .events() - .contents() - .finish()]); - let mut subscription = sui_client - .subscription_client() - .subscribe_checkpoints( - SubscribeCheckpointsRequest::default().with_read_mask(subscription_read_mask), - ) - .await? - .into_inner(); - - while let Some(item) = subscription.next().await { - if start.elapsed() > timeout { - return Err(anyhow!( - "Timeout waiting for deposit confirmation after {:?}", - timeout - )); - } - - let checkpoint = match item { - Ok(checkpoint) => checkpoint, - Err(e) => { - debug!("Error in checkpoint stream: {}", e); - continue; - } - }; - - debug!( - "Received checkpoint {}, checking for DepositConfirmedEvent...", - checkpoint.cursor() - ); - - for txn in checkpoint.checkpoint().transactions() { - for event in txn.events().events() { - let event_type = event.contents().name(); - - if event_type.contains("DepositConfirmedEvent") { - match DepositConfirmedEvent::from_bcs(event.contents().value()) { - Ok(event_data) => { - if event_data.request_id == request_id { - info!( - "Deposit confirmed! Found DepositConfirmedEvent for request_id: {}", - request_id - ); - return Ok(()); - } - } - Err(e) => { - debug!("Failed to parse DepositConfirmedEvent: {}", e); - } - } - } - } - } - tokio::time::sleep(Duration::from_millis(100)).await; - } - - Err(anyhow!("Checkpoint subscription ended unexpectedly")) - } - async fn wait_for_withdrawal_confirmation( sui_client: &mut sui_rpc::Client, timeout: Duration, @@ -207,151 +119,6 @@ mod tests { Err(anyhow!("Checkpoint subscription ended unexpectedly")) } - async fn get_hbtc_balance( - sui_client: &mut sui_rpc::Client, - package_id: Address, - owner: Address, - ) -> Result { - let btc_type = format!("{}::btc::BTC", package_id); - let btc_struct_tag: StructTag = btc_type.parse()?; - let request = GetBalanceRequest::default() - .with_owner(owner.to_string()) - .with_coin_type(btc_struct_tag.to_string()); - - let response = sui_client - .state_client() - .get_balance(request) - .await? - .into_inner(); - - let balance = response.balance().balance_opt().unwrap_or(0); - debug!("hBTC balance for {}: {} sats", owner, balance); - Ok(balance) - } - - fn lookup_vout( - networks: &TestNetworks, - txid: Txid, - address: bitcoin::Address, - amount: u64, - ) -> Result { - let tx = networks - .bitcoin_node - .rpc_client() - .get_raw_transaction(txid) - .and_then(|r| r.transaction().map_err(Into::into))?; - let vout = tx - .output - .iter() - .position(|output| { - output.value == Amount::from_sat(amount) - && output.script_pubkey == address.script_pubkey() - }) - .ok_or_else(|| { - anyhow!( - "Could not find output with amount {} and deposit address", - amount - ) - })?; - debug!("Found deposit in tx output {}", vout); - Ok(vout) - } - - async fn create_deposit_and_wait( - networks: &mut TestNetworks, - amount_sats: u64, - ) -> Result
{ - let user_key = networks.sui_network.user_keys.first().unwrap(); - let hbtc_recipient = user_key.public_key().derive_address(); - let hashi = networks.hashi_network.nodes()[0].hashi().clone(); - // Use the on-chain MPC key rather than the local key-ready channel. - // The on-chain key is set during end_reconfig and is guaranteed - // available once HashiNetworkBuilder::build() returns. - let deposit_address = - hashi.get_deposit_address(&hashi.get_onchain_mpc_pubkey()?, Some(&hbtc_recipient))?; - - info!("Sending Bitcoin to deposit address..."); - let txid = networks - .bitcoin_node - .send_to_address(&deposit_address, Amount::from_sat(amount_sats))?; - info!("Transaction sent: {}", txid); - - info!("Mining blocks for confirmation..."); - let blocks_to_mine = 10; - networks.bitcoin_node.generate_blocks(blocks_to_mine)?; - info!("{blocks_to_mine} blocks mined"); - - info!("Creating deposit request on Sui..."); - let vout = lookup_vout(networks, txid, deposit_address, amount_sats)?; - let mut executor = SuiTxExecutor::from_config(&hashi.config, hashi.onchain_state())? - .with_signer(user_key.clone()); - let request_id = executor - .execute_create_deposit_request( - txid_to_address(&txid), - vout as u32, - amount_sats, - Some(hbtc_recipient), - ) - .await?; - info!("Deposit request created: {}", request_id); - - // Mine blocks in the background so the leader's BTC-block-driven - // deposit processing loop fires. - let _miner = BackgroundMiner::start(&networks.bitcoin_node); - wait_for_deposit_confirmation( - &mut networks.sui_network.client, - request_id, - Duration::from_secs(300), - ) - .await?; - info!("Deposit confirmed on Sui"); - - Ok(hbtc_recipient) - } - - /// Mines one block per second on Bitcoin regtest until stopped. - /// Stops automatically when dropped. - struct BackgroundMiner { - stop_flag: Arc, - handle: Option>, - } - - impl BackgroundMiner { - fn start(bitcoin_node: &crate::BitcoinNodeHandle) -> Self { - let stop_flag = Arc::new(AtomicBool::new(false)); - let stop_clone = stop_flag.clone(); - let rpc_url = bitcoin_node.rpc_url().to_string(); - let handle = std::thread::spawn(move || { - let rpc = corepc_client::client_sync::v29::Client::new_with_auth( - &rpc_url, - corepc_client::client_sync::Auth::UserPass( - crate::bitcoin_node::RPC_USER.to_string(), - crate::bitcoin_node::RPC_PASSWORD.to_string(), - ), - ) - .expect("failed to create mining RPC client"); - let addr = rpc.new_address().expect("failed to get mining address"); - while !stop_clone.load(Ordering::Relaxed) { - let _ = rpc.generate_to_address(1, &addr); - std::thread::sleep(Duration::from_secs(1)); - } - }); - Self { - stop_flag, - handle: Some(handle), - } - } - } - - impl Drop for BackgroundMiner { - fn drop(&mut self) { - self.stop_flag.store(true, Ordering::Relaxed); - if let Some(handle) = self.handle.take() { - let _ = handle.join(); - } - } - } - fn extract_witness_program(address: &bitcoin::Address) -> Result> { let script = address.script_pubkey(); let bytes = script.as_bytes(); diff --git a/crates/e2e-tests/src/lib.rs b/crates/e2e-tests/src/lib.rs index 1c4baff5f..22e1af1cc 100644 --- a/crates/e2e-tests/src/lib.rs +++ b/crates/e2e-tests/src/lib.rs @@ -23,6 +23,8 @@ pub mod e2e_flow; pub mod hashi_network; mod publish; pub mod sui_network; +pub mod test_helpers; +pub mod upgrade_flow; pub use bitcoin_node::BitcoinNodeBuilder; pub use bitcoin_node::BitcoinNodeHandle; @@ -69,6 +71,10 @@ impl TestNetworks { &self.bitcoin_node } + pub fn dir(&self) -> &Path { + self.dir.path() + } + pub async fn restart(&mut self) -> Result<()> { self.hashi_network.restart().await } @@ -271,9 +277,13 @@ async fn apply_onchain_config_overrides( ) -> Result<()> { use hashi::cli::client::CreateProposalParams; use hashi::cli::client::build_create_proposal_transaction; - use hashi::cli::client::build_execute_update_config_transaction; - use hashi::cli::client::build_vote_update_config_transaction; + use hashi::cli::client::build_vote_transaction; + use hashi::cli::upgrade::build_execute_proposal_transaction; + use hashi::cli::upgrade::extract_proposal_id_from_response; use hashi::sui_tx_executor::SuiTxExecutor; + use sui_sdk_types::Identifier; + use sui_sdk_types::StructTag; + use sui_sdk_types::TypeTag; let nodes = networks.hashi_network.nodes(); @@ -298,6 +308,13 @@ async fn apply_onchain_config_overrides( // to wait for all nodes to catch up to the last applied override. let mut exec_checkpoint: u64 = 0; + let update_config_type_tag = TypeTag::Struct(Box::new(StructTag::new( + hashi_ids.package_id, + Identifier::from_static("update_config"), + Identifier::from_static("UpdateConfig"), + vec![], + ))); + //TODO could we build the proposals and vote/execute on them all at the same time vs doing them //one at a time? for (key, value) in overrides { @@ -318,31 +335,14 @@ async fn apply_onchain_config_overrides( "create UpdateConfig proposal for '{key}' failed" ); - // Extract the proposal ID from the ProposalCreatedEvent. The event BCS - // layout is (Address, u64) — proposal_id followed by timestamp_ms. - let proposal_id = response - .transaction() - .events() - .events() - .iter() - .find(|e| e.contents().name().contains("ProposalCreatedEvent")) - .ok_or_else(|| { - anyhow::anyhow!( - "ProposalCreatedEvent not found after creating proposal for '{key}'" - ) - }) - .and_then(|e| { - let (id, _ts): (sui_sdk_types::Address, u64) = - bcs::from_bytes(e.contents().value())?; - Ok(id) - })?; - + let proposal_id = extract_proposal_id_from_response(&response)?; tracing::info!("proposal {proposal_id} created for '{key}'; collecting votes"); // 2. All remaining nodes vote. This gives 100% of total weight, // guaranteeing the 66.67% quorum threshold is met. for executor in &mut executors[1..] { - let vote_tx = build_vote_update_config_transaction(hashi_ids, proposal_id); + let vote_tx = + build_vote_transaction(hashi_ids, proposal_id, update_config_type_tag.clone()); let vote_resp = executor.execute(vote_tx).await?; anyhow::ensure!( vote_resp.transaction().effects().status().success(), @@ -351,7 +351,12 @@ async fn apply_onchain_config_overrides( } // 3. Node 0 executes the proposal now that quorum is reached. - let execute_tx = build_execute_update_config_transaction(hashi_ids, proposal_id); + let execute_tx = build_execute_proposal_transaction( + hashi_ids, + proposal_id, + hashi_ids.package_id, + "update_config", + )?; let exec_resp = executors[0].execute(execute_tx).await?; anyhow::ensure!( exec_resp.transaction().effects().status().success(), diff --git a/crates/e2e-tests/src/test_helpers.rs b/crates/e2e-tests/src/test_helpers.rs new file mode 100644 index 000000000..888db76d1 --- /dev/null +++ b/crates/e2e-tests/src/test_helpers.rs @@ -0,0 +1,242 @@ +// Copyright (c) Mysten Labs, Inc. +// SPDX-License-Identifier: Apache-2.0 + +//! Shared helpers used by e2e test modules. +//! +//! Test modules across this crate (`e2e_flow`, `upgrade_tests`, ...) all need +//! the same boilerplate to drive a localnet: init tracing, look up an hBTC +//! balance, wait for a `DepositConfirmedEvent`, deposit-and-wait, etc. Define +//! them here once and import from each test module. + +use anyhow::Result; +use anyhow::anyhow; +use bitcoin::Amount; +use bitcoin::Txid; +use futures::StreamExt; +use hashi::sui_tx_executor::SuiTxExecutor; +use hashi_types::move_types::DepositConfirmedEvent; +use std::sync::Arc; +use std::sync::atomic::AtomicBool; +use std::sync::atomic::Ordering; +use std::time::Duration; +use sui_rpc::field::FieldMask; +use sui_rpc::field::FieldMaskUtil; +use sui_rpc::proto::sui::rpc::v2::Checkpoint; +use sui_rpc::proto::sui::rpc::v2::GetBalanceRequest; +use sui_rpc::proto::sui::rpc::v2::SubscribeCheckpointsRequest; +use sui_sdk_types::Address; +use sui_sdk_types::StructTag; +use sui_sdk_types::bcs::FromBcs; +use tracing::debug; +use tracing::info; + +use crate::BitcoinNodeHandle; +use crate::TestNetworks; + +pub fn init_test_logging() { + tracing_subscriber::fmt() + .with_test_writer() + .with_env_filter( + tracing_subscriber::EnvFilter::from_default_env() + .add_directive(tracing::Level::INFO.into()), + ) + .try_init() + .ok(); +} + +pub fn txid_to_address(txid: &Txid) -> Address { + hashi_types::bitcoin_txid::BitcoinTxid::from(*txid).into() +} + +pub async fn get_hbtc_balance( + sui_client: &mut sui_rpc::Client, + package_id: Address, + owner: Address, +) -> Result { + let btc_type = format!("{package_id}::btc::BTC"); + let btc_struct_tag: StructTag = btc_type.parse()?; + let request = GetBalanceRequest::default() + .with_owner(owner.to_string()) + .with_coin_type(btc_struct_tag.to_string()); + + let response = sui_client + .state_client() + .get_balance(request) + .await? + .into_inner(); + + let balance = response.balance().balance_opt().unwrap_or(0); + debug!("hBTC balance for {owner}: {balance} sats"); + Ok(balance) +} + +pub async fn wait_for_deposit_confirmation( + sui_client: &mut sui_rpc::Client, + request_id: Address, + timeout: Duration, +) -> Result<()> { + info!("Waiting for deposit confirmation for request_id: {request_id}"); + + let start = std::time::Instant::now(); + let read_mask = FieldMask::from_paths([Checkpoint::path_builder() + .transactions() + .events() + .events() + .contents() + .finish()]); + let mut subscription = sui_client + .subscription_client() + .subscribe_checkpoints(SubscribeCheckpointsRequest::default().with_read_mask(read_mask)) + .await? + .into_inner(); + + while let Some(item) = subscription.next().await { + if start.elapsed() > timeout { + return Err(anyhow!( + "Timeout waiting for deposit confirmation after {timeout:?}" + )); + } + + let checkpoint = match item { + Ok(cp) => cp, + Err(e) => { + debug!("Error in checkpoint stream: {e}"); + continue; + } + }; + + for txn in checkpoint.checkpoint().transactions() { + for event in txn.events().events() { + if event.contents().name().contains("DepositConfirmedEvent") + && let Ok(evt) = DepositConfirmedEvent::from_bcs(event.contents().value()) + && evt.request_id == request_id + { + info!("Deposit confirmed for request_id: {request_id}"); + return Ok(()); + } + } + } + } + + Err(anyhow!("Checkpoint subscription ended unexpectedly")) +} + +pub fn lookup_vout( + networks: &TestNetworks, + txid: Txid, + address: bitcoin::Address, + amount: u64, +) -> Result { + let tx = networks + .bitcoin_node + .rpc_client() + .get_raw_transaction(txid) + .and_then(|r| r.transaction().map_err(Into::into))?; + let vout = tx + .output + .iter() + .position(|output| { + output.value == Amount::from_sat(amount) + && output.script_pubkey == address.script_pubkey() + }) + .ok_or_else(|| anyhow!("Could not find output with amount {amount} and deposit address"))?; + debug!("Found deposit in tx output {vout}"); + Ok(vout) +} + +/// Deposit BTC and wait for the validators to auto-confirm it via the full +/// observe → sign → confirm path. Returns the hBTC recipient address. +pub async fn create_deposit_and_wait( + networks: &mut TestNetworks, + amount_sats: u64, +) -> Result
{ + let user_key = networks.sui_network.user_keys.first().unwrap(); + let hbtc_recipient = user_key.public_key().derive_address(); + let hashi = networks.hashi_network.nodes()[0].hashi().clone(); + // Use the on-chain MPC key rather than the local key-ready channel. + // The on-chain key is set during end_reconfig and is guaranteed + // available once HashiNetworkBuilder::build() returns. + let deposit_address = + hashi.get_deposit_address(&hashi.get_onchain_mpc_pubkey()?, Some(&hbtc_recipient))?; + + info!("Sending Bitcoin to deposit address..."); + let txid = networks + .bitcoin_node + .send_to_address(&deposit_address, Amount::from_sat(amount_sats))?; + info!("Transaction sent: {txid}"); + + info!("Mining blocks for confirmation..."); + let blocks_to_mine = 10; + networks.bitcoin_node.generate_blocks(blocks_to_mine)?; + info!("{blocks_to_mine} blocks mined"); + + info!("Creating deposit request on Sui..."); + let vout = lookup_vout(networks, txid, deposit_address, amount_sats)?; + let mut executor = SuiTxExecutor::from_config(&hashi.config, hashi.onchain_state())? + .with_signer(user_key.clone()); + let request_id = executor + .execute_create_deposit_request( + txid_to_address(&txid), + vout as u32, + amount_sats, + Some(hbtc_recipient), + ) + .await?; + info!("Deposit request created: {request_id}"); + + // Mine blocks in the background so the leader's BTC-block-driven + // deposit processing loop fires. + let _miner = BackgroundMiner::start(&networks.bitcoin_node); + wait_for_deposit_confirmation( + &mut networks.sui_network.client, + request_id, + Duration::from_secs(300), + ) + .await?; + info!("Deposit confirmed on Sui"); + + Ok(hbtc_recipient) +} + +/// Mines one block per second on Bitcoin regtest until stopped. +/// Stops automatically when dropped. +pub struct BackgroundMiner { + stop_flag: Arc, + handle: Option>, +} + +impl BackgroundMiner { + pub fn start(bitcoin_node: &BitcoinNodeHandle) -> Self { + let stop_flag = Arc::new(AtomicBool::new(false)); + let stop_clone = stop_flag.clone(); + let rpc_url = bitcoin_node.rpc_url().to_string(); + let handle = std::thread::spawn(move || { + let rpc = corepc_client::client_sync::v29::Client::new_with_auth( + &rpc_url, + corepc_client::client_sync::Auth::UserPass( + crate::bitcoin_node::RPC_USER.to_string(), + crate::bitcoin_node::RPC_PASSWORD.to_string(), + ), + ) + .expect("failed to create mining RPC client"); + let addr = rpc.new_address().expect("failed to get mining address"); + while !stop_clone.load(Ordering::Relaxed) { + let _ = rpc.generate_to_address(1, &addr); + std::thread::sleep(Duration::from_secs(1)); + } + }); + Self { + stop_flag, + handle: Some(handle), + } + } +} + +impl Drop for BackgroundMiner { + fn drop(&mut self) { + self.stop_flag.store(true, Ordering::Relaxed); + if let Some(handle) = self.handle.take() { + let _ = handle.join(); + } + } +} diff --git a/crates/e2e-tests/src/upgrade_flow.rs b/crates/e2e-tests/src/upgrade_flow.rs new file mode 100644 index 000000000..3de136705 --- /dev/null +++ b/crates/e2e-tests/src/upgrade_flow.rs @@ -0,0 +1,235 @@ +// Copyright (c) Mysten Labs, Inc. +// SPDX-License-Identifier: Apache-2.0 + +//! Upgrade test infrastructure. +//! +//! Provides helpers to exercise the full governance-gated upgrade lifecycle: +//! programmatically patch the package source, build an upgrade, propose/vote/ +//! execute the upgrade, publish the new bytecode, and finalize. + +use anyhow::Result; +use hashi::cli::client::CreateProposalParams; +use hashi::cli::client::build_create_proposal_transaction; +use hashi::cli::client::build_vote_transaction; +use hashi::cli::upgrade::build_execute_proposal_transaction; +use hashi::cli::upgrade::build_upgrade_execution_transaction; +use hashi::cli::upgrade::build_upgrade_package; +use hashi::cli::upgrade::extract_new_package_id_from_response; +use hashi::cli::upgrade::extract_proposal_id_from_response; +use hashi::config::HashiIds; +use hashi::sui_tx_executor::SuiTxExecutor; +use std::path::Path; +use std::path::PathBuf; +use sui_sdk_types::Address; +use sui_sdk_types::Identifier; +use sui_sdk_types::StructTag; +use sui_sdk_types::TypeTag; + +use crate::TestNetworks; +use crate::sui_network::sui_binary; + +/// Prepare an upgrade package by copying the deployed source and patching it. +/// +/// 1. Copies `/packages/hashi` to `/packages/hashi-upgrade` +/// 2. Bumps `PACKAGE_VERSION` from 1 to 2 in `config.move` +/// 3. Sets `published-at` in `Move.toml` to the original package ID +/// +/// Returns the path to the patched package directory. +pub fn prepare_upgrade_package(test_dir: &Path, original_package_id: Address) -> Result { + let src = test_dir.join("packages/hashi"); + let dst = test_dir.join("packages/hashi-upgrade"); + + anyhow::ensure!( + src.exists(), + "source package not found at {}", + src.display() + ); + + // Copy the package + let output = std::process::Command::new("cp") + .args(["-r", &src.to_string_lossy(), &dst.to_string_lossy()]) + .output()?; + anyhow::ensure!( + output.status.success(), + "failed to copy package: {}", + String::from_utf8_lossy(&output.stderr) + ); + + // Patch config.move: bump PACKAGE_VERSION from 1 to 2 + let config_path = dst.join("sources/core/config/config.move"); + let config_src = std::fs::read_to_string(&config_path)?; + let patched = config_src.replace( + "const PACKAGE_VERSION: u64 = 1;", + "const PACKAGE_VERSION: u64 = 2;", + ); + anyhow::ensure!( + patched != config_src, + "PACKAGE_VERSION replacement failed — pattern not found in config.move" + ); + std::fs::write(&config_path, patched)?; + + // Patch Move.toml: add published-at + let move_toml_path = dst.join("Move.toml"); + let move_toml = std::fs::read_to_string(&move_toml_path)?; + let patched_toml = move_toml.replace( + "[package]", + &format!("[package]\npublished-at = \"{}\"", original_package_id), + ); + std::fs::write(&move_toml_path, patched_toml)?; + + // Add a trivial v2-only module to prove new code is callable post-upgrade + let test_module_path = dst.join("sources/upgrade_canary.move"); + std::fs::write( + &test_module_path, + "module hashi::upgrade_canary;\n\npublic fun version(): u64 { 2 }\n", + )?; + + // Clean build artifacts from the copy + let _ = std::fs::remove_dir_all(dst.join("build")); + + tracing::info!( + "upgrade package prepared at {} (published-at = {})", + dst.display(), + original_package_id + ); + + Ok(dst) +} + +/// Run the full upgrade lifecycle: prepare → build → propose → vote → execute+publish+finalize. +/// +/// Returns the new package ID on success. +pub async fn execute_full_upgrade(networks: &mut TestNetworks) -> Result
{ + let nodes = networks.hashi_network.nodes(); + let hashi_ids = networks.hashi_network.ids(); + + let mut executors: Vec = nodes + .iter() + .map(|node| { + let hashi = node.hashi(); + SuiTxExecutor::from_config(&hashi.config, hashi.onchain_state()) + }) + .collect::>()?; + + // 1. Prepare the upgrade package (copy + patch) + let test_dir = networks.dir(); + let upgrade_path = prepare_upgrade_package(test_dir, hashi_ids.package_id)?; + + let client_config_path = test_dir.join("sui/client.yaml"); + let client_config = client_config_path + .exists() + .then_some(client_config_path.as_path()); + + // 2. Build the upgrade + tracing::info!("building upgrade package from {}", upgrade_path.display()); + let (compiled, digest) = build_upgrade_package(sui_binary(), &upgrade_path, client_config)?; + tracing::info!("upgrade package built, digest: {digest:?}"); + + // 3. Propose the upgrade + tracing::info!("proposing upgrade..."); + let create_tx = build_create_proposal_transaction( + hashi_ids, + CreateProposalParams::Upgrade { + digest: digest.clone(), + metadata: vec![("reason".to_string(), "upgrade test".to_string())], + }, + ); + let response = executors[0].execute(create_tx).await?; + anyhow::ensure!( + response.transaction().effects().status().success(), + "create Upgrade proposal failed" + ); + + let proposal_id = extract_proposal_id_from_response(&response)?; + tracing::info!("upgrade proposal {proposal_id} created"); + + // 4. All other nodes vote (upgrade requires 100% quorum) + let upgrade_type_tag = TypeTag::Struct(Box::new(StructTag::new( + hashi_ids.package_id, + Identifier::from_static("upgrade"), + Identifier::from_static("Upgrade"), + vec![], + ))); + + for executor in &mut executors[1..] { + let vote_tx = build_vote_transaction(hashi_ids, proposal_id, upgrade_type_tag.clone()); + let vote_resp = executor.execute(vote_tx).await?; + anyhow::ensure!( + vote_resp.transaction().effects().status().success(), + "vote on Upgrade proposal failed" + ); + } + tracing::info!("all nodes voted on upgrade proposal"); + + // 5. Execute upgrade + publish + finalize in one PTB + tracing::info!("executing upgrade (execute + publish + finalize in one PTB)..."); + let upgrade_tx = build_upgrade_execution_transaction(hashi_ids, proposal_id, compiled); + let upgrade_resp = executors[0].execute(upgrade_tx).await?; + anyhow::ensure!( + upgrade_resp.transaction().effects().status().success(), + "upgrade execute+publish+finalize failed: {:?}", + upgrade_resp.transaction().effects().status() + ); + + let new_package_id = extract_new_package_id_from_response(&upgrade_resp)?; + tracing::info!("upgrade complete! new package: {new_package_id}"); + Ok(new_package_id) +} + +/// Propose + vote + execute a DisableVersion governance action. +/// +/// `execute_package_id` is the package whose `disable_version::execute` is called. +/// When disabling an old version after upgrade, this must be the NEW package ID +/// (whose `PACKAGE_VERSION` differs from the version being disabled). +pub async fn disable_version( + executors: &mut [SuiTxExecutor], + hashi_ids: HashiIds, + version: u64, + execute_package_id: Address, +) -> Result<()> { + let create_tx = build_create_proposal_transaction( + hashi_ids, + CreateProposalParams::DisableVersion { + version, + metadata: vec![], + }, + ); + let response = executors[0].execute(create_tx).await?; + anyhow::ensure!( + response.transaction().effects().status().success(), + "create DisableVersion proposal failed" + ); + + let proposal_id = extract_proposal_id_from_response(&response)?; + + let disable_version_type = TypeTag::Struct(Box::new(StructTag::new( + hashi_ids.package_id, + Identifier::from_static("disable_version"), + Identifier::from_static("DisableVersion"), + vec![], + ))); + + for executor in &mut executors[1..] { + let vote_tx = build_vote_transaction(hashi_ids, proposal_id, disable_version_type.clone()); + let vote_resp = executor.execute(vote_tx).await?; + anyhow::ensure!( + vote_resp.transaction().effects().status().success(), + "vote on DisableVersion proposal failed" + ); + } + + let execute_tx = build_execute_proposal_transaction( + hashi_ids, + proposal_id, + execute_package_id, + "disable_version", + )?; + let exec_resp = executors[0].execute(execute_tx).await?; + anyhow::ensure!( + exec_resp.transaction().effects().status().success(), + "execute DisableVersion proposal failed" + ); + + tracing::info!("version {version} disabled"); + Ok(()) +} diff --git a/crates/e2e-tests/tests/upgrade_tests.rs b/crates/e2e-tests/tests/upgrade_tests.rs new file mode 100644 index 000000000..a2126f588 --- /dev/null +++ b/crates/e2e-tests/tests/upgrade_tests.rs @@ -0,0 +1,221 @@ +// Copyright (c) Mysten Labs, Inc. +// SPDX-License-Identifier: Apache-2.0 + +//! End-to-end test for the package upgrade lifecycle. +//! +//! Exercises real cascading effects of upgrading the hashi package: +//! - Rust watcher picks up the new package version via PackageUpgradedEvent +//! - Validators auto-confirm deposits against the upgraded package +//! - Package ID routing updates correctly in OnchainState + +use anyhow::Result; +use e2e_tests::TestNetworksBuilder; +use e2e_tests::test_helpers::create_deposit_and_wait; +use e2e_tests::test_helpers::get_hbtc_balance; +use e2e_tests::test_helpers::init_test_logging; +use e2e_tests::upgrade_flow; +use hashi::sui_tx_executor::SuiTxExecutor; +use std::time::Duration; +use sui_sdk_types::Address; +use sui_sdk_types::Identifier; +use sui_transaction_builder::Function; +use sui_transaction_builder::ObjectInput; +use sui_transaction_builder::TransactionBuilder; +use tracing::info; + +/// Test the full upgrade lifecycle, exercising real cascading effects. +/// +/// 1. Watcher picks up new package — PackageUpgradedEvent updates OnchainState +/// 2. Validators confirm deposits post-upgrade — leader routes calls correctly +/// 3. Package ID routing — OnchainState.package_id() returns the new package +#[tokio::test] +async fn test_upgrade_v1_to_v2() -> Result<()> { + init_test_logging(); + let mut networks = TestNetworksBuilder::new().with_nodes(4).build().await?; + + let hashi_ids = networks.hashi_network.ids(); + info!("original package ID: {}", hashi_ids.package_id); + + networks.hashi_network.nodes()[0] + .wait_for_mpc_key(Duration::from_secs(120)) + .await?; + + // ── Pre-upgrade: deposit to establish state ───────────────────────── + info!("depositing 100k sats before upgrade..."); + let hbtc_recipient = create_deposit_and_wait(&mut networks, 100_000).await?; + let balance_before = get_hbtc_balance( + &mut networks.sui_network.client, + hashi_ids.package_id, + hbtc_recipient, + ) + .await?; + assert_eq!(balance_before, 100_000); + info!("pre-upgrade balance: {balance_before} sats"); + + // ── Upgrade ───────────────────────────────────────────────────────── + let new_package_id = upgrade_flow::execute_full_upgrade(&mut networks).await?; + info!("upgraded to v2: {new_package_id}"); + assert_ne!(new_package_id, hashi_ids.package_id); + + // ── Cascading effect 1: Watcher picks up new package ──────────────── + // + // The PackageUpgradedEvent handler in watcher.rs should update + // OnchainState's package_versions map. Poll until all nodes see the + // new package — this proves the watcher correctly processes the event. + info!("waiting for all nodes to detect the new package version..."); + let wait_start = std::time::Instant::now(); + let max_wait = Duration::from_secs(30); + loop { + let all_updated = networks + .hashi_network + .nodes() + .iter() + .all(|node| node.hashi().onchain_state().package_id() == Some(new_package_id)); + if all_updated { + break; + } + if wait_start.elapsed() > max_wait { + // Print diagnostic info before failing + for (i, node) in networks.hashi_network.nodes().iter().enumerate() { + let latest = node.hashi().onchain_state().package_id(); + let versions = node + .hashi() + .onchain_state() + .state() + .package_versions() + .clone(); + info!("node {i}: package_id={latest:?}, versions={versions:?}"); + } + anyhow::bail!("timeout: not all nodes detected the new package version"); + } + tokio::time::sleep(Duration::from_millis(500)).await; + } + + // ── Cascading effect 2: Package ID routing ────────────────────────── + // + // Verify all nodes have the correct version map. + for (i, node) in networks.hashi_network.nodes().iter().enumerate() { + let versions = node + .hashi() + .onchain_state() + .state() + .package_versions() + .clone(); + assert!( + versions.len() >= 2, + "node {i}: should have at least 2 package versions, got {}", + versions.len() + ); + info!("node {i}: package_versions = {versions:?}"); + } + info!("all nodes correctly track the new package version"); + + // ── Cascading effect 3: Validator deposit confirmation post-upgrade ── + // + // This is the real test: deposit BTC, submit a deposit request, and + // wait for the validators to auto-confirm it. The leader must: + // - Observe the DepositRequestedEvent + // - Build a BLS certificate + // - Call confirm_deposit on the correct (upgraded) package + // + // If the watcher or leader has stale package routing, this will fail. + info!("depositing 50k sats post-upgrade (full validator confirmation path)..."); + create_deposit_and_wait(&mut networks, 50_000).await?; + let balance_after = get_hbtc_balance( + &mut networks.sui_network.client, + hashi_ids.package_id, + hbtc_recipient, + ) + .await?; + assert_eq!( + balance_after, 150_000, + "post-upgrade deposit should be confirmed by validators" + ); + info!("post-upgrade deposit confirmed by validators, balance: {balance_after}"); + + // ── Bonus: v2-only canary module callable ─────────────────────────── + info!("calling v2-only upgrade_canary::version()..."); + let user_key = networks.sui_network.user_keys.first().unwrap(); + let hashi = networks.hashi_network.nodes()[0].hashi().clone(); + let mut executor = SuiTxExecutor::from_config(&hashi.config, hashi.onchain_state())? + .with_signer(user_key.clone()); + + let mut builder = TransactionBuilder::new(); + builder.move_call( + Function::new( + new_package_id, + Identifier::from_static("upgrade_canary"), + Identifier::from_static("version"), + ), + vec![], + ); + let canary_resp = executor.execute(builder).await?; + assert!( + canary_resp.transaction().effects().status().success(), + "v2-only canary module should be callable" + ); + info!("v2 canary module call succeeded"); + + // ── Disable v1, verify rejection ──────────────────────────────────── + let mut executors: Vec = networks + .hashi_network + .nodes() + .iter() + .map(|node| SuiTxExecutor::from_config(&node.hashi().config, node.hashi().onchain_state())) + .collect::>()?; + + upgrade_flow::disable_version(&mut executors, hashi_ids, 1, new_package_id).await?; + info!("version 1 disabled"); + + let mut builder = TransactionBuilder::new(); + let hashi_arg = builder.object( + ObjectInput::new(hashi_ids.hashi_object_id) + .as_shared() + .with_mutable(true), + ); + let txid_arg = builder.pure(&Address::ZERO); + let vout_arg = builder.pure(&0u32); + let utxo_id = builder.move_call( + Function::new( + hashi_ids.package_id, + Identifier::from_static("utxo"), + Identifier::from_static("utxo_id"), + ), + vec![txid_arg, vout_arg], + ); + let amount_arg = builder.pure(&50_000u64); + let derivation_arg = builder.pure(&Option::
::None); + let utxo = builder.move_call( + Function::new( + hashi_ids.package_id, + Identifier::from_static("utxo"), + Identifier::from_static("utxo"), + ), + vec![utxo_id, amount_arg, derivation_arg], + ); + let clock_arg = builder.object( + ObjectInput::new(hashi::sui_tx_executor::SUI_CLOCK_OBJECT_ID) + .as_shared() + .with_mutable(false), + ); + builder.move_call( + Function::new( + hashi_ids.package_id, + Identifier::from_static("deposit"), + Identifier::from_static("deposit"), + ), + vec![hashi_arg, utxo, clock_arg], + ); + + let v1_result = executors[0].execute(builder).await; + assert!(v1_result.is_err(), "v1 should be rejected after disable"); + let err_msg = v1_result.unwrap_err().to_string(); + assert!( + err_msg.contains("EVersionDisabled") || err_msg.contains("assert_version_enabled"), + "expected EVersionDisabled, got: {err_msg}" + ); + info!("v1 entry point correctly rejected"); + + info!("=== UPGRADE TEST PASSED ==="); + Ok(()) +} diff --git a/crates/hashi-types/src/move_types/mod.rs b/crates/hashi-types/src/move_types/mod.rs index b8795096b..8ebb0487b 100644 --- a/crates/hashi-types/src/move_types/mod.rs +++ b/crates/hashi-types/src/move_types/mod.rs @@ -596,6 +596,9 @@ impl HashiEvent { StartReconfigEvent::MODULE_NAME => StartReconfigEvent::from_bcs(bcs.value())?.into(), EndReconfigEvent::MODULE_NAME => EndReconfigEvent::from_bcs(bcs.value())?.into(), AbortReconfigEvent::MODULE_NAME => AbortReconfigEvent::from_bcs(bcs.value())?.into(), + PackageUpgradedEvent::MODULE_NAME => { + PackageUpgradedEvent::from_bcs(bcs.value())?.into() + } _ => { return Ok(None); } diff --git a/crates/hashi/src/cli/client.rs b/crates/hashi/src/cli/client.rs index fdeab1a03..4e7b65977 100644 --- a/crates/hashi/src/cli/client.rs +++ b/crates/hashi/src/cli/client.rs @@ -222,31 +222,7 @@ impl HashiClient { proposal_id: Address, type_arg: TypeTag, ) -> TransactionBuilder { - let mut builder = TransactionBuilder::new(); - - let hashi_arg = builder.object( - ObjectInput::new(self.hashi_ids.hashi_object_id) - .as_shared() - .with_mutable(true), - ); - let proposal_id_arg = builder.pure(&proposal_id); - let clock_arg = builder.object( - ObjectInput::new(SUI_CLOCK_OBJECT_ID) - .as_shared() - .with_mutable(false), - ); - - builder.move_call( - Function::new( - self.hashi_ids.package_id, - Identifier::from_static("proposal"), - Identifier::from_static("vote"), - ) - .with_type_args(vec![type_arg]), - vec![hashi_arg, proposal_id_arg, clock_arg], - ); - - builder + build_vote_transaction(self.hashi_ids, proposal_id, type_arg) } /// Build a remove_vote transaction for a proposal. @@ -494,18 +470,14 @@ fn build_metadata( map } -/// Build a `TransactionBuilder` for voting on an `UpdateConfig` proposal. -/// -/// Calls: `proposal::vote(hashi, proposal_id, clock, ctx)` -/// -/// This is a standalone function so it can be reused outside `HashiClient` -/// (e.g., in test infrastructure where a `HashiClient` is not available). -pub fn build_vote_update_config_transaction( +/// Build a `proposal::vote` transaction as a standalone. Reusable outside +/// `HashiClient` — e2e test infra needs to build vote PTBs for every +/// committee member. +pub fn build_vote_transaction( hashi_ids: HashiIds, proposal_id: Address, + type_arg: TypeTag, ) -> TransactionBuilder { - let type_arg = update_config_type_tag(hashi_ids.package_id); - let mut builder = TransactionBuilder::new(); let hashi_arg = builder.object( ObjectInput::new(hashi_ids.hashi_object_id) @@ -532,52 +504,6 @@ pub fn build_vote_update_config_transaction( builder } -/// Build a `TransactionBuilder` for executing an `UpdateConfig` proposal once -/// quorum has been reached. -/// -/// Calls: `update_config::execute(hashi, proposal_id, clock)` -/// -/// This is a standalone function so it can be reused outside `HashiClient` -/// (e.g., in test infrastructure where a `HashiClient` is not available). -pub fn build_execute_update_config_transaction( - hashi_ids: HashiIds, - proposal_id: Address, -) -> TransactionBuilder { - let mut builder = TransactionBuilder::new(); - let hashi_arg = builder.object( - ObjectInput::new(hashi_ids.hashi_object_id) - .as_shared() - .with_mutable(true), - ); - let proposal_id_arg = builder.pure(&proposal_id); - let clock_arg = builder.object( - ObjectInput::new(SUI_CLOCK_OBJECT_ID) - .as_shared() - .with_mutable(false), - ); - - builder.move_call( - Function::new( - hashi_ids.package_id, - Identifier::from_static("update_config"), - Identifier::from_static("execute"), - ), - vec![hashi_arg, proposal_id_arg, clock_arg], - ); - - builder -} - -/// Returns the `TypeTag` for the `UpdateConfig` proposal type. -fn update_config_type_tag(package_id: Address) -> TypeTag { - TypeTag::Struct(Box::new(StructTag::new( - package_id, - Identifier::from_static("update_config"), - Identifier::from_static("UpdateConfig"), - vec![], - ))) -} - /// Get the TypeTag for a proposal type (from on-chain type) /// /// Returns an error if the proposal type is `Unknown`. diff --git a/crates/hashi/src/cli/mod.rs b/crates/hashi/src/cli/mod.rs index 92259cf5b..95ede5ba9 100644 --- a/crates/hashi/src/cli/mod.rs +++ b/crates/hashi/src/cli/mod.rs @@ -17,6 +17,7 @@ pub mod client; pub mod commands; pub mod config; pub mod types; +pub mod upgrade; pub const STYLES: Styles = Styles::styled() .header(AnsiColor::Yellow.on_default().effects(Effects::BOLD)) diff --git a/crates/hashi/src/cli/upgrade.rs b/crates/hashi/src/cli/upgrade.rs new file mode 100644 index 000000000..6e234e6a4 --- /dev/null +++ b/crates/hashi/src/cli/upgrade.rs @@ -0,0 +1,236 @@ +// Copyright (c) Mysten Labs, Inc. +// SPDX-License-Identifier: Apache-2.0 + +//! Reusable helpers for the package-upgrade governance flow. +//! +//! Covers the non-orchestrating pieces that both the CLI (`hashi proposal +//! create upgrade` + `proposal execute`) and the e2e test harness need: +//! +//! - Building an upgrade package via `sui move build` +//! - Constructing the `execute + publish + finalize` PTB +//! - Constructing the generic `execute` PTB for non-upgrade proposals +//! - Parsing the proposal ID out of a `ProposalCreatedEvent` in tx effects +//! - Finding the new package ID in the effects of an upgrade transaction +//! +//! Orchestration (collecting votes from committee members, driving the full +//! propose → vote → execute lifecycle end to end) still lives in the caller — +//! the e2e harness has all four validator keys so it can drive it +//! programmatically, while the CLI only has one operator key and drives it +//! one step at a time. + +use crate::config::HashiIds; +use crate::sui_tx_executor::SUI_CLOCK_OBJECT_ID; +use anyhow::Result; +use anyhow::anyhow; +use std::path::Path; +use sui_rpc::proto::sui::rpc::v2::ExecuteTransactionResponse; +use sui_sdk_types::Address; +use sui_sdk_types::Identifier; +use sui_sdk_types::Publish; +use sui_transaction_builder::Function; +use sui_transaction_builder::ObjectInput; +use sui_transaction_builder::TransactionBuilder; + +/// Build an upgrade package by invoking `sui move build --dump-bytecode-as-base64` +/// and parsing the resulting JSON. +/// +/// `sui_binary` is the path (or PATH-resolvable name) of the `sui` executable +/// to shell out to. `package_path` is the directory containing `Move.toml`. +/// `client_config` is passed as `--client.config` when supplied, otherwise the +/// `sui` binary's default client config is used. +/// +/// Returns the compiled `Publish` (modules + dependencies) plus the package +/// digest — the latter is what goes into the `Upgrade` proposal. +pub fn build_upgrade_package( + sui_binary: &Path, + package_path: &Path, + client_config: Option<&Path>, +) -> Result<(Publish, Vec)> { + let mut cmd = std::process::Command::new(sui_binary); + cmd.arg("move"); + + if let Some(config) = client_config { + cmd.arg("--client.config").arg(config); + } + + cmd.arg("-p") + .arg(package_path) + .arg("build") + .arg("-e") + .arg("testnet") + .arg("--dump-bytecode-as-base64"); + + let output = cmd.output()?; + anyhow::ensure!( + output.status.success(), + "sui move build failed:\nstdout: {}\nstderr: {}", + output.stdout.escape_ascii(), + output.stderr.escape_ascii() + ); + + #[derive(serde::Deserialize)] + struct MoveBuildOutput { + modules: Vec, + dependencies: Vec
, + digest: Vec, + } + + let build_output: MoveBuildOutput = serde_json::from_slice(&output.stdout)?; + let digest = build_output.digest.clone(); + let modules = build_output + .modules + .into_iter() + .map(|b64| ::decode_vec(&b64)) + .collect::, _>>()?; + + Ok(( + Publish { + modules, + dependencies: build_output.dependencies, + }, + digest, + )) +} + +/// Build the PTB that executes an `Upgrade` proposal in a single transaction: +/// `upgrade::execute` → `builder.upgrade(...)` → `upgrade::finalize_upgrade`. +/// +/// The three steps must be in one PTB so the `UpgradeTicket` and +/// `UpgradeReceipt` hot potatoes can be consumed without leaving the +/// transaction. +pub fn build_upgrade_execution_transaction( + hashi_ids: HashiIds, + proposal_id: Address, + compiled: Publish, +) -> TransactionBuilder { + let mut builder = TransactionBuilder::new(); + let hashi_arg = builder.object( + ObjectInput::new(hashi_ids.hashi_object_id) + .as_shared() + .with_mutable(true), + ); + let proposal_id_arg = builder.pure(&proposal_id); + let clock_arg = builder.object( + ObjectInput::new(SUI_CLOCK_OBJECT_ID) + .as_shared() + .with_mutable(false), + ); + + // Step A: upgrade::execute → UpgradeTicket + let ticket = builder.move_call( + Function::new( + hashi_ids.package_id, + Identifier::from_static("upgrade"), + Identifier::from_static("execute"), + ), + vec![hashi_arg, proposal_id_arg, clock_arg], + ); + + // Step B: publish upgrade → UpgradeReceipt + let receipt = builder.upgrade( + compiled.modules, + compiled.dependencies, + hashi_ids.package_id, + ticket, + ); + + // Step C: finalize_upgrade — takes the receipt and swaps the package in-place. + // Needs a second mutable reference to the hashi object since the first one + // was consumed by `upgrade::execute`. + let hashi_arg2 = builder.object( + ObjectInput::new(hashi_ids.hashi_object_id) + .as_shared() + .with_mutable(true), + ); + builder.move_call( + Function::new( + hashi_ids.package_id, + Identifier::from_static("upgrade"), + Identifier::from_static("finalize_upgrade"), + ), + vec![hashi_arg2, receipt], + ); + + builder +} + +/// Build the PTB that executes a non-upgrade proposal (UpdateConfig, +/// EnableVersion, DisableVersion, EmergencyPause). +/// +/// Calls `::::execute(hashi, proposal_id, clock)`. +/// +/// `execute_package_id` is almost always `hashi_ids.package_id`, but may +/// differ when disabling an old version after an upgrade: the `execute` call +/// has to go through the NEW package (whose `PACKAGE_VERSION` differs from +/// the version being disabled), not through the stored original +/// `hashi_ids.package_id`. +pub fn build_execute_proposal_transaction( + hashi_ids: HashiIds, + proposal_id: Address, + execute_package_id: Address, + proposal_module: &str, +) -> Result { + let module = Identifier::new(proposal_module) + .map_err(|e| anyhow!("invalid proposal module {proposal_module:?}: {e}"))?; + + let mut builder = TransactionBuilder::new(); + let hashi_arg = builder.object( + ObjectInput::new(hashi_ids.hashi_object_id) + .as_shared() + .with_mutable(true), + ); + let proposal_id_arg = builder.pure(&proposal_id); + let clock_arg = builder.object( + ObjectInput::new(SUI_CLOCK_OBJECT_ID) + .as_shared() + .with_mutable(false), + ); + + builder.move_call( + Function::new( + execute_package_id, + module, + Identifier::from_static("execute"), + ), + vec![hashi_arg, proposal_id_arg, clock_arg], + ); + + Ok(builder) +} + +/// Extract the newly-created proposal's object ID from a transaction that +/// called `::propose(...)`. Looks for a single `ProposalCreatedEvent` +/// in the transaction's emitted events. +/// +/// The BCS payload of the event is `(proposal_id, timestamp_ms)`; we only +/// return the proposal ID here. +pub fn extract_proposal_id_from_response(response: &ExecuteTransactionResponse) -> Result
{ + let event = response + .transaction() + .events() + .events() + .iter() + .find(|e| e.contents().name().contains("ProposalCreatedEvent")) + .ok_or_else(|| anyhow!("ProposalCreatedEvent not found in transaction effects"))?; + + let (id, _ts): (Address, u64) = bcs::from_bytes(event.contents().value()) + .map_err(|e| anyhow!("failed to deserialize ProposalCreatedEvent payload: {e}"))?; + Ok(id) +} + +/// Extract the new package ID from the effects of a successful upgrade +/// transaction. The upgrade PTB creates exactly one `package` changed object. +pub fn extract_new_package_id_from_response( + response: &ExecuteTransactionResponse, +) -> Result
{ + response + .transaction() + .effects() + .changed_objects() + .iter() + .find(|o| o.object_type() == "package") + .ok_or_else(|| anyhow!("new package not found in upgrade effects"))? + .object_id() + .parse::
() + .map_err(|e| anyhow!("failed to parse new package ID: {e}")) +}