The four disperse flows.
Register, approve, disperse and recover, with the hooks that ship each step on the 2.0 line.
confidentialTransferFrom, so the sender must first make the singleton an ERC-7984 operator on the token: setOperator({ publicClient, walletClient, token, spender, deadline }) or ensureOperator from @tokenops/sdk/fhe, with spender set to the singleton address. Without it the transaction reverts with ERC7984UnauthorizedSpender, which the SDK surfaces as OperatorNotApprovedError. Do not route disperse through Multicall3: it becomes msg.sender and breaks both the operator grant and the subwallet lookup.- 1
Register (once per user)
register({ token }) deploys two ERC-1167 subwallet clones for the caller under deterministic salts and has them approve the singleton as their ERC-7984 operator on token. Predict the pair before registering; read it back after.
- useIsRegistered({ user }) gates the UI; a second register reverts and surfaces as AlreadyRegisteredError
- useRegister().mutate({ token }) resolves { hash, wallets } from the caller's own UserRegistered event; more than one such event throws ReceiptEventAmbiguousError
- From a Safe, pass waitForReceipt: false: data is a PendingRegisterResult with wallets: undefined, so narrow with if (data.pending) and read the pair later with useGetWallets({ user })
- usePredictWallets({ user }) returns the same pair before registration; useWalletImplementation caches the clone implementation with staleTime: Infinity
- 2
Approve the singleton and the subwallets
Two different approvals. Every mode pulls from the sender, so the sender approves the singleton as an ERC-7984 operator on the token. The wallet modes also need both subwallets approved for that token, which register does for the token it was given.
- useIsOperator({ token, spender: singleton }) reads the sender's grant; useEnsureOperator sends setOperator only when it is missing
- Without it, preflightDisperse reports OperatorNotApprovedError (TOKENOPS_OPERATOR_NOT_APPROVED) in every mode, and hasApprovedSingleton is false
- useHasApprovedSubwallets({ user, token }) reads both subwallets; useApproveTokenOnWallets().mutate({ token }) approves them for another token, useRevokeTokenOnWallets rolls it back
- Missing subwallet approval surfaces as SubwalletsNotApprovedError, whose message names approveTokenOnWallets({ token })
- 3
Disperse to N recipients
One call, one input proof. The SDK validates the batch, splits the wallet-mode subtotals, encrypts amounts and subtotals together, attaches the ETH fee and routes by mode. A proof carries 32 values: 32 recipients direct, 30 in the wallet modes.
- usePreflightDisperse({ user, token, recipients, amounts, mode }) returns the PreflightReport: ready plus typed blockerErrors. Its query key holds a salted digest of amounts, never the plaintext
- Blockers to expect: OperatorNotApprovedError (any mode), NotRegisteredError and SubwalletsNotApprovedError (wallet modes), InsufficientBalanceError (ETH below the fee, balanceKind: "eth"), InvalidArgumentError (over the proof limit with batchOk: false, or a token that is not ERC-7984), BatchTooLargeError (over the on-chain cap), PausedError
- useDisperse({ encryptor: () => zamaSDK }).mutate({ token, mode, recipients, amounts }) resolves { hash, distributions }; a receipt with no distribution event throws ReceiptEventNotFoundError
- From a Safe, waitForReceipt: false resolves a PendingDisperseResult; read the WalletDistribution / DirectDistribution logs once the transaction executes
- 4
Recover, collect fees, respond to incidents
Users sweep their own subwallets. Role holders collect fees, read the encrypted fee reserve, disclose handles and pause the singleton.
- useRecoverFromWallets().mutate({ token, to }) moves residual confidential tokens out of the caller's subwallets; useRecoverERC20FromWallets does the same for stray ERC-20s
- FEE_COLLECTOR_ROLE: useWithdrawGasFee, and useWithdrawTokenFee({ encryptor: () => zamaSDK }).mutate({ token, to, amount }) resolving { hash, transferredHandle } (or a PendingWithdrawTokenFeeResult with waitForReceipt: false)
- useAccessEncryptedFeeReserve().mutate({ token }) is a write that grants the caller the reserve handle; it has no receipt-free form
- useDiscloseHandleToParty / useBatchDiscloseHandlesToParty resolve a DisclosureResult and accept waitForReceipt: false; usePause / useUnpause for incident response