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.


A snapshot is taken of holder balances at a specific incentive or coupon deposition block to create a dataset of (holder, amount) pairs used to build the Merkle tree. Coupon entitlements are snapshot-based to ensure Bond Tokens remain fully fungible.

Onchain Claim Interfaces

The Distribution contract exposes two functions for manual claiming if ever needed:


  • claimIncentive(amount, proof) claims a one‑time incentive using the following parameters; amount is the exact integer entitlement (base‑10 string) and proof is the Merkle proof from leaf to root.

  • claimCoupon(couponId, amount, proof) claims a specific coupon by couponId using parameters: couponId starting at 1, amount as exact integer (base‑10 string), and proof as the Merkle proof from leaf to root.

Manual claiming is typically unnecessary because Bondi runs orchestration and automation services for coupons. The remainder of this document specifies the canonical snapshot and Merkle procedures required to produce identical roots and proofs on any platform.

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):


  • Required Contracts: fpUSD token, BondTokenProxy (Bond Token), and Distribution
  • Snapshot Block: The block of BondTokenProxyBondsEmitted(uint256 mintedSupply, uint256 mintPrice, uint256 maturityDate) for the relevant issuance
  • Announced Amount: DistributionIncentiveAnnounced(uint256 amount)


Coupon Snapshot (Bond Token → Coupon distribution):


  • Required Contracts: BondTokenProxy (Bond Token) and Distribution
  • Multiple Coupons: couponId starts at 1; each coupon is independent
  • Snapshot Block: DistributionCouponAnnounced(uint256 couponId, uint256 blockNumber, uint256 amount) (use this blockNumber)
  • Announced Amount: the amount in the same CouponAnnounced event

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 from0x0000000000000000000000000000000000000000, subtract value from from
  • If to0x0000000000000000000000000000000000000000, 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.

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