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 insideCofheTest with createCofheClient(), then bind it to an address with connect(pkey):
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():
Encrypting inputs
The client mirrors the JS SDK’sencryptInputs API — one method per encrypted Solidity type:
| Method | Returns |
|---|---|
createInEbool(bool) | InEbool |
createInEuint8(uint8) | InEuint8 |
createInEuint16(uint16) | InEuint16 |
createInEuint32(uint32) | InEuint32 |
createInEuint64(uint64) | InEuint64 |
createInEuint128(uint128) | InEuint128 |
createInEaddress(address) | InEaddress |
EncryptedInput shapes — drop straight into the InEuintN parameter on the contract under test.
Decrypting
The plugin exposes both decryption flows the SDK supports:| Method | Returns | Use 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 plaintext | Off-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:
MockThresholdNetworkSigner that FHE.verifyDecryptResult accepts.
decryptForView — permit-based unseal
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:| Method | Purpose |
|---|---|
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)
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)
permit_importShared reverts unless the calling client’s account() matches export.recipient — preventing Alice from importing a permit shared to someone else.
Common pitfalls
Wrong client for the prank
Wrong client for the prank
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.Stale handle reads
Stale handle reads
euint32.unwrap(counter.count()) returns the current handle. Storing it in a local then asserting after a write reads the old handle.Permit issuer must derive from the connected key
Permit issuer must derive from the connected key
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.`decryptForView` reverts on deny
`decryptForView` reverts on deny
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: