For the complete documentation index, see llms.txt. This page is also available as Markdown.

Offline signing

How to build unsigned transactions that are signed and broadcast out-of-process by a custody platform, HSM, or policy engine.

sdk.offline.prepare builds a fully populated unsigned transaction and hands it to you. Signing and broadcasting happen out-of-process: an institutional custody platform, an HSM ceremony, a policy engine with human approval. The preparing process never holds the wallet private key.

Prefer the atomic API when you can. If your signer can complete a signature inside one Promise (even a slow one that polls a custody API), implement a custom BaseSigner and keep the one-call Token methods. Reach for prepare only when signing genuinely leaves the process.

Steps

1. Configure the SDK without a signer

The signer is optional. A provider is all prepare needs:

import { createConfig, MemoryStorage, ZamaSDK } from "@zama-fhe/sdk";
import { sepolia } from "@zama-fhe/sdk/chains";
import { node } from "@zama-fhe/sdk/node";
import { ViemProvider } from "@zama-fhe/sdk/viem";

const sdk = new ZamaSDK(
  createConfig({
    chains: [sepolia],
    relayers: { [sepolia.id]: node() },
    provider: new ViemProvider({ publicClient }), // reads only
    storage: new MemoryStorage(),
  }),
);

2. Prepare an unsigned transaction

For a transfer, the amount is encrypted during prepare, including the required relayer interactions, and the calldata is ready to sign. from must match the address of the key that eventually signs: encrypted inputs are bound to that sender, so a mismatch reverts on-chain. The result is JSON-safe and crosses a process boundary as-is. unsignedTx carries the whole EIP-1559 transaction (chain id, nonce, calldata, gas and fee caps); from travels alongside because an unsigned transaction has no sender field and the custodian needs it to pick the signing key.

Nonce, gas, and fees are read from chain state; override them per call when you need control:

3. Sign and broadcast out-of-process

The custody platform signs the prepared transaction after policy approval, preserving its nonce, gas limit, fees, and calldata. Custody platforms typically accept the unsigned payload directly and broadcast in the same call:

Some platforms also support signing without broadcasting. When that API accepts serialized transactions, it returns the serialized signed transaction for you to broadcast:

Either way, watch the chain yourself: fetch the receipt for the transaction hash through your own provider, and wait for enough confirmations for your risk policy before acting on it. A receipt returned at first inclusion can still be invalidated by a reorganization, for example after you use it to prepare FinalizeUnwrap.

Request kinds

Each prepare call produces one transaction. The kind selects what it builds:

Kind
Transaction
Notes

ConfidentialTransfer / ConfidentialTransferFrom

ERC-7984 transfer

amount encrypted during prepare

SetOperator

operator approval

explicit until timestamp required

TransferAndCall

single-transaction shield

ERC-1363 underlyings only

ApproveUnderlying + Wrap

two-transaction shield

see the batch warning below

Unwrap / UnwrapAll

unshield phase 1

Unwrap encrypts the amount; UnwrapAll reads the on-chain balance

FinalizeUnwrap

unshield phase 2

public decryption happens during prepare

DelegateDecryption / RevokeDelegation

ACL delegation

explicit expiry, or omit for permanent

Multi-transaction flows

WrappedToken.shield() and WrappedToken.unshield() need a live signer, so offline workflows compose their underlying steps with prepare: TransferAndCall or ApproveUnderlying then Wrap for shielding, and Unwrap then FinalizeUnwrap for unshielding. Offline workflows are one of the few places where composing below the Token API is correct.

Shield mirrors the shielding paths: one TransferAndCall for ERC-1363 underlyings, otherwise ApproveUnderlying then Wrap. Check which path applies with await wrappedToken.isPayable(); this provider-only read does not require a signer.

Unshield is the request-then-finalize round-trip. The finalize input comes from the phase-1 receipt:

Neither phase requires a separate wallet signature: both perform the required relayer interactions during prepare.

Approval delays

Policy approval can take hours or days. The cryptographic proofs tolerate that, but the transaction still depends on chain state:

  • The input proof (Unwrap) and the public decryption proof (FinalizeUnwrap) embedded in the calldata have no on-chain expiry. Only rebroadcasting the same signed bytes is safe without further checks. A duplicate FinalizeUnwrap reverts instead of paying twice, but re-preparing any other kind (including Unwrap) with a fresh nonce creates a new transaction; confirm the original never landed first.

  • The nonce can become stale if another transaction from the same wallet is mined first. Reserve or otherwise coordinate nonces across concurrent workflows, and re-prepare if the nonce is consumed.

  • Contract state can change while approval is pending, and explicit timestamps such as SetOperator.until or a delegation expiry keep advancing. Re-prepare when the transaction's assumptions no longer hold.

  • The fee cap can fall below the base fee. Add suitable headroom to maxFeePerGas; only the base fee plus priority tip is charged, so unused cap headroom costs nothing. Keep maxPriorityFeePerGas at an appropriate tip because raising it can increase the amount paid.

Offline permits

A decryption permit is not a transaction — nothing is broadcast, and registering the signature is a local operation — so it gets its own two-step flow instead of a prepare kind: sdk.offline.preparePermit builds the unsigned EIP-712 typed data, and sdk.permits.registerPermit verifies and persists the signature the custodian returns.

preparePermit is signer-offline, not network-offline: resolving the transport key pair and building the typed data still reads the chain's KMS signers context on-chain, so the provider must be reachable. It never touches a configured signer or connected wallet — request.signer is an explicit address, matching the offline prepare contract above.

Hand prepared.eip712 to the custodian for eth_signTypedData_v4, exactly as you would for the atomic sdk.permits.grantPermit path — nothing about the payload changes for the offline flow:

Then register the signature. This verifies it against prepared.eip712 and persists the permit — no further wallet interaction:

One permit per call: unlike grantPermit, preparePermit never widens an existing permit or chunks a request over 10 contracts — contracts maps to exactly one signature.

KMS context rotation. A registered permit is bound to the chain's KMS context. If that context is revoked on-chain, decrypts throw RevokedKmsContextError with a SigningFailedError as cause: the SDK's automatic re-grant cannot sign in a signerless session, so it keeps the scope's other permits and surfaces the error instead. Run preparePermit and registerPermit again for the affected contracts. See the error reference for how to tell this case apart from the retryable one.

Next steps

Last updated