|
| 1 | +// Copyright (c) Mysten Labs, Inc. |
| 2 | +// SPDX-License-Identifier: Apache-2.0 |
| 3 | + |
| 4 | +use crate::withdraw::verify_hashi_cert; |
| 5 | +use crate::Enclave; |
| 6 | +use hashi_types::committee::certificate_threshold; |
| 7 | +use hashi_types::guardian::CommitteeTransition; |
| 8 | +use hashi_types::guardian::CommitteeUpdateLogMessage; |
| 9 | +use hashi_types::guardian::GuardianError; |
| 10 | +use hashi_types::guardian::GuardianError::EnclaveUninitialized; |
| 11 | +use hashi_types::guardian::GuardianError::InvalidInputs; |
| 12 | +use hashi_types::guardian::GuardianResult; |
| 13 | +use hashi_types::guardian::HashiCommittee; |
| 14 | +use hashi_types::guardian::HashiSigned; |
| 15 | +use std::sync::Arc; |
| 16 | +use tracing::error; |
| 17 | +use tracing::info; |
| 18 | + |
| 19 | +/// Advance the guardian's committee from the current epoch N to N+1 (or no-op |
| 20 | +/// if `signed.message().new_committee.epoch <= current_epoch`). |
| 21 | +/// |
| 22 | +/// Verifies that the outgoing committee (the guardian's current one) signed |
| 23 | +/// the transition with sufficient weight, then swaps the in-memory committee |
| 24 | +/// atomically. Both successful and rejected attempts are logged to S3 for |
| 25 | +/// audit. |
| 26 | +/// |
| 27 | +/// Returns the guardian's committee epoch *after* the call (which equals the |
| 28 | +/// proposed epoch on success, or the unchanged current epoch for a no-op). |
| 29 | +pub async fn update_committee( |
| 30 | + enclave: Arc<Enclave>, |
| 31 | + signed: HashiSigned<CommitteeTransition>, |
| 32 | +) -> GuardianResult<u64> { |
| 33 | + if !enclave.is_fully_initialized() { |
| 34 | + return Err(EnclaveUninitialized); |
| 35 | + } |
| 36 | + |
| 37 | + let current = enclave.state.get_committee()?; |
| 38 | + let current_epoch = current.epoch(); |
| 39 | + let proposed_epoch = signed.message().new_committee.epoch; |
| 40 | + |
| 41 | + // Idempotency: silently accept already-applied or older transitions. |
| 42 | + // Lets the leader's catch-up loop retry without races. |
| 43 | + if proposed_epoch <= current_epoch { |
| 44 | + info!( |
| 45 | + current_epoch, |
| 46 | + proposed_epoch, "update_committee: no-op (already at or past proposed epoch)" |
| 47 | + ); |
| 48 | + return Ok(current_epoch); |
| 49 | + } |
| 50 | + |
| 51 | + // Strictly sequential transitions. Catch-up walks one epoch at a time. |
| 52 | + if proposed_epoch != current_epoch + 1 { |
| 53 | + let err = InvalidInputs(format!( |
| 54 | + "non-sequential committee transition: current {current_epoch} -> proposed {proposed_epoch}" |
| 55 | + )); |
| 56 | + log_failure(&enclave, current_epoch, &signed, &err).await; |
| 57 | + return Err(err); |
| 58 | + } |
| 59 | + |
| 60 | + // The outgoing committee's threshold is derived from its own weight, not |
| 61 | + // from `WithdrawalConfig.committee_threshold` — that field is genesis-only. |
| 62 | + let threshold = certificate_threshold(current.total_weight()); |
| 63 | + if let Err(e) = verify_hashi_cert(current.clone(), threshold, &signed) { |
| 64 | + log_failure(&enclave, current_epoch, &signed, &e).await; |
| 65 | + return Err(e); |
| 66 | + } |
| 67 | + |
| 68 | + // The transition carries a `move_types::Committee` (BCS-stable); convert |
| 69 | + // back to the in-memory form (which rebuilds the address index, etc.). |
| 70 | + let new_committee_move = signed.message().new_committee.clone(); |
| 71 | + let new_committee: HashiCommittee = new_committee_move |
| 72 | + .clone() |
| 73 | + .try_into() |
| 74 | + .map_err(|e| InvalidInputs(format!("invalid new committee in transition: {e}")))?; |
| 75 | + |
| 76 | + // Defensive: the BCS payload's epoch field must match the wire-level |
| 77 | + // proposed epoch we already checked. Disagreement would indicate a |
| 78 | + // proto-conversion bug; reject rather than silently re-bind. |
| 79 | + if new_committee.epoch() != proposed_epoch { |
| 80 | + let err = InvalidInputs(format!( |
| 81 | + "new committee epoch ({}) does not match transition epoch ({proposed_epoch})", |
| 82 | + new_committee.epoch() |
| 83 | + )); |
| 84 | + log_failure(&enclave, current_epoch, &signed, &err).await; |
| 85 | + return Err(err); |
| 86 | + } |
| 87 | + |
| 88 | + // Log success FIRST (immutable audit), then swap in memory. If the S3 |
| 89 | + // write fails, the committee isn't advanced — caller retries. |
| 90 | + log_success(&enclave, current_epoch, &signed, &new_committee_move).await?; |
| 91 | + enclave.state.replace_committee(new_committee)?; |
| 92 | + |
| 93 | + info!( |
| 94 | + from_epoch = current_epoch, |
| 95 | + to_epoch = proposed_epoch, |
| 96 | + "Committee updated" |
| 97 | + ); |
| 98 | + Ok(proposed_epoch) |
| 99 | +} |
| 100 | + |
| 101 | +async fn log_success( |
| 102 | + enclave: &Enclave, |
| 103 | + from_epoch: u64, |
| 104 | + signed: &HashiSigned<CommitteeTransition>, |
| 105 | + new_committee: &hashi_types::move_types::Committee, |
| 106 | +) -> GuardianResult<()> { |
| 107 | + let msg = CommitteeUpdateLogMessage::Success { |
| 108 | + from_epoch, |
| 109 | + to_epoch: new_committee.epoch, |
| 110 | + new_committee: new_committee.clone(), |
| 111 | + request_sign: signed.committee_signature().clone(), |
| 112 | + }; |
| 113 | + enclave.log_committee_update(msg).await |
| 114 | +} |
| 115 | + |
| 116 | +async fn log_failure( |
| 117 | + enclave: &Enclave, |
| 118 | + from_epoch: u64, |
| 119 | + signed: &HashiSigned<CommitteeTransition>, |
| 120 | + err: &GuardianError, |
| 121 | +) { |
| 122 | + let msg = CommitteeUpdateLogMessage::Failure { |
| 123 | + from_epoch, |
| 124 | + proposed_epoch: signed.message().new_committee.epoch, |
| 125 | + request_sign: signed.committee_signature().clone(), |
| 126 | + error: err.clone(), |
| 127 | + }; |
| 128 | + if let Err(log_err) = enclave.log_committee_update(msg).await { |
| 129 | + error!( |
| 130 | + from_epoch, |
| 131 | + proposed_epoch = signed.message().new_committee.epoch, |
| 132 | + "failed to log committee update failure to S3: {log_err:?}" |
| 133 | + ); |
| 134 | + } |
| 135 | +} |
| 136 | + |
| 137 | +#[cfg(test)] |
| 138 | +mod tests { |
| 139 | + use super::*; |
| 140 | + use crate::test_utils::create_fully_initialized_enclave; |
| 141 | + use crate::test_utils::FullyInitializedArgs; |
| 142 | + use bitcoin::Network; |
| 143 | + use hashi_types::committee::Bls12381PrivateKey; |
| 144 | + use hashi_types::committee::BlsSignatureAggregator; |
| 145 | + use hashi_types::committee::EncryptionPublicKey; |
| 146 | + use hashi_types::committee::DEFAULT_MPC_MAX_FAULTY_IN_BASIS_POINTS; |
| 147 | + use hashi_types::committee::DEFAULT_MPC_THRESHOLD_IN_BASIS_POINTS; |
| 148 | + use hashi_types::committee::DEFAULT_MPC_WEIGHT_REDUCTION_ALLOWED_DELTA; |
| 149 | + use hashi_types::guardian::test_utils::create_btc_keypair; |
| 150 | + use hashi_types::guardian::HashiCommitteeMember; |
| 151 | + use hashi_types::guardian::LimiterState; |
| 152 | + use hashi_types::guardian::WithdrawalConfig; |
| 153 | + use hashi_types::guardian::WithdrawalID as SuiAddress; |
| 154 | + |
| 155 | + fn mock_signer_address() -> SuiAddress { |
| 156 | + SuiAddress::new([1u8; 32]) |
| 157 | + } |
| 158 | + |
| 159 | + fn mock_bls_sk() -> Bls12381PrivateKey { |
| 160 | + // Deterministic key derived from a fixed seed RNG so tests are |
| 161 | + // reproducible. We avoid sharing the in-crate `TEST_HASHI_BLS_SK_BYTES` |
| 162 | + // constant because it's private. |
| 163 | + use rand::SeedableRng; |
| 164 | + let mut rng = rand::rngs::StdRng::seed_from_u64(0x000C_0FFE_EBAD_F00D); |
| 165 | + Bls12381PrivateKey::generate(&mut rng) |
| 166 | + } |
| 167 | + |
| 168 | + fn mock_encryption_pk() -> EncryptionPublicKey { |
| 169 | + use rand::SeedableRng; |
| 170 | + let mut rng = rand::rngs::StdRng::seed_from_u64(0xDEAD_BEEF); |
| 171 | + let sk = hashi_types::committee::EncryptionPrivateKey::new(&mut rng); |
| 172 | + EncryptionPublicKey::from_private_key(&sk) |
| 173 | + } |
| 174 | + |
| 175 | + fn committee_at(epoch: u64) -> HashiCommittee { |
| 176 | + let pk = mock_bls_sk().public_key(); |
| 177 | + let member = HashiCommitteeMember::new(mock_signer_address(), pk, mock_encryption_pk(), 10); |
| 178 | + HashiCommittee::new( |
| 179 | + vec![member], |
| 180 | + epoch, |
| 181 | + DEFAULT_MPC_THRESHOLD_IN_BASIS_POINTS, |
| 182 | + DEFAULT_MPC_WEIGHT_REDUCTION_ALLOWED_DELTA, |
| 183 | + DEFAULT_MPC_MAX_FAULTY_IN_BASIS_POINTS, |
| 184 | + ) |
| 185 | + } |
| 186 | + |
| 187 | + /// Sign a transition with `signing_epoch` (which becomes the embedded |
| 188 | + /// epoch in the certificate) over the given `new_committee`. |
| 189 | + fn sign_transition_at( |
| 190 | + signing_epoch: u64, |
| 191 | + new_committee: HashiCommittee, |
| 192 | + ) -> HashiSigned<CommitteeTransition> { |
| 193 | + let outgoing = committee_at(signing_epoch); |
| 194 | + let transition = CommitteeTransition { |
| 195 | + new_committee: hashi_types::move_types::Committee::from(&new_committee), |
| 196 | + }; |
| 197 | + let sk = mock_bls_sk(); |
| 198 | + let sig = sk.sign(signing_epoch, mock_signer_address(), &transition); |
| 199 | + let mut agg = BlsSignatureAggregator::new(&outgoing, transition); |
| 200 | + agg.add_signature(sig).expect("member sig should verify"); |
| 201 | + agg.finish().expect("threshold should be met") |
| 202 | + } |
| 203 | + |
| 204 | + async fn enclave_at_epoch(epoch: u64) -> Arc<Enclave> { |
| 205 | + let kp = create_btc_keypair(&[1u8; 32]); |
| 206 | + create_fully_initialized_enclave(FullyInitializedArgs { |
| 207 | + network: Network::Regtest, |
| 208 | + committee: committee_at(epoch), |
| 209 | + master_pubkey: kp.x_only_public_key().0, |
| 210 | + withdrawal_config: WithdrawalConfig { |
| 211 | + committee_threshold: 0, |
| 212 | + refill_rate_sats_per_sec: 0, |
| 213 | + max_bucket_capacity_sats: 1_000, |
| 214 | + }, |
| 215 | + limiter_state: LimiterState { |
| 216 | + num_tokens_available: 1_000, |
| 217 | + last_updated_at: 0, |
| 218 | + next_seq: 0, |
| 219 | + }, |
| 220 | + }) |
| 221 | + .await |
| 222 | + } |
| 223 | + |
| 224 | + #[tokio::test] |
| 225 | + async fn happy_path_advances_committee() { |
| 226 | + let enclave = enclave_at_epoch(5).await; |
| 227 | + let signed = sign_transition_at(5, committee_at(6)); |
| 228 | + |
| 229 | + let new_epoch = update_committee(enclave.clone(), signed).await.unwrap(); |
| 230 | + assert_eq!(new_epoch, 6); |
| 231 | + assert_eq!(enclave.state.get_committee().unwrap().epoch(), 6); |
| 232 | + } |
| 233 | + |
| 234 | + #[tokio::test] |
| 235 | + async fn already_applied_is_noop() { |
| 236 | + let enclave = enclave_at_epoch(5).await; |
| 237 | + // Try to "advance" to an epoch we're already at — must be a no-op. |
| 238 | + let signed = sign_transition_at(5, committee_at(5)); |
| 239 | + |
| 240 | + let new_epoch = update_committee(enclave.clone(), signed).await.unwrap(); |
| 241 | + assert_eq!(new_epoch, 5); |
| 242 | + assert_eq!(enclave.state.get_committee().unwrap().epoch(), 5); |
| 243 | + } |
| 244 | + |
| 245 | + #[tokio::test] |
| 246 | + async fn non_sequential_rejected() { |
| 247 | + let enclave = enclave_at_epoch(5).await; |
| 248 | + // Skipping ahead by 2 must be rejected — catch-up walks one epoch at a time. |
| 249 | + let signed = sign_transition_at(5, committee_at(7)); |
| 250 | + |
| 251 | + let err = update_committee(enclave.clone(), signed) |
| 252 | + .await |
| 253 | + .expect_err("non-sequential transition must error"); |
| 254 | + assert!( |
| 255 | + matches!(err, GuardianError::InvalidInputs(_)), |
| 256 | + "expected InvalidInputs, got {err:?}" |
| 257 | + ); |
| 258 | + // Committee unchanged. |
| 259 | + assert_eq!(enclave.state.get_committee().unwrap().epoch(), 5); |
| 260 | + } |
| 261 | + |
| 262 | + #[tokio::test] |
| 263 | + async fn wrong_signing_epoch_rejected() { |
| 264 | + // Outgoing committee is at epoch 5, but the signature is made with |
| 265 | + // signing_epoch = 4 — the guardian must reject because the embedded |
| 266 | + // epoch doesn't match the current committee. |
| 267 | + let enclave = enclave_at_epoch(5).await; |
| 268 | + let signed = sign_transition_at(4, committee_at(6)); |
| 269 | + |
| 270 | + let err = update_committee(enclave.clone(), signed) |
| 271 | + .await |
| 272 | + .expect_err("mismatched signing epoch must error"); |
| 273 | + assert!( |
| 274 | + matches!(err, GuardianError::InvalidInputs(_)), |
| 275 | + "expected InvalidInputs, got {err:?}" |
| 276 | + ); |
| 277 | + assert_eq!(enclave.state.get_committee().unwrap().epoch(), 5); |
| 278 | + } |
| 279 | +} |
0 commit comments