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:
| Function | Type | Purpose |
|---|---|---|
fhe_processor | fn(FHEProcessorInput<'_>) -> Vec<u8> | The computation. It returns the serialized output ciphertext. |
policy | fn() -> InputPolicy | How 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:
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:
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>,
}| Field | Content |
|---|---|
ciphertexts | The serialized fhe.rs BFV ciphertexts that the policy selected, one at a time, in index order. Each has its on-chain input index. |
params | The 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.
paramSet | Preset | Degree | Plaintext modulus | Ciphertext moduli |
|---|---|---|---|---|
0 | InsecureThreshold64 | 64 | 17000000 | 3 |
2 | SecureThreshold8192 | 8192 | 17000000 | 3 |
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):
| Value | How the crate computes it |
|---|---|
ciphertext_hash | Keccak-256 of the output bytes |
ciphertext_commitment | The SAFE commitment of the output ciphertext |
params_hash | Keccak-256 of the encoded BFV parameters |
merkle_root | The 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):
pub type LeafFn = fn(&PublishedInput) -> Result<String, ComputeError>;
pub type SelectFn = fn(&[InputRecord]) -> Vec<usize>;
pub struct InputPolicy {
pub leaf: LeafFn,
pub select: SelectFn,
}leafbuilds 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.selectreturns 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:
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.
ComputeInputhas no field for a root or for leaves. - Every published input gets a leaf, also when
selectdoes 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 withComputeError::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, negation | Two components. The output can be published. |
| Ciphertext plus, minus, or times plaintext | Two components. The output can be published. |
| Ciphertext times ciphertext | Three 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_Zincrates/bfv/params/src/constants.rs). The ciphernodes make the smudging noise of their decryption shares large enough for an output of at mostzfresh ciphertexts added together.zis 100,000 for parameter set2and for parameter set0. 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, andzdoes not count it. - The processor returns
Vec<u8>, not aResult. Anunwrapthat fails panics. Inside the zkVM a panic stops the guest, and the E3 gets no output. - When
selectreturns no inputs,Ciphertext::zerohas 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.
- Open a terminal in the project root.
- 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.