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
import { CofheTest } from "@cofhe/foundry-plugin/contracts/CofheTest.sol";
import { CofheClient } from "@cofhe/foundry-plugin/contracts/CofheClient.sol";
import { InEuint32 } from "@fhenixprotocol/cofhe-contracts/FHE.sol";
import { Counter } from "../src/Counter.sol";

contract CounterTest is CofheTest {
    Counter public counter;
    CofheClient bob;
    CofheClient alice;

    uint256 constant BOB_PKEY   = 0xB0B;
    uint256 constant ALICE_PKEY = 0xA11CE;

    function setUp() public {
        deployMocks();

        bob   = createCofheClient();   bob.connect(BOB_PKEY);
        alice = createCofheClient();   alice.connect(ALICE_PKEY);

        vm.prank(bob.account());
        counter = new Counter();
    }

    function test_Increments() public {
        vm.prank(bob.account());
        counter.increment();
        expectPlaintext(counter.count(), uint32(1));
    }
}
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.
CofheClient bob = createCofheClient();
bob.connect(0xB0B);         // bob.account() == vm.addr(0xB0B)
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

vm.prank(bob.account());
counter.reset(bob.createInEuint32(2000));
Mismatching the prank address and the client that produced the input fails the ZK-verifier signature check.

4. Assert with expectPlaintext whenever possible

expectPlaintext(counter.count(), uint32(2000));    // typed overload
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:
// Step 1: contract grants public decrypt permission
vm.prank(bob.account());
counter.allowCounterPublicly();   // 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);
assertEq(counter.getDecryptedValue(), plaintext);
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

import { Permission } from "@cofhe/mock-contracts/contracts/Permissioned.sol";

Permission memory bobPermit = bob.permit_createSelf();
uint256 value = bob.decryptForView(ctHash, bobPermit);
assertEq(value, expected);
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:
Permission memory alicePermit = alice.permit_createSelf();
(bool allowed, string memory err, ) = mockThresholdNetwork.querySealOutput(
    uint256(ctHash), block.chainid, alicePermit
);
assertFalse(allowed, "Alice should NOT be allowed");
assertEq(err, "NotAllowed");
mockThresholdNetwork is a public field on CofheTest.

8. Fuzz tests inherit normally

function testFuzz_Reset(uint32 v) public {
    InEuint32 memory enc = bob.createInEuint32(v);
    vm.prank(bob.account());
    counter.reset(enc);
    expectPlaintext(counter.count(), v);
}
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:
Old API (mock-contracts ≤ 0.4)New API (@cofhe/foundry-plugin)
import { CoFheTest } from "@cofhe/mock-contracts/foundry/CoFheTest.sol"import { CofheTest } from "@cofhe/foundry-plugin/contracts/CofheTest.sol"
is Test, CoFheTestis CofheTest (Test already inherited)
assertHashValue(handle, value)expectPlaintext(handle, value)
mockStorage(ctHash)getPlaintext(ctHash)
createInEuint32(v, bob)bob.createInEuint32(v)
createPermissionSelf(bob) + signPermissionSelf(perm, bobKey)bob.permit_createSelf() (auto-signs)
createSealingKey(seed)bob.createSealingKey(seed) (rarely needed — permit_createSelf derives one)
queryDecrypt(hash, chainId, permit)bob.decryptForView(hash, permit) (reverts on deny)
Same, asserting denymockThresholdNetwork.querySealOutput(hash, block.chainid, permit)
querySealOutput + unsealbob.decryptForView (does both)
decryptForTxWithoutPermit(ct) returns (allowed, error, plaintext)bob.decryptForTx_withoutPermit(ct) returns (ctHash, plaintext, signature)

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:
bytes32 oldHandle = euint32.unwrap(counter.count());
counter.increment();
expectPlaintext(oldHandle, uint32(0));   // passes by accident
expectPlaintext(counter.count(), uint32(1)); // ✅ re-fetch
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.