Skip to main content
CofheClient is the Foundry plugin’s in-Solidity SDK shim. One client per “user” in your scenario. The client carries a private key and produces encrypted inputs / signed permits as if it were that user’s frontend SDK — no JS bridge required.

Creating and connecting

Spin up a client from inside CofheTest with createCofheClient(), then bind it to an address with connect(pkey):
CofheClient bob = createCofheClient();
bob.connect(0xB0B);            // bob.account() == vm.addr(0xB0B)
After connect, the client knows which address to sign as. All createInEuintN and permit_* calls use that account automatically — there’s no account argument to pass. To act on-chain as that user, prank with client.account():
vm.prank(bob.account());
counter.reset(bob.createInEuint32(2000));
A mismatch between the prank address and the client that produced the input will fail the ZK-verifier signature check — the input was signed for bob.account(), not whoever you pranked. Always match the client to the prank.

Encrypting inputs

The client mirrors the JS SDK’s encryptInputs API — one method per encrypted Solidity type:
MethodReturns
createInEbool(bool)InEbool
createInEuint8(uint8)InEuint8
createInEuint16(uint16)InEuint16
createInEuint32(uint32)InEuint32
createInEuint64(uint64)InEuint64
createInEuint128(uint128)InEuint128
createInEaddress(address)InEaddress
All produce signed EncryptedInput shapes — drop straight into the InEuintN parameter on the contract under test.
InEuint32 memory encrypted = bob.createInEuint32(42);

vm.prank(bob.account());
counter.reset(encrypted);

Decrypting

The plugin exposes both decryption flows the SDK supports:
MethodReturnsUse for
decryptForTx_withoutPermit(ctHash)(bytes32 ctHash, uint256 plaintext, bytes signature)Globally-allowed (FHE.allowPublic) ciphertexts. Pass signature to FHE.publishDecryptResult.
decryptForTx_withPermit(ctHash, permit)(bytes32, uint256, bytes)ACL-gated decryptForTx flow.
decryptForView(ctHash, permit)uint256 plaintextOff-chain seal/unseal flow. Reverts on deny — use the mock directly to assert deny.

decryptForTx_withoutPermit — public-decrypt 3-step flow

Mirrors the production flow when a contract calls FHE.publishDecryptResult:
// Step 1: contract grants public decrypt permission
vm.prank(bob.account());
counter.allowCounterPublicly();   // calls FHE.allowPublic(handle)

// Step 2: SDK fetches plaintext + threshold-network signature
bytes32 ctHash = euint32.unwrap(counter.count());
(, uint256 plaintext, bytes memory sig) = bob.decryptForTx_withoutPermit(ctHash);

// Step 3: contract verifies signature and stores plaintext
counter.revealCounter(uint32(plaintext), sig);
The same shape runs unmodified against real CoFHE on testnet — the mock signature is produced by the same MockThresholdNetworkSigner that FHE.verifyDecryptResult accepts.

decryptForView — permit-based unseal

Permission memory bobPermit = bob.permit_createSelf();
uint256 value = bob.decryptForView(ctHash, bobPermit);
assertEq(value, 42);
decryptForView reverts when the caller isn’t on the ACL. To assert the deny path (e.g. “Alice should NOT be able to decrypt Bob’s value”), drop down to the mock directly — see Testing: Deny path.

Permits

The client signs EIP-712 permits against the ACL’s domain. Two flavors:
MethodPurpose
permit_createSelf()Self-permit for the connected account; sealing key is auto-derived (keccak(address)).
permit_createShared(recipient)Issuer half of a shared permit (no sealing key — the recipient adds it on import).
permit_exportShared(perm)Strip sensitive fields → SharedPermitExport (safe to transmit out-of-band).
permit_importShared(export)Recipient-side completion: adds sealing key + recipient signature. Reverts unless export.recipient == account().
createSealingKey(seed)Custom sealing key. Rarely needed — permit_createSelf derives one for you.

Self-permit (most common)

Permission memory bobPermit = bob.permit_createSelf();
uint256 plaintext = bob.decryptForView(ctHash, bobPermit);
permit_createSelf builds the EIP-712 typed-data, derives a sealing key from the connected account, and signs — all in one call.

Shared permits (issuer → recipient)

// Bob (issuer) creates a permit shared to Alice (recipient)
Permission memory shared = bob.permit_createShared(alice.account());

// Bob exports it (strips bob's sealing key) for transmission
SharedPermitExport memory exported = bob.permit_exportShared(shared);

// Alice imports it — adds her sealing key and recipient signature
Permission memory aliceImported = alice.permit_importShared(exported);
permit_importShared reverts unless the calling client’s account() matches export.recipient — preventing Alice from importing a permit shared to someone else.

Common pitfalls

vm.prank(bob.account()) while the input came from alice.createInEuintN(...) fails ZK verification — the input was signed for Alice’s address, not Bob’s. Match the client to the prank.
euint32.unwrap(counter.count()) returns the current handle. Storing it in a local then asserting after a write reads the old handle.
bytes32 oldHandle = euint32.unwrap(counter.count());
counter.increment();
// ❌ oldHandle still references the pre-increment handle
expectPlaintext(oldHandle, uint32(0));   // passes by accident
expectPlaintext(counter.count(), uint32(1));   // ✅ fetch the new handle
Re-fetch after each state change.
The pkey passed to connect must derive the address used as permit.issuer. If you call bob.permit_createSelf() after bob.connect(0xB0B), the issuer is vm.addr(0xB0B). Trying to forge an issuer mismatch will fail signature verification.
Useful default — most tests want a hard failure when the caller isn’t permitted. To assert “Alice cannot decrypt”, call the mock’s querySealOutput directly:
(bool allowed, string memory err, ) = mockThresholdNetwork.querySealOutput(
    uint256(ctHash), block.chainid, alicePermit
);
assertFalse(allowed);
assertEq(err, "NotAllowed");

Next steps

  • Testing — full test-writing patterns.
  • CofheTest — the test base contract that creates clients.