A Merkle campaign is built off chain and published as one 32-byte root. buildMerkleCampaign is the one-call version; encryptCampaignAmounts + buildMerkleTree is the same work split in two, for when the ciphertexts come from somewhere else.
The mutable-root path#
When the instance already exists, encrypt against its real address and publish with setMerkleRoot:
import {
createMerkleAirdropClient,
buildMerkleCampaign,
} from "@tokenops/sdk/fhe-airdrop";
const { root, entries } = await buildMerkleCampaign({
instance: airdrop,
recipients: [{ recipient: alice, cumulativeTotal: 3_000_000n }],
encryptor,
});
const merkle = createMerkleAirdropClient({ publicClient, walletClient, address: airdrop });
await merkle.setMerkleRoot({ newRoot: root });
// Distribute `entries` out of band. The chain only ever stores `root`.rotateMerkleRoot does the build-then-publish in one call, and publishes the new root last, so a failed rebuild never touches the live campaign.
The immutable-root path#
An isMerkleRootMutable: false campaign needs its root baked in at createMerkleAirdrop time - before the instance exists. planMerkleCampaign predicts the not-yet-deployed address via the factory, encrypts against that predicted address, builds the tree, then re-reads the factory's Merkle init-code hash and compares it against the value read before the build. See PredictionDriftError on the guardrails page for what happens if an implementation rotation lands mid-build.
Create from the plan#
Every entry in a PlannedCampaign is bound to plan.predictedAddress, so the create has to land exactly there. Pass the plan itself as plan on createMerkleAirdrop or createAndFundMerkleAirdrop:
import { zeroHash } from "viem";
import { planMerkleCampaign } from "@tokenops/sdk/fhe-airdrop";
const params = {
common, // token, window, fee bound, ...
merkleRoot: zeroHash, // the plan supplies the real root below
isMerkleRootMutable: false,
};
const plan = await planMerkleCampaign({
factory,
params,
mode: "clone",
creator: account.address,
userSalt,
recipients,
encryptor,
});
await factory.createMerkleAirdrop({
params: { ...params, merkleRoot: plan.root },
mode: "clone",
userSalt,
plan, // pins the address, checks the root, refuses after an implementation rotation
});- It pins
expected.airdroptoplan.predictedAddress, so the factory reverts rather than deploying anywhere the leaves are not bound to. - A
params.merkleRootthat differs fromplan.root, or anexpected.airdropthat differs from the prediction, throwsInvalidArgumentErrorbefore any RPC. - If the factory's live Merkle init-code hash no longer equals
plan.initCodeHashAfter, the send is refused withPredictionDriftError.
preflightCreate reports the same conditions as blockers, so a UI can show them before the wallet prompt.
One roster, both claim paths#
Every campaign builder encrypts each leaf's cumulative total against (airdrop, airdrop) - the instance itself, not any recipient or relayer. The contract verifies each input against the airdrop, so nothing about a leaf decides who may submit it. The same entries let each recipient claim their own and let a relayer submit all of them.
const { root, entries } = await buildMerkleCampaign({
instance: airdrop,
recipients, // 500 recipients: 32 per proof, ~16 relayer requests
encryptor,
});
await merkle.setMerkleRoot({ newRoot: root });
// The same entries serve both paths. Each recipient may claim their own,
// or a relayer may submit all of them - payout lands on entry.account.
for (const entry of entries) await merkle.claim({ entry });See claim identity for who gets paid, who may redirect, and the settle-first caveat.
Bring your own handles#
When the ciphertexts didn't come from this SDK's own encryption call - a platform's own pipeline, a persisted roster from an earlier run - hand them straight to buildMerkleTree instead of re-encrypting:
import {
encryptCampaignAmounts,
buildMerkleTree,
type EncryptedCampaignLeaf,
} from "@tokenops/sdk/fhe-airdrop";
// buildMerkleCampaign split into its two steps, so you can persist the
// encrypted leaves before the root is published.
const leaves = await encryptCampaignAmounts({ instance: airdrop, recipients, encryptor });
const { root, entries } = buildMerkleTree({ instance: airdrop, leaves });
// Handles you already hold (a persisted roster, a platform's own encryption
// pipeline) skip encryption and go straight to the tree. Keep each inputProof
// on the leaf so the entries stay claimable.
const existingLeaves: readonly EncryptedCampaignLeaf[] = loadPersistedLeaves();
const rebuilt = buildMerkleTree({ instance: airdrop, leaves: existingLeaves });CampaignEntry and what the chain stores#
Every entry the campaign builders return has the shape { account, handle, inputProof, merkleProof }. The instance itself stores only the current 32-byte root - no getter maps an account back to its handle, proof, or branch. A recipient without their own entry cannot claim, and no on-chain read recovers it, so the roster you persist is the durable artifact, not the chain.
Cumulative accounting and root rotation#
A leaf commits a recipient's cumulative total, never a per-drop tranche. A claim pays total − min(total, alreadyDelivered). Two consequences follow directly from that:
- A rotation is a top-up, not a second payout.
rotateMerkleRootrepublishes updated cumulative totals; an account's delivered total is keyed by account, not by root, so it survives rotation and a claim against the new root only pays the increase. - There is no per-leaf nullifier. Replay safety is entirely the accounting - re-presenting an already-settled leaf computes an encrypted zero outstanding and pays nothing, though the exact gas fee is still charged. Two leaves for one recipient in a single root pay
max, neversum, which is exactly why every campaign builder runsvalidateCampaignRecipientsfirst and rejects duplicate recipients before any encryption work.
leafOfand building without the SDK's tree#
leafOf({ instance, account, handle }) computes the same double-keccak256 leaf the contract verifies against - useful for reproducing a specific leaf by hand. buildMerkleTree needs only (account, handle) per leaf; the input proof plays no role in the tree itself; it is verified separately, at claim time, against the instance. Neither the root nor the proof commits anything about who may submit.