2.0 RC docsView 1.x docs
Concept · Guardrails

The twelve guardrails the SDK enforces

Contract behaviors that would silently produce a wrong result, caught before a transaction is sent.

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.

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#

RuleTriggerTyped errorWhat to do
No UUPS MerklecreateMerkleAirdrop({ mode: "uups" })MerkleUupsUnsupportedErrorThrown before any RPC call. There is no way to opt back in - use mode: "clone" for every Merkle campaign.
Stock wrappers onlyunwrappable: true at createNonStockWrapperErrorPreflight reads underlying() and blocks non-stock ERC7984ERC20Wrapper tokens. Only wrap stock, non-fee-taking wrappers when unwrappable is true.
UUPS policyAny create with mode: "uups"UpgradeabilityNotAllowedErrorPreflight 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 collisionpredict*AirdropAddress, then create*SaltCollisionErrorPreflight re-checks the predicted address's code before sending. Choose a userSalt unique per (variant, mode, deployer) - the SDK ships no derivation helper.
Create commitmentsEvery create*CreateCommitmentMismatchErrorEach 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 driftplanMerkleCampaign, and creating with planPredictionDriftErrorRe-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 paramspreflightCreateInvalidArgumentError (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 allowlistAny client constructionUnsupportedChainErrorRejects 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 exactnessclaim / 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 boundcreate* whose resolved fee exceeds maxGasFee() or maxAcceptedGasFeeInvalidArgumentError (gasFee), then GasFeeNotAcceptedErrorThe 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 pausewithdrawConfidential while unpaused inside the claim windowClawbackRequiresPauseErrorThe 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 genuinenesspreflightClaim({ factory }) - opt in by supplying the factoryUnrecognisedAirdropErrorAsks 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.

See also