Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 36 additions & 23 deletions crates/ika-core/src/sui_connector/sui_syncer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<ClassGroupsEncryptionKeyAndProof>(
&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::<ClassGroupsEncryptionKeyAndProof>(&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::<DwalletMPCResult<HashMap<_, _>>>()?;

// Chain fallback (legacy mode): only the bare class-groups key is on
// chain, so there are no off-chain PVSS/VSS bundles to deliver.
Expand Down
30 changes: 29 additions & 1 deletion crates/ika-sui-client/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
11 changes: 11 additions & 0 deletions crates/ika-types/src/dwallet_mpc_error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,

Expand Down Expand Up @@ -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",
}
}
Expand Down
26 changes: 25 additions & 1 deletion crates/ika-types/src/sui/epoch_start_system.rs
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,14 @@ impl EpochStartSystemTrait for EpochStartSystemV1 {
bcs::from_bytes::<ClassGroupsEncryptionKeyAndProof>(
&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()
});

Expand Down Expand Up @@ -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::<ClassGroupsEncryptionKeyAndProof>(
&mpc_data.mpc_data_bytes(),
) {
Expand Down
27 changes: 25 additions & 2 deletions dev-docs/specs/validator-mpc-data-announcements.md
Original file line number Diff line number Diff line change
Expand Up @@ -202,12 +202,35 @@ 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),
`crates/ika-core/src/validator_metadata.rs` (assembly + freeze inputs),
`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).
Loading