Skip to main content
This page shows the load-bearing patterns for writing FHE contract tests under Foundry with @cofhe/foundry-plugin. Hardhat counterpart: Hardhat Plugin: Testing.

Skeleton

test/Counter.t.sol
That’s the load-bearing shape. Everything below is what to add as the contract grows more complex.

Rules

1. Inherit CofheTest, not Test

CofheTest already inherits forge-std/Test and exposes the mock state vars (mockTaskManager, mockAcl, mockThresholdNetwork, …) you’ll occasionally reach into. Inheriting both is a redeclaration error.

2. One CofheClient per scenario address

Each user with their own ACP and encrypted inputs gets their own client. Connect with a deterministic plaintext private key. Don’t recycle real keys; these are visible in test output.
You don’t pass account to createExternalEuintN; the client is bound at connect.

3. vm.prank(client.account()) to act onchain as that user

Mismatching the prank address and the client that produced the input fails the ZK-verifier signature check.

4. Assert with expectPlaintext whenever possible

Faster than decryptForView and needs no ACP. Reserve the SDK path for tests where the SDK behavior itself is under test.

5. Test the public-decrypt 3-step flow with decryptForTx_withoutACP

When the contract calls FHE.publishDecryptResult:
Same shape runs unmodified against real CoFHE on testnet. The mock signature is produced by the same MockThresholdNetworkSigner that FHE.verifyDecryptResult accepts.

6. ACP-based unseal: decryptForView for the success path

ACP_createSelf builds the EIP-712 typed-data, derives a sealing key from the connected account, and signs, with no manual signPermissionSelf boilerplate.

  1. Deny path: when the caller is not on the ACL

decryptForView reverts when the caller isn’t on the ACL. To assert the deny path, drop down to the mock directly:
mockThresholdNetwork is a public field on CofheTest.

8. Fuzz tests inherit normally

createExternalEuintN accepts the full uintN range, so no shaping needed.

Migration from the old mock-contracts API

If you’re upgrading from @cofhe/mock-contracts@0.4.x (where CoFheTest lived inside the mocks package), here’s the rename table:

Common pitfalls

LSPs like Wake/Cursor often resolve from the monorepo root; the plugin’s remappings are package-local. forge is the truth. If forge build and forge test succeed, the test is correct.
Tests pass on the first op, then a second op reverts with ACLNotAllowed because the contract itself isn’t on the ACL. Toggle enableLogs() to see the missing grant. Every op prints a line showing whether allowThis/allow was called.
vm.prank(bob.account()) while the input came from alice.createExternalEuintN(...) fails ZK verification. Always 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:
Re-fetch after each state change.
The pkey passed to connect must derive the address used as acp.issuer. bob.ACP_createSelf() after bob.connect(0xB0B) produces issuer == vm.addr(0xB0B). Trying to forge a mismatch fails signature verification.