Skip to content
38 changes: 21 additions & 17 deletions client-sdk/examples/end-to-end.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,8 @@ import '@fhenixprotocol/cofhe-contracts/FHE.sol';
contract ConfidentialVault {
mapping(address => euint64) private _balances;

function deposit(InEuint64 calldata encryptedAmount) external {
euint64 amount = FHE.asEuint64(encryptedAmount);
function deposit(externalEuint64 encryptedAmount, bytes calldata inputProof) external {
euint64 amount = FHE.asEuint64(encryptedAmount, inputProof);
_balances[msg.sender] = FHE.add(_balances[msg.sender], amount);
FHE.allowThis(_balances[msg.sender]);
FHE.allowSender(_balances[msg.sender]);
Expand Down Expand Up @@ -67,18 +67,19 @@ const walletClient = createWalletClient({

await client.connect(publicClient, walletClient);

// 2. Create a permit
await client.permits.getOrCreateSelfPermit();
// 2. Create an ACP
await client.acp.getOrCreateSelfACP();

// 3. Encrypt and deposit
const [encryptedAmount] = await client
const [amountHash, signature] = await client
.encryptInputs([Encryptable.uint64(100n)])
.setConsumingContract(contract.address)
.onStep((step, ctx) => {
if (ctx?.isStart) console.log(`Encrypting: ${step}...`);
})
.execute();

await contract.deposit(encryptedAmount);
await contract.deposit(amountHash, signature);

// 4. Decrypt for UI display
const ctHash = await contract.getBalance();
Expand All @@ -91,7 +92,7 @@ console.log('Balance:', balance); // 100n
// 5. Decrypt for on-chain verification
const { decryptedValue, signature } = await client
.decryptForTx(ctHash)
.withPermit()
.withACP()
.execute();

await contract.publishBalance(ctHash, decryptedValue, signature);
Expand All @@ -116,15 +117,16 @@ const client = createCofheClient(config);
const { publicClient, walletClient } = await Ethers6Adapter(provider, wallet);
await client.connect(publicClient, walletClient);

// 2. Create a permit
await client.permits.getOrCreateSelfPermit();
// 2. Create an ACP
await client.acp.getOrCreateSelfACP();

// 3. Encrypt and deposit
const [encryptedAmount] = await client
const [amountHash, signature] = await client
.encryptInputs([Encryptable.uint64(100n)])
.setConsumingContract(contract.address)
.execute();

await contract.deposit(encryptedAmount);
await contract.deposit(amountHash, signature);

// 4. Decrypt for UI display
const ctHash = await contract.getBalance();
Expand All @@ -137,7 +139,7 @@ console.log('Balance:', balance); // 100n
// 5. Decrypt for on-chain verification
const { decryptedValue, signature } = await client
.decryptForTx(ctHash)
.withPermit()
.withACP()
.execute();

await contract.publishBalance(ctHash, decryptedValue, signature);
Expand All @@ -161,10 +163,11 @@ describe('ConfidentialVault', () => {
const vault = await Factory.deploy();

// Encrypt and deposit
const [encrypted] = await cofheClient
const [amountHash, proof] = await cofheClient
.encryptInputs([Encryptable.uint64(100n)])
.setConsumingContract(await vault.getAddress())
.execute();
await (await vault.deposit(encrypted)).wait();
await (await vault.deposit(amountHash, proof)).wait();

// Decrypt and verify
const ctHash = await vault.getBalance();
Expand All @@ -180,16 +183,17 @@ describe('ConfidentialVault', () => {
const vault = await Factory.deploy();

// Encrypt and deposit
const [encrypted] = await cofheClient
const [amountHash, proof] = await cofheClient
.encryptInputs([Encryptable.uint64(42n)])
.setConsumingContract(await vault.getAddress())
.execute();
await (await vault.deposit(encrypted)).wait();
await (await vault.deposit(amountHash, proof)).wait();

// Decrypt for on-chain verification
const ctHash = await vault.getBalance();
const { decryptedValue, signature } = await cofheClient
.decryptForTx(ctHash)
.withPermit()
.withACP()
.execute();

// Publish on-chain with Threshold Network proof
Expand Down
4 changes: 2 additions & 2 deletions client-sdk/examples/templates.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ npx hardhat test
- `@cofhe/hardhat-plugin` and `@cofhe/sdk` pre-installed
- `hardhat.config.ts` with `evmVersion: 'cancun'` and the plugin imported
- A sample FHE contract
- A test demonstrating the encryptstore decrypt flow
- A test demonstrating the encrypt, store, and decrypt flow
- Pre-configured network settings for local development and testnets

## Foundry Starter
Expand All @@ -50,5 +50,5 @@ forge test -vvv
- `foundry.toml` with `evm_version = "cancun"`, `solc_version = "0.8.25"`, and `code_size_limit = 100000`
- `remappings.txt` configured for the plugin and mocks
- A sample `Counter` contract using FHE
- Tests demonstrating `expectPlaintext`, `decryptForTx_withoutPermit`, permit-based unseal, ACL deny assertions, and fuzz tests
- Tests demonstrating `expectPlaintext`, `decryptForTx_withoutACP`, ACP-based unseal, ACL deny assertions, and fuzz tests
- Deploy scripts for `eth-sepolia`, `arb-sepolia`, and `base-sepolia`
92 changes: 48 additions & 44 deletions client-sdk/foundry-plugin/cofhe-client.mdx
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
---
title: CofheClient
description: "The in-Solidity SDK shim: one client per user, produces encrypted inputs and signed permits"
description: "The in-Solidity SDK shim: one client per user, produces encrypted inputs and signed ACPs"
---

`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**, with no JS bridge required.
`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 and signed ACPs **as if it were that user's frontend SDK**, with no JS bridge required.

## Creating and connecting

Expand All @@ -14,13 +14,15 @@ 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.
After `connect`, the client knows which address to sign as. All `createExternalEuintN` and `ACP_*` calls use that account automatically; there's no `account` argument to pass.

To act onchain as that user, prank with `client.account()`:

```solidity
(externalEuint32 hash, bytes memory proof) = bob.createExternalEuint32(2000, address(counter));

vm.prank(bob.account());
counter.reset(bob.createInEuint32(2000));
counter.reset(hash, proof);
```

<Warning>
Expand All @@ -29,38 +31,40 @@ A mismatch between the prank address and the client that produced the input will

## Encrypting inputs

The client mirrors the JS SDK's `encryptInputs` API, one method per encrypted Solidity type:
The client mirrors the JS SDK's `encryptInputs` API, one method per encrypted Solidity type. Each takes the plaintext plus the contract that will consume it, and returns the handle and its proof as a pair:

| Method | Returns |
| --- | --- |
| `createInEbool(bool)` | `InEbool` |
| `createInEuint8(uint8)` | `InEuint8` |
| `createInEuint16(uint16)` | `InEuint16` |
| `createInEuint32(uint32)` | `InEuint32` |
| `createInEuint64(uint64)` | `InEuint64` |
| `createInEuint128(uint128)` | `InEuint128` |
| `createInEaddress(address)` | `InEaddress` |
| `createExternalEbool(bool, address)` | `(externalEbool, bytes)` |
| `createExternalEuint8(uint8, address)` | `(externalEuint8, bytes)` |
| `createExternalEuint16(uint16, address)` | `(externalEuint16, bytes)` |
| `createExternalEuint32(uint32, address)` | `(externalEuint32, bytes)` |
| `createExternalEuint64(uint64, address)` | `(externalEuint64, bytes)` |
| `createExternalEuint128(uint128, address)` | `(externalEuint128, bytes)` |
| `createExternalEaddress(address, address)` | `(externalEaddress, bytes)` |

All produce signed `EncryptedInput` shapes that drop straight into the `InEuintN` parameter on the contract under test.
The second argument is the consuming contract. The verifier binds it into the signature, so a proof made for one contract will not verify in another.

```solidity
InEuint32 memory encrypted = bob.createInEuint32(42);
(externalEuint32 hash, bytes memory proof) = bob.createExternalEuint32(42, address(counter));

vm.prank(bob.account());
counter.reset(encrypted);
counter.reset(hash, proof);
```

For several values under one signature, use `createEncryptedInputsBatch`.

## 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` | Offchain seal/unseal flow. **Reverts on deny**, so use the mock directly to assert deny. |
| `decryptForTx_withoutACP(ctHash)` | `(bytes32 ctHash, uint256 plaintext, bytes signature)` | Globally-allowed (`FHE.allowPublic`) ciphertexts. Pass `signature` to `FHE.publishDecryptResult`. |
| `decryptForTx_withACP(ctHash, acp)` | `(bytes32, uint256, bytes)` | ACL-gated `decryptForTx` flow. |
| `decryptForView(ctHash, acp)` | `uint256 plaintext` | Offchain seal/unseal flow. **Reverts on deny**, so use the mock directly to assert deny. |

### Public-decrypt 3-step flow with `decryptForTx_withoutPermit`
### Public-decrypt 3-step flow with `decryptForTx_withoutACP`

Mirrors the production flow when a contract calls `FHE.publishDecryptResult`:

Expand All @@ -71,65 +75,65 @@ 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);
(, uint256 plaintext, bytes memory sig) = bob.decryptForTx_withoutACP(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.

### Permit-based unseal with `decryptForView`
### ACP-based unseal with `decryptForView`

```solidity
Permission memory bobPermit = bob.permit_createSelf();
uint256 value = bob.decryptForView(ctHash, bobPermit);
ACP memory bobAcp = bob.ACP_createSelf();
uint256 value = bob.decryptForView(ctHash, bobAcp);
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](/client-sdk/foundry-plugin/testing#deny-path).

## Permits
## Access Control Permissions

The client signs EIP-712 permits against the ACL's domain. Two flavors:
The client signs EIP-712 ACPs 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 to produce `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. |
| `ACP_createSelf()` | Self-ACP for the connected account; sealing key is auto-derived (`keccak(address)`). |
| `ACP_createShared(recipient)` | Issuer half of a shared ACP (no sealing key; the recipient adds it on import). |
| `ACP_exportShared(acp)` | Strip sensitive fields to produce `SharedACPExport` (safe to transmit out-of-band). |
| `ACP_importShared(export)` | Recipient-side completion: adds sealing key and recipient signature. Reverts unless `export.recipient == account()`. |
| `createSealingKey(seed)` | Custom sealing key. Rarely needed: `ACP_createSelf` derives one for you. |

### Self-permit (most common)
### Self-ACP (most common)

```solidity
Permission memory bobPermit = bob.permit_createSelf();
uint256 plaintext = bob.decryptForView(ctHash, bobPermit);
ACP memory bobAcp = bob.ACP_createSelf();
uint256 plaintext = bob.decryptForView(ctHash, bobAcp);
```

`permit_createSelf` builds the EIP-712 typed-data, derives a sealing key from the connected account, and signs, all in one call.
`ACP_createSelf` builds the EIP-712 typed-data, derives a sealing key from the connected account, and signs, all in one call.

### Shared permits (issuer to recipient)
### Shared ACPs (issuer to recipient)

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

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

// Alice imports it — adds her sealing key and recipient signature
Permission memory aliceImported = alice.permit_importShared(exported);
ACP memory aliceImported = alice.ACP_importShared(exported);
```

`permit_importShared` reverts unless the calling client's `account()` matches `export.recipient`, preventing Alice from importing a permit shared to someone else.
`ACP_importShared` reverts unless the calling client's `account()` matches `export.recipient`, preventing Alice from importing an ACP shared to someone else.

## Common pitfalls

<AccordionGroup>
<Accordion title="Wrong client for the prank" icon="triangle-exclamation">
`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.
`vm.prank(bob.account())` while the input came from `alice.createExternalEuintN(...)` fails ZK verification. The input was signed for Alice's address, not Bob's. Match the client to the prank.
</Accordion>

<Accordion title="Stale handle reads" icon="rotate">
Expand All @@ -146,16 +150,16 @@ expectPlaintext(counter.count(), uint32(1)); // ✅ fetch the new handle
Re-fetch after each state change.
</Accordion>

<Accordion title="Permit issuer must derive from the connected key" icon="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.
<Accordion title="ACP issuer must derive from the connected key" icon="key">
The `pkey` passed to `connect` must derive the address used as `acp.issuer`. If you call `bob.ACP_createSelf()` after `bob.connect(0xB0B)`, the issuer is `vm.addr(0xB0B)`. Trying to forge an issuer mismatch will fail signature verification.
</Accordion>

<Accordion title="`decryptForView` reverts on deny" icon="ban">
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:

```solidity
(bool allowed, string memory err, ) = mockThresholdNetwork.querySealOutput(
uint256(ctHash), block.chainid, alicePermit
uint256(ctHash), block.chainid, aliceAcp
);
assertFalse(allowed);
assertEq(err, "NotAllowed");
Expand Down
6 changes: 3 additions & 3 deletions client-sdk/foundry-plugin/cofhe-test.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ One client per scenario address. Connecting with a deterministic plaintext priva

## Reading plaintext values

Because `MockTaskManager` stores plaintext values onchain, you can read the underlying plaintext of any encrypted handle directly, with no permit needed.
Because `MockTaskManager` stores plaintext values onchain, you can read the underlying plaintext of any encrypted handle directly, with no ACP needed.

### `getPlaintext(handle)`

Expand All @@ -76,7 +76,7 @@ Reverts if the handle isn't in mock storage.

### `expectPlaintext(handle, value)` and `(handle, value, "msg")`

Assertion variant, with typed overloads for the same type set. Faster than `decryptForView` (no SDK round-trip, no permit needed). Use it whenever you only care about the value, not the SDK code path.
Assertion variant, with typed overloads for the same type set. Faster than `decryptForView` (no SDK round-trip, no ACP needed). Use it whenever you only care about the value, not the SDK code path.

```solidity
expectPlaintext(counter.count(), uint32(42));
Expand Down Expand Up @@ -138,5 +138,5 @@ The remappings are relative to the foundry package's root. Running `forge test`

## Next steps

- [CofheClient](/client-sdk/foundry-plugin/cofhe-client): per-user encrypt / decrypt / permit shim.
- [CofheClient](/client-sdk/foundry-plugin/cofhe-client): per-user encrypt, decrypt, and ACP shim.
- [Testing](/client-sdk/foundry-plugin/testing): canonical test-writing patterns and migration mapping.
Loading
Loading