2.0 RC docsView 1.x docs
Recipe · Relay a claim

Relay a claim for a recipient

Anyone may submit a leaf. The contract pays the leaf's account, never you.

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.

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#

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

relay-rotated.ts
ts
// 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:

who-can-read.ts
ts
// 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 handle

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

See also