diff --git a/crates/e2e-tests/src/e2e_flow.rs b/crates/e2e-tests/src/e2e_flow.rs index 722ae0c4d..a777fbb6b 100644 --- a/crates/e2e-tests/src/e2e_flow.rs +++ b/crates/e2e-tests/src/e2e_flow.rs @@ -1317,6 +1317,34 @@ mod tests { Ok(()) } + #[tokio::test] + async fn test_mpc_config_defaults_match_rust() -> Result<()> { + init_test_logging(); + + let networks = TestNetworksBuilder::new().with_nodes(1).build().await?; + networks.hashi_network.nodes()[0] + .wait_for_mpc_key(Duration::from_secs(60)) + .await?; + + use hashi::onchain::types::DEFAULT_MPC_THRESHOLD_BASIS_POINTS; + use hashi::onchain::types::DEFAULT_MPC_WEIGHT_REDUCTION_ALLOWED_DELTA; + + let hashi = networks.hashi_network.nodes()[0].hashi(); + let threshold_bps = hashi.onchain_state().mpc_threshold_basis_points(); + let weight_reduction_allowed_delta = + hashi.onchain_state().mpc_weight_reduction_allowed_delta(); + + assert_eq!( + threshold_bps, DEFAULT_MPC_THRESHOLD_BASIS_POINTS, + "on-chain mpc_threshold_basis_points ({threshold_bps}) != Rust default ({DEFAULT_MPC_THRESHOLD_BASIS_POINTS})" + ); + assert_eq!( + weight_reduction_allowed_delta, DEFAULT_MPC_WEIGHT_REDUCTION_ALLOWED_DELTA, + "on-chain mpc_weight_reduction_allowed_delta ({weight_reduction_allowed_delta}) != Rust default ({DEFAULT_MPC_WEIGHT_REDUCTION_ALLOWED_DELTA})" + ); + Ok(()) + } + /// Verify that a withdrawal can spend a change output whose producing /// transaction is mined on Bitcoin but not yet confirmed on Sui. The /// actual Bitcoin confirmation count must be queried from the node diff --git a/crates/hashi/src/lib.rs b/crates/hashi/src/lib.rs index 79d53388d..522e25db2 100644 --- a/crates/hashi/src/lib.rs +++ b/crates/hashi/src/lib.rs @@ -28,9 +28,6 @@ pub mod tls; pub mod utxo_pool; pub mod withdrawals; -/// The allowed delta for weight reduction in basis points (800 means 8%). -/// This matches Sui's `random_beacon_reduction_allowed_delta` configuration. -const WEIGHT_REDUCTION_ALLOWED_DELTA: u16 = 800; // TODO: Tune based on production workload. const BATCH_SIZE_PER_WEIGHT: u16 = 10; @@ -220,7 +217,10 @@ impl Hashi { protocol_type: mpc::types::ProtocolType, ) -> anyhow::Result { let state = self.onchain_state().state(); - let committee_set = &state.hashi().committees; + let hashi = state.hashi(); + let committee_set = &hashi.committees; + let threshold_basis_points = hashi.config.mpc_threshold_basis_points(); + let weight_reduction_allowed_delta = hashi.config.mpc_weight_reduction_allowed_delta(); let session_id = mpc::SessionId::new(self.config.sui_chain_id(), epoch, &protocol_type); let encryption_key = self.config.encryption_private_key()?; self.db @@ -261,7 +261,8 @@ impl Hashi { encryption_key, signing_key, store, - WEIGHT_REDUCTION_ALLOWED_DELTA, + threshold_basis_points, + weight_reduction_allowed_delta, chain_id, self.config.test_weight_divisor, batch_size_per_weight, diff --git a/crates/hashi/src/mpc/mpc_except_signing.rs b/crates/hashi/src/mpc/mpc_except_signing.rs index e939b4a6e..391934a86 100644 --- a/crates/hashi/src/mpc/mpc_except_signing.rs +++ b/crates/hashi/src/mpc/mpc_except_signing.rs @@ -74,6 +74,7 @@ const ERR_PUBLISH_CERT_FAILED: &str = "Failed to publish certificate"; const EXPECT_THRESHOLD_VALIDATED: &str = "Threshold already validated"; const EXPECT_THRESHOLD_MET: &str = "Already checked earlier that threshold is met"; const EXPECT_SERIALIZATION_SUCCESS: &str = "Serialization should always succeed"; +const MAX_BASIS_POINTS: u32 = 10000; // DKG protocol // 1) A dealer sends out a message to all parties containing the encrypted shares and the public keys of the nonces. @@ -123,7 +124,8 @@ impl MpcManager { encryption_key: PrivateKey, signing_key: Bls12381PrivateKey, public_message_store: Box, - allowed_delta: u16, + threshold_basis_points: u16, + weight_reduction_allowed_delta: u16, chain_id: &str, weight_divisor: Option, batch_size_per_weight: u16, @@ -144,8 +146,12 @@ impl MpcManager { .get(&epoch) .ok_or_else(|| MpcError::InvalidConfig(format!("no committee for epoch {epoch}")))? .clone(); - // TODO: Pass t and f as arguments instead of computing them - let (nodes, threshold) = build_reduced_nodes(&committee, allowed_delta, weight_divisor)?; + let (nodes, threshold) = build_reduced_nodes( + &committee, + threshold_basis_points, + weight_reduction_allowed_delta, + weight_divisor, + )?; let total_weight = nodes.total_weight(); let max_faulty = ((total_weight - threshold) / 2).min(threshold - 1); let dkg_config = MpcConfig::new(epoch, nodes, threshold, max_faulty)?; @@ -199,8 +205,12 @@ impl MpcManager { }; let (previous_nodes, previous_threshold) = match previous_committee.as_ref() { Some(prev_committee) => { - let (nodes, threshold) = - build_reduced_nodes(prev_committee, allowed_delta, weight_divisor)?; + let (nodes, threshold) = build_reduced_nodes( + prev_committee, + threshold_basis_points, + weight_reduction_allowed_delta, + weight_divisor, + )?; (Some(nodes), Some(threshold)) } None => (None, None), @@ -3505,18 +3515,10 @@ fn compute_messages_hash(messages: &Messages) -> MessageHash { MessageHash::from(Blake2b256::digest(&bytes).digest) } -fn compute_bft_threshold(total_weight: u16) -> MpcResult { - if total_weight == 0 { - return Err(MpcError::InvalidConfig( - "committee has zero total weight".into(), - )); - } - Ok((total_weight - 1) / 3 + 1) -} - fn build_reduced_nodes( committee: &Committee, - allowed_delta: u16, + threshold_basis_points: u16, + weight_reduction_allowed_delta: u16, test_weight_divisor: u16, ) -> MpcResult<(Nodes, u16)> { let nodes_vec: Vec> = committee @@ -3530,8 +3532,9 @@ fn build_reduced_nodes( }) .collect(); let total_weight: u16 = nodes_vec.iter().map(|n| n.weight).sum(); - let threshold = compute_bft_threshold(total_weight)?; - Nodes::new_reduced(nodes_vec, threshold, allowed_delta, 1) + let threshold = + (total_weight as u32 * threshold_basis_points as u32).div_ceil(MAX_BASIS_POINTS) as u16; + Nodes::new_reduced(nodes_vec, threshold, weight_reduction_allowed_delta, 1) .map_err(|e| MpcError::CryptoError(e.to_string())) } diff --git a/crates/hashi/src/mpc/mpc_except_signing_tests.rs b/crates/hashi/src/mpc/mpc_except_signing_tests.rs index c4361e527..b62a86ce3 100644 --- a/crates/hashi/src/mpc/mpc_except_signing_tests.rs +++ b/crates/hashi/src/mpc/mpc_except_signing_tests.rs @@ -24,8 +24,9 @@ use std::sync::Arc; use std::sync::atomic::AtomicUsize; use std::sync::atomic::Ordering; -/// Use 0 for allowed_delta in tests to disable weight reduction. -const TEST_ALLOWED_DELTA: u16 = 0; +const TEST_THRESHOLD_BASIS_POINTS: u16 = 3333; +/// Use 0 for weight_reduction_allowed_delta in tests to disable weight reduction. +const TEST_WEIGHT_REDUCTION_ALLOWED_DELTA: u16 = 0; /// Use 1 for test_weight_divisor in unit tests (they already use small weights). const TEST_WEIGHT_DIVISOR: u16 = 1; const TEST_CHAIN_ID: &str = "testchain"; @@ -284,7 +285,8 @@ impl TestSetup { self.encryption_keys[validator_index].clone(), self.signing_keys[validator_index].clone(), store, - TEST_ALLOWED_DELTA, + TEST_THRESHOLD_BASIS_POINTS, + TEST_WEIGHT_REDUCTION_ALLOWED_DELTA, TEST_CHAIN_ID, None, TEST_BATCH_SIZE_PER_WEIGHT, @@ -897,7 +899,8 @@ fn test_mpc_manager_new_from_committee_set() { encryption_key, signing_key, Box::new(MockPublicMessagesStore), - TEST_ALLOWED_DELTA, + TEST_THRESHOLD_BASIS_POINTS, + TEST_WEIGHT_REDUCTION_ALLOWED_DELTA, TEST_CHAIN_ID, None, TEST_BATCH_SIZE_PER_WEIGHT, @@ -960,7 +963,8 @@ fn test_mpc_manager_new_fails_if_no_committee_for_epoch() { encryption_keys[0].clone(), signing_keys[0].clone(), Box::new(MockPublicMessagesStore), - TEST_ALLOWED_DELTA, + TEST_THRESHOLD_BASIS_POINTS, + TEST_WEIGHT_REDUCTION_ALLOWED_DELTA, "test", None, TEST_BATCH_SIZE_PER_WEIGHT, @@ -983,7 +987,8 @@ fn test_mpc_manager_new_with_weighted_committee() { let manager = setup.create_manager(0); - // With total_weight=15: max_faulty = (15-1)/3 = 4, threshold = 5 + // With total_weight=15, threshold=ceil(15*3333/10000)=ceil(4.9995)=5 + // max_faulty = min((15-5)/2, 5-1) = min(5, 4) = 4 assert_eq!(manager.mpc_config.threshold, 5); assert_eq!(manager.mpc_config.max_faulty, 4); } @@ -1262,7 +1267,7 @@ fn test_complete_dkg_success() { let mut rng = rand::thread_rng(); // Use different weights: [3, 2, 4, 1, 2] (total = 12) - // threshold = (12 - 1) / 3 + 1 = 4 + // threshold = ceil(12 * 3333 / 10000) = 4 let weights = [3, 2, 4, 1, 2]; let setup = TestSetup::with_weights(&weights); @@ -4756,8 +4761,8 @@ struct RotationTestSetup { impl RotationTestSetup { /// Creates a rotation test setup with weighted validators and completed DKG. - /// Uses weights [3, 2, 4, 1, 2] (total = 12, threshold = 4). - /// Dealers are validators 0, 1, 4 (total weight = 7 >= threshold). + /// Uses weights [3, 2, 4, 1, 2] (total = 12, threshold = ceil(12*3333/10000) = 4). + /// Dealers are validators 0, 1, 4 (total weight = 7 >= threshold + max_faulty = 7). fn new() -> Self { let mut rng = rand::thread_rng(); let weights = [3, 2, 4, 1, 2]; @@ -4816,8 +4821,13 @@ impl RotationTestSetup { /// This matches `run_as_party` behavior during live DKG. fn threshold_dealer_addresses(&self) -> Vec
{ let committee = self.setup.committee(); - let (nodes, threshold) = - build_reduced_nodes(committee, TEST_ALLOWED_DELTA, TEST_WEIGHT_DIVISOR).unwrap(); + let (nodes, threshold) = build_reduced_nodes( + committee, + TEST_THRESHOLD_BASIS_POINTS, + TEST_WEIGHT_REDUCTION_ALLOWED_DELTA, + TEST_WEIGHT_DIVISOR, + ) + .unwrap(); let mut result = Vec::new(); let mut weight_sum = 0u16; for addr in self.certificates.keys() { @@ -4837,8 +4847,13 @@ impl RotationTestSetup { fn prepare_for_rotation(&self, manager: &mut MpcManager) { let previous_committee = self.setup.committee_set.previous_committee().cloned(); if let Some(ref prev) = previous_committee { - let (nodes, threshold) = - build_reduced_nodes(prev, TEST_ALLOWED_DELTA, TEST_WEIGHT_DIVISOR).unwrap(); + let (nodes, threshold) = build_reduced_nodes( + prev, + TEST_THRESHOLD_BASIS_POINTS, + TEST_WEIGHT_REDUCTION_ALLOWED_DELTA, + TEST_WEIGHT_DIVISOR, + ) + .unwrap(); manager.previous_nodes = Some(nodes); manager.previous_threshold = Some(threshold); } @@ -5830,7 +5845,8 @@ async fn test_prepare_previous_output_for_new_member() { new_member_encryption_key, new_member_signing_key, Box::new(InMemoryPublicMessagesStore::new()), - TEST_ALLOWED_DELTA, + TEST_THRESHOLD_BASIS_POINTS, + TEST_WEIGHT_REDUCTION_ALLOWED_DELTA, TEST_CHAIN_ID, None, TEST_BATCH_SIZE_PER_WEIGHT, @@ -6965,7 +6981,8 @@ fn test_reconstruct_from_dkg_certificates_with_shifted_party_ids() { rotation_setup.setup.encryption_keys[shifted_member_index].clone(), rotation_setup.setup.signing_keys[shifted_member_index].clone(), Box::new(store), - TEST_ALLOWED_DELTA, + TEST_THRESHOLD_BASIS_POINTS, + TEST_WEIGHT_REDUCTION_ALLOWED_DELTA, TEST_CHAIN_ID, None, TEST_BATCH_SIZE_PER_WEIGHT, @@ -7127,7 +7144,8 @@ fn test_reconstruct_from_dkg_certificates_stops_at_threshold() { setup.encryption_keys[target_index].clone(), setup.signing_keys[target_index].clone(), Box::new(store), - TEST_ALLOWED_DELTA, + TEST_THRESHOLD_BASIS_POINTS, + TEST_WEIGHT_REDUCTION_ALLOWED_DELTA, TEST_CHAIN_ID, None, TEST_BATCH_SIZE_PER_WEIGHT, @@ -7207,7 +7225,8 @@ fn test_reconstruct_from_rotation_certificates_with_shifted_party_ids() { rotation_setup.setup.encryption_keys[dealer_idx].clone(), rotation_setup.setup.signing_keys[dealer_idx].clone(), Box::new(InMemoryPublicMessagesStore::new()), - TEST_ALLOWED_DELTA, + TEST_THRESHOLD_BASIS_POINTS, + TEST_WEIGHT_REDUCTION_ALLOWED_DELTA, TEST_CHAIN_ID, None, TEST_BATCH_SIZE_PER_WEIGHT, @@ -7238,7 +7257,8 @@ fn test_reconstruct_from_rotation_certificates_with_shifted_party_ids() { rotation_setup.setup.encryption_keys[other_idx].clone(), rotation_setup.setup.signing_keys[other_idx].clone(), Box::new(InMemoryPublicMessagesStore::new()), - TEST_ALLOWED_DELTA, + TEST_THRESHOLD_BASIS_POINTS, + TEST_WEIGHT_REDUCTION_ALLOWED_DELTA, TEST_CHAIN_ID, None, TEST_BATCH_SIZE_PER_WEIGHT, @@ -7325,7 +7345,8 @@ fn test_reconstruct_from_rotation_certificates_with_shifted_party_ids() { rotation_setup.setup.encryption_keys[shifted_member_index].clone(), rotation_setup.setup.signing_keys[shifted_member_index].clone(), Box::new(store), - TEST_ALLOWED_DELTA, + TEST_THRESHOLD_BASIS_POINTS, + TEST_WEIGHT_REDUCTION_ALLOWED_DELTA, TEST_CHAIN_ID, None, TEST_BATCH_SIZE_PER_WEIGHT, diff --git a/crates/hashi/src/onchain/mod.rs b/crates/hashi/src/onchain/mod.rs index fb5ccab78..18a4a25b3 100644 --- a/crates/hashi/src/onchain/mod.rs +++ b/crates/hashi/src/onchain/mod.rs @@ -394,6 +394,17 @@ impl OnchainState { self.state().hashi().config.bitcoin_confirmation_threshold() } + pub fn mpc_threshold_basis_points(&self) -> u16 { + self.state().hashi().config.mpc_threshold_basis_points() + } + + pub fn mpc_weight_reduction_allowed_delta(&self) -> u16 { + self.state() + .hashi() + .config + .mpc_weight_reduction_allowed_delta() + } + pub fn bridge_service_client( &self, validator: &Address, diff --git a/crates/hashi/src/onchain/types.rs b/crates/hashi/src/onchain/types.rs index 0cc4d3140..4ce7d1ddd 100644 --- a/crates/hashi/src/onchain/types.rs +++ b/crates/hashi/src/onchain/types.rs @@ -454,6 +454,10 @@ pub struct Config { // This constant mirrors the value in btc_config.move and must be kept in sync. const DUST_RELAY_MIN_VALUE: u64 = 546; +// These mirror the defaults in mpc_config.move and must be kept in sync. +pub const DEFAULT_MPC_THRESHOLD_BASIS_POINTS: u16 = 3333; +pub const DEFAULT_MPC_WEIGHT_REDUCTION_ALLOWED_DELTA: u16 = 800; + impl Config { /// Minimum deposit amount, mirroring the floor logic in btc_config.move. pub fn bitcoin_deposit_minimum(&self) -> u64 { @@ -495,6 +499,24 @@ impl Config { _ => 6, } } + + pub fn mpc_threshold_basis_points(&self) -> u16 { + match self.config.get("mpc_threshold_basis_points") { + Some(ConfigValue::U64(v)) => { + u16::try_from(*v).expect("mpc_threshold_basis_points exceeds u16::MAX") + } + _ => DEFAULT_MPC_THRESHOLD_BASIS_POINTS, + } + } + + pub fn mpc_weight_reduction_allowed_delta(&self) -> u16 { + match self.config.get("mpc_weight_reduction_allowed_delta") { + Some(ConfigValue::U64(v)) => { + u16::try_from(*v).expect("mpc_weight_reduction_allowed_delta exceeds u16::MAX") + } + _ => DEFAULT_MPC_WEIGHT_REDUCTION_ALLOWED_DELTA, + } + } } #[derive(Debug)] diff --git a/crates/internal-tools/src/key_recovery.rs b/crates/internal-tools/src/key_recovery.rs index a697065df..ae869b651 100644 --- a/crates/internal-tools/src/key_recovery.rs +++ b/crates/internal-tools/src/key_recovery.rs @@ -113,14 +113,18 @@ pub async fn run(args: Args, onchain_state: &OnchainState, chain_id: &str) -> an let session_id = SessionId::new(chain_id, reconstruction_epoch, &ProtocolType::KeyRotation); let mut manager = { let state = onchain_state.state(); + let hashi = state.hashi(); + let threshold_basis_points = hashi.config.mpc_threshold_basis_points(); + let weight_reduction_allowed_delta = hashi.config.mpc_weight_reduction_allowed_delta(); MpcManager::new( validator_address, - &state.hashi().committees, + &hashi.committees, session_id, encryption_key, dummy_signing_key.clone(), Box::new(store), - 800, // allowed_delta (same as devnet) + threshold_basis_points, + weight_reduction_allowed_delta, chain_id, None, // weight_divisor 0, // batch_size_per_weight (unused for reconstruction) diff --git a/packages/hashi/sources/core/mpc_config.move b/packages/hashi/sources/core/mpc_config.move new file mode 100644 index 000000000..480e8d0ef --- /dev/null +++ b/packages/hashi/sources/core/mpc_config.move @@ -0,0 +1,38 @@ +// Copyright (c) Mysten Labs, Inc. +// SPDX-License-Identifier: Apache-2.0 + +module hashi::mpc_config; + +use hashi::{config::Config, config_value}; + +const DEFAULT_THRESHOLD_BASIS_POINTS: u64 = 3333; + +const MAX_BPS: u64 = 10000; + +const DEFAULT_WEIGHT_REDUCTION_ALLOWED_DELTA: u64 = 800; + +#[allow(implicit_const_copy)] +public(package) fun is_valid_config_entry( + key: &std::string::String, + value: &config_value::Value, +): bool { + let k = key.as_bytes(); + if (k == &b"mpc_threshold_basis_points") { + value.is_u64() && (*value).as_u64() > 0 && (*value).as_u64() <= MAX_BPS + } else if (k == &b"mpc_weight_reduction_allowed_delta") { + value.is_u64() && (*value).as_u64() <= MAX_BPS + } else { + false + } +} + +public(package) fun init_defaults(config: &mut Config) { + config.upsert( + b"mpc_threshold_basis_points", + config_value::new_u64(DEFAULT_THRESHOLD_BASIS_POINTS), + ); + config.upsert( + b"mpc_weight_reduction_allowed_delta", + config_value::new_u64(DEFAULT_WEIGHT_REDUCTION_ALLOWED_DELTA), + ); +} diff --git a/packages/hashi/sources/core/proposal/types/update_config.move b/packages/hashi/sources/core/proposal/types/update_config.move index 1282ced58..8f54145c4 100644 --- a/packages/hashi/sources/core/proposal/types/update_config.move +++ b/packages/hashi/sources/core/proposal/types/update_config.move @@ -33,7 +33,8 @@ public fun execute(hashi: &mut Hashi, proposal_id: ID, clock: &Clock) { let UpdateConfig { key, value } = proposal::execute(hashi, proposal_id, clock); assert!( config::is_valid_core_config_entry(&key, &value) - || hashi::btc_config::is_valid_config_entry(&key, &value), + || hashi::btc_config::is_valid_config_entry(&key, &value) + || hashi::mpc_config::is_valid_config_entry(&key, &value), EInvalidConfigEntry, ); let bytes = *key.as_bytes(); diff --git a/packages/hashi/sources/hashi.move b/packages/hashi/sources/hashi.move index 612dca75b..4393635c5 100644 --- a/packages/hashi/sources/hashi.move +++ b/packages/hashi/sources/hashi.move @@ -44,6 +44,7 @@ fun init(ctx: &mut TxContext) { config: { let mut config = hashi::config::create(); hashi::btc_config::init_defaults(&mut config); + hashi::mpc_config::init_defaults(&mut config); config }, treasury: hashi::treasury::create(ctx),