2.0 RC docsView 1.x docs
v2.0.0-rc.1 release candidate
The API is frozen and later candidates carry fixes only, except the receipt-free and Safe create surface of /fhe-airdrop, which is @beta. Published on the next dist-tag; latest stays on 1.6.0 until 2.0.0.
Airdrop v2 · Errors · 17@tokenops/sdk/fhe-airdrop

Airdrop v2 errors you can catch by class.

Claim errors carry renamed fields. Eight classes are guardrails catching a bad result before it ships - seven at create time, and the genuineness check on the claim path.

For the catch-ladder pattern + how SDK-level and generic-fallback errors fit alongside these, read Concepts › Typed errors + recovery.

ClassWhen thrownRecovery
AlreadyClaimedErrorThe active replay guard already recorded this claim. context.replayGuard names which one fired: "address" for ECDSA's PerAddress/Both dedup slot, "signature" for the EIP-712 digest guard - the only guard active under DedupMode.None.The caller field from v1 is gone; replayGuard says which dimension blocked the claim. Either way this claim already settled: treat it as done and do not re-sign. Re-issue only if your own records show the allocation was not paid - under perDedupId or none, a new voucher is a new payout, and under perAddress or both the recipient's slot is consumed for good.
ClaimNotStartedErrorblock.timestamp is still before the instance's startTime. context now carries both startTime and currentTime (renamed from v1's startsAt).Render a countdown from context.startTime and pre-check with useAirdropWindow before paying gas - it bundles startTime, endTime, hasStarted, hasEnded, and isActive in one read. useAirdropWindow
ClaimWindowClosedErrorblock.timestamp is past the instance's endTime (end inclusive on-chain). context carries endTime and currentTime (renamed from v1's endedAt).Surface a window-closed banner from context.endTime. useAirdropWindow's isActive already ANDs the window check with the pause flag, so one hook covers both reasons a claim button should disable. useAirdropWindow
ClawbackRequiresPauseErrorwithdrawConfidential reverted ClawbackRequiresPause: the campaign is unpaused and still inside its claim window, so a clawback would pull funds out from under live claims.Pause first (PAUSER_ROLE), or run the clawback outside the window - before startTime or after endTime. useAirdropPause
CreateCommitmentMismatchErrorA create reverted because one of its commitments no longer matches what the factory would deploy: the instance address, the compliance-manager implementation, or the creator's resolved compliance delegate moved between quote and send. context.field names which (airdrop | complianceManagerImpl | complianceDelegate), with expected and actual. preflightCreate reports a pinned expected field that differs from the fresh quote as the same blocker.Nothing was deployed and the salt is unused. Re-quote with quoteCreateCommitments; if the field is airdrop, also rebuild anything bound to the old prediction - Merkle leaves, vouchers, encrypted inputs - before sending again. usePreflightCreateAirdrop
DedupIdConsumedErrorECDSA claim reverted DedupIdAlreadyClaimed: this dedupId's slot was already consumed under DedupMode.PerDedupId or Both. context.dedupId and the message name the consumed id, for claim, claimAndUnwrap and getClaimAmount.dedupMode is fixed at create time; read it with readDedupMode() / useDedupMode rather than tracking it yourself. Mint a fresh dedupId per claim rather than reusing one across signatures. useDedupMode
FeeCollectorSelfAdministeredErrorplanInstanceRoleSplit / grantInstanceRoles refused to plan a FEE_COLLECTOR_ROLE grant - that role is its own role admin, not DEFAULT_ADMIN_ROLE, so a grant issued from the caller's admin role would fail on-chain.An existing FEE_COLLECTOR_ROLE holder must call grantRole directly instead of routing the grant through grantInstanceRoles. Check current membership first. useAirdropRoleMembers
GasFeeNotAcceptedErrorcreate* refused the per-claim fee the factory resolved for this creator - it landed above the CommonAirdropParams.maxAcceptedGasFee they declared. Mapped from the on-chain revert, and also collected as a blocker by preflightCreate before anything is sent. context carries resolvedFee next to maxAcceptedGasFee, so a raised default is distinguishable from a custom override the creator did not know they had.Not a retry-the-same-inputs failure: the bound exists so a creator can decline a fee that moved between planning and sending. Re-send with a higher bound, or wait for the fee to come down. Read useResolveGasFee first to show the current number; UINT96_MAX accepts whatever resolves, now and at every later re-plan. useResolveGasFee
MerkleUupsUnsupportedErrorA Merkle campaign create requested mode: "uups". A prior storage retype in the Merkle claimed-amount slot means an in-place UUPS upgrade could reinterpret existing storage and re-open settled claims.Static, client-side check with no RPC - independent of the factory's mutable defaultUpgradeable policy. Deploy Merkle campaigns as clones; only ECDSA campaigns may opt into UUPS.
NativeRescueRequiresZeroFeeErrorrescueNativeToken reverted NativeRescueRequiresZeroFee: the instance charges a per-claim gas fee, so the ETH it holds is fee revenue, not stranded funds.Use withdrawGasFee (FEE_COLLECTOR_ROLE) on a fee-charging campaign. rescueNativeToken only sweeps ETH from a zero-fee campaign. useWithdrawGasFee
NonStockWrapperErrorunwrappable: true was requested at create time for a token that does not probe as a stock ERC7984ERC20Wrapper (its underlying() call reverts, returns no data, or answers the zero address).claimAndUnwrap burns from the instance's pooled wrapper balance, so the delivered amount is bounded by the pool rather than the claimant's entitlement, a bound that is exact only for a stock wrapper. Use a stock ERC7984ERC20Wrapper for any unwrappable campaign, or set unwrappable: false. useAirdropConfig
PredictionDriftErrorThe factory's variant init-code hash changed between two observations bracketing a prediction's use - an implementation rotation (setEcdsaImplementation / setMerkleImplementation) landed mid-flight. Most visible inside planMerkleCampaign.The predicted address is stale and a Merkle campaign's ciphertexts were bound to it. Re-run planMerkleCampaign against a fresh prediction rather than reusing the drifted one. Creating with plan raises this before the send, once the live Merkle init-code hash no longer equals plan.initCodeHashAfter.
SaltCollisionErrorThe predicted (variant, mode, deployer, userSalt) address already holds code. Instance addresses commit only to (implementation, mode, deployer, userSalt) - the params struct is ignored, so two campaigns differing only in token, window, signer, or root still collide.Pick a new userSalt. Thrown by assertSaltAvailable before the create transaction is sent, so no gas is spent finding out. A create that raced the check reverts SaltAlreadyUsed on-chain and is reported as this same class; on that path the variant, mode, deployer and userSalt context fields may be absent.
SignatureExpiredErrorECDSA claim reverted SignatureExpired: block.timestamp is past the signature's deadline. Deliberately distinct from InvalidSignatureError, whose message would misdirect a developer to check SIGNER_ROLE membership.Re-sign with a later deadline via signClaimAuthorization. Pre-check with useIsSignatureValid before paying gas - it returns false once the deadline has passed. useIsSignatureValid
UnauthorizedRedirectErrorSomeone other than entry.account tried to redirect a Merkle payout's to address away from entry.account (the claim identity). Anyone may submit a claim, but to may only differ from account when account itself sends it.Raised client-side before the write, and mapped from the on-chain UnauthorizedRedirect revert. A relayer submitting on someone else's behalf must leave to at the default (or set it equal to account).
UnrecognisedAirdropErrorThe canonical factory's registry does not contain the instance address, so it is not an airdrop this SDK deployed. Unlike every other blocker here, it does not describe a claim that will fail - a claim against a look-alike SUCCEEDS and delivers nothing.Opt-in, and the only check here no revert makes for you: no write path consults the registry, so claim() against a look-alike still lands. Pass factory to preflightClaim, which collects this as a blocker rather than throwing it, or call factory.isAirdrop yourself. Use a factory address from the SDK's own registry, never one the instance names - a fake instance names a fake factory that answers true. A hit means stop, not retry. (A factory bound to a different chain than the instance is refused first, as InvalidArgumentError.) useIsAirdrop
UpgradeabilityNotAllowedErrorFactory create* reverted UpgradeabilityNotAllowed: the creator's effective upgradeability policy (factory default, or a per-creator override) is off for a mode: "uups" request.Check useEffectiveUpgradeable(creator) before offering a UUPS create in a UI, or fall back to mode: "clone". useEffectiveUpgradeable
Each class extends the SDK's base TokenOpsSdkError and carries the offending values under err.context — render specific messages instead of generic "transaction failed."Read the catch ladder

The shared cross-product error palette

@tokenops/sdk/fhe-airdrop re-exports the whole TokenOpsSdkError hierarchy from ../core/errors.js unchanged - the 37 classes below, the same ones vesting and disperse throw, plus the base predicate isTokenOpsSdkError(error). Prefer it over instanceof when the SDK might be duplicated in your bundle graph (monorepos with hoisting issues, ESM/CJS dual loads) - it narrows on a realm-global brand instead. Every class below extends TokenOpsSdkError and carries a stable error.code (the TokenOpsSdkErrorCode union - see types) plus a typed context payload, grouped below by where in the stack they fire.

TOKENOPS_UNEXPECTED_CONTRACT_RESPONSE is in the code union for a read that succeeded but returned a value this SDK cannot interpret - readDedupMode() throws it, with context.contractAddress and context.ordinal, for an unknown dedup ordinal. An exhaustive switch (err.code) with a never check needs a case for every code. Airdrop reverts also keep their arguments now: InvalidMerkleProof puts { leaf, merkleRoot } in context.value, and EndTimeInPast / InvalidDuration carry both timestamps.

Missing dependencies

Thrown before the contract is touched - a client was constructed or a method was called without something it needed.

ClassWhen thrownRecovery
MissingPublicClientErrorA method needing an on-chain read was called on a client built without a publicClient. context.method names the call.Construct the client with a publicClient (a viem PublicClient for the target chain) before calling read or preflight methods.
MissingWalletClientErrorA write (create, fund, claim, admin action) was called on a client with no walletClient configured. context.method names the call.Pass a walletClient at construction, or supply one via the hook's wagmi wallet client before invoking the mutation.
MissingAccountErrorA write needs a signing account and none could be resolved - the walletClient has no default account and none was passed as an override. context.method names the call.Pass account explicitly in the call args - every write method accepts a WriteAccountOverride - or connect a wallet client with a default account.
MissingEncryptorErrorA method that needs to encrypt a value (fund with a plaintext amount, encryptUint64 / encryptUint64Batch, campaign encryption) has no resolvable Encryptor. context.hint, when present, narrows what to configure.Pass an encryptor (or a lazy EncryptorSource) to the client or campaign function.
MissingClientErrorA React hook's headless client could not be constructed, typically because the wagmi publicClient isn't ready yet or the chain id has no resolvable address. context.hook and context.clientKind name which hook and client. Reads gate on enabled: !!client, so this only fires on the mutation path.Wait for wagmi's public client to be ready before calling the mutation.useAirdropConfig
DeploymentAddressUnavailableErrorThe SDK could not resolve a deployed contract address for this chain. context.reason says why: "chain-id-missing" (no chainId passed and publicClient.chain was undefined), "registry-not-deployed" (chain is known but this product isn't deployed there yet), or "registry-unknown-chain" (chain id absent from the registry entirely).Pass chainId explicitly, switch to a supported chain, or set context.overrideName's field (defaults to address) to point the client at a manual deployment address.
UnsupportedChainErrorA client was built for a chain the SDK does not support (airdrop v2 is deployed on chain 1 and chain 11155111). context.chainId, and context.method / context.hint when set. The /fhe-airdrop/react instance hooks no longer throw it from render: read hooks stay disabled and mutations reject with it.Switch the wallet and public client to mainnet or Sepolia, or pass address for a local deployment.
InvalidArgumentErrorAn argument failed a check before anything was sent. context.method, context.argument and context.reason name it; context.value is left out wherever the value is confidential. Raised, among others, by extendClaimWindow for a newEndTime less than 60 seconds after the latest block (argument "newEndTime"), by planInstanceRoleSplit for an assignment.admin equal to the caller, by signClaimAuthorization for a handle that is not 32 bytes, and for a factory read past the end (IndexOutOfBounds).Fix the named argument and retry - no on-chain state changed.

On-chain reverts

The contract reverted, and the SDK decoded the revert into a typed class carrying the offending values.

ClassWhen thrownRecovery
PausedErrorThe instance is paused - PAUSER_ROLE called pause(). context.method and context.contractAddress name the call and target.Read paused() before retrying, or wait for an admin to unpause().useAirdropPaused
AccessDeniedErrorOpenZeppelin AccessControl rejected the caller for a role-gated method. context.role is the bytes32 role selector when decodable from AccessControlUnauthorizedAccount.Map context.role back to a name with the client's role-constant getters, then check current membership before retrying with the right signer.useAirdropRoleMembers
OperatorNotApprovedErrorThe ERC-7984 token rejected this contract as a spender (ERC7984UnauthorizedSpender) - raised by fundAirdrop / createAndFund* when the funder never called setOperator. context.tokenAddress, context.holder, and context.spender identify the missing grant.Call setOperator with this contract as the spender before funding - see the operator prerequisite on the flows page.
InsufficientFeeErrorThe instance's gas fee (feeKind: "gas") or an encrypted token fee (feeKind: "token") did not match what the contract required. context.required / context.provided are populated only for the gas case - token amounts stay encrypted. preflightClaim no longer reports a short wallet this way; see InsufficientBalanceError.Read gasFee() fresh and re-attach it as value. The SDK does this automatically for airdrop claims, so this usually signals a stale hand-rolled call.
InsufficientBalanceErrorA balance was too low. context.balanceKind is "eth", "erc20" or "confidential", with requested / available when known. preflightClaim reports a claimant whose ETH is below the gas fee as balanceKind "eth" (requested the fee, available the balance); withdrawGasFee raises it for an over-request.Fund the wallet with at least context.requested wei, or request less. Code that branched on TOKENOPS_INSUFFICIENT_FEE for the short-wallet blocker needs TOKENOPS_INSUFFICIENT_BALANCE.usePreflightClaim
FeatureDisabledErrorThe instance was created with a feature off: claimAndUnwrap on a non-unwrappable campaign (TokenNotUnwrappable), extendClaimWindow without canExtendClaimWindow, setMerkleRoot on an immutable root. context.feature names it.Not retryable on this instance; read useAirdropConfig / useIsMerkleRootMutable before offering the action.useAirdropConfig
InvalidSignatureErrorAn ECDSA claim's signer lacks SIGNER_ROLE or the EIP-712 signature does not verify - including every voucher once the SIGNER_ROLE key is EIP-7702-delegated.Pre-check with isSignatureValid, which never reverts; re-sign from a key that holds SIGNER_ROLE.useIsSignatureValid
BatchTooLargeErrorA batch argument (parties, handles, recipients) exceeded the contract's configured maximum. context.max is a bigint - call Number(error.context.max) before interpolating.Split the batch into chunks no larger than context.max and issue multiple calls.
TransferFailedErrorA native ETH or ERC-20 transfer failed at the contract layer - e.g. a rescue recipient rejects receive(), or the underlying ERC-20 returned false. context.asset is "eth" or the token address.Check that the recipient can accept the asset; retry with a different recipient if it can't.
FheHandleNotAllowedErrorA contract entrypoint called FHE.isSenderAllowed(handle) on-chain and reverted - the caller has no ACL grant on this encrypted handle. The on-chain sibling of UserDecryptNotAllowedError below. Also how discloseHandleToParty reports a gate #1 failure, carrying the offending handle.Grant ACL access first (discloseHandleToParty / adminDiscloseBalanceToParty) before the call that consumes the handle. On disclosure specifically the grant must be PERSISTENT in the (caller, instance) context - a handle straight out of an FHE op carries only a transient allowance and no longer passes. Disclose handles that came from this client's encrypted views; their grants are persistent.useDiscloseHandleToParty
AlreadyInitializedErrorinitialize() was called a second time on a contract that only accepts one call. Not expected in normal SDK usage - the factory calls it exactly once per create*.Verify you aren't re-running a deploy script against an already-initialized clone; this usually signals a script bug, not a race.
ReentrancyErrorOpenZeppelin's ReentrancyGuardTransient blocked a nested call into a guarded method.Don't call back into the same instance from within a callback triggered by one of its own writes (e.g. a token hook); serialize the calls instead.
ContractRevertErrorThe generic fallback for any on-chain revert the SDK decodes but has no specific typed class for. context.revertName / context.revertSelector / context.revertArgs carry what could be decoded; if decoding failed entirely those stay undefined and context.rawRevertData holds the raw calldata.Branch on context.revertName to message specific reverts without importing the ABI. File an issue with the revert name so the SDK can add a typed class.
ReceiptEventNotFoundErrorExtracting an event from a transaction receipt found zero matches - typically a silent revert, a misrouted log, or (most often for FHE flows) an ACL Allowed grant that didn't fire because the entrypoint took an early return path. An airdrop create whose receipt lacks ConfidentialAirdropCreated or ComplianceManagerCloned throws it with a code; the hint says "The transaction reverted." when it did.Inspect the tx (context.txHash) on a block explorer; check context.hint if present for a narrower diagnosis.
ReceiptEventAmbiguousErrorReceipt-event extraction found more than one matching event from the target contract - a re-entrancy surprise or an SDK filter-shape bug, such as a create receipt that repeats ConfidentialAirdropCreated or ComplianceManagerCloned. The SDK refuses to guess which entry is authoritative.Not a retry candidate. Inspect the tx (context.txHash, context.count) on a block explorer and file a bug with the hash.
TokenOpsContractErrorGeneric contract-interaction error from SDK helpers that wrap writeContract + receipt-wait outside the typed revert-mapper path - setOperator / revokeOperator throw it for a receipt with status reverted, and for receipt-wait or isOperator read failures. A rejected prompt is WalletRejectedError and an input error InvalidArgumentError, not this. Re-exported from /fhe-airdrop/react. Code TOKENOPS_CONTRACT_REVERT.Inspect error.cause for the original viem error; the message and free-form context carry whatever the helper attached.
TokenOpsValidationErrorFree-form validation error from SDK helpers that don't have a more specific typed subclass yet. Shares the same code as InvalidArgumentError ("TOKENOPS_INVALID_ARGUMENT"). Re-exported from /fhe-airdrop/react.Read the message and error.context for the offending value; fix the input and retry - no on-chain state changed.

Wallet + RPC failures

Failures at the wallet or transport layer, before or instead of a contract revert.

ClassWhen thrownRecovery
WalletRejectedErrorThe wallet UI cancelled the request - the user clicked Reject, or the wallet bridge returned EIP-1193 code 4001.Safe to retry - the underlying transaction never reached the network. Re-prompt the user.
WalletChainMismatchErrorEvery SDK write refuses, before estimating, a wallet whose chain is not the public client's (TOKENOPS_WALLET_CHAIN_MISMATCH). The airdrop contracts share their addresses on mainnet and Sepolia, so without it a Sepolia public client paired with a mainnet wallet would simulate on one chain and send on the other. context.method names the write.Prompt the user to switch networks, or build the public and wallet clients for the same chain, before retrying.
NetworkErrorNetwork-level failure reaching the RPC endpoint - HTTP non-2xx, timeout, DNS, or socket close. context.statusCode is set when the underlying viem HttpRequestError exposed one.Retry with backoff; if it persists, check the RPC endpoint's health independently of this call.
InsufficientGasFundsErrorThe wallet account doesn't hold enough native balance to cover the transaction's gas limit - which the SDK pads by gasHeadroomPercent, though only the gas used is charged. Distinct from InsufficientFeeError, which fires when the contract itself enforces a fee.Fund the account with native currency for gas, then retry. A per-call gas or a lower gasHeadroomPercent lowers the reserved limit.
UnknownWriteFailureErrorFallback for a write failure the SDK couldn't classify against either an on-chain revert frame or a known viem error class.Inspect error.cause for diagnostics and file a bug - this is exactly the case the SDK wants reported.
UserRejectedSignatureErrorThe user cancelled the wallet's signature prompt - distinct from rejecting a transaction.Safe to retry; re-prompt for the signature.useSignClaimAuthorization
SigningFailedErrorWallet signature failed for a reason other than an explicit user rejection - timeout, wallet crash, malformed request.Retry the signature request; if it persists, check the wallet client's connection and chain id.useSignClaimAuthorization

Zama-SDK wrappers

Failures inside the FHE encryption / decryption / relayer path - the Zama relayer SDK's own errors, normalized into the same typed shape as everything else.

ClassWhen thrownRecovery
RelayerUnreachableErrorA network call to the Zama relayer failed - HTTP non-2xx, timeout, DNS. context.statusCode is set when exposed by the underlying error.Retry with backoff against the relayer; check the relayer endpoint's status if it persists.
EncryptionFailedErrorThe encryption flow (input-proof generation via the Zama relayer SDK) failed before any transaction was sent. fundAirdrop, the createAndFund creates, encryptCampaignAmounts and buildMerkleCampaign map Zama failures to this and RelayerUnreachableError instead of letting the raw @zama-fhe/sdk error escape.Check the encryptor's configuration (relayer URL, chain id) and retry; no on-chain state changed.
DecryptionFailedErrorThe decryption flow (user-decrypt or public-decrypt against the Zama relayer) failed.Retry the decrypt call; verify the handle is one you were actually granted access to.
AclNotPropagatedErrorThe relayer answered that the ACL grant has not reached the gateway yet (TOKENOPS_ACL_NOT_PROPAGATED, context.statusCode when known) - typically in the first minutes after addDelegate or a fresh grant. Retryable, unlike DecryptionFailedError.Retry with backoff. isAclPropagationError(error) returns true for it.useAddComplianceDelegate
UserDecryptNotAllowedErrorThe Zama ACL refused to issue a decryption response for this caller on the requested handle - fires before any chain interaction, typically because no FHE.allow(handle, sender) ever ran for this caller. The off-chain sibling of FheHandleNotAllowedError above.Confirm the handle was actually granted to this account (e.g. via adminDiscloseBalanceToParty or a claim flow that grants the caller); request a fresh grant if not.useAdminDiscloseBalanceToParty

The full code list, with the codes every product shares, is on the 2.0 error palette. For the catch-ladder pattern - product classes first, this palette as the generic fallback, SDK-level catches around both - see Concepts · Typed errors + recovery.