Concept

EIP-712 Permits

How a read-only signature lets only you decrypt your own balance — and how to never fire it by accident.

Reading a confidential balance requires an EIP-712 typed-data signature from the token owner's wallet. This signature authorizes the Zama Gateway to decrypt the ciphertext and return the plaintext to the frontend session. It is off-chain — no gas, no transaction.

Security rule — never auto-fire permits. Every call to useConfidentialBalance or useConfidentialBalances with enabled: true immediately requests a wallet signature. Always gate it behind an explicit decryptRequested boolean that is only set true on a user click.
Click Decryptexplicit actionSign EIP-712off-chain, no gasSession keyscoped to youGateway decryptsonly your ciphertextPlaintextin browser
Reading a confidential balance (EIP-712 permit)

How it works

1
User clicks "Decrypt"

Set decryptRequested = true in your component state. Nothing fires until this happens.

2
SDK requests an EIP-712 signature

The hook builds a typed-data payload and asks the wallet to sign it. Off-chain: no gas, no transaction.

3
A session key is derived

The signature derives a short-lived key scoped to your address and the specific contract.

4
The Gateway decrypts

The Gateway uses the session key to decrypt the on-chain ciphertext. Only your account's ciphertexts are decryptable with your key.

5
Plaintext returns to the browser

The decrypted bigint balance is handed to your component. It is never written back on-chain in plaintext.

Reset on token change: when the user switches the selected token, reset decryptRequested synchronously in the onChange handler — not only in a useEffect. A one-frame delay in the effect can let the old true combine with the new token address and auto-fire a permit.
permit-gate.tsx
const [selectedToken, setSelectedToken] = useState(pairs[0]);
const [decryptRequested, setDecryptRequested] = useState(false);

// ✓ Reset synchronously on token change
const handleTokenChange = (newToken: WrapperPair) => {
  setSelectedToken(newToken);
  setDecryptRequested(false);  // ← same handler, not a useEffect
};

const { data: balance } = useConfidentialBalance({
  tokenAddress: selectedToken.erc7984Address,
  enabled: decryptRequested && !!address,  // ← explicit gate
});

Rejections are terminal — do not retry

If the user declines the signature, treat it as done: re-arm the button and wait for another click. Do not re-fire the query on error, on window focus, or on remount — that produces the "wallet keeps popping up" loop. ShadowLine disables the query after any decrypt error and only re-enables it on a fresh click.