From cb9447400a9a9a4429a24b2d4705a5d0e70c6de6 Mon Sep 17 00:00:00 2001 From: Luke Gordon Date: Wed, 10 Jun 2026 13:59:14 -0500 Subject: [PATCH] Add timelock spend condition to hashi utxos --- crates/e2e-tests/src/lib.rs | 242 +++++++++++++++++++++- crates/hashi-types/src/bitcoin/mod.rs | 6 +- crates/hashi-types/src/bitcoin/taproot.rs | 133 ++++++++---- crates/hashi/src/deposits.rs | 78 +++---- crates/hashi/src/utxo_pool/mod.rs | 6 +- crates/hashi/src/utxo_pool/tests.rs | 2 +- crates/hashi/src/withdrawals.rs | 3 +- design/docs/address-scheme.mdx | 32 +-- design/docs/withdraw.mdx | 6 - 9 files changed, 395 insertions(+), 113 deletions(-) diff --git a/crates/e2e-tests/src/lib.rs b/crates/e2e-tests/src/lib.rs index ac33ad7966..38154318c8 100644 --- a/crates/e2e-tests/src/lib.rs +++ b/crates/e2e-tests/src/lib.rs @@ -588,6 +588,12 @@ impl Default for TestNetworksBuilder { #[cfg(test)] mod tests { use super::*; + use anyhow::Context; + use bitcoin::Amount; + use bitcoin::OutPoint; + use bitcoin::TxIn; + use bitcoin::TxOut; + use bitcoin::Witness; use fastcrypto::groups::GroupElement; use fastcrypto::groups::Scalar; use fastcrypto::serde_helpers::ToFromByteArray; @@ -876,6 +882,7 @@ mod tests { epoch: u64, sui_request_id: sui_sdk_types::Address, global_presig_index: u64, + derivation_address: Option<[u8; 32]>, ) -> Vec< hashi::mpc::types::SigningResult, > { @@ -904,7 +911,7 @@ mod tests { &message, global_presig_index, &beacon, - None, + derivation_address.as_ref(), SIGNING_TIMEOUT, &metrics, ) @@ -921,7 +928,7 @@ mod tests { fastcrypto::groups::secp256k1::schnorr::SchnorrSignature, >, >, - ) { + ) -> fastcrypto::groups::secp256k1::schnorr::SchnorrSignature { let mut signatures = Vec::new(); for (i, result) in results.into_iter().enumerate() { let sig = result.unwrap_or_else(|e| panic!("Node {i} signing failed: {e}")); @@ -935,6 +942,10 @@ mod tests { "Node {i} signature differs from node 0" ); } + signatures + .into_iter() + .next() + .expect("MPC signing returned no signatures") } async fn run_signing_test(num_nodes: usize, corrupt_node_indices: &[usize]) -> Result<()> { @@ -970,12 +981,230 @@ mod tests { let message: &[u8] = b"Hello, Hashi signing!"; let request_id = sui_sdk_types::Address::ZERO; - let results = sign_on_all_nodes(nodes, message, epoch, request_id, 0).await; + let results = sign_on_all_nodes(nodes, message, epoch, request_id, 0, None).await; assert_all_signatures_match(results); Ok(()) } + #[tokio::test(flavor = "multi_thread")] + async fn test_mpc_recovery_spend_before_and_after_csv_delay() -> Result<()> { + crate::test_helpers::init_test_logging(); + + // Start a full localnet and wait until all Hashi nodes can participate + // in MPC signing for the current epoch. + let test_networks = TestNetworksBuilder::new().with_nodes(4).build().await?; + let nodes = test_networks.hashi_network().nodes(); + let mpc_key_futures: Vec<_> = nodes + .iter() + .map(|node| node.wait_for_mpc_key(DKG_TIMEOUT)) + .collect(); + let results: Vec> = futures::future::join_all(mpc_key_futures).await; + for (i, result) in results.into_iter().enumerate() { + result.unwrap_or_else(|e| panic!("Node {i} DKG failed: {e}")); + } + + let epoch = nodes[0] + .current_epoch() + .context("Hashi epoch not available")?; + wait_for_signing_manager(nodes, epoch, DKG_TIMEOUT).await?; + + // Create a real Hashi-controlled Bitcoin UTXO by funding a deposit + // address derived for a test Sui address. + let hashi = nodes[0].hashi().clone(); + let derivation_path = test_networks + .sui_network + .user_keys + .first() + .context("test network has no Sui user keys")? + .public_key() + .derive_address(); + let deposit_address = hashi.get_deposit_address(Some(&derivation_path))?; + let deposit_amount = Amount::from_sat(100_000); + let miner_fee = Amount::from_sat(1_000); + + tracing::info!(%deposit_address, "Funding Hashi-controlled deposit address"); + let funding_txid = test_networks + .bitcoin_node() + .send_to_address(&deposit_address, deposit_amount)?; + test_networks.bitcoin_node().generate_blocks(10)?; + let vout = crate::test_helpers::lookup_vout( + &test_networks, + funding_txid, + deposit_address.clone(), + deposit_amount.to_sat(), + )?; + + // Build a raw Bitcoin transaction that attempts to spend the deposit + // through the delayed MPC-only recovery path. + let destination = test_networks.bitcoin_node().get_new_address()?; + let destination_balance_before = test_networks + .bitcoin_node() + .rpc_client() + .get_received_by_address(&destination)? + .into_model()? + .0; + let mut recovery_tx = hashi_types::bitcoin::construct_tx( + vec![TxIn { + previous_output: OutPoint { + txid: funding_txid, + vout: vout as u32, + }, + script_sig: bitcoin::ScriptBuf::new(), + sequence: hashi_types::bitcoin::taproot::mpc_recovery_delay_sequence(), + witness: Witness::new(), + }], + vec![TxOut { + value: deposit_amount - miner_fee, + script_pubkey: destination.script_pubkey(), + }], + ); + + // Fetch the delayed MPC-only recovery leaf artifacts used for sighash + // and witness construction. + let guardian_pubkey = hashi + .guardian_btc_pubkey() + .copied() + .context("guardian BTC pubkey not pinned")?; + let mpc_master_g = hashi + .signing_verifying_key() + .context("MPC signing verifying key not available")?; + let (recovery_script, recovery_control_block, recovery_leaf_hash) = + hashi_types::bitcoin::taproot::taproot_mpc_recovery_witness_artifacts( + &guardian_pubkey, + &mpc_master_g, + &derivation_path, + ); + + // Compute the sighash for the recovery leaf and sign it with the real + // MPC protocol using the same derivation path as the deposit address. + let prevout = TxOut { + value: deposit_amount, + script_pubkey: deposit_address.script_pubkey(), + }; + let sighash = hashi_types::bitcoin::taproot_script_spend_sighashes( + &recovery_tx, + &[prevout], + &[recovery_leaf_hash], + )[0]; + let derivation_address = derivation_path.into_inner(); + // The signing request id just needs to be unique and agreed on by all + // nodes; a random one keeps it independent of the message being signed. + let mut request_id_bytes = [0u8; 32]; + rand::Rng::fill(&mut rand::thread_rng(), &mut request_id_bytes); + let results = sign_on_all_nodes( + nodes, + &sighash, + epoch, + sui_sdk_types::Address::new(request_id_bytes), + 0, + Some(derivation_address), + ) + .await; + let mpc_signature = assert_all_signatures_match(results); + + // Attach the delayed-path witness: one MPC signature plus the recovery + // script and control block. There is deliberately no guardian signature. + let mut witness = Witness::new(); + witness.push(mpc_signature.to_byte_array()); + witness.push(recovery_script.to_bytes()); + witness.push(recovery_control_block.serialize()); + recovery_tx.input[0].witness = witness; + + // Before the relative CSV delay has elapsed, Bitcoin should reject the + // otherwise valid recovery spend as non-final. + let before = test_networks + .bitcoin_node() + .rpc_client() + .test_mempool_accept(&[recovery_tx.clone()])? + .into_model()? + .results; + assert_eq!(before.len(), 1); + assert!( + !before[0].allowed, + "recovery spend should not be accepted before CSV delay" + ); + tracing::info!( + reject_reason = ?before[0].reject_reason, + "Recovery spend rejected before CSV delay" + ); + + // Advance regtest median-time-past beyond the 60-day CSV delay. Mock + // time alone is not enough; mining moves the chain MTP forward. The 2h + // margin covers MTP lagging the mocked wall clock, since it is the + // median of the last 11 block timestamps. + let tip_hash = test_networks + .bitcoin_node() + .rpc_client() + .best_block_hash()?; + let tip_header = test_networks + .bitcoin_node() + .rpc_client() + .get_block_header_verbose(&tip_hash)?; + let future_time = tip_header.median_time + + hashi_types::bitcoin::taproot::HASHI_MPC_RECOVERY_DELAY_SECONDS as i64 + + 2 * 60 * 60; + test_networks + .bitcoin_node() + .rpc_client() + .call::("setmocktime", &[serde_json::json!(future_time)])?; + test_networks.bitcoin_node().generate_blocks(20)?; + + // After the delay, the exact same recovery transaction should be valid + // for the mempool. + let after = test_networks + .bitcoin_node() + .rpc_client() + .test_mempool_accept(&[recovery_tx.clone()])? + .into_model()? + .results; + assert_eq!(after.len(), 1); + assert!( + after[0].allowed, + "recovery spend should be accepted after CSV delay; reject_reason={:?}", + after[0].reject_reason + ); + + // Broadcast, mine, and confirm the recovery spend, then verify it paid + // the expected destination output. + let recovery_txid = test_networks + .bitcoin_node() + .rpc_client() + .send_raw_transaction(&recovery_tx)? + .into_model()? + .0; + test_networks.bitcoin_node().generate_blocks(1)?; + test_networks + .bitcoin_node() + .wait_for_transaction(&recovery_txid, std::time::Duration::from_secs(30)) + .await?; + + let confirmed_tx = test_networks + .bitcoin_node() + .rpc_client() + .get_raw_transaction(recovery_txid) + .and_then(|r| r.transaction().map_err(Into::into))?; + let expected_recovery_amount = deposit_amount - miner_fee; + assert!(confirmed_tx.output.iter().any(|output| { + output.value == expected_recovery_amount + && output.script_pubkey == destination.script_pubkey() + })); + let destination_balance_after = test_networks + .bitcoin_node() + .rpc_client() + .get_received_by_address(&destination)? + .into_model()? + .0; + assert_eq!( + destination_balance_after - destination_balance_before, + expected_recovery_amount, + "destination address balance should increase by the recovered amount" + ); + + tracing::info!(%recovery_txid, "MPC recovery spend e2e test passed"); + Ok(()) + } + /// Shutdown a node, open its DB, delete the first half of messages listed /// by `list_fn`, using `delete_fn` to remove each one. fn delete_first_half_of_messages( @@ -1541,6 +1770,7 @@ mod tests { epoch, request_id, 0, + None, ) .await; assert_all_signatures_match(results); @@ -1769,7 +1999,7 @@ mod tests { bytes[..8].copy_from_slice(&(i as u64).to_be_bytes()); let request_id = sui_sdk_types::Address::new(bytes); let results = - sign_on_all_nodes(nodes, b"refill test", epoch, request_id, i as u64).await; + sign_on_all_nodes(nodes, b"refill test", epoch, request_id, i as u64, None).await; assert_all_signatures_match(results); // After crossing the refill threshold, wait for the refill to @@ -1824,7 +2054,7 @@ mod tests { // 2. Sign to verify nonce generation presigs (built via in-memory complaint recovery) work let epoch = nodes[0].hashi().onchain_state().epoch(); let request_id = sui_sdk_types::Address::ZERO; - let results = sign_on_all_nodes(nodes, b"complaint test", epoch, request_id, 0).await; + let results = sign_on_all_nodes(nodes, b"complaint test", epoch, request_id, 0, None).await; assert_all_signatures_match(results); // 3. First rotation — reconstruct_previous_output hits corrupted DKG @@ -1888,7 +2118,7 @@ mod tests { let nodes = test_networks.hashi_network().nodes(); let epoch = nodes[0].hashi().onchain_state().epoch(); let request_id = sui_sdk_types::Address::ZERO; - let results = sign_on_all_nodes(nodes, b"post-restart", epoch, request_id, 0).await; + let results = sign_on_all_nodes(nodes, b"post-restart", epoch, request_id, 0, None).await; assert_all_signatures_match(results); Ok(()) diff --git a/crates/hashi-types/src/bitcoin/mod.rs b/crates/hashi-types/src/bitcoin/mod.rs index 7fc0f28779..ead7ca13a5 100644 --- a/crates/hashi-types/src/bitcoin/mod.rs +++ b/crates/hashi-types/src/bitcoin/mod.rs @@ -76,9 +76,9 @@ pub fn address_string_from_witness_program( Ok(address.to_string()) } -/// Full input weight (WU) for a 2-of-2 taproot script-path spend. -/// TXIN_BASE_WEIGHT (164 WU) + satisfaction (234 WU) = 398 WU (100 vB). -pub const SCRIPT_PATH_2OF2_TXIN_WEIGHT: u64 = 164 + 234; +/// Full input weight (WU) for an immediate 2-of-2 taproot script-path spend. +/// TXIN_BASE_WEIGHT (164 WU) + satisfaction (268 WU) = 432 WU (108 vB). +pub const SCRIPT_PATH_2OF2_TXIN_WEIGHT: u64 = 164 + 268; /// Non-witness fixed overhead for a segwit transaction: /// nVersion(4x4) + nLockTime(4x4) = 32 WU, plus the segwit marker/flag (2 WU). diff --git a/crates/hashi-types/src/bitcoin/taproot.rs b/crates/hashi-types/src/bitcoin/taproot.rs index be5bef3a5e..46ef6810c3 100644 --- a/crates/hashi-types/src/bitcoin/taproot.rs +++ b/crates/hashi-types/src/bitcoin/taproot.rs @@ -1,9 +1,9 @@ // Copyright (c) Mysten Labs, Inc. // SPDX-License-Identifier: Apache-2.0 -//! Taproot descriptor, address, and child-key derivation for the 2-of-2 -//! (enclave + Hashi) deposit/withdrawal scheme. The UTXO/transaction types that -//! consume these live in `super::utxo`. +//! Taproot descriptor, address, and child-key derivation for Hashi-controlled +//! Bitcoin UTXOs. The UTXO/transaction types that consume these live in +//! `super::utxo`. use super::BitcoinAddress; use super::BitcoinPubkey; @@ -11,14 +11,15 @@ use super::DerivationPath; use super::HashiMasterG; use bitcoin::Network; use bitcoin::ScriptBuf; +use bitcoin::Sequence; use bitcoin::TapSighashType; use bitcoin::Transaction; use bitcoin::TxOut; use bitcoin::hashes::Hash; +use bitcoin::relative; use bitcoin::sighash::Prevouts; use bitcoin::sighash::SighashCache; use bitcoin::taproot::ControlBlock; -use bitcoin::taproot::LeafVersion; use bitcoin::taproot::TapLeafHash; use fastcrypto::serde_helpers::ToFromByteArray; use fastcrypto_tbls::threshold_schnorr::key_derivation::derive_verifying_key; @@ -33,25 +34,41 @@ static NUMS_INTERNAL_KEY: LazyLock = LazyLock::new(|| { .expect("valid nums key") }); -/// scriptPubKey and tap leaf hash for the 2-of-2 output at `derivation_path`. +/// Initial MPC-only recovery delay for Hashi-controlled UTXOs. +/// +/// WARNING: this value is part of the taproot script, so changing it changes +/// deposit/change addresses. Future governance-controlled changes need a +/// policy/versioning and grace-period mechanism so deposits broadcast under the +/// previous delay are still accepted and spendable. +pub const HASHI_MPC_RECOVERY_DELAY_SECONDS: u32 = 60 * 24 * 60 * 60; + +/// Leaf indices into the taproot tree built by [`compute_taproot_descriptor`]. +/// These must match the leaf order in its descriptor string. +const IMMEDIATE_2OF2_LEAF_INDEX: usize = 0; +const MPC_RECOVERY_LEAF_INDEX: usize = 1; + +/// scriptPubKey and immediate 2-of-2 tap leaf hash for the output at +/// `derivation_path`. pub(super) fn taproot_script_pubkey_and_leaf_hash( enclave_pubkey: &BitcoinPubkey, hashi_master_g: &HashiMasterG, hashi_derivation_path: &DerivationPath, ) -> (ScriptBuf, TapLeafHash) { let desc = compute_taproot_descriptor(enclave_pubkey, hashi_master_g, hashi_derivation_path); - let address_script = desc.script_pubkey(); - let item = desc + + // Keep the named index visible here instead of using .next() + #[allow(clippy::iter_nth_zero)] + let leaf_hash = desc .leaves() - .next() - .expect("tap tree should have at least one leaf"); - let leaf_hash = item.compute_tap_leaf_hash(); + .nth(IMMEDIATE_2OF2_LEAF_INDEX) + .expect("tap tree should have the immediate 2-of-2 leaf") + .compute_tap_leaf_hash(); (address_script, leaf_hash) } -/// Deposit address for `tr(NUMS, multi_a(2, enclave, derived_hashi))`. +/// Deposit address for the Hashi taproot tree. pub fn taproot_address( enclave_pubkey: &BitcoinPubkey, hashi_master_g: &HashiMasterG, @@ -62,30 +79,44 @@ pub fn taproot_address( .address(network) } -/// Spend artifacts for `tr(NUMS, multi_a(2, enclave, derived_hashi))`. +/// Immediate 2-of-2 spend artifacts for the Hashi taproot tree. pub fn taproot_witness_artifacts( enclave_pubkey: &BitcoinPubkey, hashi_master_g: &HashiMasterG, hashi_derivation_path: &DerivationPath, ) -> (ScriptBuf, ControlBlock, TapLeafHash) { let desc = compute_taproot_descriptor(enclave_pubkey, hashi_master_g, hashi_derivation_path); + taproot_witness_artifacts_at_leaf(&desc, IMMEDIATE_2OF2_LEAF_INDEX) +} + +/// Delayed MPC-only recovery spend artifacts for the Hashi taproot tree. +pub fn taproot_mpc_recovery_witness_artifacts( + enclave_pubkey: &BitcoinPubkey, + hashi_master_g: &HashiMasterG, + hashi_derivation_path: &DerivationPath, +) -> (ScriptBuf, ControlBlock, TapLeafHash) { + let desc = compute_taproot_descriptor(enclave_pubkey, hashi_master_g, hashi_derivation_path); + taproot_witness_artifacts_at_leaf(&desc, MPC_RECOVERY_LEAF_INDEX) +} - let tap_tree = desc.tap_tree().expect("descriptor should have a tap tree"); - let leaf = tap_tree +fn taproot_witness_artifacts_at_leaf( + desc: &Tr, + leaf_index: usize, +) -> (ScriptBuf, ControlBlock, TapLeafHash) { + let leaf = desc .leaves() - .next() - .expect("tap tree should have at least one leaf"); + .nth(leaf_index) + .expect("tap tree should have requested leaf"); let tap_script = leaf.compute_script(); + let leaf_hash = leaf.compute_tap_leaf_hash(); let control_block = desc .spend_info() .leaves() - .next() - .expect("spend info should have at least one leaf") + .nth(leaf_index) + .expect("spend info should have requested leaf") .into_control_block(); - let leaf_hash = TapLeafHash::from_script(&tap_script, LeafVersion::TapScript); - (tap_script, control_block, leaf_hash) } @@ -114,11 +145,14 @@ pub fn taproot_script_spend_sighashes( .collect() } -/// Creates a taproot descriptor for the given enclave and hashi keys with a 2-of-2 multi_a script. -/// Taproot addresses are constructed as follows: -/// 1. Derive a child hashi pubkey from the derivation path -/// 2. Create a 2-of-2 tapscript with the enclave key and derived hashi key -/// 3. Place the tapscript as the sole leaf with a NUMS internal key +/// Creates a taproot descriptor for the Hashi taproot tree: +/// +/// - Immediate 2-of-2 leaf: guardian/enclave + derived Hashi MPC child key. +/// - Delayed recovery leaf: after `HASHI_MPC_RECOVERY_DELAY_SECONDS`, derived +/// Hashi MPC child key only. +/// +/// Both leaves are committed under a NUMS internal key, disabling meaningful key +/// path spends. fn compute_taproot_descriptor( enclave_pubkey: &BitcoinPubkey, hashi_master_g: &HashiMasterG, @@ -127,12 +161,11 @@ fn compute_taproot_descriptor( let derived_hashi_pubkey = derive_hashi_child_pubkey(hashi_master_g, hashi_derivation_path); let internal = *NUMS_INTERNAL_KEY; + let recovery_delay = mpc_recovery_delay_sequence().to_consensus_u32(); - // Taproot descriptor with one leaf: 2-of-2 checksigadd-style multisig - // Descriptor docs: https://github.com/bitcoin/bitcoin/blob/master/doc/descriptors.md let desc_str = format!( - "tr({},multi_a(2,{},{}))", - internal, enclave_pubkey, derived_hashi_pubkey + "tr({},{{multi_a(2,{},{}),and_v(v:older({}),pk({}))}})", + internal, enclave_pubkey, derived_hashi_pubkey, recovery_delay, derived_hashi_pubkey, ); match Descriptor::::from_str(&desc_str).expect("valid descriptor") { @@ -141,6 +174,15 @@ fn compute_taproot_descriptor( } } +/// BIP68 sequence encoding the [`HASHI_MPC_RECOVERY_DELAY_SECONDS`] relative +/// timelock. Used both in the recovery leaf's `older(...)` and as the input +/// sequence of any transaction spending through that leaf. +pub fn mpc_recovery_delay_sequence() -> Sequence { + relative::LockTime::from_seconds_ceil(HASHI_MPC_RECOVERY_DELAY_SECONDS) + .expect("60 days fits in BIP68 time-based relative locktime") + .to_sequence() +} + /// Derives the hashi child pubkey at `derivation_path` from `hashi_master_g`. /// /// `hashi_master_g` must be the raw MPC verifying key with y-parity preserved: @@ -258,6 +300,17 @@ mod bitcoin_tests { ); } + #[test] + fn mpc_recovery_delay_is_time_based_csv() { + let sequence = mpc_recovery_delay_sequence(); + assert_eq!(sequence.to_consensus_u32(), (1 << 22) | 10_125); + assert!( + bitcoin::relative::LockTime::from_sequence(sequence) + .expect("sequence should encode a relative locktime") + .is_block_time() + ); + } + // Party 1: Enclave // Party 2: Hashi // Scenario: @@ -448,8 +501,8 @@ mod bitcoin_tests { label: "zero path", path: DerivationPath::ZERO, expected_derived: "80583e4abd7e73b0868a44e24dd05379375f1c3a85c4c1329bb0572df8577985", - expected_addr_regtest: "bcrt1p0y0fqatuhy4rwt5ac99z7wse6u8zqzu73jmk0rls57uulnl7q4mq0pk06r", - expected_addr_signet: "tb1p0y0fqatuhy4rwt5ac99z7wse6u8zqzu73jmk0rls57uulnl7q4mqzcuf0e", + expected_addr_regtest: "bcrt1p674xfkudr0myzu3jpschmc4wx9xjllf5wyqt4x8y48jnd099dchs0ww4kp", + expected_addr_signet: "tb1p674xfkudr0myzu3jpschmc4wx9xjllf5wyqt4x8y48jnd099dchszhynrm", expected_leaf_script: concat!( "20", "1b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078f", @@ -464,8 +517,8 @@ mod bitcoin_tests { label: "path = [1u8; 32]", path: DerivationPath::from([1u8; 32]), expected_derived: "1b79f716fb1f7beba697f012edcf7b81a96ceac2920b181bd217c9cc017ac7fb", - expected_addr_regtest: "bcrt1pftf88nkuljl4rlsd4xqyq7sy0fzjedws5egf7nuyq4lkkj3hdz2sdfq4a0", - expected_addr_signet: "tb1pftf88nkuljl4rlsd4xqyq7sy0fzjedws5egf7nuyq4lkkj3hdz2sqs2ng4", + expected_addr_regtest: "bcrt1plf0jem4745f5yhu4x3q226q4f34jw6nxysyqvyxjxem0gugqrxnsn6mjae", + expected_addr_signet: "tb1plf0jem4745f5yhu4x3q226q4f34jw6nxysyqvyxjxem0gugqrxns7r35gr", expected_leaf_script: concat!( "20", "1b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078f", @@ -480,8 +533,8 @@ mod bitcoin_tests { label: "path = 0xab..00..cd", path: DerivationPath::from(path_ab_cd), expected_derived: "1403322badfd7823bebf81e9c5ff74f32f856348ac0f5abe33130cc4b6a14c84", - expected_addr_regtest: "bcrt1pe82wsztzxt97jwkx6wcls257xaycfxw7up4k0ju7r6rsf07zxdlsyg9dfv", - expected_addr_signet: "tb1pe82wsztzxt97jwkx6wcls257xaycfxw7up4k0ju7r6rsf07zxdlsf30tuk", + expected_addr_regtest: "bcrt1p2zdq5arv2k7cec0jwstrt3twsnvrze66q4eaqujr4aykuzzu7wwq893cha", + expected_addr_signet: "tb1p2zdq5arv2k7cec0jwstrt3twsnvrze66q4eaqujr4aykuzzu7wwq2um7z8", expected_leaf_script: concat!( "20", "1b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078f", @@ -519,8 +572,9 @@ mod bitcoin_tests { c.label, ); - let (script, _control_block, leaf_hash) = + let (script, control_block, leaf_hash) = taproot_witness_artifacts(&enclave_pk, &master_g, &c.path); + assert_eq!(control_block.serialize().len(), 65); assert_eq!(script.as_bytes().len(), 70, "leaf script must be 70 bytes"); assert_eq!( script.as_bytes().as_hex().to_string(), @@ -563,9 +617,9 @@ mod bitcoin_tests { const EXPECTED_DERIVED: &str = "d6305db510d6cb87554c942aaaffa3ff277366c2a04b8e64f633cceebd05f937"; const EXPECTED_ADDR_REGTEST: &str = - "bcrt1pcpxn30jztmndchw204yr2hjzpy6eqq3k8lauehq2nf2wduu3yzcs2uprtq"; + "bcrt1p09kjf0dz6a4qmdvwqydp902zxz4tr0rp60pe4nl7y4y8vfakf7zsv6mzk8"; const EXPECTED_ADDR_SIGNET: &str = - "tb1pcpxn30jztmndchw204yr2hjzpy6eqq3k8lauehq2nf2wduu3yzcs89t976"; + "tb1p09kjf0dz6a4qmdvwqydp902zxz4tr0rp60pe4nl7y4y8vfakf7zspr3yra"; const EXPECTED_LEAF_SCRIPT: &str = concat!( "20", "1b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078f", @@ -586,8 +640,9 @@ mod bitcoin_tests { let addr_signet = taproot_address(&enclave_pk, &master_g, &path, Network::Signet); assert_eq!(addr_signet.to_string(), EXPECTED_ADDR_SIGNET); - let (script, _control_block, leaf_hash) = + let (script, control_block, leaf_hash) = taproot_witness_artifacts(&enclave_pk, &master_g, &path); + assert_eq!(control_block.serialize().len(), 65); assert_eq!(script.as_bytes().len(), 70); assert_eq!(script.as_bytes().as_hex().to_string(), EXPECTED_LEAF_SCRIPT); assert_eq!(leaf_hash.to_string(), EXPECTED_TAP_LEAF_HASH); diff --git a/crates/hashi/src/deposits.rs b/crates/hashi/src/deposits.rs index d4811a0795..de5b1008e6 100644 --- a/crates/hashi/src/deposits.rs +++ b/crates/hashi/src/deposits.rs @@ -19,34 +19,6 @@ use hashi_types::bitcoin as hashi_bitcoin; use hashi_types::proto::MemberSignature; use thiserror::Error; -pub(crate) fn path_bytes_or_zero(derivation_path: Option<&sui_sdk_types::Address>) -> [u8; 32] { - derivation_path.map(|p| p.into_inner()).unwrap_or([0u8; 32]) -} - -/// `tr(NUMS, multi_a(2, guardian_btc_pubkey, derive(mpc_pubkey, path)))`. -/// `path = None` (change address) maps to a zero-byte path so deposit -/// and withdrawal sides agree on the leaf key without a special case. -/// -/// `mpc_key` is the raw MPC verifying key (`G`). The derivation is taken -/// directly against this point — using only the x-only projection would -/// silently force the parent to even-y and produce a different child key -/// for ~half of all MPC vks, breaking the 2-of-2 leaf script. -pub fn derive_deposit_address( - mpc_key: &ProjectivePoint, - guardian_btc_pubkey: &XOnlyPublicKey, - derivation_path: Option<&sui_sdk_types::Address>, - btc_network: bitcoin::Network, -) -> anyhow::Result { - Ok(hashi_bitcoin::taproot_address( - guardian_btc_pubkey, - mpc_key, - &derivation_path - .copied() - .unwrap_or(sui_sdk_types::Address::ZERO), - btc_network, - )) -} - impl Hashi { #[tracing::instrument(level = "info", skip_all, fields(deposit_id = %deposit_request.id))] pub async fn validate_and_sign_deposit_confirmation( @@ -248,14 +220,12 @@ impl Hashi { ) -> anyhow::Result { let mpc_g = self.mpc_master_g()?; let guardian_pubkey = self.require_guardian_btc_pubkey()?; - Ok(hashi_bitcoin::taproot_address( - &guardian_pubkey, + derive_deposit_address( &mpc_g, - &derivation_path - .copied() - .unwrap_or(sui_sdk_types::Address::ZERO), + &guardian_pubkey, + derivation_path, self.config.bitcoin_network(), - )) + ) } /// 2-of-2 taproot leaf artifacts (script, control block, leaf hash) @@ -274,9 +244,7 @@ impl Hashi { Ok(hashi_bitcoin::taproot_witness_artifacts( &guardian_pubkey, &mpc_g, - &derivation_path - .copied() - .unwrap_or(sui_sdk_types::Address::ZERO), + &normalized_derivation_path(derivation_path), )) } @@ -304,9 +272,10 @@ impl Hashi { .map(Ok) .unwrap_or_else(|| self.onchain_verifying_key_g()) .context("MPC public key not available yet")?; + let derivation_path = normalized_derivation_path(derivation_path).into_inner(); let derived = fastcrypto_tbls::threshold_schnorr::key_derivation::derive_verifying_key( &verifying_key, - &path_bytes_or_zero(derivation_path), + &derivation_path, ); XOnlyPublicKey::from_slice(&derived.to_byte_array()).context("valid 32-byte x-only key") } @@ -373,6 +342,39 @@ impl Hashi { } } +/// `tr(NUMS, {multi_a(2, guardian_btc_pubkey, h), and_v(v:older(delay), pk(h))})` +/// where `h = derive(mpc_pubkey, path)`: an immediate guardian+MPC 2-of-2 leaf, +/// plus an MPC-only recovery leaf spendable after +/// `HASHI_MPC_RECOVERY_DELAY_SECONDS`. +/// `path = None` (change address) maps to a zero-byte path so deposit +/// and withdrawal sides agree on the leaf key without a special case. +/// +/// `mpc_key` is the raw MPC verifying key (`G`). The derivation is taken +/// directly against this point — using only the x-only projection would +/// silently force the parent to even-y and produce a different child key +/// for ~half of all MPC vks, breaking the 2-of-2 leaf script. +pub fn derive_deposit_address( + mpc_key: &ProjectivePoint, + guardian_btc_pubkey: &XOnlyPublicKey, + derivation_path: Option<&sui_sdk_types::Address>, + btc_network: bitcoin::Network, +) -> anyhow::Result { + Ok(hashi_bitcoin::taproot_address( + guardian_btc_pubkey, + mpc_key, + &normalized_derivation_path(derivation_path), + btc_network, + )) +} + +pub(crate) fn normalized_derivation_path( + derivation_path: Option<&sui_sdk_types::Address>, +) -> sui_sdk_types::Address { + derivation_path + .copied() + .unwrap_or(sui_sdk_types::Address::ZERO) +} + #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub(crate) enum UnapprovedDepositErrorKind { RetryOnNextBlock, diff --git a/crates/hashi/src/utxo_pool/mod.rs b/crates/hashi/src/utxo_pool/mod.rs index 843e27a422..ca5a17162a 100644 --- a/crates/hashi/src/utxo_pool/mod.rs +++ b/crates/hashi/src/utxo_pool/mod.rs @@ -73,8 +73,8 @@ pub enum SpendPath { /// /// Witness items: /// items_count(1) + sig1_len(1) + sig1(64) + sig2_len(1) + sig2(64) - /// + script_len(1) + script(68) + control_block_len(1) + control_block(33) - /// = 234 WU + /// + script_len(1) + script(70) + control_block_len(1) + control_block(65) + /// = 268 WU TaprootScriptPath2of2, /// Taproot key-path spend (single x-only Schnorr signature). @@ -91,7 +91,7 @@ impl SpendPath { /// Returns the witness-only satisfaction weight. pub fn satisfaction_weight(&self) -> Weight { match self { - SpendPath::TaprootScriptPath2of2 => Weight::from_wu(234), + SpendPath::TaprootScriptPath2of2 => Weight::from_wu(268), SpendPath::TaprootKeyPath => Weight::from_wu(66), SpendPath::Custom(w) => *w, } diff --git a/crates/hashi/src/utxo_pool/tests.rs b/crates/hashi/src/utxo_pool/tests.rs index aede38ff2e..f003ba9bcd 100644 --- a/crates/hashi/src/utxo_pool/tests.rs +++ b/crates/hashi/src/utxo_pool/tests.rs @@ -1624,7 +1624,7 @@ fn test_withdrawal_output_at_dust_boundary_p2tr() { // A P2TR withdrawal output exactly at the dust threshold (330 sat) // should succeed. let utxos = vec![confirmed_utxo(1, 1_000_000)]; - let req = make_request(1, 1_310, 0); + let req = make_request(1, 1_353, 0); let result = select_coins(&utxos, &[req], &default_params(), default_fee_rate()) .expect("output at dust boundary should succeed"); assert!( diff --git a/crates/hashi/src/withdrawals.rs b/crates/hashi/src/withdrawals.rs index 7fec6b0542..ab8499072b 100644 --- a/crates/hashi/src/withdrawals.rs +++ b/crates/hashi/src/withdrawals.rs @@ -788,7 +788,8 @@ impl Hashi { let derivation_address = inputs .get(input_index) .map(|input| { - crate::deposits::path_bytes_or_zero(input.derivation_path.as_ref()) + crate::deposits::normalized_derivation_path(input.derivation_path.as_ref()) + .into_inner() }) .expect("input_index iterated from signing_messages.len() == txn.inputs.len()"); let sign_start = std::time::Instant::now(); diff --git a/design/docs/address-scheme.mdx b/design/docs/address-scheme.mdx index dd19bb773d..4c595e60c1 100644 --- a/design/docs/address-scheme.mdx +++ b/design/docs/address-scheme.mdx @@ -1,21 +1,30 @@ --- title: Bitcoin Address Scheme -description: Hashi derives a unique Bitcoin Taproot deposit address for every Sui address using a 2-of-2 multisig between the MPC committee and the Guardian. +description: Hashi derives a unique Bitcoin Taproot deposit address for every Sui address using a 2-of-2 multisig between the MPC committee and the Guardian, plus a delayed MPC-only recovery path. keywords: [ address scheme, Taproot, P2TR, BIP-341, key derivation, deposit address, MPC ] sidebar_label: Address Scheme --- Every Sui address has its own unique Hashi Bitcoin deposit address. This gives Hashi a lightweight way to identify which Sui address to credit for a deposit. -All Hashi deposit addresses are `P2TR` (Pay-to-Taproot), where the 2-of-2 -multisig script between Hashi and the Guardian is encoded as the sole leaf in -the Taproot tree. +All Hashi deposit addresses are `P2TR` (Pay-to-Taproot) with a Taproot tree of +two leaves: + +1. A 2-of-2 multisig script between Hashi and the Guardian, used for all + normal spends. +2. A recovery script that lets the Hashi key alone spend the output after a + 60-day BIP-68 relative timelock enforced by `OP_CHECKSEQUENCEVERIFY` + (BIP-112). This is an + escape hatch: if the Guardian key is ever lost, funds become recoverable by + the MPC committee once the delay elapses. The delay is measured from when + the UTXO confirms and is baked into the script, so changing it changes + every deposit address. The exact [descriptor](https://github.com/bitcoin/bitcoin/blob/master/doc/descriptors.md) is: ``` -tr({i}, multi_a(2, {g}, {h})) +tr({i}, {multi_a(2, {g}, {h}), and_v(v:older({delay}), pk({h}))}) ``` where: @@ -24,6 +33,8 @@ where: - `h = derive(H, d)` is the child public key derived from `H` using derivation path `d` (the depositor's Sui address). - `g` is the guardian's fixed public key. +- `delay` is the 60-day relative timelock, encoded as a BIP-68 time-based + sequence value. - `i` is the NUMS (nothing-up-my-sleeve) internal key defined in BIP-341 (`50929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac0`) with no known private key, ensuring all spends occur through the script path. @@ -32,14 +43,3 @@ The key derivation is not BIP-32. It is a purpose-built unhardened derivation over secp256k1, keyed by the Sui address, giving each depositor a unique Bitcoin address while the master signing key remains shared across the MPC committee. - -:::info - -On `devnet` the deposit address omits the guardian key and uses a single-key -script path: - -``` -tr({i}, pk({h})) -``` - -::: diff --git a/design/docs/withdraw.mdx b/design/docs/withdraw.mdx index 3acce43e88..1e6d7b0b88 100644 --- a/design/docs/withdraw.mdx +++ b/design/docs/withdraw.mdx @@ -161,12 +161,6 @@ Signing is a two-step process matching the 2-of-2 Taproot script (see Both signatures are combined into the taproot script-path witness for each input. -:::info - -On `devnet`, no guardian is configured. Only the MPC signature is used. - -::: - The signed transaction is committed onchain by calling `hashi::withdraw::sign_withdrawal`: