Skip to content

Commit 993cdcf

Browse files
committed
feat: add tree fee claim instruction
1 parent a2ef25e commit 993cdcf

35 files changed

Lines changed: 2034 additions & 24 deletions

File tree

.specify/memory/constitution.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@
1111

1212
### II. Security-First
1313

14-
- All account validation MUST use `light-account-checks` (`check_owner`, `check_signer`, `check_mut`, `check_discriminator`, `check_pda_seeds`).
14+
- All account validation MUST use `light-account-checks` (`check_owner`, `check_signer`, `check_mut`, `check_discriminator`, `check_pda_seeds`) or Anchor's `AccountLoader` (which checks owner + discriminator).
15+
- When an instruction dispatches on runtime-determined account types: use `*_from_account_info` (light-account-checks) for V2 batched accounts and `AccountLoader::try_from` (Anchor) for V1 accounts. If Anchor lifetime constraints prevent `AccountLoader`, an explicit owner check + discriminator match is acceptable but MUST be documented with the reason.
1516
- All arithmetic in on-chain programs MUST use checked math (`checked_add`, `checked_sub`, `checked_mul`) or equivalent safe operations.
1617
- Merkle tree integrity: all state transitions MUST be verified by ZK proofs before any compressed account mutation.
1718
- No upgradeable program authority without explicit constitution amendment.
@@ -26,6 +27,7 @@
2627
- MUST NOT run `cargo test` at the monorepo root.
2728
- Assert pattern: single `assert_eq` against a fully constructed expected struct (borsh-deserialized actual vs hand-built expected).
2829
- Tests that depend on `light-test-utils` MUST live in `program-tests/` or `sdk-tests/`, never in `program-libs/` or `programs/`.
30+
- When writing tests, MUST use the `/rust-test` skill for conventions: assertion patterns, assert functions per instruction, property tests with proptest, failing tests for every error variant, and Solana program testing with light-program-test.
2931

3032
### IV. Spec-First
3133

@@ -45,6 +47,7 @@
4547
- **Instruction Dispatch**: Anchor `#[program]` for `account-compression` / `registry`; manual 8-byte discriminator dispatch for pinocchio programs (`system`).
4648
- **Fluent CPI Builder**: `LightCpiInstruction` trait with `.new_cpi()` -> `.with_light_account()` -> `.invoke()` chaining.
4749
- **Account Validation**: `check_owner`, `check_signer`, `check_mut`, `check_discriminator`, `check_pda_seeds` from `light-account-checks`.
50+
- **Authority Checks**: MUST use `check_signer_is_registered_or_authority` or its `manual_*` variant via `GroupAccess` trait. Custom auth functions that mirror this logic MUST document the equivalence and the reason for not using the canonical function (e.g., Anchor lifetime constraints in dynamic dispatch).
4851

4952
### VII. Zero-Copy
5053

@@ -101,4 +104,4 @@ Examples: `jorrit/feat-compressible-mint`, `jorrit/fix-macro-deps`, `jorrit/chor
101104
- Amendments require: (1) written proposal, (2) explicit approval, (3) updated version below.
102105
- All code reviews MUST verify compliance with these principles.
103106

104-
**Version**: 1.0.0 | **Ratified**: 2026-03-20 | **Last Amended**: 2026-03-20
107+
**Version**: 1.1.0 | **Ratified**: 2026-03-20 | **Last Amended**: 2026-03-21

CLAUDE.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -287,3 +287,10 @@ CompressAndClose: [96, 94, 135, 18, 121, 42, 213, 117]
287287
- Building instructions manually without Anchor's `InstructionData` trait
288288
- Creating SDK functions that don't depend on Anchor crate
289289
- Verifying instruction data in tests or validators
290+
291+
## Active Technologies
292+
- Rust (Solana BPF target), Anchor framework for account-compression and registry programs + anchor-lang, light-batched-merkle-tree, light-merkle-tree-metadata, light-account-checks, light-compressed-accoun (001-tree-fee-distribution)
293+
- Solana on-chain accounts (tree/queue accounts owned by account-compression, PDA owned by registry) (001-tree-fee-distribution)
294+
295+
## Recent Changes
296+
- 001-tree-fee-distribution: Added Rust (Solana BPF target), Anchor framework for account-compression and registry programs + anchor-lang, light-batched-merkle-tree, light-merkle-tree-metadata, light-account-checks, light-compressed-accoun

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

program-libs/merkle-tree-metadata/src/fee.rs

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,45 @@
11
use crate::errors::MerkleTreeMetadataError;
22

3+
/// Cap on lamports reimbursed to a forester per tree operation.
4+
pub const FORESTER_REIMBURSEMENT_CAP: u64 = 5000;
5+
6+
/// Hardcoded rent exemption based on Solana's rent formula at deployment time.
7+
/// Formula: `(data_len + ACCOUNT_STORAGE_OVERHEAD) * LAMPORTS_PER_BYTE_YEAR * EXEMPTION_THRESHOLD`
8+
/// = `(data_len + 128) * 3480 * 2`
9+
///
10+
/// Hardcoded rather than queried via Sysvar because Solana's rent rate may
11+
/// change after trees are initialized, and existing trees must retain correct reserves.
12+
pub fn hardcoded_rent_exemption(data_len: u64) -> Option<u64> {
13+
const LAMPORTS_PER_BYTE: u64 = 6960; // 3480 * 2
14+
const ACCOUNT_STORAGE_OVERHEAD: u64 = 128;
15+
data_len
16+
.checked_add(ACCOUNT_STORAGE_OVERHEAD)?
17+
.checked_mul(LAMPORTS_PER_BYTE)
18+
}
19+
20+
/// Computes excess lamports claimable from a tree/queue account.
21+
///
22+
/// Formula: `account_lamports - rent_exemption - rollover_fee * (capacity - next_index + 1)`
23+
///
24+
/// The first leaf (index 0) does not pay a rollover fee, so:
25+
/// - paid fees = `next_index - 1`
26+
/// - remaining unfunded = `capacity - next_index + 1`
27+
///
28+
/// Returns `None` if there is no excess (underflow in any step).
29+
pub fn compute_claimable_excess(
30+
account_lamports: u64,
31+
rent_exemption: u64,
32+
rollover_fee: u64,
33+
capacity: u64,
34+
next_index: u64,
35+
) -> Option<u64> {
36+
let remaining = capacity.checked_sub(next_index)?.checked_add(1)?;
37+
let reserved_rollover = rollover_fee.checked_mul(remaining)?;
38+
account_lamports
39+
.checked_sub(rent_exemption)?
40+
.checked_sub(reserved_rollover)
41+
}
42+
343
pub fn compute_rollover_fee(
444
rollover_threshold: u64,
545
tree_height: u32,
@@ -87,3 +127,131 @@ fn test_address_tree_compute_rollover_fee() {
87127
);
88128
assert!(lifetime_lamports > (merkle_tree_lamports + queue_lamports));
89129
}
130+
131+
#[test]
132+
fn test_hardcoded_rent_exemption() {
133+
// 8-byte discriminator account: (8 + 128) * 6960 = 946_560
134+
assert_eq!(hardcoded_rent_exemption(8), Some(946_560));
135+
// 0-byte account: (0 + 128) * 6960 = 890_880
136+
assert_eq!(hardcoded_rent_exemption(0), Some(890_880));
137+
// Large account
138+
let large = 10_000_000u64;
139+
assert_eq!(
140+
hardcoded_rent_exemption(large),
141+
Some((large + 128) * 6960)
142+
);
143+
// Overflow: u64::MAX
144+
assert_eq!(hardcoded_rent_exemption(u64::MAX), None);
145+
}
146+
147+
#[test]
148+
fn test_compute_claimable_excess_normal() {
149+
let rent = 1_000_000u64;
150+
let rollover_fee = 100u64;
151+
let capacity = 1000u64;
152+
let next_index = 500u64;
153+
// remaining = 1000 - 500 + 1 = 501
154+
// reserved = 100 * 501 = 50_100
155+
// excess = lamports - rent - reserved
156+
let lamports = rent + 50_100 + 5_000;
157+
assert_eq!(
158+
compute_claimable_excess(lamports, rent, rollover_fee, capacity, next_index),
159+
Some(5_000)
160+
);
161+
}
162+
163+
#[test]
164+
fn test_compute_claimable_excess_zero() {
165+
let rent = 1_000_000u64;
166+
let rollover_fee = 100u64;
167+
let capacity = 1000u64;
168+
let next_index = 500u64;
169+
let remaining = capacity - next_index + 1;
170+
let lamports = rent + rollover_fee * remaining;
171+
assert_eq!(
172+
compute_claimable_excess(lamports, rent, rollover_fee, capacity, next_index),
173+
Some(0)
174+
);
175+
}
176+
177+
#[test]
178+
fn test_compute_claimable_excess_negative_underflow() {
179+
let rent = 1_000_000u64;
180+
let rollover_fee = 100u64;
181+
let capacity = 1000u64;
182+
let next_index = 500u64;
183+
// Lamports less than rent alone
184+
let lamports = rent - 1;
185+
assert_eq!(
186+
compute_claimable_excess(lamports, rent, rollover_fee, capacity, next_index),
187+
None
188+
);
189+
// Lamports between rent and rent + reserved
190+
let remaining = capacity - next_index + 1;
191+
let lamports = rent + rollover_fee * remaining - 1;
192+
assert_eq!(
193+
compute_claimable_excess(lamports, rent, rollover_fee, capacity, next_index),
194+
None
195+
);
196+
}
197+
198+
#[test]
199+
fn test_compute_claimable_excess_next_index_zero() {
200+
// First leaf at index 0 does not pay rollover fee.
201+
// remaining = capacity - 0 + 1 = capacity + 1
202+
let rent = 1_000_000u64;
203+
let rollover_fee = 100u64;
204+
let capacity = 1000u64;
205+
let next_index = 0u64;
206+
let remaining = capacity + 1; // 1001
207+
let lamports = rent + rollover_fee * remaining + 42;
208+
assert_eq!(
209+
compute_claimable_excess(lamports, rent, rollover_fee, capacity, next_index),
210+
Some(42)
211+
);
212+
}
213+
214+
#[test]
215+
fn test_compute_claimable_excess_next_index_at_capacity() {
216+
// Tree is full: next_index == capacity
217+
// remaining = capacity - capacity + 1 = 1
218+
let rent = 1_000_000u64;
219+
let rollover_fee = 100u64;
220+
let capacity = 1000u64;
221+
let next_index = capacity;
222+
let remaining = 1; // one slot reserved
223+
let lamports = rent + rollover_fee * remaining + 99;
224+
assert_eq!(
225+
compute_claimable_excess(lamports, rent, rollover_fee, capacity, next_index),
226+
Some(99)
227+
);
228+
}
229+
230+
#[test]
231+
fn test_compute_claimable_excess_next_index_exceeds_capacity() {
232+
// Edge case: next_index > capacity should return None (checked_sub underflow)
233+
let rent = 1_000_000u64;
234+
let rollover_fee = 100u64;
235+
let capacity = 1000u64;
236+
let next_index = 1001u64;
237+
let lamports = rent + 999_999;
238+
assert_eq!(
239+
compute_claimable_excess(lamports, rent, rollover_fee, capacity, next_index),
240+
None
241+
);
242+
}
243+
244+
#[test]
245+
fn test_compute_claimable_excess_zero_rollover_fee() {
246+
// Accounts with rollover_fee == 0 (e.g. V1 nullifier queues)
247+
let rent = 1_000_000u64;
248+
let rollover_fee = 0u64;
249+
let capacity = 1000u64;
250+
let next_index = 500u64;
251+
let lamports = rent + 7777;
252+
// reserved = 0 * 501 = 0
253+
assert_eq!(
254+
compute_claimable_excess(lamports, rent, rollover_fee, capacity, next_index),
255+
Some(7777)
256+
);
257+
}

program-tests/registry-test/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,4 +43,5 @@ light-compressible = { workspace = true }
4343
light-token-client = { workspace = true }
4444
light-token-interface = { workspace = true }
4545
light-zero-copy = { workspace = true }
46+
light-merkle-tree-metadata = { workspace = true }
4647
borsh = { workspace = true }

0 commit comments

Comments
 (0)