2.0 RC docsView 1.x docs
Concept · Merkle campaigns

Building and rotating Merkle campaigns

How buildMerkleCampaign, planMerkleCampaign, and rotateMerkleRoot construct and republish a claim tree.

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.

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:

campaign.ts
ts
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:

planned-campaign.ts
ts
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.airdrop to plan.predictedAddress, so the factory reverts rather than deploying anywhere the leaves are not bound to.
  • A params.merkleRoot that differs from plan.root, or an expected.airdrop that differs from the prediction, throws InvalidArgumentError before any RPC.
  • If the factory's live Merkle init-code hash no longer equals plan.initCodeHashAfter, the send is refused with PredictionDriftError.

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.

campaign.ts
ts
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:

campaign.ts
ts
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, never sum, which is exactly why every campaign builder runs validateCampaignRecipients first 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.

See also