From f299409fd25fe7780932f0cac7c575f23ac3d20f Mon Sep 17 00:00:00 2001 From: Alexandre Carvalheira Date: Fri, 28 Aug 2026 12:09:16 -0300 Subject: [PATCH 1/2] [DOCS] fhe-library/handles: explain what wrapping a handle does and does not give you unwrap and wrap were documented only as bare signatures in the utility reference, which left the question they actually raise unanswered. The new page states it: both are internal pure casts over the same bytes32, and wrapping grants nothing. The type records what kind of value this is, the ACL decides whether you may use it, and wrap touches only the first. Wrapping an arbitrary handle succeeds; the first FHE operation on it reverts. Covers when unwrap is right (storage slots, events, mapping keys, identity comparison), when wrap is right (recovering a handle the contract already owns), and routes the arrived-from-outside case to the typed forms rather than a guarded bare handle: externalEuintXX with its proof, receiveEuintXXParam for an argument, receiveEuintXXFromCall for a return value. Co-Authored-By: Claude Opus 5 (1M context) --- docs.json | 1 + fhe-library/core-concepts/handles.mdx | 125 ++++++++++++++++++++++++++ 2 files changed, 126 insertions(+) create mode 100644 fhe-library/core-concepts/handles.mdx diff --git a/docs.json b/docs.json index 2d604ef..d79c031 100644 --- a/docs.json +++ b/docs.json @@ -146,6 +146,7 @@ "group": "Core Concepts", "pages": [ "fhe-library/core-concepts/inputs", + "fhe-library/core-concepts/handles", "fhe-library/core-concepts/trivial-encryption", "fhe-library/core-concepts/data-evaluation", "fhe-library/core-concepts/encrypted-operations", diff --git a/fhe-library/core-concepts/handles.mdx b/fhe-library/core-concepts/handles.mdx new file mode 100644 index 0000000..c0f9a84 --- /dev/null +++ b/fhe-library/core-concepts/handles.mdx @@ -0,0 +1,125 @@ +--- +title: "Handles, wrap, and unwrap" +description: "What a ciphertext handle is, and what wrapping one does and does not give you" +--- + +Every encrypted value in a contract is a **handle**: a `bytes32` identifier for a ciphertext that CoFHE holds offchain. `euint32`, `ebool`, and the rest are Solidity user-defined value types over that `bytes32`. + +```solidity +type euint32 is bytes32; +``` + +`FHE.unwrap` and `FHE.wrapEuintXX` move between the two spellings. They are the only FHE functions that do no work at all. + +## What the two functions do + +```solidity +bytes32 handle = FHE.unwrap(myValue); // euint32 -> bytes32 +euint32 value = FHE.wrapEuint32(handle); // bytes32 -> euint32 +``` + +Both are `internal pure`. They perform no cryptography, make no call to the TaskManager, touch no storage, and cost nothing beyond the surrounding code. They change how Solidity types the same 32 bytes, and nothing else. + +There is one `unwrap` for every encrypted type, and one `wrap` per type because the return type has to differ: `wrapEbool`, `wrapEuint8`, `wrapEuint16`, `wrapEuint32`, `wrapEuint64`, `wrapEuint128`, and `wrapEaddress`. + + +The bindings give you the same thing in dot form, so `myValue.unwrap()` reads better inside an expression. See [bindings](/fhe-library/reference/fhe-sol/bindings). + + +## Wrapping does not grant permission + +**Wrapping a handle grants you nothing.** + +The type records what kind of value this is. The [ACL](/fhe-library/core-concepts/access-control) decides whether you may use it. Those are separate systems, and `wrap` only touches the first one. + +You can wrap any 32 bytes you like. Nothing reverts, because nothing is checked: + +```solidity +euint32 notYours = FHE.wrapEuint32(someHandleYouFoundInAnEvent); +``` + +That line succeeds. The first FHE operation on `notYours` is where it fails, because the TaskManager checks whether **this contract** is allowed on that handle: + +```solidity +euint32 doubled = FHE.add(notYours, notYours); // reverts: not allowed +``` + +So `wrap` is a cast, not an acquisition. Holding a typed value is not the same as being permitted to compute on it. + +## Handles are public + +A handle is not a secret. It sits in contract storage, travels in calldata, and shows up in event logs. Anyone can read one. + +That is fine, because a handle reveals nothing about the plaintext. It is an identifier, not the ciphertext and not the value. Knowing that Alice's balance is handle `0x9f3c…` tells you nothing about the balance. + +What a handle **does** give its holder is the ability to name that ciphertext in a call. That is why the ACL exists, and why the next section matters. + +## When to unwrap + +Reach for `unwrap` when you need the value to be plain `bytes32` because something else demands it: + +- Storing a handle in a generic `bytes32` slot or struct field. +- Emitting a handle in an event so a client can read it back. +- Using a handle as a mapping key. +- Comparing two handles for identity, which asks "is this the same ciphertext", not "are these values equal". For an encrypted comparison use `FHE.eq`. + +```solidity +event BalanceUpdated(address indexed user, bytes32 handle); + +emit BalanceUpdated(msg.sender, FHE.unwrap(_balances[msg.sender])); +``` + +## When to wrap + +Reach for `wrapEuintXX` when you are recovering a handle **your own contract** already owns and stored as `bytes32`: + +```solidity +mapping(address => bytes32) private _rawBalances; + +function balanceOf(address user) internal view returns (euint32) { + return FHE.wrapEuint32(_rawBalances[user]); +} +``` + +This is safe because the contract is already allowed on the handle. Wrapping only restores the type the storage slot lost. + +## When not to wrap + + +Do not wrap a handle that arrived from outside the contract. + +A function that accepts a `bytes32` (or a bare `euintXX`) from a caller and computes on it can be turned into a decryption oracle. + +FHE operations check the permission of the **contract performing them**, not of whoever called it. A contract is always allowed on its own stored state. So an attacker passes a handle read from your storage or an event. The operation passes the ACL check because *you* hold the value, and the function hands back something derived from a ciphertext they were never meant to read. + + +Use a type that carries provenance instead. There is one for every way a value can reach you, so a wrapped `bytes32` parameter is never the right answer: + +| Where the value came from | Parameter type | How you convert it | +| --- | --- | --- | +| A user, offchain | `externalEuintXX` plus a `bytes` proof | `FHE.asEuintXX(handle, proof)` | +| Another contract, as an argument | `sharedEuintXX` | `FHE.receiveEuintXXParam(shared)`, which checks the sharer is `msg.sender` | +| Another contract, as a return value | `sharedEuintXX` | `FHE.receiveEuintXXFromCall(shared, callee)`, which checks the sharer is the contract you called | +| Your own storage or computation | `euintXX` | `wrapEuintXX` if you stored it as `bytes32` | + +Each conversion authenticates the value as part of converting it, which is exactly what wrapping a raw handle skips. The two `receive` forms are not interchangeable: pick by how the value arrived, because naming the wrong party silently weakens the check rather than failing. + +All of these are covered in [inputs](/fhe-library/core-concepts/inputs). + +## Checking a handle is real + +`wrap` accepts anything, so a wrapped value can be meaningless. `FHE.isInitialized` tells you whether a handle is non-zero, which catches an unset storage slot: + +```solidity +if (!FHE.isInitialized(balance)) { + // never written, treat as zero +} +``` + +It does not tell you the handle refers to a ciphertext that exists, or that you are allowed on it. Only the operation itself can tell you that. + +## Related + +- [Inputs](/fhe-library/core-concepts/inputs): how values arrive from users and from other contracts. +- [Access control](/fhe-library/core-concepts/access-control): what the ACL permits, and how to grant it. +- [Utility functions](/fhe-library/reference/fhe-sol/utility): the reference entries for `unwrap`, `wrap`, and `isInitialized`. From 1d71c03dccef0896ebfaf577324d3adb481284d4 Mon Sep 17 00:00:00 2001 From: Alexandre Carvalheira Date: Fri, 28 Aug 2026 12:09:16 -0300 Subject: [PATCH 2/2] [DOCS] client-sdk/acps: document the onchain sharing registry properly The section was four lines and a code block. It now covers who calls each method, which of them send transactions, and what the registry does. Checked against the published ACP page rather than the SDK source alone, which corrected four things: shareOnChain returns { txHash, shareId }, getIncomingShares filters to still-importable shares, importFromChain also stores and activates the ACP, and cancelShare only works on a share that has not been imported yet. Drops a claim I could not support. Both cancelShare and dismissShare resolve to the same registry call in the SDK, but that does not mean either party can clear any share, since the registry's own authorization was not verified. The note now says only that they share a call and are named for who is acting. Adds the privacy caveat: posting onchain makes the issuer to recipient relationship public, which the offline route avoids. Co-Authored-By: Claude Opus 5 (1M context) --- client-sdk/guides/acps.mdx | 37 +++++++++++++++++++++++++++++++++---- 1 file changed, 33 insertions(+), 4 deletions(-) diff --git a/client-sdk/guides/acps.mdx b/client-sdk/guides/acps.mdx index 2cbdbcc..3c21374 100644 --- a/client-sdk/guides/acps.mdx +++ b/client-sdk/guides/acps.mdx @@ -155,18 +155,47 @@ 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. +Passing the offer yourself means building a channel for it: an API, a message, a QR code. `0.7` adds an alternative. The issuer posts the signed offer to a sharing registry, and the recipient discovers it by reading the chain. ```typescript -// Issuer +// Issuer: post the signed sharing ACP await client.acp.shareOnChain(sharingAcp); -// Recipient +// Recipient: discover and import 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. +Both sides need a connected wallet. Only `getIncomingShares` is a read; the rest send transactions. + +| Method | Who calls it | What it does | +| --- | --- | --- | +| `shareOnChain(acp)` | Issuer | Posts a signed sharing ACP to the registry. Returns `{ txHash, shareId }` | +| `getIncomingShares()` | Recipient | Reads the importable shares addressed to the connected account | +| `importFromChain(share)` | Recipient | Fills in the recipient's sealing key, signs, stores, and makes it the active ACP | +| `cancelShare(shareId)` | Issuer | Retracts a pending share that has not been imported yet | +| `dismissShare(shareId)` | Recipient | Declines a share, or clears an entry the recipient no longer wants | + +`getIncomingShares()` returns only shares that are still importable, so expired and revoked ones are filtered out. Each entry carries a `shareId` alongside the shared ACP fields, and that id is what `cancelShare` and `dismissShare` take. + + +`cancelShare` and `dismissShare` resolve to the same registry call. They are named for who is acting and why: the issuer retracting an offer, or the recipient declining one. + + +The registry address resolves from the chain's ACL. Override it per chain with the `sharingRegistry` key in the `acp` config block: + +```typescript +createCofheConfig({ + supportedChains: [chains.sepolia], + acp: { + sharingRegistry: { 11155111: '0x…' }, + }, +}); +``` + + +Posting onchain makes the offer's existence public. The registry records that this issuer shared with this recipient, and when. The encrypted values stay confidential, but the relationship does not, which the offline route avoids. + ## Revoking access