API Reference
Exports
| Export | Kind | Description |
|---|---|---|
ConfidentialDisperseClient | class | Typed wrapper around the DisperseConfidential singleton. Methods: register, disperse, preflightDisperse, predictWallets, getWallets, getFees, getBatchLimits, calculateFee, hasApprovedSubwallets, approveTokenOnWallets, revokeTokenOnWallets, recoverFromWallets, recoverERC20FromWallets, getEncryptedFeeReserve, discloseHandleToParty, batchDiscloseHandlesToParty, role management, admin/fee writes |
createConfidentialDisperseClient | function | Convenience constructor; mirrors viem's create* convention |
ConfidentialDisperseClientConfig | interface | Constructor config |
computeSubtotals | function | Compute { group0, group1 } subtotals using the contract's exact partition rule. Exported for callers who want to verify independently. |
DisperseSubwalletNotFoundError | class | Thrown by register() when no UserRegistered event matching the singleton is found in the receipt |
DisperseEncryptedReserveNotGrantedError | class | Thrown by getEncryptedFeeReserve() when no ACL Allowed event granting the caller access is found (reserve uninitialised) |
DisperseMode | type | "wallet" | "wallet-token-fee" | "direct" |
PreflightReport | interface | Comprehensive pre-flight result from preflightDisperse — the contract between SDK and any UI rendering a readiness checklist |
PreflightDisperseArgs | interface | Args for preflightDisperse |
RecipientCheck | interface | Per-recipient validation result inside PreflightReport |
DisperseArgs | interface | Args for disperse |
DisperseResult | interface | { hash: Hex } returned by disperse |
RegisterResult | interface | { hash: Hex, wallets: [Address, Address] } returned by register |
UserFees | interface | Consolidated fee state from getFees |
FeeConfig | interface | Global fee configuration from on-chain feeConfig struct |
CalculatedFee | interface | { ethValue: bigint, tokenFeeAmount?: bigint } from calculateFee |
CalculateFeeArgs | interface | Args for calculateFee |
BatchLimits | interface | Per-mode batch size limits from getBatchLimits |
SubwalletApprovalState | interface | { wallet0, wallet1, both } from hasApprovedSubwallets |
GetEncryptedFeeReserveArgs | interface | Args for getEncryptedFeeReserve |
EncryptedViewResult | interface | { handle: Hex, hash: Hex } from getEncryptedFeeReserve |
DiscloseHandleArgs | interface | Args for discloseHandleToParty |
BatchDiscloseHandlesArgs | interface | Args for batchDiscloseHandlesToParty |
EncryptedInput | interface | Single { handle: Hex, inputProof: Hex } |
EncryptedInputs | interface | Batch { handles: Hex[], inputProof: Hex } |
encryptUint64 | function | Encrypt a single uint64 to an externalEuint64 handle + input proof |
encryptUint64Batch | function | Encrypt N uint64 values under a single input proof |
resolveEncryptor | function | Normalize an EncryptorSource to the live Encryptor |
Encryptor | interface | Structural interface for Zama RelayerWeb / RelayerNode / mock |
EncryptorSource | type | Encryptor | (() => Encryptor | undefined) — eager or lazy |
EncryptUint64Args | interface | Args for encryptUint64 |
EncryptUint64BatchArgs | interface | Args for encryptUint64Batch |
FheValueInput | type | Discriminated union of FHE input types |
disperseConfidentialAbi | const | ABI for the DisperseConfidential singleton |
disperseWalletAbi | const | ABI for the per-user DisperseWallet clone |
API reference
ConfidentialDisperseClient
Wraps the deployed DisperseConfidential singleton. Read methods use publicClient; write methods require walletClient.
Constructor / factory function
new ConfidentialDisperseClient(config: ConfidentialDisperseClientConfig)
createConfidentialDisperseClient(config: ConfidentialDisperseClientConfig)
The constructor throws immediately when neither config.address nor a recognized chain id resolves a singleton address. It also throws when no FHEVM ACL address is known for the chain — pass aclAddress explicitly for custom networks.
Registration
| Method | Description |
|---|---|
register({ token, account? }) | Deploy the caller's dedicated wallet pair AND approve both wallets as ERC-7984 operators for token in the same tx. Returns { hash, wallets }. Throws DisperseSubwalletNotFoundError if no UserRegistered event is found. Can only be called once per user — use approveTokenOnWallets to add approvals for additional tokens later. |
approveTokenOnWallets({ token, account? }) | Approve the caller's wallets as ERC-7984 operators for a different token after initial registration. |
revokeTokenOnWallets({ token, account? }) | Revoke operator approval on token for the caller's wallets. |
Reads
| Method | Returns | Description |
|---|---|---|
isRegistered(user) | boolean | Whether the user has a registered wallet pair |
getWallets(user) | [Address, Address] | null | On-chain wallet pair, or null if not registered |
predictWallets(user) | [Address, Address] | Deterministic wallet addresses — works before registration |
getFees(user) | UserFees | Consolidated fee state: gas fee, token BPS, global toggles |
getBatchLimits() | BatchLimits | Per-mode batch size limits (0n = no limit) |
calculateFee(args) | CalculatedFee | Compute { ethValue, tokenFeeAmount? } without writing |
hasApprovedSubwallets({ user, token }) | SubwalletApprovalState | Approval state of the user's registered wallets for the token |
paused() | boolean | Whether the singleton is paused |
deploymentBlockNumber() | bigint | Block at which the singleton was deployed |
walletImplementation() | Address | WALLET_IMPLEMENTATION — the ERC-1167 clone target |
hasRole(role, address) | boolean | Check role membership |
Preflight
| Method | Returns | Description |
|---|---|---|
preflightDisperse(args) | PreflightReport | All readiness checks in one call. Check report.ready before calling disperse. |
Disperse
| Method | Returns | Description |
|---|---|---|
disperse(args) | DisperseResult | Encrypt amounts + subtotals, compute fee, dispatch to the correct mode's contract function. |
Recovery
| Method | Description |
|---|---|
recoverFromWallets({ token, to, account? }) | Sweep residual confidential tokens from the caller's wallets to to. Use after a subtotal-mismatch disperse. |
recoverERC20FromWallets({ token, to, account? }) | Sweep accidentally-sent ERC-20 tokens from the caller's wallets. |
Encrypted view (admin / fee collector)
| Method | Returns | Description |
|---|---|---|
getEncryptedFeeReserve(args) | EncryptedViewResult | Write transaction. Grant caller FHE ACL on the encrypted fee reserve handle and return it. Throws DisperseEncryptedReserveNotGrantedError if reserve is uninitialised. |
Disclosure
| Method | Description |
|---|---|
discloseHandleToParty({ handle, party, account? }) | Grant party persistent FHE ACL on a single handle. Caller must already hold ACL on handle. ACL is append-only — cannot be revoked. |
batchDiscloseHandlesToParty({ handles, party, account? }) | Atomically grant party ACL on all handles. Reverts on the first handle that fails ACL checks. |
Admin / fee-manager writes
| Method | Description |
|---|---|
pause(account?) | Pause all disperses. Requires PAUSER_ROLE. |
unpause(account?) | Unpause. Requires PAUSER_ROLE. |
setFeeConfig(config, account?) | Update global fee toggles and defaults. Requires FEE_MANAGER_ROLE. |
setCustomFee(user, gasFee, tokenFee, account?) | Set per-user fee override. Requires FEE_MANAGER_ROLE. |
disableCustomFee(user, account?) | Remove per-user fee override. Requires FEE_MANAGER_ROLE. |
setMaxBatchSizeHolding(size, account?) | Set wallet-mode batch limit. 0n = no limit. Requires FEE_MANAGER_ROLE. |
setMaxBatchSizeDirect(size, account?) | Set direct-mode batch limit. Requires FEE_MANAGER_ROLE. |
setMaxBatchSizeTokenFee(size, account?) | Set token-fee-mode batch limit. Requires FEE_MANAGER_ROLE. |
withdrawGasFee(to, amount, account?) | Withdraw accumulated ETH gas fees. Requires FEE_COLLECTOR_ROLE. |
withdrawTokenFee(args: WithdrawTokenFeeArgs) | Withdraw accumulated encrypted token fees. Requires FEE_COLLECTOR_ROLE. WithdrawTokenFeeArgs is a discriminated union: pass either { token, to, amount, encryptor?, account? } (SDK encrypts amount) or { token, to, encryptedInput, account? } (pre-encrypted), never both — passing both is a compile error. |
rescueConfidentialTokens(token, to, account?) | Rescue accidentally-sent ERC-7984 tokens. Requires DEFAULT_ADMIN_ROLE. |
rescueERC20(token, to, account?) | Rescue accidentally-sent ERC-20 tokens. Requires DEFAULT_ADMIN_ROLE. |
grantRole(role, accountTarget, account?) | Grant role (admin only). |
revokeRole(role, accountTarget, account?) | Revoke role (admin only). |
computeSubtotals
import { computeSubtotals } from "@tokenops/sdk/fhe-disperse";
const { group0, group1 } = computeSubtotals(amounts);
Computes the two group subtotals matching the contract's _distributeFromWallets partition:
group0= sum of the firstceil(n/2)amounts (wallet0's group)group1= sum of the remainingfloor(n/2)amounts (wallet1's group)
Called internally by disperse(). Exported for callers who need to verify the values before encrypting.
Error catalogue
SDK typed errors
All SDK errors extend TokenOpsSdkError and carry a stable code: TokenOpsSdkErrorCode you can match on in onError handlers. Use isTokenOpsSdkError(error) for cross-realm-safe branding (preferred over instanceof in monorepos with hoisting). Every typed error preserves the original viem / Zama error as error.cause.
| Error class | code | Triggered by | How to recover |
|---|---|---|---|
DisperseSubwalletNotFoundError | TOKENOPS_RECEIPT_EVENT_NOT_FOUND | register() — no UserRegistered event from the singleton in the receipt. Indicates the tx reverted silently or the singleton address is wrong. | Verify client.address points to the deployed singleton. Check isRegistered — user may already be registered. |
DisperseEncryptedReserveNotGrantedError | TOKENOPS_RECEIPT_EVENT_NOT_FOUND | getEncryptedFeeReserve() — no ACL Allowed event granting the caller access to the fee reserve. Reserve is uninitialised. | No token fees have accrued for this token yet. Check that a "wallet-token-fee" disperse has been successfully submitted first. |
NotRegisteredError | TOKENOPS_NOT_REGISTERED | disperse() / approveUserWalletsForToken() / recoverFromWallets() — caller has not called register(token) yet. | Call register(token) first. |
AlreadyRegisteredError | TOKENOPS_ALREADY_REGISTERED | register() — caller is already registered. | Use approveUserWalletsForToken(token) to whitelist a new token against the existing wallet pair. |
PausedError | TOKENOPS_PAUSED | Any user-facing write while the singleton is paused. | Read paused() before retrying. |
AccessDeniedError | TOKENOPS_ACCESS_DENIED | Role-gated admin / fee-manager / fee-collector writes by a caller who lacks the role. context.role is the bytes32 role selector. | Call hasRole(role, account) to confirm; resolve the bytes32 selector via the on-chain role constants. |
InsufficientFeeError | TOKENOPS_INSUFFICIENT_FEE | disperse() with gasFeeOverride not matching the configured gas fee. feeKind: "gas". | Read the live fee with getFees(user) or call preflightDisperse and use its feeEth. |
BatchTooLargeError | TOKENOPS_BATCH_TOO_LARGE | disperse() with recipients.length > maxBatchSize[mode]. context.requested / context.max carry the numbers. | Check getBatchLimits() or preflightDisperse.batchOk before submitting. |
FheHandleNotAllowedError | TOKENOPS_FHE_HANDLE_NOT_ALLOWED | discloseHandleToParty(...) with a handle the caller does not hold ACL on. | Confirm the caller received an ACL grant for the handle (parse the Allowed event of the tx that produced it). |
ContractRevertError | TOKENOPS_CONTRACT_REVERT | Any other on-chain revert the SDK decodes but does not have a more specific typed subclass for. context.revertName + context.revertArgs carry the decoded payload. | Read context.revertName for the contract-side error name; consult the contract source if no SDK guidance applies. |
Zama relayer / encryption errors are also wrapped:
| Error class | code | Triggered by |
|---|---|---|
UserRejectedSignatureError | TOKENOPS_USER_REJECTED | User cancelled a wallet signature prompt (e.g. for user-decrypt). |
SigningFailedError | TOKENOPS_SIGNING_FAILED | Wallet signature failed for a reason other than user rejection (timeout, wallet crash, malformed request). |
RelayerUnreachableError | TOKENOPS_RELAYER_UNREACHABLE | Network call to the Zama relayer failed; context.statusCode? is populated when the underlying HTTP error exposed it. |
EncryptionFailedError | TOKENOPS_ENCRYPTION_FAILED | Encryption flow (input proof generation) failed. |
DecryptionFailedError | TOKENOPS_DECRYPTION_FAILED | Decryption flow failed (user-decrypt / public-decrypt). |
UserDecryptNotAllowedError | TOKENOPS_USER_DECRYPT_NOT_ALLOWED | Zama ACL refused to grant decryption for the caller on the requested handle. Distinct from FheHandleNotAllowedError (which is the on-chain revert for FHE.isSenderAllowed). |
Contract named errors
| Error | Cause |
|---|---|
EmptyRecipients | recipients array is empty. |
ArrayLengthMismatch | recipients.length !== amounts.length. The SDK validates this before submitting. |
BatchTooLarge(requested, max) | Recipient count exceeds the configured batch limit. Check preflightDisperse.batchOk. |
ZeroAddressRecipient | A recipient is the zero address. The SDK validates this before submitting. |
InsufficientAmount(sent, required) | msg.value does not exactly match the required gas fee. The SDK computes this automatically; may arise from a stale fee read if the fee config changed between preflight and disperse. |
TokenFeeTooHigh | BPS fee configured above 10000 (100%). Admin configuration error. |
InvalidAddress | A zero address was passed where a non-zero address is required (e.g. to in recover). |
EmptyBatch | handles array is empty in a batch disclosure call. |
TransferFailed | A native ETH transfer (gas fee withdrawal) failed. |
CustomFeeNotSet | disableCustomFee called for a user with no custom fee entry. |
NotController | Wallet method called by an address that is not the wallet controller (the singleton). |
InvalidWalletIndex | Wallet index other than 0 or 1 passed to getUserWallet. |
UserAlreadyRegistered | register() called a second time for the same user. Check isRegistered first. |
UserNotRegistered | A wallet-mode operation attempted on an unregistered user. Call register() first. |
HandleNotAllowed | discloseHandleToParty called with a handle the caller does not hold ACL on. |
ContractNotAllowed | discloseHandleToParty called with a handle the singleton itself does not hold ACL on — the handle was never disclosed to the contract. |
FHE and ACL pitfalls
getEncryptedFeeReserve is a write transaction, not a free view. The contract calls FHE.allow(handle, msg.sender) inside the tx. The handle value is determined by an on-chain counter at execution time — simulation runs against a snapshot that diverges from the executed counter, so the simulated handle never receives an ACL grant. The SDK extracts the correct handle from the receipt's ACL.Allowed event.
ACL grants are append-only. Once getEncryptedFeeReserve grants a handle to an address, that access cannot be revoked. Do not call it speculatively.
Deflated subtotals cause silent partial failures. The contract uses FHE.min(requested, walletBalance) to cap each transfer. If the subtotals passed to the contract are lower than the true sum of the corresponding group's amounts, each recipient gets less than intended with no revert. Always use the subtotals returned by computeSubtotals or computed internally by disperse().
euint64 overflow. Plaintext amount values must fit in uint64 (max 2^64 - 1). For ERC-7984 tokens (6 decimals), this is approximately 1.8 × 10^13 tokens — far above any realistic single-disperse amount. The SDK validates each value and throws if it exceeds the range.
For the full FHE ACL specification and userDecrypt flow, see docs.zama.ai/fhevm.
Tested on
- Local FHEVM (Anvil + forge-fhevm host contracts +
@fhevm/mock-utils): all three disperse modes happy path, operator state reads, registration flow, recovery. - Sepolia: read smokes (fee reads, batch limits,
isRegistered). Write smokes pending singleton deployment.