Skip to content
Closed
Show file tree
Hide file tree
Changes from 2 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
4 changes: 2 additions & 2 deletions agent/flow-trace/00_INDEX.md

Large diffs are not rendered by default.

23 changes: 22 additions & 1 deletion agent/flow-trace/04_DKG_AND_COMPUTATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -693,7 +693,10 @@ phase.
│ the step; a different commitment stays an error
└─ Calls contract.publishCommitteePublicKey(e3_id, publicKey) after the
commitment is available, including after restart
→ A terminal result clears the intent; a retryable failure keeps it and retries after 30s
→ A terminal result clears the in-memory intent; a retryable failure keeps it and retries
after 30s
→ RPC request-size rejection and permanent contract or payload errors are terminal for the
running writer. They produce one final error instead of an unbounded 30-second retry loop
→ A restart replays the intent, so an unfinished publication still reaches the chain.
E3RequestComplete that arrives before EffectsEnabled comes from that same replay and
drops the intent: a completed request published its candidate in an earlier run, and
Expand Down Expand Up @@ -1339,6 +1342,24 @@ publication, and only an active aggregator can start a retained submission. `Pla
not gossiped or returned by historical peer sync; only the producing node can create this EVM write
intent.

The CRISP server writes its request record at `E3Requested` and writes the generic E3 record only
after the indexer verifies the committee public key against the on-chain commitment. Current-round
lookup uses the request record, so a round remains visible while its key is pending. CRISP activates
the round only when both records exist. Either handler can complete the activation after their
records converge, and deferred checks cover slow live-handler ordering. Duplicate request and
committee events do not reset the round, replace indexed output, or resubmit an already-matching
Merkle root. Startup rebuilds deadline callbacks for active and expired rounds and releases an
interrupted compute submission for retry. The compute transition is atomic, and a synchronous
program-server request error releases the claim to `Expired` so a later deadline callback can retry
it.

Operator constraint: the Sepolia contract byte limit does not bypass an RPC transaction-size
limit. The observed secure-8192 public key transaction is rejected before Solidity executes. Until
a separate node, client, and indexer release provides a verifiable transport that fits the RPC
path, use the current small-key parameters only as a Sepolia E2E plumbing workaround. That
workaround does not validate secure-8192 and is not a mainnet security substitute. Do not interpret
`KeyPublished` as proof that CRISP has usable key bytes.

### What the compute-provider crate guarantees, and what an E3 program decides

`e3-compute-provider` is shared by every E3 program, so it holds only what is true for all of them:
Expand Down
14 changes: 8 additions & 6 deletions agent/flow-trace/05_FAILURE_REFUND_SLASHING.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,12 +26,14 @@ actually slashed and does not require an oracle or relabel one ERC-20 as another
Anyone can call `markE3Failed()` when a deadline is missed. A ready committee remains finalizable
through its absolute DKG deadline. It can fail if it remains unfinalized after that deadline.

For the aggregator-owned DKG and decryption stages, each selected ciphernode reconstructs a
canonical deadline watch from `CiphernodeSelected` and `E3StageChanged` during replay. After
`EffectsEnabled`, it reads the deadline from `Interfold`, staggers its attempt by canonical party
ID, confirms that the stage and failure condition still match, and calls `markE3Failed`. A canonical
stage change cancels the old watch. If a node restarts after the deadline, the party-ID stagger is
applied from restart time so all committee wallets do not submit at once.
The Interfold writer watches every stage that `failureCondition` supports: `Requested`,
`CommitteeFinalized`, `KeyPublished`, and `CiphertextReady`. Startup restores the stage from the
durable lifecycle map and restores the request-time registry from the DKG context. A finalized
committee member staggers its attempt by canonical party ID. A `Requested` E3 has no active
committee yet, so every node waits until the failure grace period ends and uses the permissionless
path. Before submission, the writer confirms that the stage and failure condition still match. A
canonical stage change cancels the old watch. After a restart, finalized members keep their party-ID
stagger and non-members remain outside the protected grace window.

If an honest-node allocation is smaller than the node count, the refund manager credits it to the
request-time treasury instead of creating zero-value claims.
Expand Down
5 changes: 3 additions & 2 deletions agent/flow-trace/06_DEACTIVATION_AND_COMPLETION.md
Original file line number Diff line number Diff line change
Expand Up @@ -490,8 +490,9 @@ publish that exclusion leaves the intent retryable.
The registry writer rebuilds ticket, committee-finalization, and public-key submission gates from
durable local events. It does not submit during replay. After `EffectsEnabled`, it retries temporary
RPC or contract-ordering failures, treats already-landed transactions as success, and stops retrying
a ticket after a permanent eligibility or deadline result. The Interfold writer applies the same
pattern to plaintext publication.
a ticket after a permanent eligibility or deadline result. It also stops a public-key submission
after an RPC request-size rejection or a permanent payload or contract error. The Interfold writer
applies the same pattern to plaintext publication.

The request router uses one checkpoint at `//router/recovery_checkpoint` for its active contexts,
completed set, and all aggregate cursors. Per-E3 context snapshots remain below their own router
Expand Down
33 changes: 31 additions & 2 deletions crates/ciphernode-builder/src/ciphernode_builder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,8 @@ use e3_crypto::Cipher;
use e3_data::{InMemStore, RepositoriesFactory};
use e3_events::DkgFoldAttestationContext;
use e3_events::{
AggregateConfig, AggregateId, BusHandle, E3id, EventBus, EventBusConfig, EventSubscriber,
EventType, EvmEventConfig, InterfoldEvent,
AggregateConfig, AggregateId, BusHandle, E3Stage, E3id, EventBus, EventBusConfig,
EventSubscriber, EventType, EvmEventConfig, InterfoldEvent,
};
use e3_evm::{
ensure_node_release, fetch_accusation_vote_validity, fetch_randomness_providers,
Expand Down Expand Up @@ -73,6 +73,7 @@ struct EvmStartupRecovery<'a> {
dkg_fold_contexts_by_e3: &'a HashMap<E3id, DkgFoldAttestationContext>,
active_aggregators: &'a HashMap<E3id, bool>,
selected_party_ids: &'a HashMap<E3id, u64>,
lifecycle_stages: &'a HashMap<E3id, E3Stage>,
committee_finalizer: &'a CommitteeFinalizerRecoveryState,
}

Expand Down Expand Up @@ -597,6 +598,11 @@ impl CiphernodeBuilder {
self.contract_components.slashing_manager,
)
.await?;
let lifecycle_stages = repositories
.e3_lifecycle()
.read()
.await?
.unwrap_or_default();
let dkg_fold_contexts_by_e3 = load_dkg_fold_attestation_contexts(&repositories).await?;

let mut provider_cache =
Expand Down Expand Up @@ -673,6 +679,7 @@ impl CiphernodeBuilder {
dkg_fold_contexts_by_e3: &dkg_fold_contexts_by_e3,
active_aggregators: &selector_state.is_aggregator,
selected_party_ids: &selected_party_ids,
lifecycle_stages: &lifecycle_stages,
committee_finalizer: &committee_finalizer_recovery,
},
)
Expand Down Expand Up @@ -1225,6 +1232,7 @@ async fn setup_evm_system(
dkg_fold_contexts_by_e3,
active_aggregators,
selected_party_ids,
lifecycle_stages,
committee_finalizer,
} = recovery;
let mut evm_config = EvmEventConfig::new();
Expand Down Expand Up @@ -1261,12 +1269,33 @@ async fn setup_evm_system(
.filter(|(e3_id, _)| e3_id.chain_id() == chain_id)
.map(|(e3_id, party_id)| (e3_id.clone(), *party_id))
.collect();
let chain_request_registries = dkg_fold_contexts_by_e3
.iter()
.filter(|(e3_id, _)| e3_id.chain_id() == chain_id)
.map(|(e3_id, context)| (e3_id.clone(), context.registry))
.collect();
let chain_failure_stages = lifecycle_stages
.iter()
.filter(|(e3_id, stage)| {
e3_id.chain_id() == chain_id
&& matches!(
stage,
E3Stage::Requested
| E3Stage::CommitteeFinalized
| E3Stage::KeyPublished
| E3Stage::CiphertextReady
)
})
.map(|(e3_id, stage)| (e3_id.clone(), stage.clone()))
.collect();
InterfoldSolWriter::attach_with_recovery(
bus,
write_provider.clone(),
contract.address()?,
chain_active_aggregators,
chain_party_ids,
chain_request_registries,
chain_failure_stages,
);
system.with_contract(contract.address()?, move |next| {
InterfoldSolReader::setup(&next).recipient()
Expand Down
75 changes: 73 additions & 2 deletions crates/evm/src/ciphernode_registry/effects.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
//! Idempotency preflights and CiphernodeRegistry contract effects.

use super::*;
use crate::contracts::IInterfold;

const TICKET_GAS_SAFETY_MULTIPLIER: u64 = 2;

Expand Down Expand Up @@ -83,6 +84,46 @@ pub(in crate::actors::ciphernode_registry_sol) fn ticket_submission_error_is_ter
.any(|selector| reverts_with(error, selector))
}

/// Return true when another committee-publication attempt cannot succeed unchanged.
pub(in crate::actors::ciphernode_registry_sol) fn committee_publication_error_is_terminal(
error: &anyhow::Error,
) -> bool {
let encoded = format!("{error:?}");
let message = encoded.to_ascii_lowercase();
let permanent_rpc_rejection = message.contains("oversized data")
|| (message.contains("transaction size") && message.contains("limit"))
|| message.contains("request entity too large")
|| message.contains("content length too large")
|| message.contains("function selector was not recognized");
let permanent_local_rejection = message
.contains("mandatory dkg aggregator proof payload missing")
|| message.contains("mandatory dkg attestation bundle missing")
|| (message.contains("on-chain committee commitment")
&& message.contains("does not match local commitment"));
let permanent_contract_rejection = [
ICiphernodeRegistry::InvalidPublicKeyLength::SELECTOR,
ICiphernodeRegistry::PkCommitmentRequired::SELECTOR,
ICiphernodeRegistry::DkgProofRequired::SELECTOR,
ICiphernodeRegistry::InvalidDkgProof::SELECTOR,
ICiphernodeRegistry::FoldAttestationsRequired::SELECTOR,
ICiphernodeRegistry::FoldAttestationVerifierNotSet::SELECTOR,
ICiphernodeRegistry::InvalidFoldAttestation::SELECTOR,
ICiphernodeRegistry::PartyIdNotInProof::SELECTOR,
ICiphernodeRegistry::AttestationBindingCountMismatch::SELECTOR,
ICiphernodeRegistry::PartyIdOutOfBounds::SELECTOR,
ICiphernodeRegistry::InvalidProof::SELECTOR,
ICiphernodeRegistry::InvalidPublicInputsLength::SELECTOR,
ICiphernodeRegistry::VkHashMismatch::SELECTOR,
ICiphernodeRegistry::PkCommitmentMismatch::SELECTOR,
ICiphernodeRegistry::DomainBindingMismatch::SELECTOR,
IInterfold::DKGDeadlinePassed::SELECTOR,
]
.into_iter()
.any(|selector| contains_error_selector(&encoded, selector));

permanent_rpc_rejection || permanent_local_rejection || permanent_contract_rejection
}

/// Report whether this node's ticket is already recorded on chain.
///
/// `submitTicket` reverts with `NodeAlreadySubmitted` for a sender that is
Expand Down Expand Up @@ -412,8 +453,11 @@ pub async fn fetch_randomness_providers<P: Provider + Clone>(

#[cfg(test)]
mod tests {
use super::{reverts_with, ticket_gas_limit, ticket_submission_error_is_terminal};
use crate::contracts::ICiphernodeRegistry;
use super::{
committee_publication_error_is_terminal, reverts_with, ticket_gas_limit,
ticket_submission_error_is_terminal,
};
use crate::contracts::{ICiphernodeRegistry, IInterfold};
use alloy::sol_types::{Revert, SolError};

fn selector_error(selector: [u8; 4]) -> anyhow::Error {
Expand Down Expand Up @@ -462,4 +506,31 @@ mod tests {
assert_eq!(ticket_gas_limit(250_000), 500_000);
assert_eq!(ticket_gas_limit(u64::MAX), u64::MAX);
}

#[test]
fn oversized_rpc_rejection_is_terminal() {
let error = anyhow::anyhow!(
"server returned error code -32000: oversized data: transaction size 356602, limit 131072"
);
assert!(committee_publication_error_is_terminal(&error));
assert!(!committee_publication_error_is_terminal(&anyhow::anyhow!(
"RPC connection reset"
)));
assert!(!committee_publication_error_is_terminal(&selector_error(
ICiphernodeRegistry::CommitteeNotPublished::SELECTOR
)));
}

#[test]
fn invalid_public_key_length_is_terminal() {
assert!(committee_publication_error_is_terminal(&selector_error(
ICiphernodeRegistry::InvalidPublicKeyLength::SELECTOR
)));
assert!(committee_publication_error_is_terminal(&selector_error(
ICiphernodeRegistry::InvalidProof::SELECTOR
)));
assert!(committee_publication_error_is_terminal(&selector_error(
IInterfold::DKGDeadlinePassed::SELECTOR
)));
}
}
13 changes: 11 additions & 2 deletions crates/evm/src/ciphernode_registry/handlers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -562,11 +562,16 @@ impl<P: Provider + WalletProvider + Clone + 'static> Handler<SubmitPublicKey>
false
}
Err(err) => {
let terminal = committee_publication_error_is_terminal(&err);
error!(
"Failed to preflight publishCommittee: {}",
format_evm_error(&err)
);
return (e3_id, false);
if terminal {
error!(e3_id = %e3_id, "Committee publication failed permanently; stopping retries");
}
bus.err(EType::Evm, err);
return (e3_id, terminal);
}
Ok(true) => true,
};
Expand Down Expand Up @@ -607,12 +612,16 @@ impl<P: Provider + WalletProvider + Clone + 'static> Handler<SubmitPublicKey>
match result {
Ok(()) => (e3_id, true),
Err(err) => {
let terminal = committee_publication_error_is_terminal(&err);
error!(
"Failed to publish committee data: {}",
format_evm_error(&err)
);
if terminal {
error!(e3_id = %e3_id, "Committee publication failed permanently; stopping retries");
}
bus.err(EType::Evm, err);
(e3_id, false)
(e3_id, terminal)
}
}
}
Expand Down
28 changes: 28 additions & 0 deletions crates/evm/src/contracts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,12 @@ sol! {
uint256 decryptionDeadline;
}

struct E3TimeoutConfig {
uint256 dkgWindow;
uint256 computeWindow;
uint256 decryptionWindow;
}

// ── Write functions ─────────────────────────────────────────────────
function publishPlaintextOutput(
uint256 e3Id,
Expand All @@ -63,10 +69,16 @@ sol! {

function getDeadlines(uint256 e3Id) external view returns (E3Deadlines memory deadlines);

function getE3TimeoutConfig(
uint256 e3Id
) external view returns (E3TimeoutConfig memory config);

function checkFailureCondition(
uint256 e3Id
) external view returns (bool canFail, uint8 reason);

function markFailedGracePeriod() external view returns (uint256);

function nodeReleaseRegistry() external view returns (address);
function bondingRegistry() external view returns (address);
function ciphernodeRegistry() external view returns (address);
Expand Down Expand Up @@ -95,6 +107,7 @@ sol! {
error E3AlreadyFailed(uint256 e3Id);
error E3AlreadyComplete(uint256 e3Id);
error MarkE3FailedInGracePeriod(uint256 e3Id, uint256 gracePeriodEnds);
error DKGDeadlinePassed(uint256 e3Id, uint256 deadline);
}
}

Expand Down Expand Up @@ -208,6 +221,10 @@ sol! {
// ── View functions ──────────────────────────────────────────────────
function isOpen(uint256 e3Id) external view returns (bool);

function committeeThresholdMet(uint256 e3Id) external view returns (bool);

function getCommitteeDeadline(uint256 e3Id) external view returns (uint256);

function committeePublicKey(uint256 e3Id) external view returns (bytes32 publicKeyHash);

function getDkgAnchors(
Expand Down Expand Up @@ -368,6 +385,17 @@ sol! {
error DkgProofRequired();
error InvalidDkgProof();
error FoldAttestationsRequired();
error FoldAttestationVerifierNotSet();
error InvalidFoldAttestation();
error PartyIdNotInProof();
error AttestationBindingCountMismatch();
error PartyIdOutOfBounds(uint256 partyId, uint256 committeeSize);
error InvalidProof();
error InvalidPublicInputsLength();
error VkHashMismatch();
error PkCommitmentMismatch();
error DomainBindingMismatch();
error InvalidPublicKeyLength(uint256 supplied, uint256 maximum);
}
}

Expand Down
Loading
Loading