Permit model
How EIP-712 signed permits authorize FHE decryption for specific contracts.
Decrypting an on-chain FHE ciphertext requires two things: a transport key pair (generated once, stored persistently — see Security Model) and a signed permit that authorizes decryption for specific contract addresses. This page explains how permits work.
What is a permit
A permit is an EIP-712 typed data signature from the user's wallet. It binds:
A set of contract addresses (up to 10 per permit).
A chain ID.
A start timestamp and duration (derived from
permitTTL).The signer address.
An optional delegator address (for delegated decryption).
The relayer verifies this signature before re-encrypting any ciphertext. Without a valid permit covering the target contract, the relayer rejects the request.
Key properties
Immutable — the signed contract addresses are part of the EIP-712 payload and cannot be edited after signing.
Chunked — each permit covers at most 10 contracts. When more are needed, the SDK chunks the addresses and requests one wallet signature per chunk.
Chain-scoped — stored under
(signerAddress, chainId, delegatorAddress), so permits on Sepolia never collide with permits on Mainnet, and direct permits never collide with delegated ones.Additive — calling
permits.grantPermit()with new contracts signs additional permits for the uncovered subset only. Existing permits remain valid and are not re-signed.Time-bounded — each permit records its creation timestamp and duration. Expired permits are pruned on next access.
Lifecycle
First visit
User connects wallet.
App calls
permits.grantPermit([contractA, contractB]).SDK checks storage — no permits cover these contracts yet.
SDK builds EIP-712 typed data (contract addresses, timestamp, duration) and requests a wallet signature.
Wallet signs → permit is stored (keyed by signer address, chain ID, and delegator).
Ready — subsequent decrypts reuse the stored permit silently.
Returning visit
Page loads → SDK reads permits from storage.
Permits cover the requested contracts and haven't expired.
Ready — no wallet popup.
New contract coverage
Decrypt request arrives for a contract not covered by existing permits.
SDK signs a new permit for the uncovered contracts only.
New permit is appended alongside existing ones — nothing is invalidated.
Expiration
A permit's duration elapses (default: 30 days, configurable via
permitTTL).Permit is pruned from storage on next access.
The next decrypt for that contract set prompts a single wallet re-sign.
The transport key pair is not affected.
How additive permits work
Unlike a session model where re-authorizing replaces the previous authorization, permits are purely additive:
This means users see fewer wallet popups over time. As they interact with more contracts, their permit coverage grows without invalidating earlier permits.
Revocation
Permits can be removed in two ways:
Selective —
sdk.permits.revokePermits(["0xTokenA"])removes permits touching those contracts on the current chain. Other permits are untouched.Full wipe —
sdk.permits.revokePermits()removes all permits for the current signer across all chains and delegators. The transport key pair is not affected.
For a complete "log out" that also removes the transport key pair, use sdk.permits.clear(). See the ZamaSDK reference for the full API.
Two revocation tiers with a shared scope
Both revokePermits() and clear() are signer-level: they only ever act on the calling signer's own permits (and, for clear(), that signer's own key-pair slot). This holds even when transportKeyPairScope is configured — an end-user disconnecting never invalidates other signers sharing the scope's key pair, because the shared key pair was never stored under any individual signer's slot to begin with.
Invalidating the shared key pair is a separate, operator-level operation: sdk.permits.revokeTransportKeyPair(scopeId). It deletes the scope's key pair — no permit needs to be touched directly, and no wallet needs to be connected. hasPermit/grantPermit then treat every permit in the scope as stale on next access, though not via one single mechanism: immediately after the revoke, hasPermit() returns false because there's no stored key pair left to compare against; once some later caller regenerates a key for the scope, it's the newly-mismatched embedded public key that pruneUnusable then filters existing permits on. Same end-user-visible outcome either way. Unlike other credential-store writes, this call is not best-effort: a storage failure rejects rather than being logged and swallowed, since a resolved promise here is expected to mean the key pair is actually gone — the primitive an operator reaches for on suspected compromise. This only stops the SDK from reissuing or reusing the key going forward; it does not revoke any permit already issued under it, which stays independently valid until its own permitTTL expiry — see Security Model for the full explanation and when a shared scope makes sense.
Wallet account changes
The SDK automatically manages permits when the wallet state changes:
Disconnect / lock
All permits and transport key pair cleared for the previous account
Account switch
Previous account's permits cleared; new account starts fresh
Chain switch
Permits are chain-scoped, so existing permits on the previous chain remain intact
See ZamaSDK.onWalletAccountChange for programmatic access to these transitions.
Offline permits
The lifecycle above assumes a connected wallet signs grantPermit's EIP-712 message in-process. Custody partners — HSMs, policy engines, out-of-process signers — cannot do that: sdk.offline.preparePermit builds the same EIP-712 typed data without signing it, and sdk.permits.registerPermit verifies and persists the signature once the custodian returns it. See the Offline signing guide for the workflow.
Related
Security Model — transport key pair storage, threat model, and trust assumptions
Configuration —
transportKeyPairTTLandpermitTTLsettingsZamaSDK —
permits.grantPermit(),permits.revokePermits(),permits.clear()API
Last updated