2.0 RC docsView 1.x docs
Resources · Migrating to v2

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 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.

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.

No compatibility shim

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

shell
bash
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.0
Still operating a v1 campaign?

The 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.

Reference

Symbol-by-symbol delta

Every surface that changed shape between v1 and v2, side by side.

Surfacev1v2
EIP-712 typehashClaim(address recipient, bytes32 encryptedAmount)Claim(address recipient,bytes32 encryptedAmount,bytes32 dedupId,uint256 deadline)
Claim signer roleDEFAULT_ADMIN_ROLEDedicated SIGNER_ROLE
Factory createcreateConfidentialAirdrop / …AndGetAddresscreateEcdsaAirdrop / createMerkleAirdrop, one per variant
Factory create + fundcreateAndFundConfidentialAirdrop(AndGetAddress)createAndFundEcdsaAirdrop / createAndFundMerkleAirdrop
Factory fundfundConfidentialAirdrop({ token, params, userSalt, deployer, gasFee, amount })fundAirdrop({ airdrop, amount }) - takes the deployed address directly
Implementation getterimplementation() / airdropImplementation()ecdsaImplementation() + merkleImplementation()
Address predictionCommits gasFee via getInitCodeHashFee removed; predictEcdsaAirdropAddress / predictMerkleAirdropAddress
Instance clientConfidentialAirdropClient, one class for the only variantAirdropBaseClient + EcdsaAirdropClient / MerkleAirdropClient
Instance create helpercreateConfidentialAirdropClientcreateEcdsaAirdropClient / createMerkleAirdropClient / createAirdropBaseClient
Replay modelclaimedSignatures(hash) / isSignatureClaimed - a signature-hash mapdedupMode: perAddress / perDedupId / both / none
Deployment shellClone onlyClone or UUPS via mode - Merkle rejects "uups" outright
Lifecycle namingsetPaused(bool), withdraw, withdrawOtherToken(Confidential)pause()/unpause(), withdrawConfidential, rescueERC20/rescueOtherConfidentialToken
Merkle variantAbsentMerkleAirdropClient + buildMerkleCampaign / planMerkleCampaign / rotateMerkleRoot
Compliance managerAbsentComplianceManagerClient, wired per-instance, returned from every create call
Factory registryAbsentairdropCount() / airdropAt(i) / airdrops(offset, limit) / complianceManagerOf
claimAndUnwrapAbsentPresent on both variants (guarded - exact only for a stock wrapper)
Payout redirect (to)Absent - tokens always land on the claiming accountclaim({ to }) - ECDSA redirects payout only; Merkle: only the claim identity may redirect
Merkle claim identityAbsentclaim({ entry }) names entry.account as the identity; anyone may submit it and the account is paid unless it submits itself and redirects with to
getClaimedAmountAbsentMerkle-only: cumulative delivered total per account
Role granularityOne admin field at create; ~7 OZ role constantsNo admin field (factory injects admin = msg.sender); grantInstanceRoles / planInstanceRoleSplit
GuardrailsNone SDK-enforced beyond input validationSaltCollisionError, PredictionDriftError, MerkleUupsUnsupportedError, NonStockWrapperError, UpgradeabilityNotAllowedError, GasFeeNotAcceptedError, CreateCommitmentMismatchError, UnrecognisedAirdropError
DeploymentsSepolia onlyCanonical factory at the same addresses on mainnet and Sepolia
Gas limitWallet estimateEstimate plus gasHeadroomPercent (client / hook option), or a per-call gas
Safe signersAbsentwaitForReceipt: false on the four creates (beta), encodeInstanceRoleSplit for the role split
Params

Params restructuring

AirdropParams splits into a shared CommonAirdropParams nested under each variant's params, with two renames, one removal, and three additions.

v1
v1 AirdropParams
ts
interface AirdropParams {
  token: Address;
  startTimestamp: number;
  endTimestamp: number;
  canExtendClaimWindow: boolean;
  admin: Address;
}
v2
v2 CommonAirdropParams + variants
ts
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.

Signing

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
v1 typehash
ts
// v1 - 2 fields, signed by DEFAULT_ADMIN_ROLE
CLAIM_TYPEHASH = keccak256(
  "Claim(address recipient,bytes32 encryptedAmount)"
);
v2
@tokenops/sdk/fhe-airdrop
ts
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.

Addresses

DEPLOYED_ADDRESSES.fheAirdrop reshaped

One key becomes four, registered on mainnet and Sepolia at the same addresses, so chainsWithDeployment("fheAirdrop") is [1, 11155111].

v1
v1 DEPLOYED_ADDRESSES
ts
// v1 - one key
fheAirdrop: {
  confidentialAirdropFactory: {
    [sepolia.id]: "0xbE6A3B78B36684fFee48De77d47Bc3393F5Acd4c",
  },
}
v2
v2 DEPLOYED_ADDRESSES
ts
// 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.

Zama peers

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.

Node
@tokenops/sdk/fhe
ts
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();
React
@tokenops/sdk/fhe-airdrop/react
tsx
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.

Support policy

The 1.x support window

1.x is not withdrawn by this release. It simply stops receiving new features.

1.x

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.

2.0.0 release candidates

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.