> For the complete documentation index, see [llms.txt](https://docs.velthoryn.site/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.velthoryn.site/architecture-decisions/adr-003-issue-29-per-leaf-ledger.md).

# ADR-003: Issue #29 Per-Leaf Ledger

**Status:** Accepted

{% hint style="info" %}
This ADR was originally titled "defer the on-chain fix, mitigate at the backend" (2026-06-14). The on-chain fix was subsequently implemented on 2026-06-16. This document is retained for historical context.
{% endhint %}

## Context

A beneficiary can appear in more than one cliff/linear leaf of the same Merkle tree (e.g., two separate allocations). On-chain, a beneficiary has exactly one `ClaimRecord` PDA per campaign (seeded `[b"claim", vesting_tree, beneficiary]`), whose `claimed_amount` is cumulative across all leaves, while `vested()` is computed per leaf.

For a beneficiary with two fully-vested linear leaves of amount A each:

1. Claim leaf 1: `claimable = vested(leaf1) - claimed_amount(0) = A`; `claimed_amount = A`.
2. Claim leaf 2: `claimable = vested(leaf2) - claimed_amount(A) = 0` -> `NothingToClaim`.

The beneficiary receives A instead of 2A -- an under-count, never an over-count. Two independent guards make overspend impossible: the per-beneficiary `saturating_sub` on `claimed_amount`, and the global `require!(new_total <= total_supply, OverClaim)`.

This is a fairness/availability bug, not a fund-safety bug.

## Decision

**Original decision (2026-06-14):** Defer the on-chain fix. Mitigate at the backend by rejecting any campaign ingest that assigns more than one cliff/linear leaf to the same beneficiary. Milestone leaves (release\_type 2) are exempt because `milestone_bitmap` prevents double-claiming.

**Revised decision (2026-06-16):** The on-chain fix was implemented. `ClaimRecord` is now `#[account(zero_copy)]` with a bounded per-leaf ledger:

* `leaf_claimed_idx: [u32; 8]` + `leaf_claimed_amt: [u64; 8]` (+ `version: u8` + explicit pad bytes for `repr(C)`/bytemuck `Pod`).
* `PER_LEAF_CAP = 8`.
* Claim math: `claimable = vested(leaf) - leaf_prior_claimed(leaf_index)`.
* `total_entitled` now accumulates on first-touch-per-leaf for all release types.

The `Vec<u64>` + realloc proposal was rejected: no realloc pattern existed in the codebase, and it would couple account size to `tree.leaf_count` while `update_root` never touches ClaimRecord.

## Consequences

**Positive:**

* Both leaves now pay in full (verified: 1,200 of 1,200 entitled in regression test).
* No breaking on-chain change for the bounded-array approach; fixed-size accounts.
* No change needed to `update_root`.
* Backend guards (`cliffLinearSeen`) were relaxed (2026-06-24) to a **cap-aware** check rather than removed: they now allow up to `PER_LEAF_CAP = 8` cliff/linear leaves per beneficiary and reject more, so a campaign can never build off-chain only to hit `PerLeafCapExceeded` on-chain. Milestone leaves remain exempt (bitmap-tracked). See `apps/web/src/lib/campaign/limits.ts` (`MAX_CLIFF_LINEAR_LEAVES_PER_BENEFICIARY`).

**Negative:**

* Per-leaf cap of 8 means a beneficiary with more than 8 distinct cliff/linear leaves in one campaign is rejected at ingest (cap-aware BE guard) rather than failing on-chain with `PerLeafCapExceeded`.
* `zero_copy` requires explicit `repr(C)` layout and bytemuck `Pod`, which is more complex than standard Borsh serialization.
* Legacy v0 accounts require migration via `AccountInfo::resize` on next touch.

## Alternatives Considered

* **Backend-only mitigation (original Option B):** Reject multi-leaf cliff/linear at ingest. Simple but makes the backend the sole guardrail; any bypass path silently under-serves beneficiaries.
* **`Vec<u64>` + realloc (Option A variant):** Dynamic per-leaf tracking. Rejected due to no existing realloc pattern, coupling to `leaf_count`, and higher bug risk.
* **Separate ClaimRecord per leaf:** One PDA per `(tree, beneficiary, leaf_index)`. Correct but expensive in rent and requires changes to every claim/close path.


---

# Agent Instructions
This documentation is published with GitBook. GitBook is the documentation platform designed so that both humans and AI agents can read, navigate, and reason over technical content effectively. Learn more at gitbook.com.

## Querying This Documentation
If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter, and the optional `goal` query parameter:

```
GET https://docs.velthoryn.site/architecture-decisions/adr-003-issue-29-per-leaf-ledger.md?ask=<question>&goal=<endgoal>
```

`ask` is the immediate question: it should be specific, self-contained, and written in natural language.
`goal` is optional and describes the broader end goal you are ultimately trying to accomplish on behalf of the user. GitBook uses it to tailor the answer towards what is most useful for that goal.

The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
