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) andproof
is the Merkle proof from leaf to root.
claimCoupon(couponId, amount, proof)
claims a specific coupon bycouponId
using parameters:couponId
starting at 1,amount
as exact integer (base‑10 string), andproof
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 frombalanceOf
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), andDistribution
- Snapshot Block: The block of
BondTokenProxy
→BondsEmitted(uint256 mintedSupply, uint256 mintPrice, uint256 maturityDate)
for the relevant issuance - Announced Amount:
Distribution
→IncentiveAnnounced(uint256 amount)
Coupon Snapshot (Bond Token → Coupon distribution):
- Required Contracts:
BondTokenProxy
(Bond Token) andDistribution
- Multiple Coupons:
couponId
starts at 1; each coupon is independent - Snapshot Block:
Distribution
→CouponAnnounced(uint256 couponId, uint256 blockNumber, uint256 amount)
(use thisblockNumber
) - Announced Amount: the
amount
in the sameCouponAnnounced
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
from
≠0x0000000000000000000000000000000000000000
, subtractvalue
fromfrom
- If
to
≠0x0000000000000000000000000000000000000000
, addvalue
toto
- 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
- Deployment blocks per network: Proof of Reserve & Deployed Addresses
- Product overview and distribution flow: Bond Tokens