Confidential disperse on the 2.0 line.
Same singleton and addresses as the 1.x line. Stricter preflight, one-proof batches, and writes a Safe can sign.
Where to start
Register, approve the singleton, preflight, and disperse, in a Node script or with the hooks.
Register, approve, disperse to N recipients, recover and collect fees, with the hooks for each.
Every singleton method except getCustomFee and renounceRole, plus the shared operator and decrypt hooks.
Deployed singletons
One DisperseConfidential singleton per chain, unchanged from the 1.x line. createConfidentialDisperseClient and every hook resolve it from DEPLOYED_ADDRESSES.fheDisperse.disperseConfidentialSingleton by chain id, so pass address only for a fork or a custom network. The two chains use different addresses.
| Chain | Chain id | Singleton |
|---|---|---|
| Ethereum mainnet | 1 | 0x4fC0d28cBe4B82D512Ad0B42F6787480Cc98cC70 |
| Sepolia | 11155111 | 0x710dD9885Cc9986EfD234E7719483147a6d8DBb4 |
Every product's addresses, per chain: Deployments.
Changed since 1.x - breaking
The contracts and their ABI are unchanged. What changed is what the SDK checks before a write and what it does when a receipt does not say what it should. The @zama-fhe/sdk peer moves to ~3.6.0 across the line.
preflightDispersechecks the singleton approval in every modeEvery entry point pulls from the sender, so the wallet modes need the approval too, as
Operator approvalsdirectalready did. A wallet-mode sender without it now gets anOperatorNotApprovedErrorblocker (TOKENOPS_OPERATOR_NOT_APPROVED) instead ofready: trueand a revert; indirectthe blocker changes fromSingletonNotApprovedErrortoOperatorNotApprovedError.PreflightReport.hasApprovedSingletonnarrows fromboolean | nulltoboolean.SingletonNotApprovedErroris deprecated and no longer raised.PreflightReport.blockersis removedRead
blockerErrors: TokenOpsSdkError[]and branch onerror.code, or rendererror.messagewhere the strings were shown.readyis unchanged.- An ETH balance below the fee is
InsufficientBalanceErrorpreflightDispersereports it withbalanceKind: "eth",requestedset to the fee andavailableto the balance. It wasInsufficientFeeErrorwith "provided: 0 wei". Code that branched onTOKENOPS_INSUFFICIENT_FEEfor this blocker needsTOKENOPS_INSUFFICIENT_BALANCE. - A reverted transaction no longer resolves as success
Disperse errorsdisperse()throwsReceiptEventNotFoundErrorwhen the receipt has no distribution event, where it returneddistributions: [].discloseHandleToPartyandbatchDiscloseHandlesToPartythrowReceiptEventNotFoundErrororReceiptEventAmbiguousErrorinstead of echoing their inputs.withdrawTokenFeethrowsReceiptEventNotFoundErrorwithout aTokenFeeWithdrawnevent, sotransferredHandleis always aHexon a mined result. useRegister,useDisperseanduseWithdrawTokenFeeresolveMined | PendingThey accept
Receipt-free writeswaitForReceipt, and a hook cannot overload on it, so receipt-derived fields ondatatype asX | undefined. Narrow withif (data.pending) return;. Headless calls that never pass the option keep the mined result type.- Role hooks name the role holder
holder
RolesuseHasRole({ role, holder }), andmutate({ role, holder })onuseGrantRole/useRevokeRole.account(read) andaccountTarget(write) still work and are deprecated.accountTargetcannot be combined withholderon the write hooks; onuseHasRole,holderwins overaccountwhen both are passed. - The fee-reserve hook has one name:
useAccessEncryptedFeeReserveThe deprecated alias is gone from
Upgrading from 1.x/fhe-disperse/react; the function behind it is the same. The root brand type for a disperse id and its cast helper are removed too: the singleton emits nobytes32disperse id, so useHexfromviemfor an identifier of your own. - Immutable reads are cached forever
Read-hook query optionsuseWalletImplementationanduseDeploymentBlockNumberdefault tostaleTime: Infinity. Passquery: { staleTime: 0 }for the old behaviour.
Added
- One input proof per disperse, enforced before encryption
Each entry point verifies every encrypted amount against a single
inputProof, which carries at mostMAX_EUINT64_PER_INPUT_PROOF(32) values. That caps"direct"at 32 recipients and"wallet"/"wallet-token-fee"at 30, since the two subtotals share the proof. A larger list throwsInvalidArgumentErrornaming the limit;preflightDispersereports it as a blocker onrecipientsand setsbatchOktofalse. - Receipt-free writes for Safe and multisig signers
Receipt-free writeswaitForReceipt: falseonregister,disperse,withdrawTokenFee,discloseHandleToPartyandbatchDiscloseHandlesToPartyreturns on submission withPendingRegisterResult,PendingDisperseResult,PendingWithdrawTokenFeeResultorPendingDisclosureResult, all carryingpending: true.getEncryptedFeeReservehas no receipt-free form: its handle is only useful to an account that can user-decrypt in-session. - Gas headroom on every write, and a per-call
gasEach write sends its estimate plus
Gas headroomDEFAULT_GAS_HEADROOM_PERCENT(25%) unless the client or hook setsgasHeadroomPercent. Writes that take an argument object (register,disperse, the subwallet methods, the disclosures,getEncryptedFeeReserve,withdrawTokenFee) acceptgas, sent as is. The positional admin setters use the client's headroom. telemetryandqueryon the hooksEvery
Read-hook query options/fhe-disperse/reacthook takestelemetry, forwarded into the headless client, and every write onConfidentialDisperseClientis bracketed with a span. Read hooks takequery(ReadHookQueryOptions): TanStack options minusqueryKey/queryFn, wherequery.enabledcan turn a ready query off but never an unready one on.- New exports
Disperse typesMAX_EUINT64_PER_INPUT_PROOF,DisclosureResult(the data type of both disclosure hooks),ReadHookQueryOptions,ReceiptMode,PendingWrite,MinedWrite,GasOverride,GasHeadroomOptionandTokenOpsValidationErroron/fhe-disperse/react;DEFAULT_GAS_HEADROOM_PERCENTand thePending*Resulttypes on/fhe-disperse.
Fixed
usePreflightDispersekeeps plaintext amounts out of its query keyThe key holds a keccak256 digest of
amounts, salted once per page or process, so React Query devtools, persisters and loggers never see them. Equal amounts in the same order still share an entry. Because the salt is per process, a preflight result is never restored through SSR hydration or a persisted cache.registerreads only the caller'sUserRegisteredeventIt throws
ReceiptEventAmbiguousErroron more than one, instead of taking the first, andDisperseSubwalletNotFoundErrorwhen none names the caller.- Preflight rejects a token that is not ERC-7984 as a blocker
It is one
InvalidArgumentErrorinblockerErrors, instead of a raw viem error rejecting the whole call. - Hooks no longer throw from render
A client constructor error (a malformed
address, a badgasHeadroomPercent, an unsupported chain) is kept as the hook's resolution error: queries stay disabled and mutations reject with it. - The ACL address follows
config.chainIdA client built with
chainIdand a chain-lesspublicClientno longer throwsDeploymentAddressUnavailableErrorongetEncryptedFeeReserve. - Error messages name the call you make
AlreadyRegisteredErrorandSubwalletsNotApprovedErrornameapproveTokenOnWallets({ token })(useApproveTokenOnWallets), not the contract function.OperatorNotApprovedErrornames the holder, the spender, the token and thesetOperator({ token, spender })call when it knows them.MissingEncryptorErrorfromwithdrawTokenFeenames the method, and thedisperseone tells you to set the encryptor on the client. Range errors fromencryptUint64/encryptUint64Batchno longer copy the amount intocontext.value.
Upgrading: from 1.x or from a 2.0 alpha.
The shape of confidential disperse
One singleton, two subwallets per user
register({ token }) deploys two ERC-1167 clones for the caller and has them approve the singleton as their ERC-7984 operator on token. The singleton controls them; you act on them through it. Approve further tokens with approveTokenOnWallets({ token }).
Three modes, one call
disperse({ token, mode, recipients, amounts }) takes plaintext amounts, encrypts them (and, in the wallet modes, the two subtotals) under one input proof, and routes "wallet", "wallet-token-fee" or "direct" to the matching entry point. Each recipient can decrypt only its own handle.
Preflight before you send
preflightDisperse returns registration, both approvals, the fee against the sender's ETH balance, the batch and input-proof limits and per-recipient checks, with ready and typed blockerErrors.
Silent-zero transfers
An ERC-7984 transfer from an insufficient balance moves an encrypted zero instead of reverting, so a mined receipt does not prove value moved. Decrypt the transferred handles in distributions with the token as the contract address.
Reference
ConfidentialDisperseClient, grouped by lifecycle.
Args, mined and pending results, the preflight report.
Preflight blockers and write-time errors.
UserRegistered, the distribution events, disclosures.
The five singleton roles and the holder-named hooks.
Singleton, subwallet and ERC-7984 operator ABIs.