Two contract behaviors on this line would produce a wrong result rather than a revert if left undocumented: an in-place UUPS upgrade across a Merkle storage retype, and a fee-taking wrapper under claimAndUnwrap. The SDK enforces both as guardrails rather than merely writing them down, alongside nine more checks that catch an on-chain revert before it costs gas. The last, the genuineness check, is the odd one out and the reason the set is worth reading as a whole: every other rule here describes a claim or create that would fail, while a claim against a look-alike instance succeeds and delivers nothing. No revert will ever catch that one for you. All but one run client-side, throw a typed TokenOpsSdkError subclass, and are collected (not thrown one-at-a-time) by preflightCreate / preflightClaim. The exception is clawback-requires-pause: the contract enforces it, and the SDK maps the revert to a typed error.
The table#
| Rule | Trigger | Typed error | What to do |
|---|---|---|---|
| No UUPS Merkle | createMerkleAirdrop({ mode: "uups" }) | MerkleUupsUnsupportedError | Thrown before any RPC call. There is no way to opt back in - use mode: "clone" for every Merkle campaign. |
| Stock wrappers only | unwrappable: true at create | NonStockWrapperError | Preflight reads underlying() and blocks non-stock ERC7984ERC20Wrapper tokens. Only wrap stock, non-fee-taking wrappers when unwrappable is true. |
| UUPS policy | Any create with mode: "uups" | UpgradeabilityNotAllowedError | Preflight reads effectiveUpgradeable(creator) and fails before the transaction, not as a burnt one. Ask whoever holds UPGRADE_MANAGER_ROLE to opt your address in first. |
| Salt collision | predict*AirdropAddress, then create* | SaltCollisionError | Preflight re-checks the predicted address's code before sending. Choose a userSalt unique per (variant, mode, deployer) - the SDK ships no derivation helper. |
| Create commitments | Every create* | CreateCommitmentMismatchError | Each create commits to the instance address, the compliance-manager implementation and the compliance delegate; the factory reverts before consuming the salt if any drifted. The SDK quotes all three at send time - pin any of them with expected. preflightCreate reports a pinned field that differs from the fresh quote as a blocker, and returns the commitments it would send. |
| Prediction drift | planMerkleCampaign, and creating with plan | PredictionDriftError | Re-reads the factory's Merkle init-code hash before and after the campaign build. Fires if an implementation rotation landed mid-build - discard the result and start over. Passing the plan to createMerkleAirdrop repeats the check right before the send against plan.initCodeHashAfter, and also refuses a merkleRoot or expected.airdrop that disagrees with the plan (InvalidArgumentError, before any RPC). The check itself is the free function assertPredictionFresh({ variant, initCodeHashBefore, initCodeHashAfter, predictedAddress?, method }), a pure comparison exported from advanced/index.ts for anyone driving prediction by hand outside planMerkleCampaign. |
| Initializer params | preflightCreate | InvalidArgumentError (blocker, naming the field) | Blocks params the factory or the instance initializer would revert on: a zero token, an endTime less than 60 seconds past the latest block's timestamp (the chain clock, not the local one - one extra getBlock read), startTime after or equal to endTime, a zero merkleRoot on an immutable-root campaign, a zero ECDSA signer. The creates refuse the same params before sending, since a simulate alone passes an endTime that has lapsed by the block that mines it. |
| Chain allowlist | Any client construction | UnsupportedChainError | Rejects chain ids outside {1, 11155111, 31337}. The canonical deployment is registered on chain 1 and chain 11155111; chain 31337 needs an explicit address for a local deployment. React instance hooks report it through the hook (disabled reads, rejecting mutations) instead of throwing during render. |
| Fee exactness | claim / claimAndUnwrap when gasFee() != 0 | (handled, not thrown) | The fee is read fresh and attached as value automatically on every write. You never source or guess it yourself. |
| Fee ceiling, then accepted-fee bound | create* whose resolved fee exceeds maxGasFee() or maxAcceptedGasFee | InvalidArgumentError (gasFee), then GasFeeNotAcceptedError | The factory checks its own maxGasFee ceiling first, and so do the create methods and preflightCreate: a fee above both reports the ceiling. Below the ceiling, the creator's declared maxAcceptedGasFee applies; the factory resolves their fee at execution time and reverts above it. The error carries the resolved fee next to the declared bound, so a raised default is distinguishable from a custom override. preflightCreate range-checks the bound before any ABI-dependent read. |
| Clawback requires pause | withdrawConfidential while unpaused inside the claim window | ClawbackRequiresPauseError | The contract refuses to pull the pool out from under live claims. Pause first (PAUSER_ROLE), or run the clawback before startTime or after endTime. |
| Airdrop genuineness | preflightClaim({ factory }) - opt in by supplying the factory | UnrecognisedAirdropError | Asks the canonical factory's registry whether it deployed the instance. Opt-in, and the only rule here no write path enforces. Address the check to a factory from the SDK's own address registry, never one the instance names. AirdropRegistry carries chainId, and a factory bound to another chain is refused before the registry is consulted - as InvalidArgumentError, since a registry answer from over there proves nothing either way. |
The two "wrong result, not revert" hazards#
claimAndUnwrapburns from the airdrop instance's own pooled ERC7984ERC20Wrapper balance rather than from any per-claimant sub-balance, so the delivered amount is bounded by the pool's balance rather than by the claimant's own outstanding amount. That bound is exact under a stock wrapper; a hooked or fee-taking wrapper can throw the accounting off, crediting a claimant against tokens that are not theirs - the stock-wrappers-only guardrail exists to keep that assumption true.
A Merkle campaign upgraded in place across the claimedLeaf → claimedAmountERC-7201 storage retype would reinterpret existing storage as the new type, resetting every account's claimed counter and re-opening every settled claim. The no-UUPS-Merkle guardrail is why the SDK refuses mode: "uups" for Merkle campaigns outright - see upgradeability for the storage-layout reasoning in full.