diff --git a/crates/ika-core/src/sui_connector/sui_syncer.rs b/crates/ika-core/src/sui_connector/sui_syncer.rs index 024e977280..1048f78f42 100644 --- a/crates/ika-core/src/sui_connector/sui_syncer.rs +++ b/crates/ika-core/src/sui_connector/sui_syncer.rs @@ -775,36 +775,49 @@ where // arrives via the off-chain validator-metadata pipeline (see PR #1721) // and is overlaid onto Committee through a separate path. No // try-then-fallback decode — one shape per path. + // + // A member's record is written at candidate registration and never + // emptied on chain, so a missing or undecodable record is always a + // read defect. Dropping the member would hand the reconfiguration MPC + // a locally-shrunken party set that peers with healthier reads don't + // agree on — divergent public inputs; exclusion decisions belong to + // the consensus-agreed freeze, never to a local read. Error instead; + // the sync loop retries on the next tick. let class_group_encryption_keys_and_proofs: HashMap<_, _> = committee .iter() - .filter_map(|(id, (name, _))| { - let mpc_data = committee_mpc_data.get(id); - - mpc_data.and_then(|mpc_data| { - let class_groups_public_key_and_proof = - bcs::from_bytes::( - &mpc_data.mpc_data_bytes(), - ); - - match class_groups_public_key_and_proof { - Ok(key_and_proof) => Some((*name, key_and_proof)), - Err(e) => { - // Handled, recoverable anomaly: this validator is - // dropped from the committee and construction - // proceeds, so this is `warn!`, not `error!` — and - // it re-runs every poll tick in legacy mode. - warn!( + .map(|(id, (name, _))| { + let mpc_data = committee_mpc_data.get(id).ok_or_else(|| { + error!( + should_never_happen = true, + authority = ?name, + validator_id = ?id, + "committee member has no decodable on-chain mpc_data record; \ + failing the chain-fallback committee build for retry" + ); + DwalletMPCError::MissingOnChainMpcData { + epoch, + authority: *name, + } + })?; + let key_and_proof = + bcs::from_bytes::(&mpc_data.mpc_data_bytes()) + .map_err(|e| { + error!( + should_never_happen = true, authority = ?name, + validator_id = ?id, error = ?e, "failed to decode on-chain class-groups encryption key and proof; \ - dropping this validator from the committee", + failing the chain-fallback committee build for retry" ); - None - } - } - }) + DwalletMPCError::MissingOnChainMpcData { + epoch, + authority: *name, + } + })?; + Ok((*name, key_and_proof)) }) - .collect(); + .collect::>>()?; // Chain fallback (legacy mode): only the bare class-groups key is on // chain, so there are no off-chain PVSS/VSS bundles to deliver. diff --git a/crates/ika-sui-client/src/lib.rs b/crates/ika-sui-client/src/lib.rs index 6ef09f5060..38d6eb6b12 100644 --- a/crates/ika-sui-client/src/lib.rs +++ b/crates/ika-sui-client/src/lib.rs @@ -529,13 +529,41 @@ where )) })?; let info = validator.verified_validator_info(); + // An active member's mpc_data record is written at candidate + // registration and never emptied on chain, so a gap here is + // always a read defect (fullnode lag, table-walk race, or a + // decode failure the fetch already logged). Skipping the + // member would install a committee whose class-groups map + // silently diverges from peers with healthier reads — an + // unagreed party-set exclusion in MPC public inputs — and + // the degraded `EpochStartSystem` would be persisted and + // rebuilt on every restart. Fail the whole read instead; + // `must_get_epoch_start_system` retries until it completes. + let Some(mpc_data) = validators_mpc_data.get(&validator.id) else { + self.sui_client_metrics + .sui_rpc_errors + .with_label_values(&["epoch_start_missing_mpc_data"]) + .inc(); + error!( + should_never_happen = true, + validator_id = ?validator.id, + validator_name = %info.name, + "active committee member has no decodable on-chain \ + mpc_data record at epoch start; failing the read for retry" + ); + return Err(IkaError::InvalidCommittee(format!( + "missing on-chain mpc_data record for active committee \ + member {} ({})", + info.name, validator.id + ))); + }; Ok(EpochStartValidatorInfoV1 { name: info.name.clone(), validator_id: validator.id, protocol_pubkey: info.protocol_pubkey.clone(), network_pubkey: info.network_pubkey.clone(), consensus_pubkey: info.consensus_pubkey.clone(), - mpc_data: validators_mpc_data.get(&validator.id).cloned(), + mpc_data: Some(mpc_data.clone()), network_address: info.network_address.clone(), p2p_address: info.p2p_address.clone(), consensus_address: info.consensus_address.clone(), diff --git a/crates/ika-types/src/dwallet_mpc_error.rs b/crates/ika-types/src/dwallet_mpc_error.rs index f5f09c946b..25fbb75d9b 100644 --- a/crates/ika-types/src/dwallet_mpc_error.rs +++ b/crates/ika-types/src/dwallet_mpc_error.rs @@ -49,6 +49,16 @@ pub enum DwalletMPCError { )] OffChainAssemblyIncomplete { epoch: EpochId, missing: usize }, + #[error( + "on-chain mpc_data record for committee member `{authority}` at epoch {epoch} is \ + missing or undecodable — the record is written at candidate registration and never \ + emptied, so this is a chain-read defect; retry the read" + )] + MissingOnChainMpcData { + epoch: EpochId, + authority: crate::crypto::AuthorityName, + }, + #[error("missing MPC class groups decryption shares in config")] MissingDwalletMPCClassGroupsDecryptionShares, @@ -356,6 +366,7 @@ impl DwalletMPCError { "invalid_centralized_party_imported_dwallet_public_output_version" } DwalletMPCError::OffChainAssemblyIncomplete { .. } => "off_chain_assembly_incomplete", + DwalletMPCError::MissingOnChainMpcData { .. } => "missing_on_chain_mpc_data", DwalletMPCError::PresignAlreadyUsed(_) => "presign_already_used", } } diff --git a/crates/ika-types/src/sui/epoch_start_system.rs b/crates/ika-types/src/sui/epoch_start_system.rs index 6ca53aa7b5..e4f93c752f 100644 --- a/crates/ika-types/src/sui/epoch_start_system.rs +++ b/crates/ika-types/src/sui/epoch_start_system.rs @@ -149,6 +149,14 @@ impl EpochStartSystemTrait for EpochStartSystemV1 { bcs::from_bytes::( &mpc_data.mpc_data_bytes(), ) + .map_err(|e| { + error!( + authority = ?validator.authority_name(), + error = ?e, + "Failed to decode mainnet-v1.1.8 ClassGroupsEncryptionKeyAndProof \ + from Move-side mpc_data" + ); + }) .ok() }); @@ -186,7 +194,23 @@ impl EpochStartSystemTrait for EpochStartSystemV1 { .active_validators .iter() .filter_map(|validator| { - let mpc_data = validator.mpc_data.as_ref()?; + let Some(mpc_data) = validator.mpc_data.as_ref() else { + // The fetch (`get_epoch_start_system`) fails the whole read + // when an active member's record is missing, so this arm is + // reachable only from a degraded `EpochStartSystem` persisted + // before that gate existed. Loud because the built committee + // then silently drops this member from every MPC public input + // seeded from it (the class-groups map must never be partial + // for a non-excluded member). + error!( + should_never_happen = true, + authority = ?validator.authority_name(), + "active committee member has no mpc_data record in the \ + persisted EpochStartSystem; its class-groups key is \ + missing from the committee" + ); + return None; + }; match bcs::from_bytes::( &mpc_data.mpc_data_bytes(), ) { diff --git a/dev-docs/specs/validator-mpc-data-announcements.md b/dev-docs/specs/validator-mpc-data-announcements.md index 400f5c410b..c26f64dc64 100644 --- a/dev-docs/specs/validator-mpc-data-announcements.md +++ b/dev-docs/specs/validator-mpc-data-announcements.md @@ -202,7 +202,27 @@ which bytes* deterministic in consensus order. fetch, assembly decode). 3. `Committee.class_groups_public_keys_and_proofs` is load-bearing for the reconfiguration MPC: it is never populated partially and never - left empty for a non-excluded member. + left empty for a non-excluded member. This binds every builder of + the map, not only the off-chain assembly. The chain-view builders — + `get_epoch_start_system` in `ika-sui-client` (feeding + `EpochStartSystem::get_ika_committee`, the epoch store's committee + and, pre-v4, the MPC manager's validator-key seed) and the + sui_syncer legacy chain fallback (`new_committee`, feeding the + reconfiguration MPC under pre-v4 protocol versions) — enforce it at + the read boundary: an active member whose on-chain `mpc_data` + record is missing or undecodable fails the WHOLE read (retried by + `must_get_epoch_start_system` / the next sync tick), never a silent + member skip. Chain state cannot legitimately lack the record (it is + written at candidate registration and never emptied; under v4 chain + writes remain), so absence is always a read defect — and each + validator reads through its own fullnode, so a tolerated local gap + would be an unagreed party-set exclusion: divergent MPC public + inputs across honest validators. Exclusion decisions belong + exclusively to the consensus-agreed freeze. The completeness check + lives at the read boundary, NOT on `Committee` construction — a + post-freeze assembled committee legitimately omits *excluded* + members, so a type-level "map covers all members" invariant would + be wrong. 4. Post-freeze, all mpc-data decisions read the frozen set only. Code anchors: `crates/ika-types/src/validator_metadata.rs` (types), @@ -210,4 +230,7 @@ Code anchors: `crates/ika-types/src/validator_metadata.rs` (types), `crates/ika-core/src/authority/authority_per_epoch_store.rs` (freeze decision, signal tables), `crates/ika-core/src/epoch_tasks/` (announcement sender, joiner announcements, peer blob fetcher), -`crates/ika-network/src/mpc_artifacts/` (blob store + hash). +`crates/ika-network/src/mpc_artifacts/` (blob store + hash), +`crates/ika-sui-client/src/lib.rs` + +`crates/ika-core/src/sui_connector/sui_syncer.rs` (chain-read +completeness gates, invariant 3).