diff --git a/Cargo.lock b/Cargo.lock index b6af4497..71a85ce1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7725,6 +7725,7 @@ version = "0.1.0" dependencies = [ "arbitrary", "bitcoin", + "bitcoin-bosd 0.10.0", "proptest", "serde", "serde_json", @@ -7868,6 +7869,7 @@ dependencies = [ name = "strata-asm-proto-bridge-v1-msgs" version = "0.1.0" dependencies = [ + "bitcoin-bosd 0.10.0", "ssz", "ssz_derive", "strata-asm-common", @@ -7906,6 +7908,7 @@ dependencies = [ "bitvec", "borsh", "serde", + "serde_json", "ssz", "ssz_derive", "strata-btc-types", @@ -8039,6 +8042,7 @@ dependencies = [ "jsonrpsee", "strata-asm-proof-types", "strata-asm-proto-bridge-v1", + "strata-asm-proto-bridge-v1-types", "strata-asm-proto-checkpoint-types", "strata-asm-worker", ] @@ -8078,6 +8082,7 @@ dependencies = [ "strata-asm-proof-types", "strata-asm-proto-bridge-v1", "strata-asm-proto-bridge-v1-txs", + "strata-asm-proto-bridge-v1-types", "strata-asm-proto-checkpoint", "strata-asm-proto-checkpoint-txs", "strata-asm-proto-checkpoint-types", diff --git a/bin/asm-runner/Cargo.toml b/bin/asm-runner/Cargo.toml index cb6c8a3b..5e961ddf 100644 --- a/bin/asm-runner/Cargo.toml +++ b/bin/asm-runner/Cargo.toml @@ -22,6 +22,7 @@ strata-asm-proof-impl.workspace = true strata-asm-proof-types.workspace = true strata-asm-proto-bridge-v1.workspace = true strata-asm-proto-bridge-v1-txs.workspace = true +strata-asm-proto-bridge-v1-types.workspace = true strata-asm-proto-checkpoint.workspace = true strata-asm-proto-checkpoint-txs.workspace = true strata-asm-proto-checkpoint-types.workspace = true diff --git a/bin/asm-runner/src/rpc_server.rs b/bin/asm-runner/src/rpc_server.rs index 58760f12..7081b1b0 100644 --- a/bin/asm-runner/src/rpc_server.rs +++ b/bin/asm-runner/src/rpc_server.rs @@ -17,6 +17,7 @@ use strata_asm_proof_db::{ProofDb, SledMohoStateDb, SledProofDb}; use strata_asm_proof_types::{AsmProof, L1Range, MohoProof}; use strata_asm_proto_bridge_v1::{AssignmentEntry, BridgeV1State, DepositEntry}; use strata_asm_proto_bridge_v1_txs::BRIDGE_V1_SUBPROTOCOL_ID; +use strata_asm_proto_bridge_v1_types::SafeHarbour; use strata_asm_proto_checkpoint::CheckpointState; use strata_asm_proto_checkpoint_txs::CHECKPOINT_SUBPROTOCOL_ID; use strata_asm_proto_checkpoint_types::CheckpointTip; @@ -138,6 +139,13 @@ impl AsmStateApiServer for AsmRpcServer { } } + async fn get_safe_harbour(&self, block_hash: BlockHash) -> RpcResult> { + match self.get_bridge_state(block_hash).await? { + Some(bridge_state) => Ok(Some(bridge_state.safe_harbour().clone())), + None => Ok(None), + } + } + async fn get_checkpoint_tip(&self, block_hash: BlockHash) -> RpcResult> { match self.get_checkpoint_state(block_hash).await? { Some(checkpoint_state) => Ok(Some(*checkpoint_state.verified_tip())), diff --git a/crates/params/Cargo.toml b/crates/params/Cargo.toml index 27b041b9..6f4f24a4 100644 --- a/crates/params/Cargo.toml +++ b/crates/params/Cargo.toml @@ -16,6 +16,7 @@ strata-predicate.workspace = true arbitrary = { workspace = true, optional = true } bitcoin = { workspace = true, optional = true } +bitcoin-bosd = { workspace = true, features = ["serde"] } serde.workspace = true ssz.workspace = true ssz_derive.workspace = true @@ -25,4 +26,9 @@ proptest.workspace = true serde_json.workspace = true [features] -arbitrary = ["dep:arbitrary", "dep:bitcoin", "strata-predicate/arbitrary"] +arbitrary = [ + "dep:arbitrary", + "dep:bitcoin", + "bitcoin-bosd/arbitrary", + "strata-predicate/arbitrary", +] diff --git a/crates/params/src/params.rs b/crates/params/src/params.rs index 648a0c0c..0767ab16 100644 --- a/crates/params/src/params.rs +++ b/crates/params/src/params.rs @@ -160,7 +160,8 @@ mod tests { "denomination": 0, "assignment_duration": 0, "operator_fee": 0, - "recovery_delay": 0 + "recovery_delay": 0, + "safe_harbour_address": "03671041727b982843f7e3db4669c2f542e05096fb" } } ] diff --git a/crates/params/src/subprotocols/bridge.rs b/crates/params/src/subprotocols/bridge.rs index 95b5100c..93540187 100644 --- a/crates/params/src/subprotocols/bridge.rs +++ b/crates/params/src/subprotocols/bridge.rs @@ -1,3 +1,4 @@ +use bitcoin_bosd::Descriptor; use serde::{Deserialize, Serialize}; use strata_btc_types::BitcoinAmount; use strata_crypto::EvenPublicKey; @@ -17,6 +18,9 @@ pub struct BridgeV1InitConfig { /// Number of Bitcoin blocks after Deposit Request Transaction that the depositor can reclaim /// funds if operators fail to process the deposit. pub recovery_delay: u16, + /// Predefined safe harbour address. Deactivated at init; the admin multisig toggles + /// activation. + pub safe_harbour_address: Descriptor, } #[cfg(feature = "arbitrary")] @@ -34,6 +38,7 @@ impl<'a> arbitrary::Arbitrary<'a> for BridgeV1InitConfig { assignment_duration: u.arbitrary()?, operator_fee: u.arbitrary()?, recovery_delay: u.arbitrary()?, + safe_harbour_address: u.arbitrary()?, }) } } diff --git a/crates/rpc/Cargo.toml b/crates/rpc/Cargo.toml index 46ef63c7..50152021 100644 --- a/crates/rpc/Cargo.toml +++ b/crates/rpc/Cargo.toml @@ -9,6 +9,7 @@ workspace = true [dependencies] strata-asm-proof-types.workspace = true strata-asm-proto-bridge-v1.workspace = true +strata-asm-proto-bridge-v1-types.workspace = true strata-asm-proto-checkpoint-types.workspace = true strata-asm-worker.workspace = true diff --git a/crates/rpc/src/traits.rs b/crates/rpc/src/traits.rs index 636b992c..86ee654a 100644 --- a/crates/rpc/src/traits.rs +++ b/crates/rpc/src/traits.rs @@ -4,6 +4,7 @@ use bitcoin::BlockHash; use jsonrpsee::{core::RpcResult, proc_macros::rpc}; use strata_asm_proof_types::{AsmProof, MohoProof}; use strata_asm_proto_bridge_v1::{AssignmentEntry, DepositEntry}; +use strata_asm_proto_bridge_v1_types::SafeHarbour; use strata_asm_proto_checkpoint_types::CheckpointTip; use strata_asm_worker::{AsmState, AsmWorkerStatus}; @@ -34,6 +35,10 @@ pub trait AsmStateApi { #[method(name = "getDeposits")] async fn get_deposits(&self, block_hash: BlockHash) -> RpcResult>; + /// Return the safe harbour address for the provided Bitcoin block hash. + #[method(name = "getSafeHarbour")] + async fn get_safe_harbour(&self, block_hash: BlockHash) -> RpcResult>; + /// Return the verified checkpoint tip for the provided Bitcoin block hash. #[method(name = "getCheckpointTip")] async fn get_checkpoint_tip(&self, block_hash: BlockHash) -> RpcResult>; diff --git a/crates/subprotocols/bridge-v1/msgs/Cargo.toml b/crates/subprotocols/bridge-v1/msgs/Cargo.toml index 8fc9e68e..0de3cd06 100644 --- a/crates/subprotocols/bridge-v1/msgs/Cargo.toml +++ b/crates/subprotocols/bridge-v1/msgs/Cargo.toml @@ -7,6 +7,7 @@ edition = "2024" workspace = true [dependencies] +bitcoin-bosd.workspace = true ssz.workspace = true ssz_derive.workspace = true diff --git a/crates/subprotocols/bridge-v1/msgs/src/lib.rs b/crates/subprotocols/bridge-v1/msgs/src/lib.rs index e3e645b3..ca169085 100644 --- a/crates/subprotocols/bridge-v1/msgs/src/lib.rs +++ b/crates/subprotocols/bridge-v1/msgs/src/lib.rs @@ -6,6 +6,7 @@ use std::any::Any; +use bitcoin_bosd::Descriptor; use ssz_derive::{Decode, Encode}; use strata_asm_common::{InterprotoMsg, SubprotocolId}; use strata_asm_proto_bridge_v1_txs::BRIDGE_V1_SUBPROTOCOL_ID; @@ -26,6 +27,18 @@ pub enum BridgeIncomingMsg { /// Emitted by the admin subprotocol when the operator set is updated. /// Adds new operators by public key and removes existing operators by index. UpdateOperatorSet(UpdateOperatorSetPayload), + + /// Emitted by the admin subprotocol to update the safe harbour destination + /// descriptor. + UpdateSafeHarbourAddress(Descriptor), + + /// Defcon1 signal raised by the admin subprotocol. The bridge must respond by + /// activating the safe harbour. + Defcon1(Defcon1Payload), + + /// Defcon3 signal raised by the admin subprotocol. The bridge must respond by + /// activating the safe harbour. + Defcon3(Defcon3Payload), } /// Payload for [`BridgeIncomingMsg::UpdateOperatorSet`]. @@ -37,6 +50,14 @@ pub struct UpdateOperatorSetPayload { pub remove_members: Vec, } +/// Empty marker payload for [`BridgeIncomingMsg::Defcon1`]; the signal itself carries no data. +#[derive(Clone, Debug, Eq, PartialEq, Default, Encode, Decode)] +pub struct Defcon1Payload {} + +/// Empty marker payload for [`BridgeIncomingMsg::Defcon3`]; the signal itself carries no data. +#[derive(Clone, Debug, Eq, PartialEq, Default, Encode, Decode)] +pub struct Defcon3Payload {} + impl InterprotoMsg for BridgeIncomingMsg { fn id(&self) -> SubprotocolId { BRIDGE_V1_SUBPROTOCOL_ID diff --git a/crates/subprotocols/bridge-v1/subprotocol/Cargo.toml b/crates/subprotocols/bridge-v1/subprotocol/Cargo.toml index 5719d308..22b01389 100644 --- a/crates/subprotocols/bridge-v1/subprotocol/Cargo.toml +++ b/crates/subprotocols/bridge-v1/subprotocol/Cargo.toml @@ -9,6 +9,7 @@ workspace = true [dependencies] arbitrary.workspace = true bitcoin.workspace = true +bitcoin-bosd.workspace = true rand_chacha.workspace = true serde.workspace = true ssz.workspace = true diff --git a/crates/subprotocols/bridge-v1/subprotocol/src/state/bridge.rs b/crates/subprotocols/bridge-v1/subprotocol/src/state/bridge.rs index ce9463a6..5f569c5b 100644 --- a/crates/subprotocols/bridge-v1/subprotocol/src/state/bridge.rs +++ b/crates/subprotocols/bridge-v1/subprotocol/src/state/bridge.rs @@ -1,7 +1,10 @@ +use bitcoin_bosd::Descriptor; use ssz_derive::{Decode, Encode}; use strata_asm_params::BridgeV1InitConfig; use strata_asm_proto_bridge_v1_txs::{deposit::DepositInfo, errors::Mismatch}; -use strata_asm_proto_bridge_v1_types::{OperatorIdx, WithdrawOutput, WithdrawalCommand}; +use strata_asm_proto_bridge_v1_types::{ + OperatorIdx, SafeHarbour, WithdrawOutput, WithdrawalCommand, +}; use strata_btc_types::BitcoinAmount; use strata_identifiers::L1BlockCommitment; @@ -38,6 +41,9 @@ pub struct BridgeV1State { /// Number of blocks after Deposit Request Transaction that the depositor can reclaim /// funds if operators fail to process the deposit. recovery_delay: u16, + + /// Safe harbour + safe_harbour: SafeHarbour, } impl BridgeV1State { @@ -64,6 +70,7 @@ impl BridgeV1State { denomination: config.denomination, operator_fee: config.operator_fee, recovery_delay: config.recovery_delay, + safe_harbour: SafeHarbour::new(config.safe_harbour_address.clone()), } } @@ -92,6 +99,21 @@ impl BridgeV1State { self.recovery_delay } + /// Returns a reference to the safe harbour. + pub fn safe_harbour(&self) -> &SafeHarbour { + &self.safe_harbour + } + + /// Sets the safe harbour activation flag. + pub fn set_safe_harbour_activated(&mut self, activated: bool) { + self.safe_harbour.set_activated(activated); + } + + /// Sets the safe harbour activation flag. + pub fn update_safe_harbour_address(&mut self, new_address: Descriptor) { + self.safe_harbour.update_address(new_address); + } + /// Processes a deposit transaction by validating and adding it to the deposits table. /// /// This function takes already parsed deposit transaction information, validates it against the diff --git a/crates/subprotocols/bridge-v1/subprotocol/src/subprotocol.rs b/crates/subprotocols/bridge-v1/subprotocol/src/subprotocol.rs index fde11c4f..eb718c99 100644 --- a/crates/subprotocols/bridge-v1/subprotocol/src/subprotocol.rs +++ b/crates/subprotocols/bridge-v1/subprotocol/src/subprotocol.rs @@ -156,7 +156,109 @@ impl Subprotocol for BridgeV1Subproto { ); state.apply_operator_set_update(add_members, remove_members); } + + BridgeIncomingMsg::UpdateSafeHarbourAddress(descriptor) => { + info!("Updating the safe harbour address from admin subprotocol"); + state.update_safe_harbour_address(descriptor.clone()); + } + + BridgeIncomingMsg::Defcon1(_) | BridgeIncomingMsg::Defcon3(_) => { + info!( + "Activating safe harbour address on Defcon1 signal from admin subprotocol" + ); + state.set_safe_harbour_activated(true); + } } } } } + +#[cfg(test)] +mod tests { + use bitcoin_bosd::Descriptor; + use strata_asm_common::Subprotocol; + use strata_asm_proto_bridge_v1_msgs::{BridgeIncomingMsg, Defcon1Payload, Defcon3Payload}; + use strata_identifiers::L1BlockCommitment; + use strata_test_utils_arb::ArbitraryGenerator; + + use super::BridgeV1Subproto; + use crate::test_utils::create_test_state; + + fn descriptor_a() -> Descriptor { + Descriptor::new_p2wpkh(&[0xAA; 20]) + } + + fn descriptor_b() -> Descriptor { + Descriptor::new_p2wpkh(&[0xBB; 20]) + } + + /// The safe harbour must start deactivated so it has no effect until the + /// admin subprotocol explicitly triggers a defcon signal. + #[test] + fn safe_harbour_starts_deactivated() { + let (state, _privkeys) = create_test_state(); + assert!(!state.safe_harbour().is_activated()); + assert_eq!(state.safe_harbour().active_address(), None); + } + + #[test] + fn process_msgs_update_safe_harbour_address() { + let (mut state, _privkeys) = create_test_state(); + let l1ref: L1BlockCommitment = ArbitraryGenerator::new().generate(); + + let new_address = descriptor_a(); + let msgs = vec![BridgeIncomingMsg::UpdateSafeHarbourAddress( + new_address.clone(), + )]; + BridgeV1Subproto::process_msgs(&mut state, &msgs, &l1ref); + + assert_eq!(state.safe_harbour().address(), &new_address); + // Address updates alone must not activate the safe harbour. + assert!(!state.safe_harbour().is_activated()); + } + + #[test] + fn process_msgs_defcon1_activates_safe_harbour() { + let (mut state, _privkeys) = create_test_state(); + let l1ref: L1BlockCommitment = ArbitraryGenerator::new().generate(); + + let msgs = vec![BridgeIncomingMsg::Defcon1(Defcon1Payload::default())]; + BridgeV1Subproto::process_msgs(&mut state, &msgs, &l1ref); + + assert!(state.safe_harbour().is_activated()); + assert_eq!( + state.safe_harbour().active_address(), + Some(state.safe_harbour().address()) + ); + } + + #[test] + fn process_msgs_defcon3_activates_safe_harbour() { + let (mut state, _privkeys) = create_test_state(); + let l1ref: L1BlockCommitment = ArbitraryGenerator::new().generate(); + + let msgs = vec![BridgeIncomingMsg::Defcon3(Defcon3Payload::default())]; + BridgeV1Subproto::process_msgs(&mut state, &msgs, &l1ref); + + assert!(state.safe_harbour().is_activated()); + } + + /// Updating the address after activation must keep the new address active — + /// the strata administrator should be able to redirect an already-activated + /// safe harbour without re-issuing a defcon signal. + #[test] + fn process_msgs_update_after_activation_keeps_activated() { + let (mut state, _privkeys) = create_test_state(); + let l1ref: L1BlockCommitment = ArbitraryGenerator::new().generate(); + + let msgs = vec![ + BridgeIncomingMsg::Defcon1(Defcon1Payload::default()), + BridgeIncomingMsg::UpdateSafeHarbourAddress(descriptor_b()), + ]; + BridgeV1Subproto::process_msgs(&mut state, &msgs, &l1ref); + + assert!(state.safe_harbour().is_activated()); + assert_eq!(state.safe_harbour().address(), &descriptor_b()); + assert_eq!(state.safe_harbour().active_address(), Some(&descriptor_b())); + } +} diff --git a/crates/subprotocols/bridge-v1/subprotocol/src/test_utils.rs b/crates/subprotocols/bridge-v1/subprotocol/src/test_utils.rs index 41316366..7db7ceeb 100644 --- a/crates/subprotocols/bridge-v1/subprotocol/src/test_utils.rs +++ b/crates/subprotocols/bridge-v1/subprotocol/src/test_utils.rs @@ -60,6 +60,7 @@ pub(crate) fn create_test_state() -> (BridgeV1State, Vec) { assignment_duration: 144, // ~24 hours operator_fee: BitcoinAmount::from_sat(100_000), recovery_delay: 1008, + safe_harbour_address: ArbitraryGenerator::new().generate(), }; let bridge_state = BridgeV1State::new(&config); (bridge_state, privkeys) diff --git a/crates/subprotocols/bridge-v1/types/Cargo.toml b/crates/subprotocols/bridge-v1/types/Cargo.toml index bcd2558b..805c2878 100644 --- a/crates/subprotocols/bridge-v1/types/Cargo.toml +++ b/crates/subprotocols/bridge-v1/types/Cargo.toml @@ -20,4 +20,5 @@ serde.workspace = true thiserror.workspace = true [dev-dependencies] +serde_json.workspace = true strata-test-utils-arb.workspace = true diff --git a/crates/subprotocols/bridge-v1/types/src/lib.rs b/crates/subprotocols/bridge-v1/types/src/lib.rs index df4ff948..86677eaa 100644 --- a/crates/subprotocols/bridge-v1/types/src/lib.rs +++ b/crates/subprotocols/bridge-v1/types/src/lib.rs @@ -30,11 +30,13 @@ use strata_identifiers::{AccountId, AccountSerial}; mod operator; +mod safe_harbour; mod withdrawal; pub use operator::{ OperatorBitmap, OperatorBitmapError, OperatorIdx, OperatorSelection, filter_eligible_operators, }; +pub use safe_harbour::SafeHarbour; pub use withdrawal::{WithdrawOutput, WithdrawalCommand}; const BRIDGE_GATEWAY_REF: u8 = 0x10; diff --git a/crates/subprotocols/bridge-v1/types/src/safe_harbour.rs b/crates/subprotocols/bridge-v1/types/src/safe_harbour.rs new file mode 100644 index 00000000..c0c9b249 --- /dev/null +++ b/crates/subprotocols/bridge-v1/types/src/safe_harbour.rs @@ -0,0 +1,126 @@ +//! Safe harbour address. +//! +//! A safe harbour is a Bitcoin output script descriptor used to redirect flows +//! under emergency conditions. Activation is restricted to the strata security +//! council; the address itself can be changed by the strata administrator. + +use arbitrary::Arbitrary; +use bitcoin_bosd::Descriptor; +use serde::{Deserialize, Serialize}; +use ssz_derive::{Decode, Encode}; + +/// A safe harbour address with an activation flag. +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, Arbitrary, Encode, Decode)] +pub struct SafeHarbour { + address: Descriptor, + activated: bool, +} + +impl SafeHarbour { + /// Creates a new deactivated safe harbour for the given address. + pub fn new(address: Descriptor) -> Self { + Self { + address, + activated: false, + } + } + + /// Returns the configured safe harbour address. + pub fn address(&self) -> &Descriptor { + &self.address + } + + /// Returns `Some(&address)` when activated, otherwise `None`. + pub fn active_address(&self) -> Option<&Descriptor> { + self.activated.then_some(&self.address) + } + + /// Returns whether the safe harbour is currently activated. + pub fn is_activated(&self) -> bool { + self.activated + } + + /// Sets the activation flag. + pub fn set_activated(&mut self, activated: bool) { + self.activated = activated; + } + + /// Updates the address + pub fn update_address(&mut self, address: Descriptor) { + self.address = address + } +} + +#[cfg(test)] +mod tests { + use ssz::{Decode, Encode}; + + use super::*; + + fn descriptor_a() -> Descriptor { + Descriptor::new_p2wpkh(&[0xAA; 20]) + } + + fn descriptor_b() -> Descriptor { + Descriptor::new_p2wpkh(&[0xBB; 20]) + } + + #[test] + fn new_is_deactivated() { + let sh = SafeHarbour::new(descriptor_a()); + assert!(!sh.is_activated()); + assert_eq!(sh.address(), &descriptor_a()); + assert_eq!(sh.active_address(), None); + } + + #[test] + fn set_activated_toggles_flag_and_active_address() { + let mut sh = SafeHarbour::new(descriptor_a()); + + sh.set_activated(true); + assert!(sh.is_activated()); + assert_eq!(sh.active_address(), Some(&descriptor_a())); + + sh.set_activated(false); + assert!(!sh.is_activated()); + assert_eq!(sh.active_address(), None); + } + + #[test] + fn update_address_preserves_activation_flag() { + let mut sh = SafeHarbour::new(descriptor_a()); + sh.update_address(descriptor_b()); + assert_eq!(sh.address(), &descriptor_b()); + assert!(!sh.is_activated()); + + sh.set_activated(true); + sh.update_address(descriptor_a()); + assert_eq!(sh.address(), &descriptor_a()); + // Updating the address must not deactivate an already-active safe harbour. + assert!(sh.is_activated()); + assert_eq!(sh.active_address(), Some(&descriptor_a())); + } + + #[test] + fn ssz_roundtrip() { + let mut sh = SafeHarbour::new(descriptor_a()); + sh.set_activated(true); + let bytes = sh.as_ssz_bytes(); + let decoded = SafeHarbour::from_ssz_bytes(&bytes).expect("ssz decode"); + assert_eq!(sh, decoded); + } + + /// The RPC `getSafeHarbour` endpoint returns `SafeHarbour` as JSON, so the + /// serde representation must round-trip and stay in sync with what clients + /// consume. + #[test] + fn json_serde_roundtrip() { + let mut sh = SafeHarbour::new(descriptor_a()); + sh.set_activated(true); + let json = serde_json::to_string(&sh).expect("serialize"); + let decoded: SafeHarbour = serde_json::from_str(&json).expect("deserialize"); + assert_eq!(sh, decoded); + assert!(json.contains("\"activated\":true")); + assert!(json.contains("\"address\"")); + } +} diff --git a/functional-tests/factory/common/asm_params.py b/functional-tests/factory/common/asm_params.py index 44cb9790..3a19147c 100644 --- a/functional-tests/factory/common/asm_params.py +++ b/functional-tests/factory/common/asm_params.py @@ -7,6 +7,10 @@ from constants import ASM_MAGIC_BYTES +# BOSD-encoded P2TR descriptor used as the default safe harbour address in +# tests. Address `bc1ppuxgmd6n4j73wdp688p08a8rte97dkn5n70r2ym6kgsw0v3c5ensrytduf`. +DEFAULT_SAFE_HARBOUR_ADDRESS = "040f0c8db753acbd17343a39c2f3f4e35e4be6da749f9e35137ab220e7b238a667" + @dataclass class Block: @@ -64,6 +68,7 @@ class BridgeSubprotocol: assignment_duration: int operator_fee: int recovery_delay: int + safe_harbour_address: str @dataclass @@ -110,6 +115,7 @@ def build_subprotocols( assignment_duration: int = 100_000, operator_fee: int = 100_000_000, recovery_delay: int = 1_008, + safe_harbour_address: str = DEFAULT_SAFE_HARBOUR_ADDRESS, ) -> list[dict[str, Any]]: compressed_keys = [f"02{key}" for key in musig2_keys] confirmation_depth = 144 @@ -154,6 +160,7 @@ def build_subprotocols( assignment_duration=assignment_duration, operator_fee=operator_fee, recovery_delay=recovery_delay, + safe_harbour_address=safe_harbour_address, ) ) } @@ -171,6 +178,7 @@ def build_asm_params( assignment_duration: int = 10_000, operator_fee: int = 100_000_000, recovery_delay: int = 1_008, + safe_harbour_address: str = DEFAULT_SAFE_HARBOUR_ADDRESS, ) -> AsmParams: anchor = build_l1_anchor(genesis_height, block_hash, header) subprotocols = build_subprotocols( @@ -180,6 +188,7 @@ def build_asm_params( assignment_duration=assignment_duration, operator_fee=operator_fee, recovery_delay=recovery_delay, + safe_harbour_address=safe_harbour_address, ) return AsmParams( magic=magic, diff --git a/functional-tests/tests/fn_asm_safe_harbour_test.py b/functional-tests/tests/fn_asm_safe_harbour_test.py new file mode 100644 index 00000000..938dbf55 --- /dev/null +++ b/functional-tests/tests/fn_asm_safe_harbour_test.py @@ -0,0 +1,75 @@ +import logging + +import flexitest + +from factory.common.asm_params import DEFAULT_SAFE_HARBOUR_ADDRESS +from utils.utils import ( + wait_until_asm_reaches_height, + wait_until_asm_ready, + wait_until_bitcoind_ready, +) + + +@flexitest.register +class AsmSafeHarbourTest(flexitest.Test): + """Verify `strata_asm_getSafeHarbour` returns the configured address + in its initial deactivated state. + + The bridge subprotocol is initialised from `asm_params.json` with + `DEFAULT_SAFE_HARBOUR_ADDRESS` and `activated=false`. Without any + admin defcon signal, every processed block must surface that exact + pair via the RPC. + """ + + def __init__(self, ctx: flexitest.InitContext): + ctx.set_env("basic") + + def main(self, ctx: flexitest.RunContext): + bitcoind_service = ctx.get_service("bitcoin") + asm_service = ctx.get_service("asm_rpc") + + bitcoin_rpc = bitcoind_service.create_rpc() + asm_rpc = asm_service.create_rpc() + + wait_until_bitcoind_ready(bitcoin_rpc, timeout=30) + wait_until_asm_ready(asm_rpc) + + initial_btc_height = bitcoin_rpc.proxy.getblockcount() + wallet_addr = bitcoin_rpc.proxy.getnewaddress() + num_blocks = 3 + bitcoin_rpc.proxy.generatetoaddress(num_blocks, wallet_addr) + + target_height = initial_btc_height + num_blocks + asm_height = wait_until_asm_reaches_height(asm_rpc, min_height=target_height) + logging.info("ASM progressed to height %s", asm_height) + + # Tip and an earlier processed block must both return the same payload — + # the safe harbour is consensus state, so it must be consistent across history + # while no admin message has touched it. + heights = (initial_btc_height + 1, target_height) + previous = None + for height in heights: + block_hash = bitcoin_rpc.proxy.getblockhash(height) + result = asm_rpc.strata_asm_getSafeHarbour(block_hash) + assert result is not None, ( + f"strata_asm_getSafeHarbour returned None for processed block at height {height}" + ) + assert set(result.keys()) >= {"address", "activated"}, ( + f"unexpected safe harbour payload at height {height}: {result!r}" + ) + normalized = result["address"].lower().removeprefix("0x") + assert normalized == DEFAULT_SAFE_HARBOUR_ADDRESS, ( + f"expected configured safe harbour address {DEFAULT_SAFE_HARBOUR_ADDRESS}, " + f"got {result['address']}" + ) + assert result["activated"] is False, ( + f"safe harbour should start deactivated, got activated={result['activated']!r}" + ) + if previous is not None: + assert result == previous, ( + "safe harbour should be identical across processed blocks: " + f"{previous} vs {result}" + ) + previous = result + + return True diff --git a/guest-builder/sp1/guest-asm/Cargo.lock b/guest-builder/sp1/guest-asm/Cargo.lock index b7cf20e5..842af033 100644 --- a/guest-builder/sp1/guest-asm/Cargo.lock +++ b/guest-builder/sp1/guest-asm/Cargo.lock @@ -2132,6 +2132,7 @@ version = "0.1.0" dependencies = [ "arbitrary", "bitcoin", + "bitcoin-bosd 0.10.0", "serde", "ssz", "ssz_derive", @@ -2208,6 +2209,7 @@ version = "0.1.0" dependencies = [ "arbitrary", "bitcoin", + "bitcoin-bosd 0.10.0", "rand_chacha 0.9.0", "serde", "ssz", @@ -2230,6 +2232,7 @@ dependencies = [ name = "strata-asm-proto-bridge-v1-msgs" version = "0.1.0" dependencies = [ + "bitcoin-bosd 0.10.0", "ssz", "ssz_derive", "strata-asm-common", diff --git a/tests/harness/bridge.rs b/tests/harness/bridge.rs index 54ed0b2b..460b4c2d 100644 --- a/tests/harness/bridge.rs +++ b/tests/harness/bridge.rs @@ -508,6 +508,7 @@ pub fn create_test_bridge_setup(num_operators: usize) -> (BridgeV1InitConfig, Br let denomination = BitcoinAmount::from_sat(1_000_000); let recovery_delay = 1008; let operator_fee = BitcoinAmount::from_sat(100_000); + let safe_harbour_address: Descriptor = ArbitraryGenerator::new().generate(); let config = BridgeV1InitConfig { operators: pubkeys.clone(), @@ -515,6 +516,7 @@ pub fn create_test_bridge_setup(num_operators: usize) -> (BridgeV1InitConfig, Br assignment_duration: 144, operator_fee, recovery_delay, + safe_harbour_address, }; // Use a deterministic recovery key for test reproducibility