diff --git a/crates/e2e-tests/src/e2e_flow.rs b/crates/e2e-tests/src/e2e_flow.rs index bcbf629cf..7d36aaf55 100644 --- a/crates/e2e-tests/src/e2e_flow.rs +++ b/crates/e2e-tests/src/e2e_flow.rs @@ -10,6 +10,7 @@ mod tests { use futures::StreamExt; use hashi::sui_tx_executor::SuiTxExecutor; + use hashi_types::move_types::WithdrawalApprovedEvent; use hashi_types::move_types::WithdrawalConfirmedEvent; use hashi_types::move_types::WithdrawalPickedForProcessingEvent; use std::time::Duration; @@ -1555,4 +1556,417 @@ mod tests { info!("=== Large Withdrawal Signature Chunking Test Passed ==="); Ok(()) } + + /// Test cancelling a withdrawal request before it gets approved. + /// + /// Strategy: shut down 2 of 4 nodes after creating the request so the + /// committee cannot reach quorum for approval. The request stays in + /// `Requested` state and can be cancelled. + #[tokio::test] + #[ignore = "requires localnet (run with --ignored)"] + async fn test_cancel_withdrawal_before_approval() -> Result<()> { + init_test_logging(); + info!("=== Starting Cancel Withdrawal (Before Approval) Test ==="); + + let mut networks = setup_test_networks().await?; + + // Deposit to get hBTC + let deposit_amount_sats = 100_000u64; + let hbtc_recipient = create_deposit_and_wait(&mut networks, deposit_amount_sats).await?; + + let balance_before = get_hbtc_balance( + &mut networks.sui_network.client, + networks.hashi_network.ids().package_id, + hbtc_recipient, + ) + .await?; + assert_eq!(balance_before, deposit_amount_sats); + info!("hBTC balance before withdrawal: {balance_before}"); + + // Shut down 2 of 4 nodes to prevent quorum + info!("Shutting down nodes 2 and 3 to prevent approval quorum..."); + networks.hashi_network.nodes_mut()[2].shutdown().await; + networks.hashi_network.nodes_mut()[3].shutdown().await; + + // Create withdrawal request — will stay in Requested state (no quorum) + let hashi = networks.hashi_network.nodes()[0].hashi().clone(); + let user_key = networks.sui_network.user_keys.first().unwrap(); + let withdrawal_amount_sats = 30_000u64; + let btc_destination = networks.bitcoin_node.get_new_address()?; + let destination_bytes = extract_witness_program(&btc_destination)?; + + let mut executor = SuiTxExecutor::from_config(&hashi.config, hashi.onchain_state())? + .with_signer(user_key.clone()); + let request_id = executor + .execute_create_withdrawal_request(withdrawal_amount_sats, destination_bytes) + .await?; + info!("Withdrawal request created: {request_id}"); + + // Small delay to ensure the request is visible on-chain + tokio::time::sleep(Duration::from_secs(2)).await; + + // Cancel the request + info!("Cancelling withdrawal request..."); + executor.execute_cancel_withdrawal(&request_id).await?; + info!("Withdrawal cancelled successfully"); + + // Verify hBTC balance is fully restored + let balance_after = get_hbtc_balance( + &mut networks.sui_network.client, + networks.hashi_network.ids().package_id, + hbtc_recipient, + ) + .await?; + assert_eq!( + balance_after, deposit_amount_sats, + "hBTC balance should be fully restored after cancellation" + ); + info!("hBTC balance restored: {balance_after}"); + + info!("=== Cancel Withdrawal (Before Approval) Test Passed ==="); + Ok(()) + } + + /// Wait for a `WithdrawalApprovedEvent` for a specific request ID. + async fn wait_for_withdrawal_approved( + sui_client: &mut sui_rpc::Client, + request_id: Address, + timeout: Duration, + ) -> Result<()> { + info!("Waiting for withdrawal approval for request: {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 withdrawal approval 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("WithdrawalApprovedEvent") + && let Ok(evt) = WithdrawalApprovedEvent::from_bcs(event.contents().value()) + && evt.request_id == request_id + { + info!("Withdrawal approved: {request_id}"); + return Ok(()); + } + } + } + } + + Err(anyhow!("Checkpoint subscription ended unexpectedly")) + } + + /// Test cancelling a withdrawal request after it has been approved but + /// before the committee commits it to a WithdrawalTransaction. + /// + /// Strategy: use a very large `withdrawal_batching_delay_ms` so the leader + /// approves the request but waits before committing. The request stays in + /// `Approved` state and can be cancelled. + #[tokio::test] + #[ignore = "requires localnet (run with --ignored)"] + async fn test_cancel_approved_withdrawal() -> Result<()> { + init_test_logging(); + info!("=== Starting Cancel Approved Withdrawal Test ==="); + + // Use a 1-hour batching delay so approved requests are not committed + let mut networks = TestNetworksBuilder::new() + .with_nodes(4) + .with_withdrawal_batching_delay_ms(3_600_000) // 1 hour + .build() + .await?; + + networks.hashi_network.nodes()[0] + .wait_for_mpc_key(Duration::from_secs(120)) + .await?; + + // Deposit to get hBTC + let deposit_amount_sats = 100_000u64; + let hbtc_recipient = create_deposit_and_wait(&mut networks, deposit_amount_sats).await?; + + let balance_before = get_hbtc_balance( + &mut networks.sui_network.client, + networks.hashi_network.ids().package_id, + hbtc_recipient, + ) + .await?; + assert_eq!(balance_before, deposit_amount_sats); + info!("hBTC balance before withdrawal: {balance_before}"); + + // Create withdrawal request — will be auto-approved by committee + let hashi = networks.hashi_network.nodes()[0].hashi().clone(); + let user_key = networks.sui_network.user_keys.first().unwrap(); + let withdrawal_amount_sats = 30_000u64; + let btc_destination = networks.bitcoin_node.get_new_address()?; + let destination_bytes = extract_witness_program(&btc_destination)?; + + let mut executor = SuiTxExecutor::from_config(&hashi.config, hashi.onchain_state())? + .with_signer(user_key.clone()); + let request_id = executor + .execute_create_withdrawal_request(withdrawal_amount_sats, destination_bytes) + .await?; + info!("Withdrawal request created: {request_id}"); + + // Wait for the committee to approve the request + wait_for_withdrawal_approved( + &mut networks.sui_network.client, + request_id, + Duration::from_secs(60), + ) + .await?; + info!("Request is now in Approved state"); + + // Cancel the approved request — this is the new behavior we're testing + info!("Cancelling approved withdrawal request..."); + executor.execute_cancel_withdrawal(&request_id).await?; + info!("Approved withdrawal cancelled successfully"); + + // Verify hBTC balance is fully restored + let balance_after = get_hbtc_balance( + &mut networks.sui_network.client, + networks.hashi_network.ids().package_id, + hbtc_recipient, + ) + .await?; + assert_eq!( + balance_after, deposit_amount_sats, + "hBTC balance should be fully restored after cancelling approved withdrawal" + ); + info!("hBTC balance restored: {balance_after}"); + + info!("=== Cancel Approved Withdrawal Test Passed ==="); + Ok(()) + } + + /// Race test: cancel lands before `commit_withdrawal_tx`. + /// + /// Stages a request in `Approved` state (leader held off by a 1-hour + /// batching delay), builds the commit PTB directly via the shared + /// `build_and_sign_withdrawal_commitment` helper WITHOUT broadcasting, + /// then broadcasts the cancellation first. The subsequent commit must + /// fail because the request is gone from the `requests` bag, and the + /// user's hBTC balance must be fully restored. + #[tokio::test] + #[ignore = "requires localnet (run with --ignored)"] + async fn test_cancel_race_cancel_wins() -> Result<()> { + init_test_logging(); + info!("=== Starting Cancel-Race (Cancel Wins) Test ==="); + + let mut networks = TestNetworksBuilder::new() + .with_nodes(4) + .with_withdrawal_batching_delay_ms(3_600_000) // 1 hour — blocks commit + .build() + .await?; + + networks.hashi_network.nodes()[0] + .wait_for_mpc_key(Duration::from_secs(120)) + .await?; + + let deposit_amount_sats = 100_000u64; + let hbtc_recipient = create_deposit_and_wait(&mut networks, deposit_amount_sats).await?; + + let balance_before = get_hbtc_balance( + &mut networks.sui_network.client, + networks.hashi_network.ids().package_id, + hbtc_recipient, + ) + .await?; + assert_eq!(balance_before, deposit_amount_sats); + + let hashi = networks.hashi_network.nodes()[0].hashi().clone(); + let user_key = networks.sui_network.user_keys.first().unwrap().clone(); + let withdrawal_amount_sats = 30_000u64; + let btc_destination = networks.bitcoin_node.get_new_address()?; + let destination_bytes = extract_witness_program(&btc_destination)?; + + let mut user_executor = + SuiTxExecutor::from_config(&hashi.config, hashi.onchain_state())?.with_signer(user_key); + let request_id = user_executor + .execute_create_withdrawal_request(withdrawal_amount_sats, destination_bytes) + .await?; + info!("Withdrawal request created: {request_id}"); + + wait_for_withdrawal_approved( + &mut networks.sui_network.client, + request_id, + Duration::from_secs(60), + ) + .await?; + info!("Request is in Approved state"); + + // Build the commit PTB directly without broadcasting it. + let request = hashi + .onchain_state() + .withdrawal_request(&request_id) + .ok_or_else(|| anyhow!("approved request disappeared from state"))?; + let (approval, cert) = hashi + .build_and_sign_withdrawal_commitment(&[request]) + .await?; + info!( + "Built commit PTB with txid {:?}; NOT broadcasting yet", + approval.txid + ); + + // Cancel first — this must win. + user_executor.execute_cancel_withdrawal(&request_id).await?; + info!("Cancellation executed"); + + // Now try to broadcast the commit — it must fail because the request + // is gone from the `requests` bag. + let mut leader_executor = SuiTxExecutor::from_hashi(hashi.clone())?; + let commit_result = leader_executor + .execute_commit_withdrawal_tx(&approval, &cert) + .await; + assert!( + commit_result.is_err(), + "commit_withdrawal_tx must fail after the request was cancelled; got Ok" + ); + info!( + "commit_withdrawal_tx correctly failed: {:?}", + commit_result.err() + ); + + // hBTC must be fully restored. + let balance_after = get_hbtc_balance( + &mut networks.sui_network.client, + networks.hashi_network.ids().package_id, + hbtc_recipient, + ) + .await?; + assert_eq!( + balance_after, deposit_amount_sats, + "hBTC balance should be fully restored after cancel-wins race" + ); + + info!("=== Cancel-Race (Cancel Wins) Test Passed ==="); + Ok(()) + } + + /// Race test: `commit_withdrawal_tx` lands before cancel. + /// + /// Stages a request in `Approved` state, directly broadcasts the commit + /// (bypassing the 1-hour batching delay that would otherwise hold the + /// leader off), waits for the `WithdrawalPickedForProcessingEvent`, then + /// attempts to cancel and asserts it fails with + /// `ECannotCancelProcessingWithdrawal`. hBTC must NOT be restored. + #[tokio::test] + #[ignore = "requires localnet (run with --ignored)"] + async fn test_cancel_race_commit_wins() -> Result<()> { + init_test_logging(); + info!("=== Starting Cancel-Race (Commit Wins) Test ==="); + + let mut networks = TestNetworksBuilder::new() + .with_nodes(4) + .with_withdrawal_batching_delay_ms(3_600_000) // 1 hour — keeps leader out of our way + .build() + .await?; + + networks.hashi_network.nodes()[0] + .wait_for_mpc_key(Duration::from_secs(120)) + .await?; + + let deposit_amount_sats = 100_000u64; + let hbtc_recipient = create_deposit_and_wait(&mut networks, deposit_amount_sats).await?; + + let balance_before = get_hbtc_balance( + &mut networks.sui_network.client, + networks.hashi_network.ids().package_id, + hbtc_recipient, + ) + .await?; + assert_eq!(balance_before, deposit_amount_sats); + + let hashi = networks.hashi_network.nodes()[0].hashi().clone(); + let user_key = networks.sui_network.user_keys.first().unwrap().clone(); + let withdrawal_amount_sats = 30_000u64; + let btc_destination = networks.bitcoin_node.get_new_address()?; + let destination_bytes = extract_witness_program(&btc_destination)?; + + let mut user_executor = + SuiTxExecutor::from_config(&hashi.config, hashi.onchain_state())?.with_signer(user_key); + let request_id = user_executor + .execute_create_withdrawal_request(withdrawal_amount_sats, destination_bytes) + .await?; + + wait_for_withdrawal_approved( + &mut networks.sui_network.client, + request_id, + Duration::from_secs(60), + ) + .await?; + + // Build and submit the commit PTB now — commit wins. + let request = hashi + .onchain_state() + .withdrawal_request(&request_id) + .ok_or_else(|| anyhow!("approved request disappeared from state"))?; + let (approval, cert) = hashi + .build_and_sign_withdrawal_commitment(&[request]) + .await?; + let mut leader_executor = SuiTxExecutor::from_hashi(hashi.clone())?; + leader_executor + .execute_commit_withdrawal_tx(&approval, &cert) + .await?; + info!("commit_withdrawal_tx submitted successfully"); + + wait_for_withdrawal_picked(&mut networks.sui_network.client, Duration::from_secs(30)) + .await?; + info!("Request moved to Processing state"); + + // Cancel must now fail with ECannotCancelProcessingWithdrawal. + let cancel_result = user_executor.execute_cancel_withdrawal(&request_id).await; + assert!( + cancel_result.is_err(), + "cancel_withdrawal must fail once the request is in Processing; got Ok" + ); + let err = cancel_result.err().unwrap(); + let err_msg = format!("{err:?}"); + assert!( + err_msg.contains("ECannotCancelProcessingWithdrawal") + || err_msg.contains("cancel_withdrawal"), + "expected ECannotCancelProcessingWithdrawal abort, got: {err_msg}" + ); + info!("cancel correctly failed: {err_msg}"); + + // hBTC must NOT be restored — the BTC was burned at commit time. + let balance_after = get_hbtc_balance( + &mut networks.sui_network.client, + networks.hashi_network.ids().package_id, + hbtc_recipient, + ) + .await?; + assert_eq!( + balance_after, + deposit_amount_sats - withdrawal_amount_sats, + "hBTC balance must reflect the burned commit amount" + ); + + info!("=== Cancel-Race (Commit Wins) Test Passed ==="); + Ok(()) + } } diff --git a/crates/e2e-tests/src/hashi_network.rs b/crates/e2e-tests/src/hashi_network.rs index 816d50fc4..6dcf5db16 100644 --- a/crates/e2e-tests/src/hashi_network.rs +++ b/crates/e2e-tests/src/hashi_network.rs @@ -235,6 +235,9 @@ pub struct HashiNetworkBuilder { /// Node index whose shares should be corrupted by all other nodes, /// triggering the complaint recovery flow. pub test_corrupt_shares_target: Option, + /// When true, the leader skips withdrawal approval + commit phases. + /// Used to stage manual cancellation tests. + pub test_disable_withdrawal_processing: Option, } impl HashiNetworkBuilder { @@ -248,6 +251,7 @@ impl HashiNetworkBuilder { withdrawal_max_batch_size: None, max_mempool_chain_depth: None, test_corrupt_shares_target: None, + test_disable_withdrawal_processing: None, } } @@ -291,6 +295,11 @@ impl HashiNetworkBuilder { self } + pub fn with_test_disable_withdrawal_processing(mut self, disable: bool) -> Self { + self.test_disable_withdrawal_processing = Some(disable); + self + } + pub async fn build( self, dir: &Path, @@ -331,6 +340,7 @@ impl HashiNetworkBuilder { config.withdrawal_batching_delay_ms = self.withdrawal_batching_delay_ms; config.withdrawal_max_batch_size = self.withdrawal_max_batch_size; config.max_mempool_chain_depth = self.max_mempool_chain_depth; + config.test_disable_withdrawal_processing = self.test_disable_withdrawal_processing; // All nodes EXCEPT the target corrupt shares for the target. if let Some(target_addr) = corrupt_target_address && Some(i) != self.test_corrupt_shares_target diff --git a/crates/e2e-tests/src/lib.rs b/crates/e2e-tests/src/lib.rs index 22e1af1cc..87a90cd73 100644 --- a/crates/e2e-tests/src/lib.rs +++ b/crates/e2e-tests/src/lib.rs @@ -197,6 +197,13 @@ impl TestNetworksBuilder { self } + pub fn with_test_disable_withdrawal_processing(mut self, disable: bool) -> Self { + self.hashi_builder = self + .hashi_builder + .with_test_disable_withdrawal_processing(disable); + self + } + pub async fn build(self) -> Result { let dir = tempfile::Builder::new() .prefix("hashi-test-env-") diff --git a/crates/e2e-tests/src/main.rs b/crates/e2e-tests/src/main.rs index 7ab91cee8..01b1e68a8 100644 --- a/crates/e2e-tests/src/main.rs +++ b/crates/e2e-tests/src/main.rs @@ -77,6 +77,12 @@ enum Commands { #[clap(long, default_value = "18443")] btc_rpc_port: u16, + /// Disable node-side withdrawal processing. Requests stay in + /// Requested/Approved state until cancelled or the localnet is + /// restarted without this flag. Debug/test only. + #[clap(long, hide = true)] + disable_withdrawal_processing: bool, + #[command(flatten)] opts: LocalnetOpts, }, @@ -231,8 +237,18 @@ async fn main() -> Result<()> { num_validators, sui_rpc_port, btc_rpc_port, + disable_withdrawal_processing, opts, - } => cmd_start(num_validators, sui_rpc_port, btc_rpc_port, &opts.data_dir).await, + } => { + cmd_start( + num_validators, + sui_rpc_port, + btc_rpc_port, + disable_withdrawal_processing, + &opts.data_dir, + ) + .await + } Commands::Stop { opts } => cmd_stop(&opts.data_dir).await, Commands::Status { opts } => cmd_status(&opts.data_dir), Commands::Info { opts } => cmd_info(&opts.data_dir), @@ -260,6 +276,7 @@ async fn cmd_start( num_validators: usize, sui_rpc_port: u16, btc_rpc_port: u16, + disable_withdrawal_processing: bool, data_dir: &Path, ) -> Result<()> { // Check for existing running instance @@ -285,6 +302,7 @@ async fn cmd_start( .with_nodes(num_validators) .with_sui_rpc_port(sui_rpc_port) .with_btc_rpc_port(btc_rpc_port) + .with_test_disable_withdrawal_processing(disable_withdrawal_processing) .build() .await?; diff --git a/crates/hashi/src/config.rs b/crates/hashi/src/config.rs index a679bf2be..c1fcaf325 100644 --- a/crates/hashi/src/config.rs +++ b/crates/hashi/src/config.rs @@ -174,6 +174,13 @@ 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: when true, the leader skips the withdrawal approval and + /// commit phases. Requests created while set stay in Requested/Approved + /// state indefinitely. Used by localnet to stage manual cancellation + /// tests. Must not be set on mainnet or testnet. + #[serde(skip_serializing_if = "Option::is_none")] + pub test_disable_withdrawal_processing: Option, } #[derive(Clone, Debug, Default, serde_derive::Deserialize, serde_derive::Serialize)] @@ -367,6 +374,10 @@ impl Config { .unwrap_or(crate::utxo_pool::CoinSelectionParams::DEFAULT_MAX_MEMPOOL_CHAIN_DEPTH) } + pub fn test_disable_withdrawal_processing(&self) -> bool { + self.test_disable_withdrawal_processing.unwrap_or(false) + } + // Creates a new config suitable for testing. In particular this config will: // - have randomly generated private key material // - localhost only listen addresses using available ports diff --git a/crates/hashi/src/leader/mod.rs b/crates/hashi/src/leader/mod.rs index 34f1fa00b..0a1e0bcb2 100644 --- a/crates/hashi/src/leader/mod.rs +++ b/crates/hashi/src/leader/mod.rs @@ -496,6 +496,11 @@ impl LeaderService { return; } + if self.inner.config.test_disable_withdrawal_processing() { + debug!("Withdrawal processing disabled (test flag), skipping approval"); + return; + } + if self.withdrawal_approval_task.is_some() { debug!("Withdrawal approval task already in-flight, skipping"); return; @@ -817,6 +822,11 @@ impl LeaderService { return; } + if self.inner.config.test_disable_withdrawal_processing() { + debug!("Withdrawal processing disabled (test flag), skipping commit"); + return; + } + if self.withdrawal_commitment_task.is_some() { debug!("Withdrawal commitment task already in-flight, skipping"); return; @@ -1817,7 +1827,7 @@ fn deposit_request_to_proto(req: &DepositRequest) -> SignDepositConfirmationRequ } } -fn parse_member_signature( +pub(crate) fn parse_member_signature( member_signature: hashi_types::proto::MemberSignature, ) -> anyhow::Result { let epoch = member_signature @@ -1847,7 +1857,7 @@ impl WithdrawalRequestApproval { } impl WithdrawalTxCommitment { - fn to_proto(&self) -> SignWithdrawalTxConstructionRequest { + pub(crate) fn to_proto(&self) -> SignWithdrawalTxConstructionRequest { SignWithdrawalTxConstructionRequest { request_ids: self .request_ids diff --git a/crates/hashi/src/withdrawals.rs b/crates/hashi/src/withdrawals.rs index 3ef640a5c..fe671bf22 100644 --- a/crates/hashi/src/withdrawals.rs +++ b/crates/hashi/src/withdrawals.rs @@ -25,12 +25,16 @@ use hashi_types::bitcoin_txid::BitcoinTxid; use hashi_types::guardian::bitcoin_utils; use std::collections::BTreeMap; use std::collections::HashMap; +use std::sync::Arc; use std::time::Duration; use sui_sdk_types::Address; +use tokio::task::JoinSet; +use tracing::error; use crate::Hashi; use crate::btc_monitor::monitor::TxStatus; use crate::leader::RetryPolicy; +use crate::leader::parse_member_signature; use crate::mpc::SigningManager; use crate::mpc::rpc::RpcP2PChannel; use crate::onchain::types::OutputUtxo; @@ -885,6 +889,71 @@ impl Hashi { Ok(()) } + /// Build a withdrawal tx commitment for `requests` and collect a quorum of + /// committee signatures over it via the same `sign_withdrawal_tx_construction` + /// RPC fan-out used by the leader loop. + /// + /// Returns the commitment and an aggregated `CommitteeSignature` ready to + /// feed into `commit_withdrawal_tx`. Useful for tests that want to drive a + /// commit PTB directly without going through the leader's periodic tick. + pub async fn build_and_sign_withdrawal_commitment( + self: &Arc, + requests: &[WithdrawalRequest], + ) -> anyhow::Result<( + WithdrawalTxCommitment, + hashi_types::committee::CommitteeSignature, + )> { + use hashi_types::committee::BlsSignatureAggregator; + use hashi_types::committee::certificate_threshold; + + let approval = self + .build_withdrawal_tx_commitment(requests) + .await + .map_err(|e| anyhow!("build_withdrawal_tx_commitment failed: {e}"))?; + + let members = self + .onchain_state() + .current_committee_members() + .ok_or_else(|| anyhow!("No current committee members"))?; + let committee = self + .onchain_state() + .current_committee() + .ok_or_else(|| anyhow!("No current committee"))?; + + let required_weight = certificate_threshold(committee.total_weight()); + let proto_request = approval.to_proto(); + + let mut sig_tasks = JoinSet::new(); + for member in members { + let hashi = self.clone(); + let proto_request = proto_request.clone(); + sig_tasks.spawn(async move { + request_withdrawal_tx_construction_signature(&hashi, proto_request, &member).await + }); + } + + let mut aggregator = BlsSignatureAggregator::new(&committee, approval.clone()); + while let Some(result) = sig_tasks.join_next().await { + let Ok(Some(sig)) = result else { continue }; + if let Err(e) = aggregator.add_signature(sig) { + error!("Failed to add withdrawal commitment signature: {e}"); + } + if aggregator.weight() >= required_weight { + break; + } + } + + if aggregator.weight() < required_weight { + anyhow::bail!( + "Insufficient withdrawal commitment signatures: weight {} < {required_weight}", + aggregator.weight() + ); + } + + let signed = aggregator.finish()?; + Ok((approval, signed.into_parts().0)) + } + /// Convert raw witness program bytes to a human-readable Bitcoin address string. fn bitcoin_address_string_from_raw(&self, address_bytes: &[u8]) -> anyhow::Result { let version = match address_bytes.len() { @@ -901,6 +970,35 @@ impl Hashi { } } +async fn request_withdrawal_tx_construction_signature( + hashi: &Arc, + proto_request: hashi_types::proto::SignWithdrawalTxConstructionRequest, + member: &hashi_types::committee::CommitteeMember, +) -> Option { + let validator_address = member.validator_address(); + let mut rpc_client = hashi + .onchain_state() + .bridge_service_client(&validator_address)?; + + let response = rpc_client + .sign_withdrawal_tx_construction(proto_request) + .await + .inspect_err(|e| { + error!("Failed to get withdrawal commitment signature from {validator_address}: {e}"); + }) + .ok()?; + + response + .into_inner() + .member_signature + .ok_or_else(|| anyhow!("No member_signature in response")) + .and_then(parse_member_signature) + .inspect_err(|e| { + error!("Failed to parse member signature from {validator_address}: {e}"); + }) + .ok() +} + #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub enum WithdrawalApprovalErrorKind { AmlServiceError,