From 01eea6fa08e8313419bfafeacad0ec9e331bcb08 Mon Sep 17 00:00:00 2001 From: Alexandre Carvalheira Date: Tue, 25 Aug 2026 00:11:07 -0300 Subject: [PATCH 1/2] [DOCS] client-sdk/migrating-to-0-7: lead with the skill, cover both share directions Moves the agent skill to the top, since most of this migration is mechanical, and names the Agent Skill format so readers on editors other than Claude Code know it applies to them. Adds the case that needs no work: a contract already taking (externalEuint32, bytes) for a single value verifies as a batch of one, so it needs no redeploy. Adds the return direction of sharedEuintXX with a worked example, the SenderNotAllowed rule, and the React hook renames. Points the skill URLs at master. The repository has no main branch, so the install command in the upstream guide 404s and tar fails on the error page. Verified the corrected command extracts all 14 files. Co-Authored-By: Claude Opus 5 (1M context) --- client-sdk/introduction/migrating-to-0-7.mdx | 102 +++++++++++++------ 1 file changed, 71 insertions(+), 31 deletions(-) diff --git a/client-sdk/introduction/migrating-to-0-7.mdx b/client-sdk/introduction/migrating-to-0-7.mdx index 9d3db9c..e9bf8d9 100644 --- a/client-sdk/introduction/migrating-to-0-7.mdx +++ b/client-sdk/introduction/migrating-to-0-7.mdx @@ -25,9 +25,39 @@ This is one migration across three independently versioned packages. Bumping a s If you are coming from `cofhejs` rather than `@cofhe/sdk`, follow [migrating from cofhejs](/client-sdk/introduction/migrating-from-cofhejs) first. If you are on `0.5.x`, the Solidity work below is identical, because the `InEuintXX` structs did not change between `0.5` and `0.6`. +## Let the skill do the mechanical work + +Most of this migration is mechanical, and the parts that are not deserve a conversation rather than a find and replace. Fhenix publishes an agent skill that drives the whole thing. It detects what your project uses, works in dependency order, shows you a diff before touching anything, and reports what it could not decide for you. + +The skill uses the open [Agent Skill](https://agentskills.io/) format, which Claude Code, Cursor, GitHub Copilot, VS Code, Codex, Gemini CLI, OpenCode, Roo, Kiro, and Goose all read. Drop the folder wherever your agent looks for skills, commonly `.claude/skills/` or `.cursor/skills/`: + +```bash +mkdir -p .claude/skills +curl -L https://github.com/FhenixProtocol/cofhesdk/archive/refs/heads/master.tar.gz \ + | tar -xz --strip-components=2 -C .claude/skills \ + '*/skills/cofhe-migrate-0-6-to-0-7' +``` + +You can also [browse the skill on GitHub](https://github.com/FhenixProtocol/cofhesdk/tree/master/skills/cofhe-migrate-0-6-to-0-7) and copy the directory by hand. + +Then ask your agent to run it: + +```text +Migrate this project to @cofhe/sdk 0.7.1 +``` + +It proposes each change as a diff and waits for your approval. Tell it to apply everything if you would rather review at the end. + +It stops and asks on the decisions that are genuinely yours: + +- A function taking two or more encrypted parameters. +- Encrypted inputs produced in one place and consumed in another. +- A contract you do not control on the other side of a handoff. +- Persisted `EncryptedItemInput` records. They carry per-item signatures, which cannot be rebuilt from a batch signature. + ## Work contract-first -The order matters. The ABI you land on decides what every call site has to look like, so going backwards means rewriting the same call sites twice. +If you are doing this by hand, the order matters. The ABI you land on decides what every call site has to look like, so going backwards means rewriting the same call sites twice. @@ -65,7 +95,9 @@ function setValue(externalEuint32 inValue, bytes memory proof) public { } ``` -The signature moves out of the struct into a `bytes` parameter that immediately follows the handle it authenticates. This changes the ABI, so the contract needs a redeploy. +The signature moves out of the struct into a `bytes` parameter that immediately follows the handle it authenticates. This changes the ABI, so a contract coming from `InEuintXX` needs a redeploy. + +A contract that already takes `(externalEuint32, bytes)` for a single encrypted value needs no change and no redeploy. `FHE.asEuint32(handle, proof)` verifies that input as a batch of one, so a `0.7` signature works against the ABI you already deployed. The proof does not have to be the last parameter. It has to follow the `external*` handle as a pair. Extra plain arguments can come after it, which is how ERC-7984's `confidentialTransferAndCall` is shaped. @@ -73,7 +105,7 @@ The proof does not have to be the last parameter. It has to follow the `external ### More than one encrypted value -One signature now covers all the hashes in the batch, computed over them together. You cannot keep two separate `(handle, proof)` pairs, because verifying one hash against a signature covering two reverts. +One signature now covers all the handles in the batch, computed over them together. You cannot keep two separate `(handle, proof)` pairs, because verifying one handle against a signature covering two reverts. You can still keep the parameter names, which most projects prefer to an array: @@ -92,13 +124,15 @@ function transfer( } ``` -The array form, `function transfer(address to, externalEuint32[] calldata values, bytes calldata signature)`, is shorter but collapses named parameters into indices. Either way the encrypted parameters must be adjacent, because they share one signature. +The array form, `function transfer(address to, externalEuint32[] calldata values, bytes calldata signature)`, is shorter but collapses named parameters into indices. Either way the encrypted parameters must be adjacent, because they share one signature and there is no other way to tell which `bytes` belongs to them. For a batch mixing types, such as a `euint32` with an `ebool`, call `ITaskManager.batchVerifyInputs` directly. ### Values that cross a contract boundary -`0.2.0` adds a `sharedEuintXX` type for encrypted values passed between contracts. This is the part of the migration the compiler cannot help with: the `0.6` spelling, an `FHE.allowTransient` grant plus a bare `euintXX` parameter, still compiles and still runs. +`0.2.0` adds a `sharedEuintXX` type for encrypted values passed between contracts. Both directions count: a value handed over as an argument, and a value returned by a function that is not `view`. A `view` function returning an encrypted value is unaffected, because it never granted anything. + +This is the part of the migration the compiler cannot help with. The `0.6` spelling, an `FHE.allowTransient` grant plus a bare `euintXX` parameter, still compiles and still runs while both sides stay on it. It is also the part with a security consequence. A function taking a bare `euintXX` from outside can be turned into an oracle over every ciphertext the contract holds. FHE operations check the permission of the contract performing them, not of whoever called it. An attacker passes a handle the contract is allowed on, such as one read from its own storage, and gets back a value derived from it. @@ -121,9 +155,29 @@ function pull(sharedEuint64 shared) external { } ``` +Both sides have to move in the same change. `pull(euint64)` and `pull(sharedEuint64)` are both `bytes32` on the wire, so an unmigrated caller compiles against a migrated callee and then reverts at runtime with `NotShared`. + Pick the receive form by how the value reached you. `receiveEuint64Param` checks the sharer against `msg.sender` and suits a value that arrived as an argument. `receiveEuint64FromCall(shared, callee)` checks it against the contract you called, and `callee` must be the address called in that same expression. -Sharing is single-use and transaction-scoped, so a share cannot be stored, replayed, or reconstructed from an event. To keep a received value past the transaction, call `FHE.allowThis` on the unwrapped `euintXX`. +Returning an encrypted value works the same way in reverse. Share the result with `msg.sender`, and unwrap it with the `FromCall` form: + +```solidity +// In Token +function swap(sharedEuint64 shared) external returns (sharedEuint64) { + euint64 amountIn = FHE.receiveEuint64Param(shared); + return FHE.shareEuint64(FHE.div(amountIn, FHE.asEuint64(2)), msg.sender); +} +``` + +```solidity +// In Vault +euint64 out = FHE.receiveEuint64FromCall(token.swap(shared), address(token)); +FHE.allowThis(out); +``` + +`FHE.shareEuint64` reverts with `SenderNotAllowed` unless your contract is itself allowed on the handle. You cannot share what you cannot use. + +Sharing is single-use and transaction-scoped, so a share cannot be stored, replayed, or reconstructed from an event. To keep a received value past the transaction, call `FHE.allowThis` on the unwrapped `euintXX`. Anything you derive from it produces a new handle that needs its own `FHE.allowThis` before you store it. ## Config keys @@ -157,9 +211,11 @@ const acp = await client.acp.createSelf(); Decrypt builders follow the same rename: `.withPermit()` becomes `.withACP()` and `.withoutPermit()` becomes `.withoutACP()`. +React hooks rename the same way. `useCofhePermits` becomes `useCofheACPs`, `useCofheActivePermit` becomes `useCofheActiveACP`, and the rest of the family follows the pattern. + Rename only identifiers that resolve to a `@cofhe/*` import. A blind replace of `Permit` corrupts unrelated code, and the English words `permitted` and `permitting` are not renames. `isPermittedCofheEnvironment` and `isAllowedWithPermission` keep their names. -For the full type table, the new scope model, and what the client gained, see [ACPs](/client-sdk/guides/permits). +For the full type table, the new scope model, and what the client gained, see [Access Control Permissions](/client-sdk/guides/acps). Stored permits are dropped. They were signed with retired EIP-712 types and cannot verify against the upgraded ACL, so your users are prompted to sign again on first use. Nothing needs migrating, but it looks like data loss if you are not expecting it. @@ -175,14 +231,14 @@ const [encA, encB] = await client.encryptInputs([a, b]).execute(); await contract.f(encA, encB); // After -const [hashA, hashB, signature] = await client +const [handleA, handleB, signature] = await client .encryptInputs([a, b]) .setConsumingContract(contractAddress) .execute(); -await contract.f([hashA, hashB], signature); +await contract.f([handleA, handleB], signature); ``` -The result now holds one hash per input followed by a single signature, so it has `inputs.length + 1` elements. Code that assumed the result matched the input count is off by one. +The result now holds one handle per input followed by a single signature, so it has `inputs.length + 1` elements. Code that assumed the result matched the input count is off by one. `setConsumingContract` is required because the verifier binds the target contract into the signed digest, which stops a batch signed for one contract being replayed into another. Omitting it is a compile error in TypeScript, since `encryptInputs()` returns a builder without an `execute()` method. @@ -190,7 +246,7 @@ The result now holds one hash per input followed by a single signature, so it ha The consuming contract is the contract that runs `FHE.asEuint*`, which is not always the contract you call. If your app calls `vault.deposit(...)` and the vault is what converts the value, the consuming contract is the vault. Naming the wrong one compiles, typechecks, and reverts at runtime. Trace the value to the `FHE.asEuint*` call. -The per-item input types are gone: `EncryptedItemInput`, `EncryptedUint64Input`, and the rest of that family. A value that used to be one of them is now a hash. These also break on your own helpers, where a fixture typed `(encAmount: EncryptedUint64Input)` fails at its definition rather than at the call site. `asHashPlusProof()` is removed, because its output is what `execute()` always returns now. +The per-item input types are gone: `EncryptedItemInput`, `EncryptedUint64Input`, and the rest of that family. A value that used to be one of them is now a handle. These also break on your own helpers, where a fixture typed `(encAmount: EncryptedUint64Input)` fails at its definition rather than at the call site. `asHashPlusProof()` is removed, because its output is what `execute()` always returns now. ## Silent changes {#silent-changes} @@ -198,35 +254,19 @@ A clean build proves very little in this migration. Each of these compiles and t | Change | What happens | | --- | --- | -| A bare `euintXX` parameter left unmigrated | Still compiles and runs. Can be a live disclosure path, not a style issue. | +| A bare `euintXX` parameter left unmigrated | Compiles, and keeps running while both sides stay on it. Can be a live disclosure path, not a style issue. | +| Only one side moved to `sharedEuintXX` | Both spellings are `bytes32` on the wire, so the call compiles and reverts with `NotShared`. | | `receiveEuintXXFromCall` naming a trusted address instead of the callee | Checks who created the share rather than who handed it over. Exploitable. | | A wrong-length destructure of the `execute()` result | Typechecks, then fails at runtime on the trailing signature. | +| A reordered or partial batch | The signature covers the exact ordered set that `execute()` produced. Compiles, then fails verification. | | `ACPUtils.export()` on a self ACP | `0.6` serialized anything; `0.7` throws unless the ACP is a signed sharing ACP. | | A transient allowance relied on across transactions | Mock transient storage is now real EIP-1153, so it expires with its own transaction rather than the block. | | Overload selector strings and ERC-165 interface ids | `InEuint64` was a tuple; `externalEuint64` is a `bytes32`, so ids change. Recompute them. | Verify by exercising a round trip, not by compiling. For every bare-handle function you kept, ask whether an arbitrary caller can reach it with a handle the contract is allowed on. If they can, guard it with `FHE.isAllowed(value, msg.sender)`. -## Let the skill do the mechanical work - -Fhenix publishes an agent skill that performs most of these edits and reports what it could not decide for you. It inventories the affected files first and waits for your approval before writing. - -```bash -mkdir -p .claude/skills -curl -L https://github.com/FhenixProtocol/cofhesdk/archive/refs/heads/main.tar.gz \ - | tar -xz --strip-components=2 -C .claude/skills \ - '*/skills/cofhe-migrate-0-6-to-0-7' -``` - -It stops and asks on the decisions that are genuinely yours: - -- A function taking two or more encrypted parameters, where the ordering is your call. -- Encrypted inputs produced in one place and consumed in another. -- A contract you do not control on the other side of a handoff. -- Persisted `EncryptedItemInput` records. Those carry per-item signatures that cannot be rebuilt from a batch signature, so that is a data migration. - ## Next steps - Check every version against the [compatibility page](/get-started/introduction/compatibility). -- Read the [ACP guide](/client-sdk/guides/permits) for the permission model in full. +- Read the [Access Control Permissions guide](/client-sdk/guides/acps) for the permission model in full. - Review [encrypting inputs](/client-sdk/guides/encrypting-inputs) for the current builder API. From bd28b1edfadf3d59c6e3fde811f3974fccce89c0 Mon Sep 17 00:00:00 2001 From: Alexandre Carvalheira Date: Tue, 25 Aug 2026 00:11:07 -0300 Subject: [PATCH 2/2] [DOCS] client-sdk/acps: rewrite the permits guide as Access Control Permissions Renames the page to match the 0.7 terminology, with a redirect from the old path. The title spells out Access Control Permission before the acronym, and STYLE.md now requires that of any page whose subject is ACPs. Documents what 0.7 added rather than only renaming: the scope model (Global, Contract, Handles) and that a scope only narrows, onchain sharing via shareOnChain and importFromChain, and revocation. Verified against the 0.7.1 source: client.acp is singular, the store key is cofhesdk-acps, ACP expiration defaults to 7 days while the config key defaultACPExpiration defaults to 30, and ACPUtils.export now throws on a self ACP. The Validating heading keeps a {#validating-permits} anchor so the inbound deep link from error-handling.mdx still resolves. Rename it when that page is migrated. Co-Authored-By: Claude Opus 5 (1M context) --- STYLE.md | 2 +- client-sdk/guides/acps.mdx | 249 +++++++++++++++++++++++++++ client-sdk/guides/permits.mdx | 315 ---------------------------------- docs.json | 6 +- 4 files changed, 255 insertions(+), 317 deletions(-) create mode 100644 client-sdk/guides/acps.mdx delete mode 100644 client-sdk/guides/permits.mdx diff --git a/STYLE.md b/STYLE.md index 78ba052..9b08763 100644 --- a/STYLE.md +++ b/STYLE.md @@ -67,7 +67,7 @@ One name per thing, used consistently. Canonical names: | FheOS Server | The service that verifies and queues incoming work. It does not execute FHE operations. | | ZK Verifier | The input proof verification service. | | TaskManager, CommitmentRegistry, ACL | Contract names, written as in the source. | -| ACP | Access Control Permission. Replaces "Permit" from `0.7` onward, so that it is not confused with an ERC-2612 permit. Never write "ACP permission". | +| ACP | Access Control Permission. Replaces "Permit" from `0.7` onward, so that it is not confused with an ERC-2612 permit. Spell it out before the acronym: a page whose subject is ACPs carries the full term in its `title`, and every other page expands it on first use. Never write "ACP permission". | | `FHE.sol` | The Solidity library, in backticks when referring to the file or API. | | `@cofhe/sdk` | The client SDK package, in backticks. "the SDK" after first mention on a page. | | Threshold Network | The future MPC decryption network. Always framed as planned, never as current. | diff --git a/client-sdk/guides/acps.mdx b/client-sdk/guides/acps.mdx new file mode 100644 index 0000000..2ab2487 --- /dev/null +++ b/client-sdk/guides/acps.mdx @@ -0,0 +1,249 @@ +--- +title: Access Control Permissions (ACP) +description: "Create and manage the EIP-712 signatures that authorize decryption" +--- + +An Access Control Permission (ACP) is an EIP-712 signature that authorizes decryption of confidential data. The `issuer` field identifies who is reading the data, and that address must already have been granted access onchain with `FHE.allow(handle, address)`. When you use an ACP, CoFHE validates it against the ACL contract to confirm the issuer really holds that access. + +Every ACP carries a sealing keypair. The public key goes to CoFHE so it can re-encrypt the result for the holder. The private key stays on the client and unseals the value when it comes back. + + +ACPs were called permits before `0.7`. The name changed so they are not confused with an ERC-2612 permit, which is a different thing entirely. If you are upgrading, see [migrating to 0.7](/client-sdk/introduction/migrating-to-0-7). This page uses "ACP" throughout. + + +## When you need one + +- `decryptForView` always requires an ACP. +- `decryptForTx` depends on the contract's ACL policy for that handle. If the policy lets anyone decrypt, use `.withoutACP()`. If it restricts decryption, use `.withACP(...)`. + +## Prerequisites + +[Create and connect a client](/client-sdk/guides/client-setup). ACPs are scoped to a chainId and account pair. + +## Quick start + + +`client.acp` is the recommended API. It signs with the connected wallet and manages the store for you. `ACPUtils` is the lower-level alternative when you want direct control over signing and storage. Note the namespace is singular: `client.acp`, not `client.acps`. + + + + +```typescript client.acp (recommended) +await client.connect(publicClient, walletClient); + +// Returns the active self ACP if there is one, otherwise creates and signs it. +const acp = await client.acp.getOrCreateSelfACP(); +``` + +```typescript ACPUtils +import { ACPUtils, setACP, setActiveACPHash } from '@cofhe/sdk/acps'; + +const acp = await ACPUtils.createSelfAndSign( + { issuer: walletClient.account.address }, + publicClient, + walletClient +); + +const chainId = await publicClient.getChainId(); +const account = walletClient.account.address; +setACP(chainId, account, acp); +setActiveACPHash(chainId, account, acp.hash); +``` + + + +After this the active ACP is picked up automatically by `decryptForView(...).execute()` and by `decryptForTx(...).withACP().execute()`. + +## The three types + +| Type | Who signs | Use for | +| --- | --- | --- | +| `self` | Issuer only | Decrypting your own data. The common case. | +| `sharing` | Issuer only | A shareable offer the issuer creates for a recipient | +| `recipient` | Recipient, carrying the issuer signature | The imported ACP after the recipient signs it | + +`expiration` is a unix timestamp in seconds and defaults to 7 days from creation. The client-wide `defaultACPExpiration` config key is a separate setting that defaults to 30 days. Creating an ACP through `client.acp.*` stores it and makes it active. + +## Creating a self ACP + +A self ACP lets you decrypt data that was allowed to your own address. + +```typescript +await client.connect(publicClient, walletClient); + +const acp = await client.acp.createSelf({ + issuer: walletClient.account.address, + name: 'My self ACP', +}); + +acp.type; // 'self' +acp.hash; // deterministic hash +``` + +`createSelf` always creates a new one. `getOrCreateSelfACP()` reuses the active ACP when there is one, which is what most applications want. + +## Narrowing what an ACP can read + +`0.7` adds a scope to every ACP. An unscoped ACP covers everything the issuer can read, which is rarely what you want to hand to someone else. + +| Scope | Value | Covers | +| --- | --- | --- | +| `Global` | `0` | Every value the issuer can read | +| `Contract` | `1` | The issuer's values readable by the listed `contracts` | +| `Handles` | `2` | Only the listed `handles`, as bytes32 hex strings | + +```typescript +const acp = await client.acp.createSharing({ + issuer: walletClient.account.address, + recipient, + contracts: [auctionAddress], // scope narrows to this contract + name: 'Auction results', +}); +``` + + +A scope only ever narrows the issuer's existing access. It cannot grant access the issuer does not already hold, so scoping is not a way to delegate something you were never allowed to read. It also does not retroactively narrow ACPs you already issued. + + +Handles are bytes32 hex strings here, not bigints. If you are carrying handle values around as bigints, convert before putting them in an ACP. + +## Sharing with another account + +An issuer can delegate their ACL access to a recipient, who can then decrypt the issuer's data without holding their own `FHE.allow` grant. There are two routes: pass the offer yourself, or post it onchain. + +### Passing the offer yourself + + + + +```typescript +const sharingAcp = await client.acp.createSharing({ + issuer: walletClient.account.address, + recipient, + name: 'Share with recipient', +}); +``` + + + +```typescript +import { ACPUtils } from '@cofhe/sdk/acps'; + +const exported = ACPUtils.export(sharingAcp); +``` + +The exported JSON holds no sensitive data and can travel over any channel. + + +`ACPUtils.export` throws unless the ACP is a signed sharing ACP. In `0.6` the equivalent call serialized anything you gave it. A call that used to always work, such as one made during a render, now always throws on a self ACP. Gate it on `acp.type === 'sharing'`. + +Never share the output of `serialize(acp)`. That is for local persistence and contains the sealing private key. + + + + +```typescript +const recipientAcp = await client.acp.importShared(exported); + +recipientAcp.type; // 'recipient' +``` + +Importing generates a fresh sealing key for the recipient. + + + + +### Sharing onchain + +The issuer can instead post the signed offer to a registry, so the recipient discovers it without a side channel. + +```typescript +// Issuer +await client.acp.shareOnChain(sharingAcp); + +// Recipient +const shares = await client.acp.getIncomingShares(); +const recipientAcp = await client.acp.importFromChain(shares[0]); +``` + +`dismissShare(shareId)` clears an entry the recipient does not want, and `cancelShare` withdraws one the issuer posted. + +## Revoking access + +An issuer can revoke an ACP they created, which matters when a sharing ACP has left their control. + +```typescript +await client.acp.revokeACP(acp.hash); +await client.acp.revokeAllACPs(); + +const revoked = await client.acp.isACPRevoked(acp.hash); +``` + +Revocation is checked when the ACP is used, so it applies to copies the issuer no longer holds. + +## Managing stored ACPs + +The SDK keeps every stored ACP and one active ACP hash per chainId and account. + +```typescript +const acps = client.acp.getACPs(); +Object.keys(acps); // ACP hashes + +const active = client.acp.getActiveACP(); +client.acp.selectActiveACP(someACPHash); + +client.acp.removeACP(acpHash); +client.acp.removeActiveACP(); +``` + +## Validating {#validating-permits} + +`ACPUtils.validate` enforces the full check: schema, signed, and not expired. The decrypt flows call it for you and surface failures as typed errors, so validate manually only when you want to inspect or filter ACPs first. + +| Function | Checks | On failure | +| --- | --- | --- | +| `ACPUtils.validate(acp)` | Schema, signed, and not expired | Throws | +| `ACPUtils.validateSchema(acp)` | Schema only | Throws on schema failure, ignores expiry and signatures | + +Use `validateSchema` on an ACP that arrived over the wire, when you want to reject a malformed payload before caring about expiry. + +For inspection without exception handling, `ValidationUtils` returns a typed result: + +```typescript +import { ValidationUtils } from '@cofhe/sdk/acps'; + +const result = ValidationUtils.isValid(acp); + +result.valid; // boolean +result.error; // 'invalid-schema' | 'expired' | 'not-signed' | null +``` + +| Function | Returns | Use for | +| --- | --- | --- | +| `ValidationUtils.isValid(acp)` | `ValidationResult` | The full check without throwing | +| `ValidationUtils.isSignedAndNotExpired(acp)` | `ValidationResult` | Skipping the schema parse when you already validated shape | +| `ValidationUtils.isSigned(acp)` | `boolean` | Whether it carries a signature on the appropriate side | +| `ValidationUtils.isExpired(acp)` | `boolean` | Comparing `acp.expiration` to now | + + +Match on `result.error` to render a precise message: + +```typescript +switch (ValidationUtils.isValid(acp).error) { + case 'expired': return 'This ACP has expired. Please sign again.'; + case 'not-signed': return 'ACP is awaiting signature.'; + case 'invalid-schema': return 'Imported ACP is malformed.'; + case null: return null; +} +``` + + +## Persistence and security + +- ACPs are stored per chainId and account. On the web the store is `localStorage` under the key `cofhesdk-acps`. +- A stored ACP contains the sealing private key. Treat it as a secret, and never hand a serialized ACP to another user. +- To share access, use `ACPUtils.export`, which strips the sensitive fields. + + +Permits stored by `0.6` are dropped on upgrade. They were signed with EIP-712 types the upgraded ACL no longer accepts, so they cannot verify and are discarded when the store loads. Your users are prompted to sign again. Nothing needs migrating, but it looks like data loss if you are not expecting it. + diff --git a/client-sdk/guides/permits.mdx b/client-sdk/guides/permits.mdx deleted file mode 100644 index e13e627..0000000 --- a/client-sdk/guides/permits.mdx +++ /dev/null @@ -1,315 +0,0 @@ ---- -title: Permits -description: "Create and manage EIP-712 permits for decryption authorization" ---- - -Permits are EIP-712 signatures that authorize decryption of confidential data. The `issuer` field identifies who is accessing the data, the issuer must have been granted access onchain via `FHE.allow(handle, address)`. When a permit is used, CoFHE validates it against the ACL contract to confirm that the issuer has access to the requested encrypted handle. - -Each permit includes a sealing keypair. The public key is sent to CoFHE so it can re-encrypt the data for the permit holder. The private key stays client-side and is used to unseal the returned data. - -## When do you need a permit? - -- **`decryptForView`**: always requires a permit. -- **`decryptForTx`**: depends on the contract's ACL policy for that `ctHash`. - - If the policy allows anyone to decrypt, you can use `.withoutPermit()`. - - If the policy restricts decryption, you must use `.withPermit(...)`. - -## Prerequisites - -[Create and connect a client](/client-sdk/guides/client-setup). Permits are scoped to a **chainId + account**. - -## Quick start - - -The examples below show two approaches. The `client.permits` API is the recommended approach, it automatically signs permits with the connected wallet and manages the permit store. The `PermitUtils` API is a lower-level alternative that gives you direct control over signing and storage. - - - - -```typescript client.permits (recommended) -await client.connect(publicClient, walletClient); - -// Returns the active self permit if one exists, otherwise creates and signs a new one. -const permit = await client.permits.getOrCreateSelfPermit(); -``` - -```typescript PermitUtils -import { - PermitUtils, - setPermit, - setActivePermitHash, -} from '@cofhe/sdk/permits'; - -const permit = await PermitUtils.createSelfAndSign( - { issuer: walletClient.account.address }, - publicClient, - walletClient -); - -// Manually store and activate the permit -const chainId = await publicClient.getChainId(); -const account = walletClient.account.address; -setPermit(chainId, account, permit); -setActivePermitHash(chainId, account, permit.hash); -``` - - - -After this, the active permit is picked up automatically: - -- `decryptForView(...).execute()` uses the active permit. -- `decryptForTx(...).withPermit().execute()` uses the active permit. - -## Permit types - -| Type | Who signs | Use case | -| --- | --- | --- | -| `self` | issuer only | Decrypt your own data (most common) | -| `sharing` | issuer only | A shareable "offer" created by the issuer for a recipient | -| `recipient` | recipient (includes issuer signature) | The imported permit after the recipient signs it | - - -- Permit `expiration` is a unix timestamp in **seconds**. The default is **7 days from creation**. -- When a permit is created via `client.permits.*`, it is automatically stored and set as the active permit. - - -## Creating a self permit - -A self permit lets you decrypt data that was allowed to your address. - -### createSelf - - - -```typescript client.permits -await client.connect(publicClient, walletClient); - -const permit = await client.permits.createSelf({ - issuer: walletClient.account.address, - name: 'My self permit', -}); - -permit.type; // 'self' -permit.hash; // deterministic hash -``` - -```typescript PermitUtils -import { PermitUtils } from '@cofhe/sdk/permits'; - -const permit = await PermitUtils.createSelfAndSign( - { - issuer: walletClient.account.address, - name: 'My self permit', - }, - publicClient, - walletClient -); - -permit.type; // 'self' -permit.hash; // deterministic hash -``` - - - -### getOrCreateSelfPermit - -Returns the active self permit if one exists. Otherwise creates and signs a new one. This is the recommended approach for most applications. - -```typescript -await client.connect(publicClient, walletClient); - -const permit = await client.permits.getOrCreateSelfPermit(); -permit.type; // 'self' -``` - -## Sharing permits - -Sharing permits let an issuer delegate their ACL access to a recipient. The recipient can then decrypt the issuer's data without needing their own `FHE.allow`. - - - - - -The issuer creates a sharing permit specifying the recipient's address. - - - -```typescript client.permits -await client.connect(publicClient, walletClient); - -const sharingPermit = await client.permits.createSharing({ - issuer: walletClient.account.address, - recipient, - name: 'Share with recipient', -}); -``` - -```typescript PermitUtils -import { PermitUtils } from '@cofhe/sdk/permits'; - -const sharingPermit = await PermitUtils.createSharingAndSign( - { - issuer: walletClient.account.address, - recipient, - name: 'Share with recipient', - }, - publicClient, - walletClient -); -``` - - - - - - - -Export the permit as a JSON blob and share it with the recipient. - -```typescript -import { PermitUtils } from '@cofhe/sdk/permits'; - -const exported = PermitUtils.export(sharingPermit); -``` - - -The exported JSON does not contain any sensitive data and can be shared via any channel. - - - -Do not share `serialize(permit)` output. Serialization is meant for local persistence and includes the sealing private key. - - - - - - -The recipient imports the exported JSON and signs it with their wallet. On import, a new sealing key is generated for the recipient. - - - -```typescript client.permits -await client.connect(publicClient, walletClient); - -const recipientPermit = await client.permits.importShared(exported); - -recipientPermit.type; // 'recipient' -recipientPermit.hash; -``` - -```typescript PermitUtils -import { - PermitUtils, - setPermit, - setActivePermitHash, -} from '@cofhe/sdk/permits'; - -const recipientPermit = await PermitUtils.importSharedAndSign( - exported, - publicClient, - walletClient -); - -const chainId = await publicClient.getChainId(); -const account = walletClient.account.address; -setPermit(chainId, account, recipientPermit); -setActivePermitHash(chainId, account, recipientPermit.hash); -``` - - - - - - - -## Active permit management - -The SDK tracks all stored permits and an **active permit hash** per `chainId + account`. Creating or importing a permit via `client.permits.*` automatically stores it and selects it as active. - -### List stored permits - -```typescript -const permits = client.permits.getPermits(); -Object.keys(permits); // permit hashes -``` - -### Read / select the active permit - -```typescript -const active = client.permits.getActivePermit(); -active?.hash; - -client.permits.selectActivePermit(somePermitHash); -``` - -### Removing permits - -```typescript -client.permits.removePermit(permitHash); -client.permits.removeActivePermit(); -``` - -## Validating permits - -Since `@cofhe/sdk@0.5.0`, `PermitUtils.validate` enforces the **full** check: schema + signed + not-expired. The decrypt flows (`decryptForView`, `decryptForTx` with `.withPermit(...)`) call this for you and surface failures as typed errors, you only need to validate manually when you want to inspect or filter permits before using them. - -### Throwing helpers: `PermitUtils.*` - -| Function | What it checks | Behavior on failure | -| --- | --- | --- | -| `PermitUtils.validate(permit)` | Schema **and** signed **and** not-expired. | Throws (`Permit is expired` / `Permit is not signed` / schema error). | -| `PermitUtils.validateSchema(permit)` | Schema only (shape + invariants). | Throws on schema failure; does **not** check expiry or signatures. | - -Use `validateSchema` when you've just received a permit from the wire (e.g. an imported sharing permit) and want to reject malformed payloads without yet caring about expiry. - -```typescript -import { PermitUtils } from '@cofhe/sdk/permits'; - -try { - PermitUtils.validate(permit); - // permit is schema-valid, signed, and not expired -} catch (err) { - // err.message is "Permit is expired" / "Permit is not signed" / a Zod schema error -} -``` - -### Non-throwing helpers: `ValidationUtils.*` - -For inspection without exception handling, use the `ValidationUtils` helpers. They return a typed `ValidationResult`: - -```typescript -import { ValidationUtils } from '@cofhe/sdk/permits'; - -const result = ValidationUtils.isValid(permit); - -result.valid; // boolean -result.error; // 'invalid-schema' | 'expired' | 'not-signed' | null -``` - -| Function | Returns | Use case | -| --- | --- | --- | -| `ValidationUtils.isValid(permit)` | `ValidationResult` | Full check (schema + signed + not-expired) without throwing. | -| `ValidationUtils.isSignedAndNotExpired(permit)` | `ValidationResult` | Skip the schema parse if you already validated the shape. | -| `ValidationUtils.isSigned(permit)` | `boolean` | "Does it carry a signature on the issuer / recipient side as appropriate?" | -| `ValidationUtils.isExpired(permit)` | `boolean` | Compare `permit.expiration` to current time. | - - -Pattern-match on `result.error` to render a precise UI message: - -```typescript -switch (ValidationUtils.isValid(permit).error) { - case 'expired': return 'This permit has expired — please re-sign.'; - case 'not-signed': return 'Permit is awaiting signature.'; - case 'invalid-schema': return 'Imported permit is malformed.'; - case null: return null; -} -``` - - -## Persistence and security - -- The SDK persists permits in a store keyed by `chainId + account`. -- In web environments, this store uses `localStorage` under the key `cofhesdk-permits`. -- A stored permit includes the **sealing private key**. Treat it like a secret. - - Never share serialized permits with other users. - - To share access, use `PermitUtils.export(...)` which strips sensitive fields. diff --git a/docs.json b/docs.json index 249d112..2d604ef 100644 --- a/docs.json +++ b/docs.json @@ -88,7 +88,7 @@ "client-sdk/guides/client-setup", "client-sdk/guides/encrypting-inputs", "client-sdk/guides/writing-encrypted-data", - "client-sdk/guides/permits", + "client-sdk/guides/acps", "client-sdk/guides/decrypt-to-view", "client-sdk/guides/decrypt-to-tx", "client-sdk/guides/writing-decrypt-result", @@ -305,6 +305,10 @@ { "source": "/docs/devdocs/overview", "destination": "/" + }, + { + "source": "/client-sdk/guides/permits", + "destination": "/client-sdk/guides/acps" } ], "integrations": {