SDK 2.1: platform toolkit + vesting engine
Concept · Confidential client

Encrypt once, reveal as a cancellable stream

Build one browser client around the acting wallet, then encrypt inputs, stream reveals, and clear permits.

v2.1.0 stable
SDK 2.1 is the stable release on the latest dist-tag. Install it with pnpm add @tokenops/sdk. The receipt-free and Safe create surface of /fhe-airdrop remains @beta.

@tokenops/sdk/confidential-client is a browser-oriented orchestration layer over Zama SDK 3.6. It binds encryption to the acting wallet, manages user-decrypt permits, and exposes reveal results as an async stream. Install the mandatory zod@^4.1.11 peer and the optional @zama-fhe/sdk@~3.6.0 peer when using createZamaSdk.

Configure the wallet boundary#

lib/confidential.ts
ts
import {
  createConfidentialClient,
  createZamaSdk,
  type I_BL04_03_Port,
  type Lb04Port,
} from "@tokenops/sdk/confidential-client";
import { systemClock } from "@tokenops/sdk/kernel";

declare const bl04: I_BL04_03_Port;
declare const telemetry: Lb04Port;

export const confidential = createConfidentialClient({
  environment: "testnet",
  origin: window.location.origin,
  bl04,
  createSdk: createZamaSdk,
  clock: systemClock,
  telemetry,
  sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
});

The application supplies three narrow ports: BL04's acting wallet, signer, and provider; a telemetry sink; and a clock/sleep pair for deterministic timing. createConfidentialClientvalidates the acting wallet chain before it creates the Zama SDK.

environmentChain presetRelayer URL
testnetSepolia (11155111)<origin>/v1/relayer/11155111
mainnet-testMainnet (1)<origin>/v1/relayer/1
productionMainnet (1)<origin>/v1/relayer/1

The generated Zama config uses in-memory storage, a one-day permit TTL, and an 86,400-second transport-key TTL. Reloading the page does not make this module a durable permit store. A wallet on another chain throws wallet-chain-mismatch before SDK construction.

Encrypt euint64 inputs#

actions/encrypt.ts
ts
import type {
  ConfidentialClient,
  EncryptInputResult,
} from "@tokenops/sdk/confidential-client";

declare const confidential: ConfidentialClient;

const contract = "0x1111111111111111111111111111111111111111";
const sender = "0x2222222222222222222222222222222222222222";

const encrypted: EncryptInputResult = await confidential.encryptInput({
  chainId: 11_155_111,
  contract,
  sender,
  values: [
    { type: "euint64", value: 1_000_000n },
    { type: "euint64", value: 250_000n },
  ],
  finalSigner: { kind: "acting-wallet" },
});

const [firstHandle, secondHandle] = encrypted.handles;
const proof = encrypted.inputProof;
const submitBefore = encrypted.expiresAt;
  • Requests are strict: positive integer chain id, valid strict viem addresses, at least one value, and only euint64.
  • Every value must be in 0..2^64 - 1. The first failure throws width-exceeded with its index.
  • The sender must be non-zero and equal the acting wallet by default. A declared final signer is allowed only when its address equals the sender.
  • The chain must equal the configured preset. The SDK encrypt call has a 30-second timeout.

expiresAtis the injected clock's Unix seconds when encryption resolves plus 600 seconds. It describes the input-proof validity window; submit the corresponding transaction before it expires.

Reveal across contracts#

actions/reveal.ts
ts
import type {
  ConfidentialClient,
  RevealInput,
} from "@tokenops/sdk/confidential-client";

declare const confidential: ConfidentialClient;
declare const inputs: RevealInput[];

const controller = new AbortController();

try {
  for await (const item of confidential.reveal(inputs, {
    signal: controller.signal,
  })) {
    if (item.status === "value") {
      console.log(item.handle, item.value);
    } else {
      console.log(item.handle, item.status); // "refused" or "unavailable"
    }
  }
} finally {
  controller.abort();
}

Reveal validates every 32-byte handle and contract first. It yields a zero encrypted handle immediately as value: 0n, deduplicates repeated non-zero handle/contract pairs, groups the rest by contract, grants permits for all groups, and decrypts up to five contract groups concurrently. Repeated zero handles each yield an item. Item order across contracts is therefore completion order, not input order.

Relayer outcomeStream behavior
NOT_ENTITLEDSplit a failed batch down to individual handles; yield refused for the denied item.
DECRYPTION_FAILED / NO_CIPHERTEXTSplit multi-handle calls; yield unavailable for failures that remain.
RPC_RATE_LIMITED / retryable RELAYER_REQUEST_FAILEDRetry at most four times. Backoff is 2, 4, 8, 16 seconds unless retry-after is supplied; waits cap at 30 seconds.
KEYPAIR_EXPIRED / INVALID_KEYPAIRClear and re-grant permits once per reveal call, then retry the affected decrypt once.
SIGNING_REJECTED / SIGNING_FAILED / CONFIGURATIONThrow and end the entire stream; these are session failures, not per-item states.
ACL_PAUSED / REVOKED_KMS_CONTEXT / unknownYield unavailable without splitting.

Abort and iterator lifecycle#

An already-aborted signal or empty input produces no items. Later abort stops yielding, aborts active decrypt requests, and lets outstanding work settle in the background. If a permit grant is already running, the client races it with abort and ignores its eventual result; the upstream permit promise itself cannot be cancelled. Breaking out of thefor awaitloop also aborts the reveal's internal controller, so consumers do not have to drain an unwanted stream.

Clear permits and dispose#

actions/sign-out.ts
ts
import type {
  ConfidentialClient,
  Address,
} from "@tokenops/sdk/confidential-client";

declare const confidential: ConfidentialClient;
declare const contract: Address;

await confidential.clear({ contracts: [contract] });
await confidential.clear();

// On component teardown: closes this client's BroadcastChannel listener.
confidential.dispose();

A non-empty, valid contracts scope revokes only those permits, swallows revocation errors, and does not notify other tabs. A global clear is also best-effort and posts a clear message so other clients on the same origin clear their Zama session state too. An empty scoped list is a no-op; an invalid scoped address falls back to the global clear.

Error contract#

CodeMeaning
request-invalidStrict schema, chainId, address, or handle validation failed; details.path names the first field.
width-exceededAn euint64 is outside its unsigned 64-bit range; details.index identifies it.
sender-not-finalSender is zero or does not equal the selected final signer.
wallet-chain-mismatchThe acting wallet is not on the environment preset; details.expected carries the chain id.

Per-handle authorization and relayer failures are usually represented as stream statuses, not thrown exceptions. Reserve a surroundingtry / catch for request validation, signing failures, configuration failures, and application telemetry errors.

See also