FHE Permit
Encrypted, time-bounded spending allowances for confidential tokens.
FHEPermit is the on-chain registry of encrypted, time-based spending allowances for any ERC-7984 confidential token. A user authorizes a spender — typically the Raycash card backend, but any EOA or contract works — to move up to an encrypted limit per period. Limits and consumed amounts are stored as FHE handles, so the chain never sees cleartext values.
Why It Exists
ERC-20 permit and approve leak the allowed amount to anyone reading the chain. Once tokens are confidential, the permit primitive itself has to become confidential too — otherwise the allowance becomes a sidechannel that defeats the privacy of the underlying balance.
The driving use case is card settlement: the Raycash backend needs to debit a user's confidential balance every time a card charge posts, without asking the user to sign each transaction and without revealing the cap or remaining headroom. FHEPermit lets the user grant a rolling, encrypted allowance once; the contract does the rest in FHE.
Architecture
FHEPermit is a permission registry, not a custodian — it never holds tokens. It calls IERC7984.confidentialTransferFrom on the user's behalf, which is why the user must first make FHEPermit an ERC-7984 operator on the token. Unlike ERC-20's unlimited approve, setOperator requires an explicit expiration, so users typically pass a far-future timestamp.
Storage is keyed by (user, token, spender) and holds an array of permissions per triple. Multiple permissions for the same triple stack via conjunctive AND (see below). Only msg.sender can ever mutate their own permissions.
Permission Model
struct Permission {
uint256 id; // keccak256(counter, user) — stable across array moves
euint64 limit; // encrypted spend cap per period
euint64 spent; // encrypted amount consumed in current period
uint64 lastUpdated; // last timestamp `spent` was mutated
uint64 startTime; // period anchor
uint64 endTime; // hard expiration (uint64.max = never)
uint64 duration; // period length in seconds (uint64.max = never resets)
}A few details worth highlighting:
idis stable, the array index is not. Expired permissions are removed by swap-and-pop, so positionitoday may point somewhere else tomorrow. Theidsurvives every reshuffle and is the right key for off-chain databases.updatePermissionaccepts both anindexand an expectedidand reverts withPermissionMismatchif they disagree, so callers can race cleanup safely.- Sentinel values for "forever".
endTime = type(uint64).maxmeans no expiration;duration = type(uint64).maxmeans the permission never resets — a one-shot allowance that runs until depleted or revoked. - Lazy period reset. Period boundaries are
startTime + k * duration.spentis zeroed the next time the permission is touched after a boundary is crossed — there is no keeper bot, no scheduled reset.
Conjunctive AND Semantics
If a user creates two permissions for the same (token, spender) — say a daily 100 USDC cap and a monthly 2000 USDC cap — both contribute to the same AND gate. A spend succeeds only if it fits within every active permission, and on success all of them are debited atomically. On failure, none of them move.
| # | Charge | Daily spent before | Monthly spent before | Permitted? | Daily after | Monthly after |
|---|---|---|---|---|---|---|
| 1 | 80 USDC | 0 | 0 | yes | 80 | 80 |
| 2 | 50 USDC | 80 | 80 | no (daily fails: >20) | 80 | 80 |
| 3 | 20 USDC | 80 | 80 | yes | 100 | 100 |
| 4 | 30 USDC | 0 (reset) | 100 | yes | 30 | 130 |
This is implemented as a two-pass design over the permission array. The first pass validates every active permission and computes a single encrypted boolean eIsPermitted as the AND of all the per-permission checks. The second pass applies the homomorphic addend FHE.select(eIsPermitted, amount, 0) uniformly to every active permission — so either all of them get debited by amount, or none do.
When at least one active permission exists, the transfer call to the token still executes even if the amount is over the cap — transferFrom passes FHE.select(eIsPermitted, amount, 0) to the token, so an over-limit denial moves a homomorphic zero. If the user has no active permissions for the (token, spender) triple, transferFrom reverts with NoPermissions() before reaching the token. Because over-limit denials map to homomorphic-zero transfers, the chain cannot distinguish those denials from successful zero-value transfers, which is what keeps the policy outcome confidential.
Public API
| Function | Purpose |
|---|---|
setPermission | Create a new encrypted allowance for (msg.sender, token, spender). |
transferFrom (single) | Spender debits one charge against the user's stacked permissions. |
transferFrom (batch) | Same, batched — gas-efficient for backend settlement of many charges. |
updatePermission | Rewrite an existing permission's limit and/or time fields, guarded by id. |
lockdown | O(1) kill switch: wipe all permissions for a list of (token, spender). |
getPermission / …ById | Read a permission by index (O(1)) or by stable id (O(n)). |
getPermissionCount | Active permission count for a (user, token, spender) triple. |
A few notes on the mutators:
setPermissiondefaults:duration = 0is converted internally totype(uint64).max(so the permission never resets),startTime = 0means "active immediately",endTime = 0means "never expire". It also runs a sweep that swap-and-pops any already-expired permissions.updatePermissiondoes not resetspent— it edits the caps only. To reset, either wait for the next period boundary orlockdownand re-create. SettingendTimein the past is the canonical way to force-expire a permission in place.lockdownis the user's emergency revoke. It is constant-time per pair regardless of how many permissions existed under it.FHEPermititself emits noTransferFromevent — the authoritative settlement record is theRaycashTransferevent on the underlying wrapper, which exposes onlyfrom,to, and encrypted handles (nooperatorfield). To rebuild "charges per user", indexRaycashTransferbyfrom = userand correlate against transactions whosetoisfhePermitAddress(or join againstPermissionSet/PermissionUpdatedevents onFHEPermitto attribute by(user, spender)).
Card Settlement Flow
End-to-end, the card flow looks like:
- One-time onboarding. The user calls
token.setOperator(fhePermitAddress, farFutureExpiration)on the confidential wrapper. Without this,FHEPermitcannot move tokens on their behalf. - Authorize the backend. The user calls
setPermission(token, backend, encLimit, inputProof, duration, startTime, endTime)once per cap they want — e.g. one daily and one monthly entry, withencLimit+inputProofproduced client-side via@raycash/fhe. - Backend settles charges. As card authorizations post, the backend encrypts each charge amount under its own caller address with the proof bound to
FHEPermitas the verifying contract, then submits them totransferFrom(token, from=user, to=CardChargesEscrow, encAmount, inputProof)(single or batched). Each call runs the AND check and debits all active permissions atomically. - Funds land in escrow. Successful transfers move the user's encrypted balance into
CardChargesEscrow, which then settles via the wrapper's two-phase unwrap flow.
The user never signs a transaction per charge, and the chain never learns the cap, the remaining headroom, or whether any individual charge actually moved value.
Security Properties
- No cleartext leakage. Limits, spent counters, transfer amounts, and the AND result are all encrypted handles. Indexers see only events with handle references, never raw values.
- Atomic AND. The two-pass design guarantees that overlapping permissions are debited together or not at all — no partial updates if any permission would have been exceeded.
- Stable
ids under cleanup. Swap-and-pop reshuffles indices;ids do not move. Callers that cached anidfrom aPermissionSetevent can always find or update the right permission later. - No admin, no global access control. Each user can only mutate their own slot; the only authority over actual value movement is the user's ERC-7984 operator approval on the token.
lockdownis a real kill switch. A user who suspects a spender is compromised can wipe every permission for that(token, spender)pair in one call. The token's operator approval can also be revoked independently.- Exit is always callable.
FHEPermitdoes not gateunwrap. Even if the registry becomes unusable, users can always recover cleartext tokens through the wrapper's unwrap flow.
Was this page helpful?