Every leaf's input is encrypted against the airdrop instance itself, so the contract does not care who submits it. A relayer needs nothing special - no role, no separate roster, no option at build time. It needs the entries, a funded wallet, and this loop.
The loop#
import { createMerkleAirdropClient } from "@tokenops/sdk/fhe-airdrop";
import { isEncryptedValueZero } from "@zama-fhe/sdk";
const merkle = createMerkleAirdropClient({
publicClient,
walletClient: relayerWallet,
address: airdrop,
});
// First root of a campaign: a non-zero delivered total means someone -
// the recipient or another relayer - already settled that leaf.
for (const entry of entries) {
if (!isEncryptedValueZero(await merkle.getClaimedAmount(entry.account))) continue;
const hash = await merkle.claim({ entry }); // pays entry.account
await publicClient.waitForTransactionReceipt({ hash });
submitted.add(`${root}:${entry.account}`);
}After a root rotation#
getClaimedAmount is the cumulative total delivered to an account, kept across roots. Once the operator rotates to a root with higher totals, every account you paid before reads non-zero while its increase is still owed - and as the relayer you cannot decrypt it to tell. Record which (root, account) pairs you submitted instead, and submit each leaf of the new root once:
// After a root rotation the zero-handle test no longer works: every account
// paid under an earlier root reads non-zero while its top-up is outstanding,
// and you cannot decrypt their totals to compare. Track what you submitted.
for (const entry of entries) {
const key = `${newRoot}:${entry.account}`;
if (submitted.has(key)) continue;
await merkle.claim({ entry }); // pays only the increase, if any
submitted.add(key);
}What you pay#
Gas for every claim, plus the instance's per-claim fee, which the SDK reads and attaches as value automatically. The recipient pays nothing and needs no ETH.
What you can see#
Nothing about the amounts. The delivered total is granted to entry.account alone, and so is a getClaimAmountpreview. Your own decrypt of a recipient's total is refused:
// The recipient, not the relayer, can read what arrived.
const handle = await merkle.getClaimedAmount(entry.account);
// asRecipient / asRelayer: a ZamaSDK built with each party's signer.
const input = [{ encryptedValue: handle, contractAddress: airdrop }];
await asRecipient.decryption.decryptValues(input); // their total
await asRelayer.decryption.decryptValues(input);
// rejects: the relayer was never granted ACL on the handleWhat you cannot do#
Redirect a payout: passing a to that differs from entry.account throws UnauthorizedRedirectError before anything is sent. Unwrap for someone: claimAndUnwrap takes no claim identity, so it only ever claims for its sender.