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 for the same Bond Token series. Incentive claims are scoped by roundId, while coupon claims are scoped by couponId (not roundId).
The Distribution contract exposes 4 claim functions in total:
1. claimIncentive(roundId, amount, proof)
2. claimCoupon(couponId, amount, proof)
3. claimIncentiveForUser(roundId, user, amount, proof)
4. claimCouponForUser(user, couponId, amount, proof)
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 all claims on behalf of holders. The relayer processes every eligible holder automatically —holders do not need to take any action to receive their coupons or incentives. Self-claim is available as a fallback for holders.
Self-claim functions:
claimIncentive(roundId, amount, proof)— self-claim for an incentive.roundIdidentifies which emission round's incentive root is used for verification.claimCoupon(couponId, amount, proof)— self-claim for a specific coupon.couponIdstarts at 1 and identifies the coupon root used for verification.
Relayer functions:
claimIncentiveForUser(roundId, user, amount, proof)— relayer claim for a specific holder and emission round.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 accepts a KYC wallet, a whitelisted Bondi vault (Reinvestment/Redemption), or the Admin Safe for compliance-custodied Bond Tokens. 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.
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
Transferlogs (not frombalanceOfat 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:
fpUSDtoken for the round,BondTokenProxy(Bond Token), andDistribution - Deposit: Distribution Safe calls
depositIncentiveForRound(roundId, amount)— emitsIncentiveAnnounced(uint256 amount) - Finalization: Orchestrator calls
finalizeIncentiveForRound(roundId, root, blockNumber)— emitsIncentiveFinalized(bytes32 root, uint256 blockNumber) - Snapshot Block: The
blockNumberpassed tofinalizeIncentiveForRound(theBondsEmittedblock for that round). For multi-round issuance,BondsEmittedfires once per round — use the block for the specific round being distributed. - Announced Amount: the
amountin theIncentiveAnnouncedevent for that round — this is the value the Merkle tree must be built from. - Per-round state: roots and claims are scoped to
roundIdviarounds[roundId].incentiveRootandincentiveClaimedByRound[roundId][user]
Coupon Snapshot (Bond Token → Coupon distribution):
- Required Contracts:
BondTokenProxy(Bond Token) andDistribution - Multiple Coupons:
couponIdstarts at 1; each coupon is independent - Registration: Distribution Safe calls
registerCoupon(amount)— emitsCouponAnnounced(uint256 couponId, uint256 blockNumber, uint256 netAmount, uint256 couponCommissionBps) - Finalization: Orchestrator calls
finalizeCoupon(couponId, root, blockNumber)— emitsCouponFinalized(uint256 couponId, bytes32 root);blockNumbermust match the value fromCouponAnnounced - Snapshot Block: the
blockNumberinCouponAnnounced(theregisterCouponblock) - Announced Amount: the
netAmountin the sameCouponAnnouncedevent — 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, subtractvaluefromfrom - If
to≠0x0000000000000000000000000000000000000000, addvaluetoto
- 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.
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()