Enclave is now The Interfold.
Build
E3 Program Anatomy
Secure Process

Secure Process

The secure process is the FHE computation of an E3 program. It receives the encrypted inputs and returns one encrypted output. It never sees a plaintext value. This page shows the function that you write, the data it receives, and the rules that the e3-compute-provider crate applies around it. At the end, you can change the computation in program/src/lib.rs and test it.

What you write

An E3 program crate exports two functions from program/src/lib.rs:

FunctionTypePurpose
fhe_processorfn(FHEProcessorInput<'_>) -> Vec<u8>The computation. It returns the serialized output ciphertext.
policyfn() -> InputPolicyHow each input becomes a tree leaf, and which inputs to compute over.

The crate is named e3-user-program in the template (program/Cargo.toml). It depends on fhe and fhe-traits (fhe.rs, tag v0.6.0), e3-compute-provider, and e3-fhe-params. The dev runner, the OpenVM service, and the OpenVM guest in guest/ link a crate with this name and call these two functions. See The guest must match your E3 program.

This is the policy and the processor of the default template (templates/default/program/src/lib.rs) without the comments and the test. The processor adds all inputs:

program/src/lib.rs
use e3_compute_provider::{FHEProcessorInput, InputPolicy};
use fhe::bfv::Ciphertext;
use fhe_traits::{DeserializeParametrized, Serialize};
 
pub fn policy() -> InputPolicy {
    InputPolicy::default()
}
 
pub fn fhe_processor(fhe_inputs: FHEProcessorInput<'_>) -> Vec<u8> {
    let mut sum = Ciphertext::zero(fhe_inputs.params);
    for (bytes, _) in fhe_inputs.ciphertexts {
        let ciphertext = Ciphertext::from_bytes(&bytes, fhe_inputs.params).unwrap();
        sum += &ciphertext;
    }
 
    sum.to_bytes()
}

CRISP uses the same processor (program/src/lib.rs (opens in a new tab) in the CRISP repository). The two programs differ only in their input policy. See Input policy.

Processor inputs

The crate defines the processor type and its input in crates/program/compute-provider/src/compute_input.rs:

crates/program/compute-provider/src/compute_input.rs
pub type FHEProcessor = for<'a> fn(FHEProcessorInput<'a>) -> Vec<u8>;
 
pub struct FHEProcessorInput<'a> {
    pub ciphertexts: &'a mut dyn Iterator<Item = (Vec<u8>, u64)>,
    pub params: &'a Arc<BfvParameters>,
}
FieldContent
ciphertextsThe serialized fhe.rs BFV ciphertexts that the policy selected, one at a time, in index order. Each has its on-chain input index.
paramsThe BFV parameters of the E3, decoded one time and shared with the processor.

The processor receives only the inputs that the policy selected, and it must read every one. The crate builds the input tree from all inputs first. Inside the OpenVM guest, each ciphertext is read from the input stream and checked against the hash of its first read only when the processor asks for it, so keep the state that the processor carries between inputs small. See Rules the crate enforces.

BFV parameters

The Interfold contract stores one ABI-encoded parameter set for each paramSet index (paramSetRegistry, set with setParamSet). The request names the index. The server reads the bytes for the E3 and sends them to the compute provider as params. The crate decodes them with decode_bfv_params_arc (crates/bfv/params/src/encoding.rs). The encoding is the tuple (degree, plaintext_modulus, moduli[], error1_variance).

The same bytes reach IE3Program.validate as e3ProgramParams. The program contract stores their Keccak-256 hash. The proof must carry the same parameter hash. See Program Contract.

paramSetPresetDegreePlaintext modulusCiphertext moduli
0InsecureThreshold6464170000003
2SecureThreshold81928192170000003

Sources: BfvPreset::from_on_chain_param_set in crates/bfv/params/src/presets.rs and the preset constants in crates/bfv/params/src/constants.rs.

⚠️

Parameter set 0 is insecure. It exists for local development and tests. The template client requests it (paramSet: 0 in client/src/pages/steps/RequestComputation.tsx). Use parameter set 2 for real data.

Processor output

The processor returns the serialized output ciphertext (Ciphertext::to_bytes). The crate then computes four values from the run (SecureProcess, which ComputeInput::run also uses):

ValueHow the crate computes it
ciphertext_hashKeccak-256 of the output bytes
ciphertext_commitmentThe SAFE commitment of the output ciphertext
params_hashKeccak-256 of the encoded BFV parameters
merkle_rootThe Poseidon root of the input tree, one leaf for each input

The compute provider writes these values into the proof journal. The program contract and the protocol verifier check them. See Verify the Compute Provider.

Input policy

An InputPolicy holds two function pointers (crates/program/compute-provider/src/policy.rs):

crates/program/compute-provider/src/policy.rs
pub type LeafFn = fn(&PublishedInput) -> Result<String, ComputeError>;
pub type SelectFn = fn(&[InputRecord]) -> Vec<usize>;
 
pub struct InputPolicy {
    pub leaf: LeafFn,
    pub select: SelectFn,
}
  • leaf builds the tree leaf for one input. It must return the same value that the program contract inserts on-chain for that input. Otherwise the input roots do not match and the output cannot be published.
  • select returns the indices of the inputs to compute over. It must be a function of data that the input root binds. Then every prover that uses the same inputs selects the same set.

PublishedInput gives the leaf function the input index, the ciphertext bytes and their Keccak-256 ciphertext_hash, the commitment that the contract stored (if any), opaque metadata, and recomputed. The recomputed field is the commitment that the crate computes from the bytes. It is None when the bytes do not deserialize.

select receives an InputRecord for each input: the same fields without the ciphertext bytes. The secure process reads each ciphertext once for its leaf and does not keep it, so selection works on what remains. Both types have matches_commitment().

Default policy

InputPolicy::default() uses commitment_leaf and all_inputs. The leaf is the SAFE commitment of the ciphertext, and every input is computed over. This matches MyProgram.publishInput, which inserts the commitment in the tree. Under this policy, an input that does not deserialize stops the run with ComputeError::LeafCommitment.

CRISP policy

CRISP binds four values in each leaf and computes over one input for each voter slot. Its leaf function matches CRISPProgram.inputLeaf:

program/src/lib.rs (CRISP repository)
pub fn leaf(input: &PublishedInput) -> Result<String, ComputeError> {
    let commitment = input
        .commitment
        .ok_or_else(|| ComputeError::LeafCommitment {
            index: input.index,
            reason: "CRISP publishes a commitment with every input".to_string(),
        })?;
    metadata_of(input.index, input.metadata)?;
 
    let mut outer = Sha256::new();
    outer.update(input.ciphertext_hash.as_slice());
    outer.update(commitment);
    outer.update(input.metadata);
    Ok(leaf_from_digest(&outer.finalize()))
}
 
pub fn crisp() -> InputPolicy {
    InputPolicy {
        leaf,
        select: chain_head_per_slot,
    }
}

The metadata is the 20-byte slot address and a 5-byte parent index. chain_head_per_slot skips an input when its bytes do not match its commitment. It also skips an input that does not extend the current head of its slot. A skipped input keeps its leaf, so the root does not change.

leaf_from_digest reduces a digest into the BN254 scalar field and returns it as hex. Use it when your leaf is a hash.

Rules the crate enforces

The crate applies these rules for every E3 program. A policy cannot change them.

  • The crate derives every leaf from the ciphertexts that the secure process consumes. ComputeInput has no field for a root or for leaves.
  • Every published input gets a leaf, also when select does not choose it.
  • A selected index outside the input list stops the run with ComputeError::MerkleTree.
  • A selected ciphertext that differs from its first read stops the run with ComputeError::InputChanged, and a processor that returns before it reads every selected input stops it with ComputeError::Unread.
  • When the program publishes per-input data, the list must have one entry for each ciphertext. Otherwise the run stops with ComputeError::MerkleTree.

These rules are necessary because anyone can call publishCiphertextOutput. The only check on the input root is the comparison that the program contract makes against its own tree.

The crate does not sort the inputs. A leaf position is its position in the input list, so the caller must send the inputs in on-chain index order. A caller that reads inputs from events must sort them by index.

Limits on the computation

The processor has no secret key. It cannot read the value of a ciphertext, so it cannot branch on encrypted data. Control flow can use only public data, for example the number of inputs, their indices, and the parameters.

Operation (fhe.rs v0.6.0)Result
Ciphertext plus or minus ciphertext, negationTwo components. The output can be published.
Ciphertext plus, minus, or times plaintextTwo components. The output can be published.
Ciphertext times ciphertextThree components. The output commitment rejects it.

The output commitment accepts only a ciphertext with exactly two components (bfv_ciphertext_to_greco in crates/zk/witness). A ciphertext product in fhe.rs has three components. The Interfold does not publish a relinearization key, so the run then stops with ComputeError::OutputCommitment.

Other limits:

  • BFV arithmetic is modulo the plaintext modulus. Both parameter sets use the modulus 17,000,000, so 10,000,000 + 8,000,000 decrypts to 1,000,000.
  • Each preset has an addition budget, z (SEARCH_Z in crates/bfv/params/src/constants.rs). The ciphernodes make the smudging noise of their decryption shares large enough for an output of at most z fresh ciphertexts added together. z is 100,000 for parameter set 2 and for parameter set 0. With a larger sum, the smudging noise protects the secret key shares with less statistical security than the preset specifies. A product with a plaintext also increases the noise, and z does not count it.
  • The processor returns Vec<u8>, not a Result. An unwrap that fails panics. Inside the zkVM a panic stops the guest, and the E3 gets no output.
  • When select returns no inputs, Ciphertext::zero has no components. The output commitment then rejects the output.
  • The zkVM guest is single threaded. Each input adds cycles to the proof, so large rounds take longer to prove. The guest holds one ciphertext at a time, so its memory does not limit the number of inputs.

Test the processor

The template includes a unit test in program/src/lib.rs. It encrypts 3 and 2 with the InsecureThreshold64 preset, runs fhe_processor, decrypts the result, and checks that it is 5.

  1. Open a terminal in the project root.
  2. Run cargo test -p e3-user-program.

The test passes when the processor returns a valid sum. Add a test for each change that you make to the computation.

Next steps