diff --git a/signer/migrations/0003__create_tables.sql b/signer/migrations/0003__create_tables.sql index 6c7aed6f..aad16dca 100644 --- a/signer/migrations/0003__create_tables.sql +++ b/signer/migrations/0003__create_tables.sql @@ -84,6 +84,8 @@ CREATE TABLE sbtc_signer.dkg_shares ( encrypted_private_shares BYTEA NOT NULL, public_shares BYTEA NOT NULL, script_pubkey BYTEA NOT NULL, + signer_set_public_keys BYTEA[] NOT NULL, + signer_set_aggregate_key BYTEA NOT NULL, created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP NOT NULL ); diff --git a/signer/src/block_observer.rs b/signer/src/block_observer.rs index e987e8eb..b7667907 100644 --- a/signer/src/block_observer.rs +++ b/signer/src/block_observer.rs @@ -704,6 +704,8 @@ mod tests { script_pubkey: signers_script_pubkey.clone(), encrypted_private_shares: Vec::new(), public_shares: Vec::new(), + signer_set_public_keys: vec![aggregate_key], + signer_set_aggregate_key: aggregate_key, }; storage.write_encrypted_dkg_shares(&shares).await.unwrap(); diff --git a/signer/src/config/default.toml b/signer/src/config/default.toml index b76bf3ab..305820d6 100644 --- a/signer/src/config/default.toml +++ b/signer/src/config/default.toml @@ -108,7 +108,7 @@ nakamoto_start_height = 31 # Format: "" (64 or 66 hex-characters) # Required: true # Environment: SIGNER_SIGNER__PRIVATE_KEY -private_key = "8183dc385a7a1fc8353b9e781ee0859a71e57abea478a5bca679334094f7adb5" +private_key = "41634762d89dfa09133a4a8e9c1378d0161d29cd0a9433b51f1e3d32947a73dc" # Specifies which network to use when constructing and sending transactions # on stacks and bitcoin. This cooresponds to the `chain` flag in the @@ -124,7 +124,7 @@ network = "regtest" # The address that deployed the sbtc smart contracts. # # Required: true -deployer = "SN2V7WTJ7BHR03MPHZ1C9A9ZR6NZGR4WM8HT4V67Y" +deployer = "SN3R84XZYA63QS28932XQF3G1J8R9PC3W76P9CSQS" # The signer database endpoint (pgsql connection string) # @@ -132,6 +132,19 @@ deployer = "SN2V7WTJ7BHR03MPHZ1C9A9ZR6NZGR4WM8HT4V67Y" # Environment: SIGNER_SIGNER__DB_ENDPOINT db_endpoint = "postgresql://postgres:postgres@localhost:5432/signer" +# The public keys of known signers who are approved to be in the signer +# set. +# The signer database endpoint (pgsql connection string) +# +# Required: true +# Environment: SIGNER_SIGNER__PEER_PUBLIC_KEYS +bootstrap_signing_set = [ + "035249137286c077ccee65ecc43e724b9b9e5a588e3d7f51e3b62f9624c2a49e46", + "031a4d9f4903da97498945a4e01a5023a1d53bc96ad670bfe03adf8a06c52e6380", + "02007311430123d4cad97f4f7e86e023b28143130a18099ecf094d36fef0f6135c", +] + +bootstrap_signatures_required = 2 # !! ============================================================================== # !! Stacks Event Observer Configuration # !! diff --git a/signer/src/config/mod.rs b/signer/src/config/mod.rs index c6320f10..67f7d748 100644 --- a/signer/src/config/mod.rs +++ b/signer/src/config/mod.rs @@ -6,6 +6,7 @@ use config::File; use libp2p::Multiaddr; use serde::Deserialize; use stacks_common::types::chainstate::StacksAddress; +use std::collections::BTreeSet; use std::path::Path; use url::Url; @@ -16,6 +17,8 @@ use crate::config::serialization::private_key_deserializer; use crate::config::serialization::url_deserializer_single; use crate::config::serialization::url_deserializer_vec; use crate::keys::PrivateKey; +use crate::keys::PublicKey; +use crate::stacks::wallet::SignerWallet; mod error; mod serialization; @@ -225,6 +228,12 @@ pub struct SignerConfig { /// The postgres database endpoint #[serde(deserialize_with = "url_deserializer_single")] pub db_endpoint: Url, + /// The public keys of the signer sit during the bootstrapping phase of + /// the signers. + pub bootstrap_signing_set: Vec, + /// The number of signatures required for the signers' bootstrapped + /// multi-sig wallet on Stacks. + pub bootstrap_signatures_required: u16, } impl Validatable for SignerConfig { @@ -243,12 +252,38 @@ impl Validatable for SignerConfig { SignerConfigError::UnsupportedDatabaseDriver(self.db_endpoint.scheme().to_string()); return Err(ConfigError::Message(err.to_string())); } + + // The requirement here is that the bootstrap wallet in the config + // is a valid wallet, and all of those checks are done by the + // `SignerWallet::load_boostrap_wallet` function. Some of these + // checks include checks for an empty `bootstrap_signing_set`, or a + // `bootstrap_signatures_required` that is to high, there are + // others. + if let Err(err) = SignerWallet::load_boostrap_wallet(self) { + return Err(ConfigError::Message(err.to_string())); + } + // db_endpoint note: we don't validate the host because we will never // get here; the URL deserializer will fail if the host is empty. Ok(()) } } +impl SignerConfig { + /// Return the bootstrapped signing set from the config. This function + /// makes sure that the signing set includes the current signer. + pub fn bootstrap_signing_set(&self) -> BTreeSet { + // We add in the current signer into the signing set from the + // config just in case it hasn't been included already. + let self_public_key = PublicKey::from_private_key(&self.private_key); + self.bootstrap_signing_set + .iter() + .copied() + .chain([self_public_key]) + .collect() + } +} + /// Configuration for the Stacks event observer server (hosted within the signer). #[derive(Debug, Clone, Deserialize)] pub struct EventObserverConfig { @@ -288,6 +323,7 @@ impl Settings { .separator("__") .list_separator(",") .try_parsing(true) + .with_list_parse_key("signer.peer_public_keys") .with_list_parse_key("signer.p2p.seeds") .with_list_parse_key("signer.p2p.listen_on") .with_list_parse_key("signer.p2p.public_endpoints") @@ -393,7 +429,7 @@ mod tests { assert_eq!( settings.signer.private_key, PrivateKey::from_str( - "8183dc385a7a1fc8353b9e781ee0859a71e57abea478a5bca679334094f7adb5" + "41634762d89dfa09133a4a8e9c1378d0161d29cd0a9433b51f1e3d32947a73dc" ) .unwrap() ); @@ -418,6 +454,8 @@ mod tests { settings.signer.event_observer.bind, "0.0.0.0:8801".parse::().unwrap() ); + assert!(!settings.signer.bootstrap_signing_set.is_empty()); + assert_eq!(settings.signer.bootstrap_signatures_required, 2); } #[test] diff --git a/signer/src/error.rs b/signer/src/error.rs index 6e4bd368..134cb3bb 100644 --- a/signer/src/error.rs +++ b/signer/src/error.rs @@ -411,3 +411,10 @@ impl From for Error { match value {} } } + +impl Error { + /// Convert a coordinator error to an `error::Error` + pub fn wsts_coordinator(err: wsts::state_machine::coordinator::Error) -> Self { + Error::WstsCoordinator(Box::new(err)) + } +} diff --git a/signer/src/main.rs b/signer/src/main.rs index c36f8432..15772282 100644 --- a/signer/src/main.rs +++ b/signer/src/main.rs @@ -375,6 +375,8 @@ async fn run_transaction_coordinator(ctx: impl Context) -> Result<(), Error> { private_key: config.signer.private_key, signing_round_max_duration: Duration::from_secs(10), threshold: 2, + bitcoin_network: config.signer.network.into(), + dkg_max_duration: Duration::from_secs(10), }; coord.run().await diff --git a/signer/src/stacks/contracts.rs b/signer/src/stacks/contracts.rs index 01deb0b4..cffa533d 100644 --- a/signer/src/stacks/contracts.rs +++ b/signer/src/stacks/contracts.rs @@ -1089,7 +1089,7 @@ mod tests { SecretKey::new(&mut rng), ]; let public_keys = secret_keys.map(|sk| sk.public_key(SECP256K1).into()); - let wallet = SignerWallet::new(&public_keys, 2, NetworkKind::Testnet, 0).unwrap(); + let wallet = SignerWallet::new(public_keys, 2, NetworkKind::Testnet, 0).unwrap(); let deployer = StacksAddress::burn_address(false); let call = RotateKeysV1::new(&wallet, deployer); diff --git a/signer/src/stacks/wallet.rs b/signer/src/stacks/wallet.rs index c107c22a..017be9bc 100644 --- a/signer/src/stacks/wallet.rs +++ b/signer/src/stacks/wallet.rs @@ -24,6 +24,7 @@ use secp256k1::ecdsa::RecoverableSignature; use secp256k1::Message; use crate::config::NetworkKind; +use crate::config::SignerConfig; use crate::context::Context; use crate::error::Error; use crate::keys::PublicKey; @@ -98,13 +99,18 @@ impl SignerWallet { /// is highly unlikely by chance, but a Byzantine actor could trigger /// it purposefully if we don't require a signer to prove that they /// control the public key that they submit. - pub fn new( - public_keys: &[PublicKey], + pub fn new( + public_keys: I, signatures_required: u16, network_kind: NetworkKind, nonce: u64, - ) -> Result { + ) -> Result + where + I: IntoIterator, + { // Check most error conditions + let public_keys: BTreeSet = public_keys.into_iter().collect(); + let num_keys = public_keys.len(); let invalid_threshold = num_keys < signatures_required as usize; let invalid_num_keys = num_keys == 0 || num_keys > MAX_KEYS as usize; @@ -116,7 +122,6 @@ impl SignerWallet { )); } - let public_keys: BTreeSet = public_keys.iter().copied().collect(); // Used for creating the combined stacks address let pubkeys: Vec = public_keys.iter().map(Secp256k1PublicKey::from).collect(); @@ -146,7 +151,9 @@ impl SignerWallet { }) } - /// Load the multi-sig wallet from storage. + /// Load the multi-sig wallet from the last rotate-keys-trasnaction + /// stored in the database. If it's not there, fall back to the + /// bootstrap multi-sig wallet in the signer's config. /// /// The wallet that is loaded is the one that cooresponds to the signer /// set defined in the last confirmed key rotation contract call. @@ -156,15 +163,26 @@ impl SignerWallet { { // Get the key rotation transaction from the database. This maps to // what the stacks network thinks the signers' address is. - let last_key_rotation = ctx - .get_storage() - .get_last_key_rotation(chain_tip) - .await? - .ok_or(Error::MissingKeyRotation)?; + let last_key_rotation = ctx.get_storage().get_last_key_rotation(chain_tip).await?; + + let config = &ctx.config().signer; + let network_kind = config.network; + + match last_key_rotation { + Some(keys) => { + let public_keys = keys.signer_set; + let signatures_required = keys.signatures_required; + SignerWallet::new(public_keys, signatures_required, network_kind, 0) + } + None => Self::load_boostrap_wallet(config), + } + } - let public_keys = last_key_rotation.signer_set.as_slice(); - let signatures_required = last_key_rotation.signatures_required; - let network_kind = ctx.config().signer.network; + /// Load the bootstrap wallet implicitly defined in the signer config. + pub fn load_boostrap_wallet(config: &SignerConfig) -> Result { + let network_kind = config.network; + let public_keys = config.bootstrap_signing_set(); + let signatures_required = config.bootstrap_signatures_required; SignerWallet::new(public_keys, signatures_required, network_kind, 0) } @@ -443,7 +461,7 @@ mod tests { .collect(); let public_keys: Vec<_> = key_pairs.iter().map(|kp| kp.public_key().into()).collect(); - let wallet = SignerWallet::new(&public_keys, signatures_required, network, 1).unwrap(); + let wallet = SignerWallet::new(public_keys, signatures_required, network, 1).unwrap(); let mut tx_signer = MultisigTx::new_contract_call(TestContractCall, &wallet, TX_FEE); let tx = tx_signer.tx(); @@ -490,7 +508,7 @@ mod tests { .collect(); let public_keys: Vec<_> = key_pairs.iter().map(|kp| kp.public_key().into()).collect(); - let wallet = SignerWallet::new(&public_keys, signatures_required, network, 1).unwrap(); + let wallet = SignerWallet::new(public_keys, signatures_required, network, 1).unwrap(); let mut tx_signer = MultisigTx::new_contract_call(TestContractCall, &wallet, TX_FEE); @@ -547,15 +565,14 @@ mod tests { .collect(); let pks1 = public_keys.clone(); - let wallet1 = SignerWallet::new(&pks1, 5, network, 0).unwrap(); - // Although it's unlikely, it's possible for the shuffle to not // shuffle anything, so we need to keep trying. while pks1 == public_keys { public_keys.shuffle(&mut OsRng); } + let wallet1 = SignerWallet::new(pks1, 5, network, 0).unwrap(); - let wallet2 = SignerWallet::new(&public_keys, 5, network, 0).unwrap(); + let wallet2 = SignerWallet::new(public_keys, 5, network, 0).unwrap(); assert_eq!(wallet1.address(), wallet2.address()) } @@ -600,7 +617,8 @@ mod tests { .collect(); let signatures_required = 5; let network = NetworkKind::Regtest; - let wallet1 = SignerWallet::new(&signer_keys, signatures_required, network, 0).unwrap(); + let public_keys = signer_keys.clone(); + let wallet1 = SignerWallet::new(public_keys, signatures_required, network, 0).unwrap(); // Let's store the key information about this wallet into the database let rotate_keys = RotateKeysTransaction { @@ -618,8 +636,12 @@ mod tests { .unwrap(); // We haven't stored any RotateKeysTransactions into the database - // yet, so loading the wallet should fail. - assert!(SignerWallet::load(&ctx, &bitcoin_chain_tip).await.is_err()); + // yet, so it will try to load the wallet from the context. + let ans = SignerWallet::load(&ctx, &bitcoin_chain_tip).await.unwrap(); + let config = &ctx.config().signer; + let bootstrap_aggregate_key = + PublicKey::combine_keys(&config.bootstrap_signing_set()).unwrap(); + assert_eq!(ans.aggregate_key, bootstrap_aggregate_key); let tx = model::StacksTransaction { txid: rotate_keys.txid, diff --git a/signer/src/storage/in_memory.rs b/signer/src/storage/in_memory.rs index f3667a41..d614b95d 100644 --- a/signer/src/storage/in_memory.rs +++ b/signer/src/storage/in_memory.rs @@ -433,6 +433,19 @@ impl super::DbRead for SharedStore { .cloned()) } + async fn get_encrypted_dkg_shares_by_signing_set( + &self, + signer_set_aggregate_key: &PublicKey, + ) -> Result, Error> { + Ok(self + .lock() + .await + .encrypted_dkg_shares + .values() + .find(|shares| &shares.signer_set_aggregate_key == signer_set_aggregate_key) + .cloned()) + } + async fn get_last_key_rotation( &self, chain_tip: &model::BitcoinBlockHash, diff --git a/signer/src/storage/mod.rs b/signer/src/storage/mod.rs index 2a65cb60..1b9059c9 100644 --- a/signer/src/storage/mod.rs +++ b/signer/src/storage/mod.rs @@ -121,6 +121,14 @@ pub trait DbRead { aggregate_key: &PublicKey, ) -> impl Future, Error>> + Send; + /// Return the applicable DKG shares for the given aggregate key of the + /// signing set. If there are more than one such key, this function + /// returns the one that was stored most recently. + fn get_encrypted_dkg_shares_by_signing_set( + &self, + signer_set_aggregate_key: &PublicKey, + ) -> impl Future, Error>> + Send; + /// Return the latest rotate-keys transaction confirmed by the given `chain-tip`. fn get_last_key_rotation( &self, diff --git a/signer/src/storage/model.rs b/signer/src/storage/model.rs index 73ada7b8..4c37ca7e 100644 --- a/signer/src/storage/model.rs +++ b/signer/src/storage/model.rs @@ -310,6 +310,9 @@ pub struct SweptWithdrawalRequest { } /// Persisted DKG shares +/// +/// This struct represents the output of a successful run of distributed +/// key generation (DKG) that was run by a set of signers. #[derive(Debug, Clone, Hash, PartialEq, Eq, PartialOrd, Ord, sqlx::FromRow)] #[cfg_attr(feature = "testing", derive(fake::Dummy))] pub struct EncryptedDkgShares { @@ -323,6 +326,11 @@ pub struct EncryptedDkgShares { pub encrypted_private_shares: Bytes, /// The public DKG shares pub public_shares: Bytes, + /// The set of public keys that were a party to the DKG. + pub signer_set_public_keys: Vec, + /// The aggregate key of the public keys that formed a party during the + /// DKG associated with this struct instance. + pub signer_set_aggregate_key: PublicKey, } /// Persisted public DKG shares from other signers diff --git a/signer/src/storage/postgres.rs b/signer/src/storage/postgres.rs index eddddd1f..32d6db48 100644 --- a/signer/src/storage/postgres.rs +++ b/signer/src/storage/postgres.rs @@ -971,6 +971,8 @@ impl super::DbRead for PgStore { , script_pubkey , encrypted_private_shares , public_shares + , signer_set_public_keys + , signer_set_aggregate_key FROM sbtc_signer.dkg_shares WHERE aggregate_key = $1; "#, @@ -981,6 +983,32 @@ impl super::DbRead for PgStore { .map_err(Error::SqlxQuery) } + async fn get_encrypted_dkg_shares_by_signing_set( + &self, + signer_set_aggregate_key: &PublicKey, + ) -> Result, Error> { + sqlx::query_as::<_, model::EncryptedDkgShares>( + r#" + SELECT + aggregate_key + , tweaked_aggregate_key + , script_pubkey + , encrypted_private_shares + , public_shares + , signer_set_public_keys + , signer_set_aggregate_key + FROM sbtc_signer.dkg_shares + WHERE signer_set_aggregate_key = $1 + ORDER BY created_at DESC + LIMIT 1; + "#, + ) + .bind(signer_set_aggregate_key) + .fetch_optional(&self.0) + .await + .map_err(Error::SqlxQuery) + } + /// Find the last key rotation by iterating backwards from the stacks /// chain tip scanning all transactions until we encounter a key /// rotation transactions. @@ -1748,8 +1776,10 @@ impl super::DbWrite for PgStore { , encrypted_private_shares , public_shares , script_pubkey + , signer_set_public_keys + , signer_set_aggregate_key ) - VALUES ($1, $2, $3, $4, $5) + VALUES ($1, $2, $3, $4, $5, $6, $7) ON CONFLICT DO NOTHING"#, ) .bind(shares.aggregate_key) @@ -1757,6 +1787,8 @@ impl super::DbWrite for PgStore { .bind(&shares.encrypted_private_shares) .bind(&shares.public_shares) .bind(&shares.script_pubkey) + .bind(&shares.signer_set_public_keys) + .bind(&shares.signer_set_aggregate_key) .execute(&self.0) .await .map_err(Error::SqlxQuery)?; diff --git a/signer/src/testing/dummy.rs b/signer/src/testing/dummy.rs index c5a4de89..6a31dcc2 100644 --- a/signer/src/testing/dummy.rs +++ b/signer/src/testing/dummy.rs @@ -222,12 +222,16 @@ pub fn encrypted_dkg_shares( .encode_to_vec() .expect("encoding to vec failed"); + let signer_public_key: PublicKey = fake::Faker.fake_with_rng(rng); + model::EncryptedDkgShares { aggregate_key: group_key, encrypted_private_shares, public_shares, tweaked_aggregate_key: group_key.signers_tweaked_pubkey().unwrap(), script_pubkey: group_key.signers_script_pubkey().into(), + signer_set_aggregate_key: signer_public_key, + signer_set_public_keys: vec![signer_public_key], } } diff --git a/signer/src/testing/transaction_coordinator.rs b/signer/src/testing/transaction_coordinator.rs index 4502a5fc..114008fc 100644 --- a/signer/src/testing/transaction_coordinator.rs +++ b/signer/src/testing/transaction_coordinator.rs @@ -63,7 +63,9 @@ where private_key, context_window, threshold, + bitcoin_network: bitcoin::Network::Regtest, signing_round_max_duration: Duration::from_secs(10), + dkg_max_duration: Duration::from_secs(10), }, context, is_started: Arc::new(AtomicBool::new(false)), diff --git a/signer/src/testing/wallet.rs b/signer/src/testing/wallet.rs index 28aceaf4..0b66dce1 100644 --- a/signer/src/testing/wallet.rs +++ b/signer/src/testing/wallet.rs @@ -10,10 +10,9 @@ use blockstack_lib::util_lib::strings::StacksString; use clarity::vm::types::TupleData; use clarity::vm::ClarityName; use clarity::vm::Value as ClarityValue; -use rand::rngs::StdRng; -use rand::SeedableRng as _; use sbtc::testing::regtest::Recipient; use secp256k1::Keypair; +use secp256k1::SECP256K1; use stacks_common::types::chainstate::StacksAddress; use crate::config::NetworkKind; @@ -32,20 +31,20 @@ use crate::stacks::wallet::SignerWallet; /// address in the default config file. pub static WALLET: LazyLock<(SignerWallet, [Keypair; 3])> = LazyLock::new(generate_wallet); -/// Helper function for generating a test 2-3 multi-sig wallet +/// Helper function for generating a test 2-3 multi-sig wallet. pub fn generate_wallet() -> (SignerWallet, [Keypair; 3]) { - let mut rng = StdRng::seed_from_u64(100); let signatures_required = 2; let key_pairs = [ - Keypair::new_global(&mut rng), - Keypair::new_global(&mut rng), - Keypair::new_global(&mut rng), - ]; + "41634762d89dfa09133a4a8e9c1378d0161d29cd0a9433b51f1e3d32947a73dc", + "9bfecf16c9c12792589dd2b843f850d5b89b81a04f8ab91c083bdf6709fbefee", + "3ec0ca5770a356d6cd1a9bfcbf6cd151eb1bd85c388cc00648ec4ef5853fdb74", + ] + .map(|sk| Keypair::from_seckey_str(SECP256K1, sk).unwrap()); let public_keys = key_pairs.map(|kp| kp.public_key().into()); let wallet = - SignerWallet::new(&public_keys, signatures_required, NetworkKind::Testnet, 0).unwrap(); + SignerWallet::new(public_keys, signatures_required, NetworkKind::Testnet, 0).unwrap(); (wallet, key_pairs) } diff --git a/signer/src/transaction_coordinator.rs b/signer/src/transaction_coordinator.rs index de06f18f..24272b02 100644 --- a/signer/src/transaction_coordinator.rs +++ b/signer/src/transaction_coordinator.rs @@ -15,6 +15,7 @@ use crate::bitcoin::utxo; use crate::bitcoin::BitcoinInteract; use crate::context::TxSignerEvent; use crate::context::{messaging::SignerEvent, messaging::SignerSignal, Context}; +use crate::ecdsa::SignEcdsa as _; use crate::error::Error; use crate::keys::PrivateKey; use crate::keys::PublicKey; @@ -30,11 +31,13 @@ use crate::stacks::wallet::MultisigTx; use crate::stacks::wallet::SignerWallet; use crate::storage::model; use crate::storage::DbRead as _; -use crate::wsts_state_machine; +use crate::wsts_state_machine::CoordinatorStateMachine; -use crate::ecdsa::SignEcdsa as _; use bitcoin::hashes::Hash as _; use wsts::state_machine::coordinator::Coordinator as _; +use wsts::state_machine::coordinator::State as WstsCoordinatorState; +use wsts::state_machine::OperationResult as WstsOperationResult; +use wsts::state_machine::StateMachine as _; #[cfg_attr(doc, aquamarine::aquamarine)] /// # Transaction coordinator event loop @@ -117,10 +120,17 @@ pub struct TxCoordinatorEventLoop { pub private_key: PrivateKey, /// the number of signatures required. pub threshold: u16, - /// How many bitcoin blocks back from the chain tip the signer will look for requests. + /// How many bitcoin blocks back from the chain tip the signer will + /// look for requests. pub context_window: u16, - /// The maximum duration of a signing round before the coordinator will time out and return an error. + /// The bitcoin network we're targeting + pub bitcoin_network: bitcoin::Network, + /// The maximum duration of a signing round before the coordinator will + /// time out and return an error. pub signing_round_max_duration: std::time::Duration, + /// The maximum duration of distributed key generation before the + /// coordinator will time out and return an error. + pub dkg_max_duration: std::time::Duration, } impl TxCoordinatorEventLoop @@ -177,6 +187,12 @@ where .await? .ok_or(Error::NoChainTip)?; + if self.needs_dkg(&bitcoin_chain_tip).await? == DkgState::NeedsDkg { + // This function returns the new DKG aggregate key. That + // aggregate key is different from aggregate key of the signers. + let _ = self.coordinate_dkg(&bitcoin_chain_tip).await?; + } + let (aggregate_key, signer_public_keys) = self .get_signer_public_keys_and_aggregate_key(&bitcoin_chain_tip) .await?; @@ -244,10 +260,12 @@ where /// determined by the public keys and threshold stored in the last /// [`RotateKeysTransaction`] object that is returned from the /// database. - /// 2. Fetch all "finalizable" requests from the database. These are - /// requests that where we have a response transactions on bitcoin - /// fulfilling the deposit or withdrawal request. - /// 3. Construct a sign-request object for each finalizable request. + /// 2. Fetch all requests from the database where we can finish the + /// fulfillment with only a Stacks transaction. These are requests + /// that where we have a response transactions on bitcoin fulfilling + /// the deposit or withdrawal request. + /// 3. Construct a sign-request object for each of the requests + /// identified in (2). /// 4. Broadcast this sign-request to the network and wait for /// responses. /// 5. If there are enough signatures then broadcast the transaction. @@ -375,7 +393,7 @@ where signer_public_keys: &BTreeSet, mut transaction: utxo::UnsignedTransaction<'_>, ) -> Result<(), Error> { - let mut coordinator_state_machine = wsts_state_machine::CoordinatorStateMachine::load( + let mut coordinator_state_machine = CoordinatorStateMachine::load( &mut self.context.get_storage_mut(), *aggregate_key, signer_public_keys.clone(), @@ -451,45 +469,79 @@ where Ok(()) } - #[tracing::instrument(skip(self))] + #[tracing::instrument(skip_all)] async fn coordinate_signing_round( &mut self, bitcoin_chain_tip: &model::BitcoinBlockHash, - coordinator_state_machine: &mut wsts_state_machine::CoordinatorStateMachine, + coordinator_state_machine: &mut CoordinatorStateMachine, txid: bitcoin::Txid, msg: &[u8], ) -> Result { let outbound = coordinator_state_machine .start_signing_round(msg, true, None) - .map_err(wsts_state_machine::coordinator_error)?; + .map_err(Error::wsts_coordinator)?; let msg = message::WstsMessage { txid, inner: outbound.msg }; self.send_message(msg, bitcoin_chain_tip).await?; let max_duration = self.signing_round_max_duration; - let run_signing_round = self.relay_messages_to_wsts_state_machine_until_signature_created( - bitcoin_chain_tip, - coordinator_state_machine, - txid, - ); + let run_signing_round = + self.drive_wsts_state_machine(bitcoin_chain_tip, coordinator_state_machine, txid); - tokio::time::timeout(max_duration, run_signing_round) + let operation_result = tokio::time::timeout(max_duration, run_signing_round) .await - .map_err(|_| Error::CoordinatorTimeout(self.signing_round_max_duration.as_secs()))? + .map_err(|_| Error::CoordinatorTimeout(max_duration.as_secs()))??; + + match operation_result { + WstsOperationResult::SignTaproot(signature) => Ok(signature), + _ => Err(Error::UnexpectedOperationResult), + } } - #[tracing::instrument(skip(self))] - async fn relay_messages_to_wsts_state_machine_until_signature_created( + #[tracing::instrument(skip_all)] + async fn coordinate_dkg( + &mut self, + chain_tip: &model::BitcoinBlockHash, + ) -> Result { + let mut state_machine = CoordinatorStateMachine::new([], self.threshold, self.private_key); + state_machine + .move_to(WstsCoordinatorState::DkgPublicDistribute) + .map_err(Error::wsts_coordinator)?; + + let outbound = state_machine + .start_public_shares() + .map_err(Error::wsts_coordinator)?; + + let identifier = self.coordinator_id(); + let txid = bitcoin::Txid::from_byte_array(identifier); + let msg = message::WstsMessage { txid, inner: outbound.msg }; + self.send_message(msg, chain_tip).await?; + + let max_duration = self.dkg_max_duration; + let dkg_fut = self.drive_wsts_state_machine(chain_tip, &mut state_machine, txid); + + let operation_result = tokio::time::timeout(max_duration, dkg_fut) + .await + .map_err(|_| Error::CoordinatorTimeout(max_duration.as_secs()))??; + + match operation_result { + WstsOperationResult::Dkg(aggregate_key) => PublicKey::try_from(&aggregate_key), + _ => Err(Error::UnexpectedOperationResult), + } + } + + #[tracing::instrument(skip_all)] + async fn drive_wsts_state_machine( &mut self, bitcoin_chain_tip: &model::BitcoinBlockHash, - coordinator_state_machine: &mut wsts_state_machine::CoordinatorStateMachine, + coordinator_state_machine: &mut CoordinatorStateMachine, txid: bitcoin::Txid, - ) -> Result { + ) -> Result { loop { let msg = self.network.receive().await?; if &msg.bitcoin_chain_tip != bitcoin_chain_tip { - tracing::warn!(?msg, "concurrent wsts signing round message observed"); + tracing::warn!(?msg, "concurrent WSTS activity observed"); continue; } @@ -517,15 +569,40 @@ where } match operation_result { - Some(wsts::state_machine::OperationResult::SignTaproot(signature)) => { - return Ok(signature) - } + Some(res) => return Ok(res), None => continue, - Some(_) => return Err(Error::UnexpectedOperationResult), } } } + /// Check whether or not we need to run DKG + /// + /// This function checks for the existence of a row in the database, + /// and one does exist the DKG has completed and the results are known + /// to the network. If such a transaction does not exist then we check + /// the `dkg_shares` table to know if we either need to run DKG or just + /// submit a `rotate-keys` transaction. + async fn needs_dkg(&self, chain_tip: &model::BitcoinBlockHash) -> Result { + let db = self.context.get_storage(); + let last_key_rotation = db.get_last_key_rotation(chain_tip).await?; + + if last_key_rotation.is_some() { + return Ok(DkgState::DkgComplete); + } + + let signer_keys = self.context.config().signer.bootstrap_signing_set(); + let signer_set_aggregate_key = PublicKey::combine_keys(&signer_keys)?; + + let shares = db + .get_encrypted_dkg_shares_by_signing_set(&signer_set_aggregate_key) + .await?; + + match shares { + Some(_) => Ok(DkgState::NeedsRotateKeysTransaction), + _ => Ok(DkgState::NeedsDkg), + } + } + // Determine if the current coordinator is the coordinator. // // The coordinator is decided using the hash of the bitcoin @@ -654,22 +731,43 @@ where &mut self, bitcoin_chain_tip: &model::BitcoinBlockHash, ) -> Result<(PublicKey, BTreeSet), Error> { - let last_key_rotation = self - .context - .get_storage() - .get_last_key_rotation(bitcoin_chain_tip) - .await? - .ok_or(Error::MissingKeyRotation)?; + let db = self.context.get_storage(); + let last_key_rotation = db.get_last_key_rotation(bitcoin_chain_tip).await?; - let aggregate_key = last_key_rotation.aggregate_key; - let signer_set = last_key_rotation.signer_set.into_iter().collect(); - Ok((aggregate_key, signer_set)) + match last_key_rotation { + Some(last_key) => { + let aggregate_key = last_key.aggregate_key; + let signer_set = last_key.signer_set.into_iter().collect(); + Ok((aggregate_key, signer_set)) + } + None => { + let signer_set = self.context.config().signer.bootstrap_signing_set(); + let signer_set_aggregate_key = PublicKey::combine_keys(&signer_set)?; + let shares = db + .get_encrypted_dkg_shares_by_signing_set(&signer_set_aggregate_key) + .await? + .ok_or(Error::MissingDkgShares)?; + Ok((shares.aggregate_key, signer_set)) + } + } } fn pub_key(&self) -> PublicKey { PublicKey::from_private_key(&self.private_key) } + /// This function provides a deterministic 32-byte identifier for the + /// signer. + /// + /// TODO: Maybe this should change deterministically with the bitcoin + /// chain tip. + fn coordinator_id(&self) -> [u8; 32] { + sha2::Sha256::new_with_prefix("SIGNER_COORDINATOR_ID") + .chain_update(self.pub_key().serialize()) + .finalize() + .into() + } + #[tracing::instrument(skip(self, msg))] async fn send_message( &mut self, @@ -687,6 +785,22 @@ where } } +/// A struct describing the state of the signers with respect to +/// distributed key generation (DKG). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DkgState { + /// This is the state of the things when the signers boot for the first + /// time. In this case, we need to run DKG and store the shares in the + /// database. + NeedsDkg, + /// This implies that DKG has been run, but there is no rotate-keys + /// transaction in the database on the canonical Stacks blockchain. + NeedsRotateKeysTransaction, + /// This implies that we have run DKG and have confirmed a rotate-keys + /// transaction. + DkgComplete, +} + /// Check if the provided public key is the coordinator for the provided chain tip pub fn given_key_is_coordinator( pub_key: PublicKey, @@ -708,11 +822,12 @@ pub fn coordinator_public_key( let mut hasher = sha2::Sha256::new(); hasher.update(bitcoin_chain_tip.into_bytes()); let digest = hasher.finalize(); - let index = usize::from_be_bytes(*digest.first_chunk().ok_or(Error::TypeConversion)?); + let index = u64::from_be_bytes(*digest.first_chunk().ok_or(Error::TypeConversion)?); + let num_signers = u64::try_from(signer_public_keys.len()).map_err(|_| Error::TypeConversion)?; Ok(signer_public_keys .iter() - .nth(index % signer_public_keys.len()) + .nth(usize::try_from(index % num_signers).map_err(|_| Error::TypeConversion)?) .copied()) } diff --git a/signer/src/transaction_signer.rs b/signer/src/transaction_signer.rs index 0df95e37..4e0f9141 100644 --- a/signer/src/transaction_signer.rs +++ b/signer/src/transaction_signer.rs @@ -144,7 +144,7 @@ where let mut term = self.context.get_termination_handle(); // TODO: We should really split these operations out into two separate - // main run-loops since they don't have anything to do with eachother. + // main run-loops since they don't have anything to do with each other. // // We run the event loop like this because `tokio::select!()` could // potentially kill either `handle_new_requests()` or `handle_signer_message()` @@ -156,7 +156,7 @@ where // new Bitcoin block observed events. It doesn't matter how many // of these we get, we only care if it has happened. It's also // important that we empty this channel as quickly as possible - // to avoid un-processed messagages being dropped. + // to avoid un-processed messages being dropped. let mut new_block_observed = false; while let Ok(signal) = signal_rx.try_recv() { if let SignerSignal::Event(SignerEvent::BitcoinBlockObserved) = signal { @@ -323,20 +323,13 @@ where .map(|canonical_chain_tip| &canonical_chain_tip == bitcoin_chain_tip) .unwrap_or(false); - let sender_is_coordinator = if let Some(last_key_rotation) = - storage.get_last_key_rotation(bitcoin_chain_tip).await? - { - let signer_set: BTreeSet = - last_key_rotation.signer_set.into_iter().collect(); - - crate::transaction_coordinator::given_key_is_coordinator( - msg_sender, - bitcoin_chain_tip, - &signer_set, - )? - } else { - false - }; + let signer_set = self.get_signer_public_keys(bitcoin_chain_tip).await?; + + let sender_is_coordinator = crate::transaction_coordinator::given_key_is_coordinator( + msg_sender, + bitcoin_chain_tip, + &signer_set, + )?; let chain_tip_status = match (is_known, is_canonical) { (true, true) => ChainTipStatus::Canonical, @@ -389,7 +382,7 @@ where } async fn is_valid_bitcoin_transaction_sign_request( - &mut self, + &self, _request: &message::BitcoinTransactionSignRequest, ) -> Result { let signer_pub_key = self.signer_pub_key(); @@ -514,7 +507,7 @@ where .. }) => { tracing::info!("DKG ended in failure: {fail:?}"); - // TODO(#414): handle DKG failute + // TODO(#414): handle DKG failure } wsts::net::Message::NonceResponse(_) | wsts::net::Message::SignatureShareResponse(_) => { @@ -559,7 +552,7 @@ where /// TODO(#380): This function needs to filter deposit requests based on /// time as well. We need to do this because deposit requests are locked - /// using OP_CSV, which lock up coins based on block hieght or + /// using OP_CSV, which lock up coins based on block height or /// multiples of 512 seconds measure by the median time past. #[tracing::instrument(skip(self))] async fn get_pending_deposit_requests( @@ -762,21 +755,17 @@ where Ok(()) } - #[tracing::instrument(skip(self))] + #[tracing::instrument(skip_all)] async fn get_signer_public_keys( - &mut self, - bitcoin_chain_tip: &model::BitcoinBlockHash, + &self, + chain_tip: &model::BitcoinBlockHash, ) -> Result, Error> { - let last_key_rotation = self - .context - .get_storage() - .get_last_key_rotation(bitcoin_chain_tip) - .await? - .ok_or(Error::MissingKeyRotation)?; - - let signer_set = last_key_rotation.signer_set.into_iter().collect(); + let db = self.context.get_storage(); - Ok(signer_set) + match db.get_last_key_rotation(chain_tip).await? { + Some(last_key) => Ok(last_key.signer_set.into_iter().collect()), + None => Ok(self.context.config().signer.bootstrap_signing_set()), + } } fn signer_pub_key(&self) -> PublicKey { @@ -790,7 +779,7 @@ where struct MsgChainTipReport { /// Whether the sender of the incoming message is the coordinator for this chain tip. sender_is_coordinator: bool, - /// The status of the chain tip relative to the signers perspective. + /// The status of the chain tip relative to the signers' perspective. chain_tip_status: ChainTipStatus, } diff --git a/signer/src/wsts_state_machine.rs b/signer/src/wsts_state_machine.rs index 6ed08f4a..86f2b196 100644 --- a/signer/src/wsts_state_machine.rs +++ b/signer/src/wsts_state_machine.rs @@ -122,6 +122,15 @@ impl SignerStateMachine { let saved_state = self.signer.save(); let aggregate_key = PublicKey::try_from(&saved_state.group_key)?; + let mut signer_set_public_keys = self + .public_keys + .signers + .values() + .map(PublicKey::from) + .collect::>(); + + signer_set_public_keys.sort(); + let encoded = saved_state.encode_to_vec().map_err(error::Error::Codec)?; let public_shares = self .dkg_public_shares @@ -139,6 +148,8 @@ impl SignerStateMachine { script_pubkey: aggregate_key.signers_script_pubkey().into(), encrypted_private_shares, public_shares, + signer_set_aggregate_key: PublicKey::combine_keys(&signer_set_public_keys)?, + signer_set_public_keys, }) } } @@ -253,11 +264,11 @@ impl CoordinatorStateMachine { // starts at 0 and we start our's at 1. let (Some(_), _) = coordinator .process_message(&packet) - .map_err(coordinator_error)? + .map_err(Error::wsts_coordinator)? else { let msg = "Bad DKG id given".to_string(); let err = wsts::state_machine::coordinator::Error::BadStateChange(msg); - return Err(coordinator_error(err)); + return Err(Error::wsts_coordinator(err)); }; // TODO(338): Replace this for-loop with a simpler method to set @@ -277,7 +288,7 @@ impl CoordinatorStateMachine { // process them. coordinator .process_message(&packet) - .map_err(coordinator_error)?; + .map_err(Error::wsts_coordinator)?; } // Once we've processed all DKG public shares for all participants, @@ -295,7 +306,7 @@ impl CoordinatorStateMachine { coordinator .move_to(WstsState::Idle) - .map_err(coordinator_error)?; + .map_err(Error::wsts_coordinator)?; Ok(coordinator) } @@ -314,8 +325,3 @@ impl std::ops::DerefMut for CoordinatorStateMachine { &mut self.0 } } - -/// Convert a coordinator error to an `error::Error` -pub fn coordinator_error(err: wsts::state_machine::coordinator::Error) -> error::Error { - error::Error::WstsCoordinator(Box::new(err)) -} diff --git a/signer/tests/fixtures/completed-deposit-event.json b/signer/tests/fixtures/completed-deposit-event.json index 0f727383..77642fe6 100644 --- a/signer/tests/fixtures/completed-deposit-event.json +++ b/signer/tests/fixtures/completed-deposit-event.json @@ -23,7 +23,7 @@ { "committed": true, "contract_event": { - "contract_identifier": "SN2V7WTJ7BHR03MPHZ1C9A9ZR6NZGR4WM8HT4V67Y.sbtc-registry", + "contract_identifier": "SN3R84XZYA63QS28932XQF3G1J8R9PC3W76P9CSQS.sbtc-registry", "raw_value": "0x0c0000000406616d6f756e7401000000000000000000000000075ed2850c626974636f696e2d74786964020000002000000000000000000000000000000000000000000000000000000000000000000c6f75747075742d696e64657801000000000000000000000000ffffffff05746f7069630d00000011636f6d706c657465642d6465706f736974", "topic": "print", "value": { diff --git a/signer/tests/fixtures/withdrawal-accept-event.json b/signer/tests/fixtures/withdrawal-accept-event.json index a1efe9eb..f670392f 100644 --- a/signer/tests/fixtures/withdrawal-accept-event.json +++ b/signer/tests/fixtures/withdrawal-accept-event.json @@ -23,7 +23,7 @@ { "committed": true, "contract_event": { - "contract_identifier": "SN2V7WTJ7BHR03MPHZ1C9A9ZR6NZGR4WM8HT4V67Y.sbtc-registry", + "contract_identifier": "SN3R84XZYA63QS28932XQF3G1J8R9PC3W76P9CSQS.sbtc-registry", "raw_value": "0x0c000000060c626974636f696e2d74786964020000002000000000000000000000000000000000000000000000000000000000000000000366656501000000000000000000000000000009c40c6f75747075742d696e64657801000000000000000000000000ffffffff0a726571756573742d696401000000000000000000000000000000010d7369676e65722d6269746d6170010000000000000000000000000000000005746f7069630d000000117769746864726177616c2d616363657074", "topic": "print", "value": { diff --git a/signer/tests/fixtures/withdrawal-create-event.json b/signer/tests/fixtures/withdrawal-create-event.json index a7907da9..7fe745c9 100644 --- a/signer/tests/fixtures/withdrawal-create-event.json +++ b/signer/tests/fixtures/withdrawal-create-event.json @@ -23,7 +23,7 @@ { "committed": true, "contract_event": { - "contract_identifier": "SN2V7WTJ7BHR03MPHZ1C9A9ZR6NZGR4WM8HT4V67Y.sbtc-registry", + "contract_identifier": "SN3R84XZYA63QS28932XQF3G1J8R9PC3W76P9CSQS.sbtc-registry", "raw_value": "0x0c0000000706616d6f756e7401000000000000000000000000000057e40c626c6f636b2d6865696768740100000000000000000000000000000089076d61782d6665650100000000000000000000000000000bb809726563697069656e740c0000000209686173686279746573020000001400000000000000000000000000000000000000000776657273696f6e0200000001000a726571756573742d696401000000000000000000000000000000010673656e6465720515b67e6a475c7001d2d1f8589527f8357f0c13944405746f7069630d000000117769746864726177616c2d637265617465", "topic": "print", "value": { diff --git a/signer/tests/fixtures/withdrawal-reject-event.json b/signer/tests/fixtures/withdrawal-reject-event.json index d5404b78..6f56b88f 100644 --- a/signer/tests/fixtures/withdrawal-reject-event.json +++ b/signer/tests/fixtures/withdrawal-reject-event.json @@ -23,7 +23,7 @@ { "committed": true, "contract_event": { - "contract_identifier": "SN2V7WTJ7BHR03MPHZ1C9A9ZR6NZGR4WM8HT4V67Y.sbtc-registry", + "contract_identifier": "SN3R84XZYA63QS28932XQF3G1J8R9PC3W76P9CSQS.sbtc-registry", "raw_value": "0x0c000000030a726571756573742d696401000000000000000000000000000000020d7369676e65722d6269746d6170010000000000000000000000000000000005746f7069630d000000117769746864726177616c2d72656a656374", "topic": "print", "value": { diff --git a/signer/tests/integration/postgres.rs b/signer/tests/integration/postgres.rs index 94e6993f..b8746f9c 100644 --- a/signer/tests/integration/postgres.rs +++ b/signer/tests/integration/postgres.rs @@ -1294,6 +1294,7 @@ async fn is_signer_script_pub_key_checks_dkg_shares_for_script_pubkeys() { // Okay let's put a row in the dkg_shares table. let aggregate_key: PublicKey = fake::Faker.fake_with_rng(&mut rng); + let signer_set_aggregate_key: PublicKey = fake::Faker.fake_with_rng(&mut rng); let script_pubkey: ScriptPubKey = aggregate_key.signers_script_pubkey().into(); let shares = EncryptedDkgShares { script_pubkey: script_pubkey.clone(), @@ -1301,6 +1302,8 @@ async fn is_signer_script_pub_key_checks_dkg_shares_for_script_pubkeys() { encrypted_private_shares: Vec::new(), public_shares: Vec::new(), aggregate_key, + signer_set_aggregate_key, + signer_set_public_keys: vec![signer_set_aggregate_key], }; db.write_encrypted_dkg_shares(&shares).await.unwrap(); mem.write_encrypted_dkg_shares(&shares).await.unwrap(); @@ -1344,9 +1347,11 @@ async fn get_signers_script_pubkeys_returns_non_empty_vec_old_rows() { , encrypted_private_shares , public_shares , script_pubkey + , signer_set_public_keys + , signer_set_aggregate_key , created_at ) - VALUES ($1, $2, $3, $4, $5, CURRENT_TIMESTAMP - INTERVAL '366 DAYS') + VALUES ($1, $2, $3, $4, $5, $6, $7, CURRENT_TIMESTAMP - INTERVAL '366 DAYS') ON CONFLICT DO NOTHING"#, ) .bind(shares.aggregate_key) @@ -1354,6 +1359,8 @@ async fn get_signers_script_pubkeys_returns_non_empty_vec_old_rows() { .bind(&shares.encrypted_private_shares) .bind(&shares.public_shares) .bind(&shares.script_pubkey) + .bind(&shares.signer_set_public_keys) + .bind(&shares.signer_set_aggregate_key) .execute(db.pool()) .await .unwrap(); diff --git a/signer/tests/integration/setup.rs b/signer/tests/integration/setup.rs index 3dc97a41..898c1373 100644 --- a/signer/tests/integration/setup.rs +++ b/signer/tests/integration/setup.rs @@ -324,6 +324,8 @@ impl TestSweepSetup { encrypted_private_shares: Vec::new(), public_shares: Vec::new(), aggregate_key, + signer_set_public_keys: self.signer_keys.clone(), + signer_set_aggregate_key: PublicKey::combine_keys(&self.signer_keys).unwrap(), }; db.write_encrypted_dkg_shares(&shares).await.unwrap(); } diff --git a/signer/tests/service-configs/stacks-node.toml b/signer/tests/service-configs/stacks-node.toml index 1edd41af..fecbd6bb 100644 --- a/signer/tests/service-configs/stacks-node.toml +++ b/signer/tests/service-configs/stacks-node.toml @@ -107,22 +107,27 @@ epoch_name = "3.0" start_height = 1000001 [[ustx_balance]] -# secret_key: 99dd7fc1ad584d9b174275ef9de7bda04fc61e38899fdce22fd31a49f3fc47d6 -address = "ST1RQHF4VE5CZ6EK3MZPZVQBA0JVSMM9H5PMHMS1Y" +# secret_key: 41634762d89dfa09133a4a8e9c1378d0161d29cd0a9433b51f1e3d32947a73dc01 +address = "ST24VB7FBXCBV6P0SRDSPSW0Y2J9XHDXNHW9Q8S7H" amount = 10000000000000000 [[ustx_balance]] -# secret_key: 440adaf1522f26e3d981d114c137090c6bf627ebb163b5cbb449c73f9659a003 -address = "ST1SJ3DTE5DN7X54YDH5D64R3BCB6A2AG2ZQ8YPD5" +# secret_key: 9bfecf16c9c12792589dd2b843f850d5b89b81a04f8ab91c083bdf6709fbefee01 +address = "ST2XAK68AR2TKBQBFNYSK9KN2AY9CVA91A7CSK63Z" +amount = 10000000000000000 + +[[ustx_balance]] +# secret_key: 3ec0ca5770a356d6cd1a9bfcbf6cd151eb1bd85c388cc00648ec4ef5853fdb7401 +address = "ST1J9R0VMA5GQTW65QVHW1KVSKD7MCGT27X37A551" amount = 10000000000000000 [[ustx_balance]] # This is a 2-3 multi-sig address controlled using the above three -# addresses. It was generated by calling secp256k1::Keypair::new_global -# three times using an rand::rng::StdRng struct created with a seed of 100. -# Once we had 3 public-private key pairs, the SignerWallet struct was used -# to generate the 2-3 multi sig address below. -address = "SN2V7WTJ7BHR03MPHZ1C9A9ZR6NZGR4WM8HT4V67Y" +# addresses. The above three accounts are also in the +# `devenv/local/docker-compose/sbtc-signer/README.md` file, and the +# resulting multi-sig address below was created using the SignerWallet +# struct. +address = "SN3R84XZYA63QS28932XQF3G1J8R9PC3W76P9CSQS" amount = 10000000000000000 [[ustx_balance]]