Skip to main content

Merkle Snapshots & Claims (Canonical Spec)

This page provides a canonical specification for taking snapshot balances, building Merkle trees, and producing proofs used to claim incentives and coupons onchain. Implementations MUST follow these rules exactly to produce proofs accepted by the contracts across all platforms. Coupon entitlements are snapshot-based to ensure Bond Tokens remain fully fungible.

The exact snapshot block for each Merkle distribution type is determined from onchain events, see Snapshot Blocks and Required Contracts below. Importantly, coupon distributions snapshot Bond Token balances at the registerCoupon block (recorded in CouponAnnounced), while incentive distributions snapshot FpUSD balances (the round's receipt token) at the BondsEmitted block for that round (passed to finalizeIncentiveForRound, not the incentive deposit block). The snapshot is then used to create a dataset of (holder, amount) pairs used to build the Merkle tree.

Bondi supports multiple emission rounds (the series of a Bond Token line) for the same Bond Token. Incentive claims are scoped by roundId, while coupon claims are scoped by couponId (not roundId).

The Distribution contract exposes this claim and redeem surface:

1. claimIncentive(roundId, amount, proof)
2. claimIncentiveForUser(roundId, user, amount, proof)
3. claimCoupon(couponId, amount, proof)
4. claimCouponForUser(user, couponId, amount, proof)
5. redeemPrincipal(burnAmt)
6. redeemPrincipalForUser(user, burnAmt)
7. redeemCouponTokens(amount)
8. redeemPrincipalTokens(amount)

Onchain Claim Interfaces​

The Distribution contract exposes both self-claim functions and relayer functions. Bondi's relayer service is the primary and intended path for submitting claims on behalf of holders. For coupons, the relayer processes every holder; eligibility decides stablecoin versus claim tokens. Holders do not need to take any action. Self-claim is available as a fallback. Incentive claims stay limited to KYC-verified wallets.


Self-claim functions:
  1. claimIncentive(roundId, amount, proof): self-claim for an incentive. roundId identifies which emission round's incentive root is used for verification.
  2. claimCoupon(couponId, amount, proof): self-claim for a specific coupon. couponId starts at 1 and identifies the coupon root used for verification.

Relayer functions:
  1. claimIncentiveForUser(roundId, user, amount, proof): relayer claim for a specific holder and emission round.
  2. claimCouponForUser(user, couponId, amount, proof): relayer claim for a specific holder and coupon.

Eligibility differs by claim type: claimIncentiveForUser is KYC-only for the target wallet, while claimCouponForUser pays stablecoin to a KYC-verified wallet or a registered Bondi vault (Reinvestment or Redemption) and issues Coupon Tokens to everyone else. This asymmetry reflects the nature of each distribution; incentive tokens are primary-market participation rewards issued to the investing wallet directly, while coupons are ongoing interest payments that must reach vault contracts so the vault can reinvest or distribute proceeds to its depositors. The Merkle tree includes vault addresses as ordinary holders, and the relayer processes vault claims with the same proof format. The remainder of this document specifies the canonical snapshot and Merkle procedures required to produce proofs accepted by both the relayer path and the self-claim fallback.

Claim tokens​

claimCall and claimCallForUser were removed. An issuer call settles in the relayer pass via executeCallForUser. Principal that cannot be paid as stablecoin is minted as a Principal Token, and a verified holder later calls redeemPrincipalTokens(amount).


Eligibility for stablecoin is a KYC-verified wallet or a registered Bondi vault. Everyone else receives claim tokens at the moment of the payout. Each Distribution contract deploys its own pair (ctbtXXX Coupon Token, ptbtXXX Principal Token). Each Reinvestment Vault deploys its own pair (ctvbtXXX, ptvbtXXX). A token is redeemed only at the contract that minted it.


The four redeem functions are redeemCouponTokens(amount) and redeemPrincipalTokens(amount) on Distribution, and the same two names on each Reinvestment Vault. The caller must be KYC-verified. Partial amounts are allowed. The call burns the tokens and pays stablecoin 1:1 from the reserve already sitting in that contract.


Distribution counters totalTokenizedCouponLiability and totalTokenizedPrincipalLiability always equal the matching token supplies, and they are part of the reserve setPrincipal must cover. The vault keeps the same counters inside its reserved stable.


Distribution events: CouponTokensIssued, CouponTokensRedeemed, PrincipalTokensIssued (source Call or Maturity), PrincipalTokensRedeemed. Vault events: VaultCouponTokensIssued, VaultPrincipalTokensIssued, VaultCouponTokensRedeemed, VaultPrincipalTokensRedeemed. Product description: Coupon and Principal Tokens.

Critical Determinism Requirements​

Follow these exact rules to ensure byte-for-byte identical results:

  • Inclusive Block Range: Reconstruct balances from the token deployment block through the snapshot block inclusive.
  • Balance Source of Truth: Derive balances exclusively from Transfer logs (not from balanceOf at snapshot time).
  • Address Normalization: Lowercase addresses before hashing.
  • Amount Normalization: Use base-10 integer strings (no decimals, no scientific notation, no leading zeros).
  • Zero Filtering: Exclude zero balances and zero entitlements from leaves.
  • Leaf Format: Solidity-packed keccak256 of (address, uint256) with normalized values.
  • Leaf Sorting: Sort leaves by normalized lowercase address using simple, locale-insensitive string compare.
  • Pair Ordering: Sort each pair by raw byte comparison before hashing parent nodes.
  • Odd Node Rule: Duplicate the last node when a level has an odd number of nodes.

Balance Snapshots​

Use the deployment block per network from Blockchain & Contract Addresses. Reconstruct token holder balances from Transfer events in the inclusive range: [deployment block → snapshot block].


Snapshot Blocks and Required Contracts​

Incentive Snapshot (FpUSD → Incentive distribution, per emission round):​

  • Required Contracts: fpUSD token for the round, BondTokenProxy (Bond Token), and Distribution
  • Deposit: Distribution Safe calls depositIncentiveForRound(roundId, amount): emits IncentiveAnnounced(uint256 amount)
  • Finalization: Orchestrator calls finalizeIncentiveForRound(roundId, root, blockNumber): emits IncentiveFinalized(bytes32 root, uint256 blockNumber)
  • Snapshot Block: The blockNumber passed to finalizeIncentiveForRound (the BondsEmitted block for that round). For multi-round issuance, BondsEmitted fires once per round, use the block for the specific round being distributed.
  • Announced Amount: the amount in the IncentiveAnnounced event for that round, this is the value the Merkle tree must be built from.
  • Per-round state: roots and claims are scoped to roundId via rounds[roundId].incentiveRoot and incentiveClaimedByRound[roundId][user]

Coupon Snapshot (Bond Token → Coupon distribution):​

  • Required Contracts: BondTokenProxy (Bond Token) and Distribution
  • Multiple Coupons: couponId starts at 1; each coupon is independent
  • Registration: Distribution Safe calls registerCoupon(amount): emits CouponAnnounced(uint256 couponId, uint256 blockNumber, uint256 netAmount, uint256 couponCommissionBps)
  • Finalization: Orchestrator calls finalizeCoupon(couponId, root, blockNumber): emits CouponFinalized(uint256 couponId, bytes32 root); blockNumber must match the value from CouponAnnounced
  • Snapshot Block: Distribution.coupons(couponId).blockNumber, the registerCoupon block; it equals the blockNumber in CouponAnnounced. On Plume, block.number is the L1 block, so the stored value is an L1 block number and every snapshot read is taken at the Plume block that contains the CouponAnnounced event instead (see Coupon Attribution to Pool LPs, Inputs)
  • Announced Amount: the netAmount in the same CouponAnnounced event, this is the post-commission amount and is the value the Merkle tree must be built from.

Balance Reconstruction​

Iterate all Transfer(from, to, value) logs in the inclusive range. Keep only addresses whose final balance is strictly positive at the snapshot block


For each event:

  • If from ≠ 0x0000000000000000000000000000000000000000, subtract value from from
  • If to ≠ 0x0000000000000000000000000000000000000000, add value to to

  • Units: All balances and announced amounts are raw onchain integers in each token’s native units (e.g., 18 decimals). Do not rescale or round; use values exactly as found in logs/events.

  • Coupons only, one extra step: after reconstruction, read BondToken.canonicalPool() at the snapshot block. If it is not address(0), rewrite the holder list by the pool attribution of the next section before computing entitlements; the full recipe is Coupon Attribution to Pool LPs. If it is address(0), the list is used as reconstructed.

Canonical Pool Liquidity Providers (Coupons Only)​

For coupons only: if BondToken.canonicalPool(), read at the snapshot block B, is not address(0), the pool's Bond Tokens are attributed to the owners of its Uniswap v3 liquidity positions before entitlements are computed. The pool address is removed from the holder list, each position owner is credited with principal plus owed plus uncollected fees on the Bond Token side, and the pool keeps only Uniswap's protocol fees plus the residual as its own leaf. The list total is unchanged and the leaf, sorting, tree and proof rules below apply as they are. The full reproducible recipe, with every contract read and the identity that must hold, is on Coupon Attribution to Pool LPs.

Proportional Entitlements and Rounding​

Let totalSnapshotBalance be the sum of all positive reconstructed balances at the snapshot. Given announcedAmount and a holder’s holderSnapshotBalance:

entitlement = floor(holderSnapshotBalance × announcedAmount ÷ totalSnapshotBalance)

Only strictly positive entitlement values are included in leaves. Amounts are integers and represented as base‑10 strings.

Building the Merkle Tree​

Leaf, Sorting, Tree, and Proofs (Canonical)​

// Address + amount normalization and leaf generation (TypeScript / ethers)
import { ethers } from 'ethers';

export function generateLeaf(address: string, amount: string | number | bigint): string {
const normalizedAddress = address.toLowerCase();
const normalizedAmount = BigInt(amount).toString(); // base-10, no leading zeros
return ethers.solidityPackedKeccak256(['address', 'uint256'], [normalizedAddress, normalizedAmount]);
}

// Sort leaves by normalized lowercase address (string compare)
export function sortLeaves(leaves: { address: string; amount: string }[]) {
return leaves.sort((a, b) => a.address.toLowerCase().localeCompare(b.address.toLowerCase()));
}

// Build Merkle tree from leaf hashes (duplicate last hash when odd)
export function buildMerkleTree(leafHashes: string[]): string[][] {
if (leafHashes.length === 0) throw new Error('Cannot build tree with no leaves');
const tree: string[][] = [leafHashes];
let level = 0;

while (tree[level].length > 1) {
const current = tree[level];
const next: string[] = [];
for (let i = 0; i < current.length; i += 2) {
const left = current[i];
const right = i + 1 < current.length ? current[i + 1] : left; // duplicate last if odd
next.push(hashPair(left, right));
}
tree.push(next);
level++;
}

return tree;
}

// Sort pair by RAW BYTE comparison before hashing, then keccak256(concat)
export function hashPair(left: string, right: string): string {
const a = ethers.getBytes(left);
const b = ethers.getBytes(right);
const firstSecond = Buffer.compare(a, b) <= 0 ? [left, right] : [right, left];
return ethers.keccak256(ethers.concat(firstSecond.map(ethers.getBytes)));
}

// Build Merkle proof (exclude root level)
export function buildProof(tree: string[][], leafIndex: number): string[] {
const proof: string[] = [];
let index = leafIndex;

for (let level = 0; level < tree.length - 1; level++) {
const nodes = tree[level];
const isRightNode = index % 2 === 1;
const siblingIndex = isRightNode ? index - 1 : (index + 1 < nodes.length ? index + 1 : index);
proof.push(nodes[siblingIndex]);
index = Math.floor(index / 2);
}

return proof;
}

Python Hash Pair Example (Byte Ordering)​

from eth_utils import keccak

def hash_pair(a: str, b: str) -> str:
# a, b are 0x-prefixed hex strings
a_bytes = bytes.fromhex(a[2:])
b_bytes = bytes.fromhex(b[2:])
first, second = (a_bytes, b_bytes) if a_bytes <= b_bytes else (b_bytes, a_bytes)
return '0x' + keccak(first + second).hex()

Cross‑Platform Test Vector (Must Match Exactly)​

// Input data (EXACT test case)
const testData = [
{ address: '0x742d35cc6634c0532925a3b8d6cf6a6502e27a84', amount: '1000000000000000000' },
{ address: '0x8ba1f109551bd432803012645fac136c783456d2', amount: '2000000000000000000' },
{ address: '0x306469457266cbb7a0030e8cdd2013f2b4ee525a', amount: '500000000000000000' }
];

// After normalization + sorting by lowercase address:
// 0: 0x306469457266cbb7a0030e8cdd2013f2b4ee525a (5e17)
// 1: 0x742d35cc6634c0532925a3b8d6cf6a6502e27a84 (1e18)
// 2: 0x8ba1f109551bd432803012645fac136c783456d2 (2e18)

const expected = {
leaves: [
'0xaf183f053d91a12690d0da2ce8cd8cfa63f49cda2645c95019909664bd1a8e4d',
'0xa98b836a62c045e28b128708a46ad8894409b395065430068abd25596a99dfa5',
'0xd72495b454e02dca3786ea1077ed7830e22d07f226aefdbbdcf298b06460a8fe'
],
root: '0x0a79f60c162341f8e62e05a19c710c1e76b738d1f48342d163a27588223aa0bb',
proof0: [
'0xa98b836a62c045e28b128708a46ad8894409b395065430068abd25596a99dfa5',
'0xd2af82110a8d3271c3dfed82ce1b2b4a590c28f48b86ddb0f293dbd3236d643f'
]
};

Validation Snippet​

import { generateLeaf, sortLeaves, buildMerkleTree, buildProof } from './your-impl';

function validateImplementation() {
const normalized = testData.map((x) => ({ address: x.address.toLowerCase(), amount: BigInt(x.amount).toString() }));
const sorted = sortLeaves(normalized);
const leafHashes = sorted.map((x) => generateLeaf(x.address, x.amount));

if (leafHashes[0] !== expected.leaves[0]) throw new Error('Leaf 0 mismatch');
const tree = buildMerkleTree(leafHashes);
const root = tree[tree.length - 1][0];

if (root !== expected.root) throw new Error('Root mismatch');
const proof0 = buildProof(tree, 0);

if (proof0.join(', ') !== expected.proof0.join(', ')) throw new Error('Proof 0 mismatch');
console.log('✅ Implementation validated - produces identical results');
}

Cross‑Reference​