This page describes the on-chain surface from the SDK's point of view: what each client class actually calls, not a full Solidity walkthrough. For the source itself, see tokenops-fhe-airdrop-v2 at 6b0edcc, the exact commit every vendored ABI and address below resolves against.
The contract set#
AirdropFactory is a single, immutable singleton - never a proxy, never upgraded. It creates campaign instances of two kinds, ECDSAConfidentialAirdrop (signature-authorized claims) and MerkleConfidentialAirdrop (proof-authorized claims), and wires a fresh ComplianceRoleManager clone alongside each one in the same transaction. The SDK's ConfidentialAirdropFactoryClient wraps the factory; EcdsaAirdropClient / MerkleAirdropClient wrap an instance; and ComplianceManagerClientwraps that instance's manager clone.
A separate CREATE3Deployer puts all three implementations and the factory itself at deterministic, bytecode-independent addresses, which is why the canonical deployment sits at the same addresses on mainnet and Sepolia. It was deployed under salt epoch 0, with build fingerprint 0x56ff…bb48.
Clone or UUPS, chosen per instance#
Every campaign instance is either an EIP-1167 minimal-proxy clone (cheap, never upgradeable) or a UUPS proxy (upgradeable via UPGRADER_ROLE) - selected with mode: "clone" | "uups" on createEcdsaAirdrop / createMerkleAirdrop. UUPS is opt-in and factory-gated: a creator needs an affirmative upgradeability policy on the factory, or the create reverts. The SDK preflights this and throws UpgradeabilityNotAllowedError before sending anything. Rotating an implementation pointer (IMPL_MANAGER_ROLE) only affects instances created afterward - clones hardcode their implementation in bytecode, and UUPS proxies keep their own impl slot until explicitly upgraded.
Merkle campaigns are the one case with no opt-in available at all: the SDK refuses mode: "uups" for every Merkle create, throwing MerkleUupsUnsupportedError client-side. See “Security-relevant behaviours” below for why.
Deployed addresses#
The canonical deployment, recorded at contracts commit 6b0edcc, carries the external-audit fixes and is live at the same addresses on Ethereum mainnet (1) and Sepolia (11155111). The table reads DEPLOYED_ADDRESSES.fheAirdrop from the installed build, and chainsWithDeployment("fheAirdrop") returns [1, 11155111], so every airdrop client resolves its factory on either chain without an explicit address. See Deployments for the other products.
| Contract | Ethereum mainnet | Sepolia | Role |
|---|---|---|---|
AirdropFactory airdropFactory | 0x3c6a…470C | 0x3c6a…470C | Singleton that creates every campaign instance and its compliance manager. |
ECDSAConfidentialAirdrop ecdsaImplementation | 0xEed7…B823 | 0xEed7…B823 | Implementation cloned for signature-authorized campaigns. |
MerkleConfidentialAirdrop merkleImplementation | 0xBB4e…a501 | 0xBB4e…a501 | Implementation cloned for Merkle-proof campaigns. |
ComplianceRoleManager complianceManagerImplementation | 0x3FC9…1c66 | 0x3FC9…1c66 | Implementation cloned once per instance, wired at create time. |
import { AIRDROP_CONTRACTS_COMMIT } from "@tokenops/sdk/fhe-airdrop";
// "6b0edcc89c65f59d9c9f4e58d00b478253b69083"
// Confirms which contract generation your installed build speaks to -
// read it at runtime rather than assuming your pinned version matches
// what you last checked.
console.log(AIRDROP_CONTRACTS_COMMIT);Claim paths at the contract level#
ECDSA - signature-authorized#
claim(to, inputAmount, inputProof, dedupId, deadline, signer, signature) is always keyed to msg.sender - there is no separate claim-identity parameter. signer must hold SIGNER_ROLE; the EIP-712 digest is checked against both an EOA (ecrecover) and an ERC-1271 contract signer through one code path. Replay protection is dedupMode-based (perAddress / perDedupId / both / none), fixed per campaign at create time. to only redirects payout delivery - a non-empty FHE input proof and every replay guard stay bound to msg.sender regardless of to. An empty proof skips coprocessor verification and accepts only a zero handle or one the claimant and instance already hold ACL on; the voucher still fixes the identity, handle, dedupId and deadline. The SIGNER_ROLE key must never be EIP-7702-delegated - delegation switches verification to ERC-1271 and every outstanding voucher fails.
Merkle - proof-authorized, explicit claim identity#
claim(account, to, inputAmount, inputProof, merkleProof) takes a mandatory account that is the actual claim identity: the leaf and the cumulative running total (claimedAmount[account]) are keyed on it, independent of whoever calls the function. Submission is deliberately ungated on the deployed contracts - there is no allowlist or self-only check. The contract verifies every leaf's input against the instance itself - (airdrop, airdrop)- so any holder of an entry can submit it, from any address. That is what makes relayed batch distribution possible without building a separate roster: a relayer submits many recipients' leaves under its own address, while each leaf still pays and advances only its own account. Only account itself may set to to a different payout address; anyone else attempting that reverts UnauthorizedRedirect before any FHE work runs. Whoever submits first settles the leaf, so a later claim by the recipient pays an encrypted zero and still costs the fee.
On both variants, claimAndUnwrap is scoped out of the identity/relay concept entirely - it takes no account parameter and is always msg.sender-keyed, since converting a confidential allocation into a plain ERC-20 transfer is irreversible and is deliberately made structurally unrepresentable for a third party, not merely rejected. Both variants also expose a fee-free getClaimAmountpreview that writes no accounting - repeatable on Merkle, where accounting is cumulative, but on ECDSA repeatable only while the claim is still available: once a replay guard is consumed the preview raises the consuming path's own error in the consuming path's own order. Ask isSignatureValid whether an ECDSA claim is still open; it never reverts and costs nothing.
Roles#
| Role | Scope | Gates |
|---|---|---|
| DEFAULT_ADMIN_ROLE | Every instance | Administers every other instance role except FEE_COLLECTOR_ROLE, which administers itself. The sole holder can never be revoked. |
| PAUSER_ROLE | Every instance | pause() / unpause() - halts claiming. |
| WINDOW_ADMIN_ROLE | Every instance | extendClaimWindow() - forward-only, only if the campaign allowed it at create. |
| TREASURY_ROLE | Every instance | withdrawConfidential() - moves the entire encrypted pool balance out. Only while paused, or outside the claim window (else ClawbackRequiresPause). |
| RESCUER_ROLE | Every instance | rescueERC20() / rescueOtherConfidentialToken() - recovers stray tokens, never the airdrop's own. rescueNativeToken() - sweeps ETH, only on a zero-fee campaign. |
| DISCLOSURE_ADMIN_ROLE | Every instance | Admin balance disclosure, and bypasses the sender-ACL check on the raw-handle disclosure functions. |
| UPGRADER_ROLE | Every instance | Authorizes a UUPS upgrade. Inert on clone-mode instances - the proxy layer rejects it regardless. |
| SIGNER_ROLE | ECDSA only | Authorizes EIP-712 claim digests. Checked before signature recovery, so an invalid signer never reaches signature verification. |
| MERKLE_ADMIN_ROLE | Merkle only | setMerkleRoot() - rotates the published root as a top-up, never a destructive reset. |
| FEE_COLLECTOR_ROLE | Every instance, self-administered | withdrawGasFee() - administers itself, not DEFAULT_ADMIN_ROLE, so an existing holder must grant a new one. |
| FEE_MANAGER_ROLE | AirdropFactory | setDefaultGasFee() / setCustomFee() / disableCustomFee() for future campaigns, and setFeeCollector() - the collector seeded on instances created afterwards. |
| IMPL_MANAGER_ROLE | AirdropFactory | Rotates the ECDSA/Merkle implementation pointers for future clones - existing clones keep their bytecode. |
| COMPLIANCE_WIRING_ROLE | AirdropFactory | Wires the compliance-manager implementation, the platform delegate, and compliance policy defaults. |
| UPGRADE_MANAGER_ROLE | AirdropFactory | Sets the default and per-creator UUPS-eligibility policy that createEcdsaAirdrop / createMerkleAirdrop check. |
| DELEGATION_ADMIN_ROLE | ComplianceRoleManager clone | addDelegate() / revokeDelegate() - manages who else can read the campaign's compliance-delegated balance. |
Security-relevant behaviours#
Why the Merkle claim path bypasses FHE.fromExternal#
The FHE library's normal input-verification helper grants the caller a transient ACL allowance on the handle it just verified - a deliberate convenience for the common case where the caller is entitled to that handle. Third-party Merkle submission breaks that assumption: a relayer submitting for someone else's leaf would otherwise pick up a transient allowance on a handle it has no entitlement to, which could be upgraded into a persistent private read or a public decryption within the same transaction. The fix, already live on the deployed contracts, has MerkleConfidentialAirdropcall the coprocessor's input verifier directly and omit that trailing ACL grant, so whoever submits never receives any allowance - persistent or transient - on the amount it relays. No ABI change, no new entrypoint; only the library wrapper's side-effect grant is removed. The ECDSA path is unaffected, since its claimant is always its own authorized party.
Two accepted, unfixed items#
claimAndUnwrapburns from the instance's own pooled wrapper balance, so the delivered amount is bounded by the pool rather than by the claimant's entitlement. That bound is exact only under a stock ERC7984ERC20Wrapper- a hooked or fee-taking wrapper can make the amount actually released diverge from what the airdrop's own accounting assumed. This is accepted, not fixed at the contract level; the SDK's guardrail preflights the token and throws NonStockWrapperError rather than letting an under-informed campaign go live.
Separately, an earlier storage layout named the Merkle per-account counter claimedLeaf before it was retyped to claimedAmount, and the two share the same ERC-7201 namespaced slot. An in-place UUPS upgrade of a Merkle instance across that change would reinterpret existing storage as the new type, resetting every account's counter and re-opening every settled claim. This is why UUPS is refused for Merkle campaigns outright rather than merely discouraged - see MerkleUupsUnsupportedError above.