Migrating airdrop v1 to v2
@tokenops/sdk 2.0.0 replaces /fhe-airdrop outright - no compatibility shim, no side-by-side import path.
v2.0.0-rc.1 targets tokenops-fhe-airdrop-v2 at commit 6b0edcc, which carries the external-audit fixes and is the canonical deployment on Ethereum mainnet and Sepolia. This guide covers /fhe-airdrop; the removals in /fhe-vesting, /fhe-disperse and /fhe are in upgrading from 1.x. Already on a 2.0 alpha? Read upgrading from a 2.0 alpha instead.
A replacement, not a version bump
v1's contracts, types, clients, and errors are deleted from the package. v2 takes the same subpath name with a different shape underneath - there is no way to import both from one install.
What this means for your code
Every v1 symbol either has no v2 successor, or has one with a different name, signature, or both. A partial migration will not typecheck - a stale import fails at pnpm typecheck, not at runtime.
Install the release candidate
pnpm add @tokenops/sdk@next @zama-fhe/sdk@~3.6.0
# React hooks:
pnpm add @zama-fhe/react-sdk@~3.6.0
# "next" resolves v2.0.0-rc.1 today; "latest" stays on 1.6.0The v1 factory is EOL in the SDK, not on-chain
Nothing about the v1 factory was paused, upgraded, or migrated. What changed is that @tokenops/sdk 2.0.0 has no code path that can address it.
The v1 Sepolia factory stays live and fully usable for reading state, signing claims, and withdrawing:
If you still need that code path, pin @tokenops/sdk@^1.6.0 for it - it is fully supported for critical fixes. See the support window below for the exact terms.
Symbol-by-symbol delta
Every surface that changed shape between v1 and v2, side by side.
| Surface | v1 | v2 |
|---|---|---|
| EIP-712 typehash | Claim(address recipient, bytes32 encryptedAmount) | Claim(address recipient,bytes32 encryptedAmount,bytes32 dedupId,uint256 deadline) |
| Claim signer role | DEFAULT_ADMIN_ROLE | Dedicated SIGNER_ROLE |
| Factory create | createConfidentialAirdrop / …AndGetAddress | createEcdsaAirdrop / createMerkleAirdrop, one per variant |
| Factory create + fund | createAndFundConfidentialAirdrop(AndGetAddress) | createAndFundEcdsaAirdrop / createAndFundMerkleAirdrop |
| Factory fund | fundConfidentialAirdrop({ token, params, userSalt, deployer, gasFee, amount }) | fundAirdrop({ airdrop, amount }) - takes the deployed address directly |
| Implementation getter | implementation() / airdropImplementation() | ecdsaImplementation() + merkleImplementation() |
| Address prediction | Commits gasFee via getInitCodeHash | Fee removed; predictEcdsaAirdropAddress / predictMerkleAirdropAddress |
| Instance client | ConfidentialAirdropClient, one class for the only variant | AirdropBaseClient + EcdsaAirdropClient / MerkleAirdropClient |
| Instance create helper | createConfidentialAirdropClient | createEcdsaAirdropClient / createMerkleAirdropClient / createAirdropBaseClient |
| Replay model | claimedSignatures(hash) / isSignatureClaimed - a signature-hash map | dedupMode: perAddress / perDedupId / both / none |
| Deployment shell | Clone only | Clone or UUPS via mode - Merkle rejects "uups" outright |
| Lifecycle naming | setPaused(bool), withdraw, withdrawOtherToken(Confidential) | pause()/unpause(), withdrawConfidential, rescueERC20/rescueOtherConfidentialToken |
| Merkle variant | Absent | MerkleAirdropClient + buildMerkleCampaign / planMerkleCampaign / rotateMerkleRoot |
| Compliance manager | Absent | ComplianceManagerClient, wired per-instance, returned from every create call |
| Factory registry | Absent | airdropCount() / airdropAt(i) / airdrops(offset, limit) / complianceManagerOf |
| claimAndUnwrap | Absent | Present on both variants (guarded - exact only for a stock wrapper) |
| Payout redirect (to) | Absent - tokens always land on the claiming account | claim({ to }) - ECDSA redirects payout only; Merkle: only the claim identity may redirect |
| Merkle claim identity | Absent | claim({ entry }) names entry.account as the identity; anyone may submit it and the account is paid unless it submits itself and redirects with to |
| getClaimedAmount | Absent | Merkle-only: cumulative delivered total per account |
| Role granularity | One admin field at create; ~7 OZ role constants | No admin field (factory injects admin = msg.sender); grantInstanceRoles / planInstanceRoleSplit |
| Guardrails | None SDK-enforced beyond input validation | SaltCollisionError, PredictionDriftError, MerkleUupsUnsupportedError, NonStockWrapperError, UpgradeabilityNotAllowedError, GasFeeNotAcceptedError, CreateCommitmentMismatchError, UnrecognisedAirdropError |
| Deployments | Sepolia only | Canonical factory at the same addresses on mainnet and Sepolia |
| Gas limit | Wallet estimate | Estimate plus gasHeadroomPercent (client / hook option), or a per-call gas |
| Safe signers | Absent | waitForReceipt: false on the four creates (beta), encodeInstanceRoleSplit for the role split |
Params restructuring
AirdropParams splits into a shared CommonAirdropParams nested under each variant's params, with two renames, one removal, and three additions.
interface AirdropParams {
token: Address;
startTimestamp: number;
endTimestamp: number;
canExtendClaimWindow: boolean;
admin: Address;
}interface CommonAirdropParams {
token: Address;
startTime: number; // renamed from startTimestamp
endTime: number; // renamed from endTimestamp
canExtendClaimWindow: boolean;
unwrappable: boolean; // new - only exact for a stock ERC7984ERC20Wrapper
complianceAdmin: Address; // new - zero means msg.sender
maxAcceptedGasFee: bigint; // new, REQUIRED - the most you accept being
// frozen in. UINT96_MAX accepts anything.
// `admin` is gone. The factory injects admin = msg.sender.
// Split roles afterwards with grantInstanceRoles.
}
interface EcdsaAirdropParams {
common: CommonAirdropParams;
signer: Address;
dedupMode: "perAddress" | "perDedupId" | "both" | "none";
}
interface MerkleAirdropParams {
common: CommonAirdropParams;
merkleRoot: Hex;
isMerkleRootMutable: boolean;
}startTimestamp / endTimestamp rename to startTime / endTime. admin is removed entirely - the factory injects admin = msg.sender at create time, and you split roles afterward with grantInstanceRoles. The three new fields are unwrappable, complianceAdmin and maxAcceptedGasFee, all required.
The EIP-712 claim typehash gained two fields
A v1 signature will not verify against a v2 instance, or vice versa. Re-issue authorizations from the v2 signing surface; do not port stored signatures.
// v1 - 2 fields, signed by DEFAULT_ADMIN_ROLE
CLAIM_TYPEHASH = keccak256(
"Claim(address recipient,bytes32 encryptedAmount)"
);import { CLAIM_TYPEHASH, CLAIM_TYPES } from "@tokenops/sdk/fhe-airdrop";
// v2 - 4 fields, signed by a dedicated SIGNER_ROLE holder
// [{"name":"recipient","type":"address"},{"name":"encryptedAmount","type":"bytes32"},{"name":"dedupId","type":"bytes32"},{"name":"deadline","type":"uint256"}]
console.log(CLAIM_TYPEHASH);
// "0x65456c4a6d0e06d0856716bb098b363916c3f9cd290392f9899e57000dbe7135"The claim signer also changes: v1 accepted a signature from anyone holding DEFAULT_ADMIN_ROLE; v2 requires a dedicated SIGNER_ROLE holder, checked before signature recovery runs at all.
DEPLOYED_ADDRESSES.fheAirdrop reshaped
One key becomes four, registered on mainnet and Sepolia at the same addresses, so chainsWithDeployment("fheAirdrop") is [1, 11155111].
// v1 - one key
fheAirdrop: {
confidentialAirdropFactory: {
[sepolia.id]: "0xbE6A3B78B36684fFee48De77d47Bc3393F5Acd4c",
},
}// v2 - reshaped from one key to four, the same on every chain:
// Ethereum mainnet (1), Sepolia (11155111)
fheAirdrop: {
airdropFactory: { [mainnet.id]: "0x3c6a7Ae9f03a7c2c30938bae94a75491027d470C", [sepolia.id]: "0x3c6a7Ae9f03a7c2c30938bae94a75491027d470C" },
ecdsaImplementation: { [mainnet.id]: "0xEed7eBCe9643be03dB785bFFC8cab2251c0BB823", [sepolia.id]: "0xEed7eBCe9643be03dB785bFFC8cab2251c0BB823" },
merkleImplementation: { [mainnet.id]: "0xBB4e333d8767242e329Fd6407618a4306c28a501", [sepolia.id]: "0xBB4e333d8767242e329Fd6407618a4306c28a501" },
complianceManagerImplementation: { [mainnet.id]: "0x3FC9c4EE1D8cE58eA901653190CD1e63510d1c66", [sepolia.id]: "0x3FC9c4EE1D8cE58eA901653190CD1e63510d1c66" },
}getFheAirdropFactoryAddress / requireFheAirdropFactoryAddress keep their names and now resolve the v2 factory - if you called these accessors directly rather than constructing a client, the signature is unchanged but the address returned points at a different contract. An instance created through a 2.0 prerelease factory is not in the canonical factory's registry; create new campaigns through the canonical one.
The encryptor is built for Zama 3.6
The optional @zama-fhe/sdk and @zama-fhe/react-sdk peers move from ^3.0.0 to ~3.6.0. Upstream 3.6 replaces the relayer classes and config presets with createConfig plus ZamaSDK.
import { createSepoliaEncryptor } from "@tokenops/sdk/fhe";
import { createConfidentialAirdropFactoryClient } from "@tokenops/sdk/fhe-airdrop";
const encryptor = await createSepoliaEncryptor({ rpcUrl: process.env.RPC_URL });
const factory = createConfidentialAirdropFactoryClient({ publicClient, walletClient, encryptor });
// ...
encryptor.terminate();import { useZamaSDK } from "@zama-fhe/react-sdk";
import { useFundAirdrop } from "@tokenops/sdk/fhe-airdrop/react";
const sdk = useZamaSDK();
// Pass the ZamaSDK itself: sdk.relayer has no encrypt in 3.6.
const fund = useFundAirdrop({ encryptor: () => sdk });A browser app without the React SDK uses createSepoliaEncryptorWeb from @tokenops/sdk/fhe/web. The Zama-hosted mainnet relayer needs an API key, passed as auth from server code only; see Relayer API key, Encryptors and Zama SDK 3.0 to 3.6.
The 1.x support window
1.x is not withdrawn by this release. It simply stops receiving new features.
Critical fixes for 90 days after 2.0.0 GA
Security and correctness fixes only, for 90 days after 2.0.0 reaches latest. After that window, 1.x receives no further fixes of any kind. No feature work has landed on 1.x since 2026-09-05, when v2 development began - every new capability lands on the 2.x line only, from that date forward. Consumers who don't import /fhe-airdrop are unaffected and can move on their own schedule.
API frozen, fixes only
Release candidates ship on the next dist-tag. A later candidate carries fixes, not API changes, with one exception: the receipt-free and Safe create surface of /fhe-airdrop is @beta and may still change until it is validated through a real Safe. The airdrop addresses are the canonical deployment. 2.0.0 (GA) then moves to latest, and from GA onward 2.x follows the SDK's normal SemVer + Conventional Commits model. The earlier 2.0.0-alpha builds had no stability promise; see upgrading from a 2.0 alpha.