RaycashDocs
Protocol

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:

  • id is stable, the array index is not. Expired permissions are removed by swap-and-pop, so position i today may point somewhere else tomorrow. The id survives every reshuffle and is the right key for off-chain databases. updatePermission accepts both an index and an expected id and reverts with PermissionMismatch if they disagree, so callers can race cleanup safely.
  • Sentinel values for "forever". endTime = type(uint64).max means no expiration; duration = type(uint64).max means the permission never resets — a one-shot allowance that runs until depleted or revoked.
  • Lazy period reset. Period boundaries are startTime + k * duration. spent is 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.

#ChargeDaily spent beforeMonthly spent beforePermitted?Daily afterMonthly after
180 USDC00yes8080
250 USDC8080no (daily fails: >20)8080
320 USDC8080yes100100
430 USDC0 (reset)100yes30130

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

FunctionPurpose
setPermissionCreate 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.
updatePermissionRewrite an existing permission's limit and/or time fields, guarded by id.
lockdownO(1) kill switch: wipe all permissions for a list of (token, spender).
getPermission / …ByIdRead a permission by index (O(1)) or by stable id (O(n)).
getPermissionCountActive permission count for a (user, token, spender) triple.

A few notes on the mutators:

  • setPermission defaults: duration = 0 is converted internally to type(uint64).max (so the permission never resets), startTime = 0 means "active immediately", endTime = 0 means "never expire". It also runs a sweep that swap-and-pops any already-expired permissions.
  • updatePermission does not reset spent — it edits the caps only. To reset, either wait for the next period boundary or lockdown and re-create. Setting endTime in the past is the canonical way to force-expire a permission in place.
  • lockdown is the user's emergency revoke. It is constant-time per pair regardless of how many permissions existed under it.
  • FHEPermit itself emits no TransferFrom event — the authoritative settlement record is the RaycashTransfer event on the underlying wrapper, which exposes only from, to, and encrypted handles (no operator field). To rebuild "charges per user", index RaycashTransfer by from = user and correlate against transactions whose to is fhePermitAddress (or join against PermissionSet / PermissionUpdated events on FHEPermit to attribute by (user, spender)).

Card Settlement Flow

End-to-end, the card flow looks like:

  1. One-time onboarding. The user calls token.setOperator(fhePermitAddress, farFutureExpiration) on the confidential wrapper. Without this, FHEPermit cannot move tokens on their behalf.
  2. 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, with encLimit + inputProof produced client-side via @raycash/fhe.
  3. Backend settles charges. As card authorizations post, the backend encrypts each charge amount under its own caller address with the proof bound to FHEPermit as the verifying contract, then submits them to transferFrom(token, from=user, to=CardChargesEscrow, encAmount, inputProof) (single or batched). Each call runs the AND check and debits all active permissions atomically.
  4. 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 an id from a PermissionSet event 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.
  • lockdown is 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. FHEPermit does not gate unwrap. Even if the registry becomes unusable, users can always recover cleartext tokens through the wrapper's unwrap flow.

Was this page helpful?

On this page