Build a Complete E3 Program
This tutorial builds an E3 program from the default template. You go through each part that you own: the Secure Process in Rust, the program contract in Solidity, the coordination server, and the client in TypeScript. You make a change to the contract, check that the parts still agree, and run a complete E3 on a local chain. At the end, you know which file does what, and which values must stay the same across the parts.
Prerequisites: the Interfold CLI, Rust, Node.js, pnpm, and Foundry (pnpm dev:all starts
anvil). See Installation. Read E3 Program Anatomy for
the concepts.
The parts of the program
| Part | File | Role |
|---|---|---|
| Secure Process | program/src/lib.rs | The FHE computation over the encrypted inputs, and the input policy. |
| Program contract | contracts/MyProgram.sol | Accepts inputs, keeps the input tree, and verifies the compute proof. |
| Deploy script | deploy/default.ts | Deploys the program and registers it with the Interfold contract. |
| Coordination server | server/index.ts | Starts the computation after the input window and publishes the ciphertext output. |
| Client | client/src/pages/steps/*.tsx | The five-step web flow: connect, request, enter inputs, submit, and results. |
| Configuration | interfold.config.yaml | Chain, contract addresses, local ciphernodes, and the program mode (program.dev). |
Create the project
Create a project from the default template:
interfold init my-e3
cd my-e3interfold init downloads templates/default at the tag of your CLI version (for example
v0.18.0). It pins the @interfold/* packages and the Rust crates to that release, runs
pnpm install, and makes the first Git commit.
- lib.rs
- MyProgram.sol
- hardhat.config.ts
- interfold.config.yaml
Run the template one time
Start the complete local stack:
pnpm dev:allThe command starts anvil, deploys the Interfold contracts and MyProgram, starts five local
ciphernodes, the program server, the coordination server, and the client. Open
http://localhost:3000, connect a wallet, and run one E3 with two numbers. The result is their sum.
The Quick Start has the wallet setup.
Stop the stack with Ctrl+C before you change the code.
Read the Secure Process
program/src/lib.rs exports two functions. The program server calls both.
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()
}fhe_processoradds the ciphertexts homomorphically and returns the encrypted sum. It never sees a plaintext. The ciphertexts arrive one at a time, which is how the OpenVM guest proves rounds larger than its memory.policyreturnsInputPolicy::default(). The input tree leaf is the SAFE commitment of each ciphertext, and the processor uses every input. This matchesMyProgram.publishInput, which inserts the commitment.
Run the unit test. It encrypts 3 and 2 with a test key, runs fhe_processor, decrypts, and expects
5:
cargo test -p e3-user-programWhen you change the computation, keep these rules:
- Return one BFV ciphertext with two parts. The decryption circuit (C6) takes
ct0andct1only. The processor receives no relinearization key, so do not multiply two ciphertexts. - Select inputs only through
policy. The compute proof commits to the root of the inputs that the processor used, andMyProgram.verifycompares it with the on-chain tree.
The Secure Process explains the input policy in detail.
Harden publishInput
contracts/MyProgram.sol implements IE3Program and IE3ProgramDataAvailability:
| Function | Called by | What the template does |
|---|---|---|
supportsInterface | Interfold contract, at registration | Reports IE3Program, IE3ProgramDataAvailability, and IERC165. Registration fails without them. |
validate | Interfold contract, at request | Stores keccak256(e3ProgramParams), starts an input tree of depth 20, returns keccak256("fhe.rs:BFV"). |
publishInput | The data provider, directly | Decodes (bytes ciphertext, bytes32 commitment), inserts the commitment, emits InputPublished. |
verify | Interfold contract, at output publication | Checks the parameter hash and the input root in the proof, rebuilds the journal, calls the receipt verifier. |
verifyDataAvailability | Interfold contract, at output publication | Local only. It accepts the output bytes as their own receipt. |
The template publishInput checks only the end of the input window. Add the stage check and the
start-of-window check that CRISPProgram makes. Add two errors next to the other errors:
error KeyNotPublished(uint256 e3Id);
error InputWindowNotOpen(uint256 e3Id);Then replace the first check in publishInput:
function publishInput(uint256 e3Id, bytes memory data) external override {
E3 memory e3 = interfold.getE3(e3Id);
if (interfold.getE3Stage(e3Id) != IInterfold.E3Stage.KeyPublished) revert KeyNotPublished(e3Id);
if (block.timestamp < e3.inputWindow[0]) revert InputWindowNotOpen(e3Id);
if (block.timestamp > e3.inputWindow[1]) revert InputDeadlineReached();
(bytes memory ciphertext, bytes32 ciphertextCommitment) = abi.decode(data, (bytes, bytes32));
if (ciphertext.length == 0) revert EmptyInputData();
uint256 index = inputs[e3Id].numberOfLeaves;
inputs[e3Id]._insert(uint256(ciphertextCommitment));
emit InputPublished(e3Id, ciphertext, index);
}Compile the contracts. The command also regenerates the TypeChain types in types/, which the
server and the deploy script import:
pnpm compileThe template does not check that the ciphertext bytes match the commitment, as the comment in
publishInput says. An input with a wrong commitment can stop the E3 from completing. A
production program must check that binding, for example with a proof. See Custom Noir
Circuits.
Keep the verify function as it is. It compares computeProof.inputRoot with
inputs[e3Id]._root(). The protocol ciphertext verifier does not check the input root, so this
comparison is the only check that the output covers your inputs.
Read the deploy script
deploy/default.ts exports deployTemplate. scripts/deploy-local.ts calls it after
deployInterfold(true, false), which deploys the protocol with mocks. deployTemplate does these
steps:
- With
TEMPLATE_UNPROVED_TEST=1(the local default), it deploysMockOpenVmReceiptVerifier, which accepts every proof on the local chain only. Otherwise it deploys anOpenVmReceiptVerifierfor the guest thatinterfold program compilebuilt. - It deploys
OpenVmBfvCiphertextVerifierfor that receipt identity and callssetCiphertextVerifieron the Interfold contract. - It deploys
MyProgramwith the Interfold address, the receipt verifier, and its identity. It linksPoseidonT3for the input tree. - It calls
registerE3Programand checkse3Programs(program). - It records the addresses in
deployed_contracts.jsonandinterfold.config.yaml.
setCiphertextVerifier and registerE3Program are onlyOwner functions. They work locally because
the deployer owns the local Interfold contract. If you add a constructor argument to MyProgram,
add it to the e3ProgramFactory.deploy(...) call.
Read the coordination server
server/index.ts runs the computation when the input window closes. It needs a private key to
publish the output, so it runs on a server and not in the browser.
- It listens for
CommitteePublicKeyChunkPublished. When a key is complete, it checks the key against its commitment withvalidatePublicKeyCommitment. - It waits until the chain time passes
inputWindow[1]. - It reads the parameter bytes from
paramSetRegistryand everyInputPublishedevent of the E3. It skips an E3 with one input or fewer. - It sends the job to the program server at
PROGRAM_RUNNER_URL(POST /run_compute). - The program server calls back at
CALLBACK_URL. The server stores the output bytes and callspublishCiphertextOutput.
| Variable | Source |
|---|---|
INTERFOLD_ADDRESS, REGISTRY_ADDRESS, E3_PROGRAM_ADDRESS, FEE_TOKEN_ADDRESS | interfold print-env --chain localhost |
RPC_URL, PRIVATE_KEY, CHAIN_ID | scripts/dev_server.sh sets them for the local chain |
PROGRAM_RUNNER_URL | Default http://127.0.0.1:13151 |
CALLBACK_URL | Default http://127.0.0.1:8080 |
PORT | Default 8080 |
The server reads the data field of InputPublished as the ciphertext. If you change the event,
change runProgram in server/index.ts too.
The program server that pnpm dev:all starts is a development service. It accepts any callback
URL and does not authenticate callers. Keep it isolated from production and untrusted networks.
Read the client
The client uses useInterfoldSDK from @interfold/react. The SDK configuration is in
client/src/utils/sdk-config.ts, and the addresses come from the VITE_* variables that
interfold print-env --vite prints.
The request step (RequestComputation.tsx) gets a quote, approves the fee token, and requests the
E3:
const inputWindow = await calculateInputWindow(publicClient, 100) // 100 Seconds
const computeProviderParams = encodeComputeProviderParams(DEFAULT_COMPUTE_PROVIDER_PARAMS)
const requestParams = {
committeeSize: DEFAULT_E3_CONFIG.committeeSize,
inputWindow,
e3Program: contracts.e3Program,
paramSet: 0, // ParamSet.Insecure64
computeProviderParams,
}
const fee = await sdk.sdk.getE3Quote(requestParams)
const approveTx = await sdk.sdk.approveFeeToken(fee)
await publicClient.waitForTransactionReceipt({ hash: approveTx })
const hash = await requestE3(requestParams)The same step assembles the committee public key from COMMITTEE_PUBLIC_KEY_CHUNK_PUBLISHED events
with CommitteePublicKeyAssembler. It accepts the key only if validatePublicKeyCommitment returns
true.
The input step (EnterInputs.tsx) encrypts each number to that key, computes its commitment, and
calls publishInput on the program contract:
const encryptedInput1 = await sdk.sdk?.encryptNumber(num1, publicKeyBytes)
const commitment1 = await sdk.sdk.computeCiphertextCommitment(encryptedInput1)
await publishInput(
walletClient,
e3State.id,
toHex(encryptedInput1),
toHex(commitment1),
address,
contracts.e3Program,
)publishInput in client/src/utils/input.ts encodes (bytes, bytes32), the format that
MyProgram.publishInput decodes. The submit step (EncryptSubmit.tsx) waits for
PLAINTEXT_OUTPUT_PUBLISHED, decodes the value with decodePlaintextOutput, and opens the results
step.
To follow the output publication, listen for
InterfoldEventType.CIPHERTEXT_OUTPUT_REFERENCE_PUBLISHED. The Interfold contract emits
CiphertextOutputReferencePublished when it accepts the ciphertext output. It does not emit
CiphertextOutputPublished.
Run the changed program
Start the stack again:
pnpm dev:allThe deploy step compiles and deploys the changed MyProgram. Run one E3 in the client. The result
is the sum of the two inputs, as before. The new checks reject an input that arrives before the
committee key is published or before inputWindow[0].
What must stay the same across the parts
A change in one part often needs a change in another part. Check this table after each change.
| Value | Where it is set | Where it must match |
|---|---|---|
| Input encoding | abi.decode(data, (bytes, bytes32)) in publishInput | encodeAbiParameters in client/src/utils/input.ts and server/input.ts |
| Input tree leaf | inputs[e3Id]._insert(uint256(ciphertextCommitment)) | policy() in program/src/lib.rs |
| Input event | InputPublished(e3Id, data, index) | getContractEvents in server/index.ts |
| BFV parameter set | paramSet: 0 in the request | thresholdBfvParamsPresetName: 'INSECURE_THRESHOLD_64' in the SDK config |
| Receipt identity | guest/ and program/, built by interfold program compile | The imageId in the MyProgram constructor and in OpenVmBfvCiphertextVerifier |
The E3 flow
The client calls publishInput on the program contract. The Interfold contract does not forward
inputs. See E3 Lifecycle for the stages and deadlines.
Next steps
Add a proof that each input is valid, and check it in publishInput.
Encrypt inputs and submit them with the SDK.
TutorialDeploy to SepoliaDeploy the program to the testnet and request an E3 there.
TutorialE3 program contractThe full IE3Program interface and what each function must check.