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 when the contract gets non-trivial.

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 permit/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 createInEuintN — the client is bound at connect.

3. vm.prank(client.account()) to act on-chain 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 permit. Reserve the SDK path for tests where the SDK behavior itself is under test.

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

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. Permit-based unseal: decryptForView for the success path

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

7. Deny path: asserting “Alice cannot decrypt Bob’s value”

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

createInEuintN accepts the full uintN range — 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.createInEuintN(...) 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 permit.issuer. bob.permit_createSelf() after bob.connect(0xB0B) produces issuer == vm.addr(0xB0B). Trying to forge a mismatch fails signature verification.