Skip to main content
Every ciphertext CoFHE produces or verifies is anchored by a hash commitment. This page shows you how to check one without trusting the coprocessor: read the public inputs, run the same computation yourself, and compare your hash to the recorded one. Nothing here needs an account, a key, or permission from anyone.

What the check proves

A commitment is keccak256 over a ciphertext’s stored bytes. Those bytes are deterministic. The same inputs, the same keys, and the same FHE parameters produce the same ciphertext, byte for byte, on your machine as on ours. So the audit is a recomputation:
  1. Read the operation and its input handles from the host chain.
  2. Fetch the input ciphertexts from the public archive.
  3. Fetch the public key material the network computes with.
  4. Re-run the operation with those inputs.
  5. Hash your result and compare it to the recorded commitment.
A match means the coprocessor ran the operation the contract asked for, and did not substitute a result. A mismatch means it did not, but read When the hashes do not match before you draw that conclusion. The check says nothing about plaintexts. You never learn what any value decrypts to, because you never hold a decryption key. That is the point: the computation is auditable while the data stays confidential.

What you need

Pending values

Three values are not published yet. Everything else on this page works today. You need one CommitmentRegistry address, not one per chain. A handle is derived from the ciphertext bytes, or from the operation and its input handles, and neither derivation mixes in a chain id. So handles are global, one registry records them all, and your host chain never appears in the lookup.
Step 1 works today and does not depend on any of the three. The computation graph and every input commitment come from the host chain alone.

How a ciphertext is committed

Every ciphertext in CoFHE, whether an encrypted input or a computed result, is stored in one format: the tfhe-rs value, compressed with compress(), then written with tfhe’s safe_serialize. The commitment is keccak256 over exactly those bytes. The security zone is not in the preimage. A handle is not the commitment, and the two relate differently depending on where the ciphertext came from. That difference is why the registry exists. A result’s handle is fixed onchain the moment the task is created, so it cannot carry a hash of bytes nobody has computed yet.
The TaskManager on the host chain, the verified input archive and the key store inside CoFHE, and the CommitmentRegistry on the registry chain, all feeding a recomputation and a hash comparison on your own machine
Color marks the zone, and the boxes group what runs where. Only the last box is yours: everything else is a public read, and none of it needs an account or a key.

Read the computation graph

The TaskManager publishes every operation it schedules. TaskCreated carries the result handle, the operation name, and up to three input handles.
One real event from Arbitrum Sepolia, decoded: Operation names come from Utils.functionIdToString in ICofhe.sol, which also defines the type constants. Byte 30 holds the type in its low seven bits. Its high bit is set when the value was trivially encrypted, so mask the byte with 0x7f before you compare it to those constants. Here every handle ends in 05 00, which is EUINT64_TFHE in security zone 0. The same type, trivially encrypted, ends 85 00 and matches no constant until you mask it. An input handle is either another task’s result, in which case you follow it and recurse, or an encrypted input. Encrypted inputs terminate the graph, and each one has its commitment onchain:
The indexed topic is the handle, and the data word is the commitment. From a real event, unrelated to the task above:
The first 30 bytes are identical, and the handle’s last two are the type byte and the zone. That relation is worth checking as you go, because it catches a mistyped handle before you spend a recomputation on it.

Read the commitment

getCommitment takes the version tag and the handle, and returns the recorded hash. bytes32(0) means nothing is posted for that pair.
The version tag is the number 2 encoded as bytes32, so 0x00...02. It is not the ASCII character 2. cast format-bytes32-string "2" returns 0x3200...00, and every lookup under that value comes back zero.
A recorded hash on its own is not enough. Every version carries a status, and the registry accepts new commitments only while that status is Active. Read the status before you trust a match:
The answer is 0 for Unset, 1 for Active, 2 for Deprecated, and 3 for Revoked. A status only moves forward: Active to Deprecated or Revoked, and Deprecated to Revoked. Deprecated means the version was superseded and its commitments stay readable. Revoked means the opposite. Do not trust a match under a revoked version. A zero answer is not proof of tampering, and it has two ordinary causes. The first is timing: a result that is still being computed has no commitment yet, because the coprocessor posts the commitment after the compute stage finishes. The second is the version. Version 1 is still Active and holds the bulk of the earlier history. A handle committed before the cutover resolves only under version 1, and reads as zero under version 2. Retry the lookup under version 1 before you conclude anything. To audit in bulk rather than one handle at a time, enumerate the version. getSize reports the total, and getHandles pages through it, clamping offset + limit at the total and returning an empty array once offset runs past the end.
Keep limit small. See CommitmentRegistry for the write rules, the version state machine, and the rest of the read surface.

Fetch the inputs

The ZK Verifier archives every input it verifies, together with the proof that covered it. This store holds inputs and proofs only, not the keys. Objects are laid out by chain and security zone, sharded by the first four hex characters of the handle:
The handle is written without its 0x prefix, and {shard} is its first four characters. For the verified input above, on Arbitrum Sepolia in zone 0, that is cofhe/v1/chain/421614/security-zone/0/verified-inputs/1b2c/1b2cd55ff9b0cbef294dfdc4cfb80ea6450bf0a272e45f9b3715a27bed350500. Each object is a bincode-encoded record holding the ciphertext bytes and its metadata. The metadata carries the handle, the proof it belongs to, the submitting account, the security zone, the chain, the index within the proof, and the type. Take the ciphertext bytes out of that record. They are the same safe_serialize of a compressed value as everything else, so keccak256 over them reproduces the commitment you read from InputVerified. Check that before going further. The metadata also gives you the proof id, which is where you find the matching object under verification-proofs. Verifying that proof is a separate check, and it tells you the input was well formed rather than that the computation was honest.

Fetch the public key material

Recomputation needs three artifacts, all public and all specific to a security zone. They live in the key store, which is separate from the verified input archive and has its own base URL. A manifest sits alongside them at keys/versionized/{zone}/public-material, carrying a SHA-256 digest of each artifact. Check the artifacts you downloaded against it. Two of the three are also served over HTTP by the coprocessor, at the CoFHE URL your chain’s SDK config already holds. Both take a security zone and return hex:
Neither HTTP endpoint serves the evaluation key, and you cannot compute without it. Only the key store has it. Until that base URL is published, this is the step that blocks a full recomputation.

Re-run the operation

Start from a crate that pins tfhe-rs to the version the network runs:
Cargo.toml
A fresh resolve picks tfhe-zk-pok 0.8.3, which pulls a tfhe-versionable that tfhe 1.5.1 does not build against. The build fails inside tfhe itself with over a hundred trait-bound errors. Pin it back with cargo update -p tfhe-zk-pok --precise 0.8.0.
Then decompress the evaluation key, deserialize both inputs, apply the operation the event named, and serialize the result the way the coprocessor does:
src/main.rs
Compression is deterministic. It is a modulus switch whose noise-reduction step picks its candidate by a fixed measure, with no randomness. The same inputs give the same bytes on every run and every machine. If the value this returns equals what getCommitment gave you for the result handle, the coprocessor computed honestly for that task. Walk the graph up from the encrypted inputs and you have checked the whole computation.

Parameters the keyset uses

You do not need these to recompute, because the evaluation key carries them. They are here so you can confirm the keyset you downloaded is the one you expect:

When the hashes do not match

A mismatch is a serious claim, and the first ones are almost always a setup problem rather than a dishonest coprocessor. Work through these before you conclude anything:
  • The type. Mask byte 30 of the handle with 0x7f, and confirm it matches the type you deserialized as. Reading a euint32 handle as a CompressedFheUint64 gives bytes that hash to something else.
  • The security zone. Byte 31 names the zone. The evaluation key, the public key, and the CRS are all per zone, so a keyset from the wrong zone produces a plausible result with the wrong bytes.
  • The serialized form. Hash safe_serialize of the compressed value. The expanded value and the compressed one are the same ciphertext and different bytes. The size limit you pass is only a guard, and never reaches the hash.
  • The version and the task. Confirm getVersionStatus returns Active, and that getCommitment returned a non-zero hash. A result still in the compute stage has no commitment yet.
  • The tfhe-rs version. Confirm your pin is =1.5.1, the version the network runs.
If all of these hold and the hashes still differ, you have a real discrepancy between what the contract asked for and what the network recorded.

Where this check runs in production

Teecryptor makes the same comparison inside its enclave on every decryption request. It hashes the ciphertext bytes it is about to decrypt and checks them against the commitment anchored onchain. Your audit and its gate read the same record.
The gate runs in warn-only mode during the rollout. A mismatch is logged and the decryption still proceeds. It moves to enforcement once handles committed under the earlier version have aged out, and until then the onchain commitment is the record you audit, not a block on decryption.

Source