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 theCouponAnnounced(couponId, …)event, which is the block in whichregisterCouponran. On every chain except Plume this equalsDistribution.coupons(couponId).blockNumber. On Plume the contract stores the L1 block number in that field (Plume'sblock.numberis the L1 block), soBis 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 atB(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 asnetAmountinCouponAnnounced. 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 + residualbalance(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
- Read every pool
Mint(sender, owner, tickLower, tickUpper, amount, amount0, amount1)event from the Bond Token deployment block throughB. (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.) - Mints whose
owneris theNonfungiblePositionManagerare NFT positions. The token ids come from theIncreaseLiquidity(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 poolMintwith the manager as owner, so this finds every NFT that ever held liquidity in the pool without scanning the manager's full history. - 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. - Precondition:
pool.factory()must equalNonfungiblePositionManager.factory(), both read atB. Otherwise every NFT position would be mis-read as a direct position owned by the manager; the run fails instead. - Burned NFTs:
NonfungiblePositionManager.positions(tokenId)reverts once a token is burned. Ifpositionsreverts, readownerOf(tokenId)atB: if it also reverts the token is burned and contributes nothing; if it returns an owner, fail. Ifpositionssucceeds butownerOfreverts, the token contributes nothing when itsliquidity,tokensOwed0andtokensOwed1are all zero; if any of them is non-zero, fail.
4.2 Reading a position at B
- NFT position:
NonfungiblePositionManager.positions(tokenId)atBgivestoken0, token1, fee, tickLower, tickUpper, liquidity, feeGrowthInside0LastX128, feeGrowthInside1LastX128, tokensOwed0, tokensOwed1; the owner isownerOf(tokenId)atB. Keep the position only iftoken0,token1andfeematch the pool's (the manager serves every pool of the factory). - Direct position:
pool.positions(keccak256(abi.encodePacked(owner, int24 tickLower, int24 tickUpper)))atBgivesliquidity, the twofeeGrowthInsideLastX128values and the twotokensOwedvalues; the owner is the mint'sowner. - 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
IncreaseLiquidityminusDecreaseLiquidityevents up toB(the decreases are read from the receipts of poolBurnevents whose owner is the manager) must equalliquidity; replaying the manager'sTransferevents for the token must give the same owner asownerOf, and the first transfer must come from the zero address; a token whose replayed owner is the zero address must hold zero liquidity and zerotokensOwed. 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.
- principal = Uniswap v3
LiquidityAmounts.getAmountsForLiquidity(sqrtPriceX96, sqrtRatioAtTick(tickLower), sqrtRatioAtTick(tickUpper), liquidity), Bond Token side, with the periphery's exact integer arithmetic:sqrtRatioAtTickasTickMath.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 issqrtPriceX96 ≤ sqrtA(all token0),sqrtA < sqrtPriceX96 < sqrtB(both), else all token1. A range entirely on the stablecoin side yields 0. - owed =
tokensOwed0ortokensOwed1on the Bond Token side: amounts already booked to the position by a previous decrease or poke and not yet collected. - fees = uncollected fee growth since the position's checkpoint, exactly as Uniswap's
Position.updatecomputes it.feeGrowthInside = Tick.getFeeGrowthInside(tickLower, tickUpper, slot0.tick, feeGrowthGlobalX128, feeGrowthOutside[lower], feeGrowthOutside[upper]): below =feeGrowthOutside[lower]iftick ≥ tickLower, elseglobal − feeGrowthOutside[lower]; above =feeGrowthOutside[upper]iftick < tickUpper, elseglobal − feeGrowthOutside[upper];inside = global − below − above, every subtraction unchecked (wrapping modulo 2^256). Thenfees = floor(((feeGrowthInside − feeGrowthInsideLastX128) mod 2^256) × liquidity / 2^128), and zero whenliquidityis zero. - 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
- Remove the pool address from the list of section 2. Its removed balance must equal
balance(pool); otherwise fail. - 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.
- If
protocolFees + residual > 0, add the pool address back with exactly that amount. This is the pool's own leaf. - The total of the list is unchanged, so it still equals
BondToken.totalSupply()atB.
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 theregisterCouponcallback. 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.redeemPrincipalForUserreverts withCanonicalPoolNotAllowedfor the address inBondToken.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.