Skip to main content

Coupon Attribution to Pool LPs

This page is the recipe a third party follows to rebuild the holder list behind any coupon Merkle root from chain state alone, and to arrive at the same leaves Bondi's orchestrator produced. It extends the Merkle Snapshots & Claims (Implementation Spec), which stays the authority for leaf hashing, sorting, tree building and proofs. Nothing here changes those rules; only the holder list that feeds them changes, and only for coupons.


Everything below is a contract fact (readable on chain) unless marked implementation choice: how Bondi's orchestrator does it, which another implementation must copy exactly to get the same root.


Pool attribution exists only on Bond Tokens whose BondToken contract has the canonicalPool() getter (the claim-token stack). Earlier Bond Tokens have no getter and no attribution: every pool is a plain holder for them.

1. Inputs​

  • Distribution address of the bond, and the couponId.
  • Snapshot block B = the block that contains the CouponAnnounced(couponId, …) event, which is the block in which registerCoupon ran. On every chain except Plume this equals Distribution.coupons(couponId).blockNumber. On Plume the contract stores the L1 block number in that field (Plume's block.number is the L1 block), so B is the Plume block of the event, not the stored value; the orchestrator checks that the stored value equals the event's L1 block and refuses to run otherwise. Every read below is taken at B (end-of-block state, which is what an archive node returns for that block tag).
  • Announced amount = Distribution.coupons(couponId).total, the net stablecoin amount after commission, also reported as netAmount in CouponAnnounced. This is the number the entitlements are computed from.
  • Bond Token = Distribution.bondToken(), and its deployment block from Deployed Addresses.
  • Reads at a past block require an archive-capable RPC. Substituting the latest block gives a different holder list and a different root; the orchestrator refuses to run on a node that cannot serve B.

2. Holder balances at B​

Replay every Bond Token Transfer(from, to, value) from the deployment block through B inclusive, exactly as the implementation spec's Balance Reconstruction describes: subtract value from from unless it is the zero address, add value to to unless it is the zero address. Keep addresses whose balance at B is strictly positive. BondToken.balanceOf(address) at B must agree with the replayed balance for every address.


No address is excluded by role, and no address is added. Registered Bondi vaults are ordinary holders and get their own leaf (the relayer pays them stablecoin and triggers the vault callback). The Distribution contract itself holds no Bond Tokens at a coupon registration: registerCoupon refuses to run while unclaimed Bond Tokens sit on the Distribution (DistributionBondsOutstanding), so it never appears in the list. The sum of the list equals BondToken.totalSupply() at B.

3. Is there a canonical pool?​

Read BondToken.canonicalPool() at block B.


  • address(0): no attribution. Every pool holding the Bond Token, if any, is a plain holder. Skip to section 5.
  • Any other address: the bond's canonical Uniswap v3 Bond Token / stablecoin pool on this chain. Apply section 4.

The value is admin-set (setCanonicalPool, DEFAULT_ADMIN_ROLE) and can change over a bond's life. Reading it at B is what makes every past tree reproducible: a change after B never affects coupon couponId. The CanonicalPoolUpdated(previousPool, newPool) event gives the full history for readers without archive access to the storage slot.


Implementation choice: the Uniswap v3 NonfungiblePositionManager used in section 4 is the chain's canonical one (Bondi keeps it in per-chain configuration); it is not read from the pool. Section 4.1 step 4 checks it belongs to the pool's factory.

4. Attributing the pool's Bond Tokens to its liquidity providers​

The pool contract is not a person and can never redeem a coupon. Instead, the Bond Tokens inside it are credited to the addresses that own the liquidity positions, position by position, at block B. The identity that must hold exactly, in Bond Token wei:

balance(pool) = Σ positions (principal + owed + fees) + protocolFees + residual

balance(pool) is the pool address's entry in the section 2 list (equal to BondToken.balanceOf(pool) at B). Each position's amount goes to its owner; protocolFees and residual stay with the pool address. There is no tolerance and no rounding into positions: whatever the positions do not explain is the residual, and a negative residual is a hard failure (the reconstruction is wrong; do not build a tree). Implementation choice: a residual above a configurable size (default 0.01 Bond Token) only logs a warning; it never changes any number.

4.1 Finding every position​

  1. Read every pool Mint(sender, owner, tickLower, tickUpper, amount, amount0, amount1) event from the Bond Token deployment block through B. (Implementation choice for the start block: the pool cannot exist before the token it holds, so this range is complete; the orchestrator scans it in 10,000-block batches.)
  2. Mints whose owner is the NonfungiblePositionManager are NFT positions. The token ids come from the IncreaseLiquidity(tokenId, liquidity, amount0, amount1) events the manager emitted in the same transactions as those mints (read from the transaction receipts). Both a manager mint and a later increase emit a pool Mint with the manager as owner, so this finds every NFT that ever held liquidity in the pool without scanning the manager's full history.
  3. Every other mint is a direct position, keyed by (owner, tickLower, tickUpper) and deduplicated on that key (address compared case-insensitively). If the owner is another contract (a vault, another position manager, a Safe), that contract is the owner of record; whoever holds its shares or NFTs receives nothing from this attribution.
  4. Precondition: pool.factory() must equal NonfungiblePositionManager.factory(), both read at B. Otherwise every NFT position would be mis-read as a direct position owned by the manager; the run fails instead.
  5. Burned NFTs: NonfungiblePositionManager.positions(tokenId) reverts once a token is burned. If positions reverts, read ownerOf(tokenId) at B: if it also reverts the token is burned and contributes nothing; if it returns an owner, fail. If positions succeeds but ownerOf reverts, the token contributes nothing when its liquidity, tokensOwed0 and tokensOwed1 are all zero; if any of them is non-zero, fail.

4.2 Reading a position at B​

  • NFT position: NonfungiblePositionManager.positions(tokenId) at B gives token0, token1, fee, tickLower, tickUpper, liquidity, feeGrowthInside0LastX128, feeGrowthInside1LastX128, tokensOwed0, tokensOwed1; the owner is ownerOf(tokenId) at B. Keep the position only if token0, token1 and fee match the pool's (the manager serves every pool of the factory).
  • Direct position: pool.positions(keccak256(abi.encodePacked(owner, int24 tickLower, int24 tickUpper))) at B gives liquidity, the two feeGrowthInsideLastX128 values and the two tokensOwed values; the owner is the mint's owner.
  • A position with tickLower ≥ tickUpper, a position owned by the pool address itself, or two positions with the same key are errors: the run fails.
  • Implementation choice (cross-checks Bondi runs on NFT positions): replaying the manager's IncreaseLiquidity minus DecreaseLiquidity events up to B (the decreases are read from the receipts of pool Burn events whose owner is the manager) must equal liquidity; replaying the manager's Transfer events for the token must give the same owner as ownerOf, and the first transfer must come from the zero address; a token whose replayed owner is the zero address must hold zero liquidity and zero tokensOwed. These checks do not change the numbers; they stop a run whose reads disagree. Direct positions have no event cross-check: pool state is authoritative.

4.3 Valuing a position (Bond Token side only)​

Let the Bond Token be token0 or token1 of the pool; take every quantity on that side and ignore the stablecoin side. Pool state at B: slot0.sqrtPriceX96, slot0.tick, feeGrowthGlobal0X128, feeGrowthGlobal1X128, protocolFees(), and ticks(tick).feeGrowthOutside0X128 / 1X128 for every tick a position references.


  1. principal = Uniswap v3 LiquidityAmounts.getAmountsForLiquidity(sqrtPriceX96, sqrtRatioAtTick(tickLower), sqrtRatioAtTick(tickUpper), liquidity), Bond Token side, with the periphery's exact integer arithmetic: sqrtRatioAtTick as TickMath.getSqrtRatioAtTick (intermediates masked to 256 bits, result rounded up to 96 bits); amount0 = floor(floor((liquidity × 2^96) × (sqrtB − sqrtA) / sqrtB) / sqrtA); amount1 = floor(liquidity × (sqrtB − sqrtA) / 2^96); the price branch is sqrtPriceX96 ≤ sqrtA (all token0), sqrtA < sqrtPriceX96 < sqrtB (both), else all token1. A range entirely on the stablecoin side yields 0.
  2. owed = tokensOwed0 or tokensOwed1 on the Bond Token side: amounts already booked to the position by a previous decrease or poke and not yet collected.
  3. fees = uncollected fee growth since the position's checkpoint, exactly as Uniswap's Position.update computes it. feeGrowthInside = Tick.getFeeGrowthInside(tickLower, tickUpper, slot0.tick, feeGrowthGlobalX128, feeGrowthOutside[lower], feeGrowthOutside[upper]): below = feeGrowthOutside[lower] if tick ≥ tickLower, else global − feeGrowthOutside[lower]; above = feeGrowthOutside[upper] if tick < tickUpper, else global − feeGrowthOutside[upper]; inside = global − below − above, every subtraction unchecked (wrapping modulo 2^256). Then fees = floor(((feeGrowthInside − feeGrowthInsideLastX128) mod 2^256) × liquidity / 2^128), and zero when liquidity is zero.
  4. position amount = principal + owed + fees. Positions whose amount is zero are dropped.

protocolFees = protocolFees().token0 or .token1 on the Bond Token side (Uniswap's uncollected protocol cut). residual = balance(pool) − Σ position amounts − protocolFees: Bond Tokens transferred straight to the pool outside Uniswap's accounting, plus rounding dust from per-position flooring. It must be at least zero.

4.4 Rewriting the holder list​

  1. Remove the pool address from the list of section 2. Its removed balance must equal balance(pool); otherwise fail.
  2. Add each position amount to its owner. An address that also holds Bond Tokens directly, or owns several positions, ends up with one combined balance: one leaf per address.
  3. If protocolFees + residual > 0, add the pool address back with exactly that amount. This is the pool's own leaf.
  4. The total of the list is unchanged, so it still equals BondToken.totalSupply() at B.

Only the canonical pool is rewritten. Any other pool holding the Bond Token (another fee tier, a v4 pool, a wrapped-token pool) stays a plain holder and its coupon strands in that contract. That is the intended incentive: liquidity in the canonical pool earns coupons, liquidity anywhere else does not.

5. Entitlements and leaves​

From the (rewritten) holder list, compute each entitlement as floor(holderBalance × announcedAmount ÷ totalBalance), where totalBalance is the sum of the list. Keep strictly positive entitlements; the dust left by flooring stays in the Distribution contract and is not redistributed. Build the leaves, tree and proofs exactly as the implementation spec's Proportional Entitlements and Rounding and Building the Merkle Tree define them: leaf = keccak256(abi.encodePacked(address, uint256 amount)) over the lowercased address (the contract checks keccak256(abi.encodePacked(user, amount))), leaves sorted by lowercased address, pairs hashed in byte order, the last node duplicated on an odd level. The root must equal Distribution.coupons(couponId).root once the coupon is finalized.

6. What happens to each leaf​

  • A KYC-verified wallet or a registered Bondi vault receives stablecoin when the relayer settles its leaf (claimCouponForUser); a registered vault also gets the registerCoupon callback. Every leaf is settled.
  • Every other owner, including a Safe or contract that owns a position without KYB, is minted Coupon Tokens for the amount and redeems them after verification.
  • The pool's own leaf (protocol fees plus residual) is settled like any non-verified holder: Coupon Tokens are minted to the pool contract and stay there by design; the matching stablecoin stays reserved in the Distribution. There is no skip list and no redistribution.
  • The pool is also protected at maturity: Distribution.redeemPrincipalForUser reverts with CanonicalPoolNotAllowed for the address in BondToken.canonicalPool(), so the post-delay principal sweep never burns Bond Tokens that belong to liquidity providers. Providers withdraw their position and redeem the Bond Tokens themselves.

7. Verifying a published root​

Given a Distribution address and a couponId: find the CouponAnnounced event for B, read coupons(couponId) for total and root; rebuild the holder list (sections 2 to 4) at B; compute entitlements and the tree (section 5); compare your root to root. Bondi's orchestrator logs the same reconstruction for every coupon run (pool state, every position with its three components, the identity of section 4 with its four numbers, and the holder totals before and after the rewrite), so a disagreement can be traced to a specific position or read.