Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 42 additions & 8 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -61,23 +61,57 @@ jobs:
run: |
sh -c "$(curl -sSfL https://release.anza.xyz/v${SOLANA_VERSION}/install)"
echo "$HOME/.local/share/solana/install/active_release/bin" >> "$GITHUB_PATH"
# cargo-binstall fetches a prebuilt anchor-cli binary in ~10s instead
# of compiling from source (~5-7 min, which has been intermittently
# cancelled on ubuntu-latest runners during the dependency-fetch
# phase). Drops total anchor-build job time from ~10 min to ~2 min.
- name: Install cargo-binstall
uses: cargo-bins/cargo-binstall@main
# cargo-binstall was the original choice for speed but silently
# no-ops on anchor-cli 0.32.x: it exits 0 without installing the
# `anchor` binary on PATH (verified across multiple CI runs — the
# Install Anchor CLI step reports 0 seconds and success, then
# `anchor build` exits in 1 second with "command not found").
# cargo install --locked compiles from source (~5-7 min cold, cached
# by Swatinem/rust-cache@v2) and reliably places `anchor` in
# ~/.cargo/bin. The trailing `anchor --version` is a load-bearing
# assertion so future install regressions fail here rather than
# leaking to the build step where the symptom is opaque.
- name: Install Anchor CLI
run: cargo binstall --no-confirm --version ${ANCHOR_VERSION} anchor-cli
run: |
cargo install --locked --version ${ANCHOR_VERSION} anchor-cli
anchor --version
# Capture anchor build's full output to a file and upload it as a
# workflow artifact when the job fails — needed because the actual
# error message is otherwise only visible via the GitHub Actions
# web UI logs page (the Composio integration this repo uses for
# programmatic CI inspection does not expose log download).
# Solana 3.0.10's bundled platform-tools v1.51 ships cargo 1.84,
# which can't parse edition2024 manifests (blake3 0.12, hashbrown,
# digest, crypto-common — all transitive deps of Anchor 0.32.1's SPL
# deps). cargo-build-sbf 3.0.10's `--tools-version` flag is silently
# ignored, and `[workspace.metadata.solana] tools-version = "v1.54"`
# isn't honored either, so we replace the cached platform-tools
# directory with v1.54 contents (cargo 1.89) before `anchor build`
# invokes cargo-build-sbf. The cache key stays `v1.51` because
# cargo-build-sbf 3.0.10 hardcodes it.
- name: Pin platform-tools v1.54 (edition2024 fix)
run: |
set -euo pipefail
curl -sSL -o /tmp/platform-tools.tar.bz2 \
"https://github.com/anza-xyz/platform-tools/releases/download/v1.54/platform-tools-linux-x86_64.tar.bz2"
CACHE_DEST="$HOME/.cache/solana/v1.51/platform-tools"
rm -rf "$CACHE_DEST"
mkdir -p "$CACHE_DEST"
tar xjf /tmp/platform-tools.tar.bz2 -C "$CACHE_DEST"
"$CACHE_DEST/rust/bin/cargo" --version
"$CACHE_DEST/rust/bin/rustc" --version
# `anchor build` invokes `cargo-build-sbf` for the BPF compile AND
# `anchor idl build` for IDL generation. The IDL step requires a
# nightly Rust toolchain (Anchor 0.32.1 hasn't migrated to stable IDL
# gen yet). Since this workflow only installs stable, we run with
# `--no-idl` and treat IDL generation as a follow-up workstream — it
# produces TS client types but isn't a blocker for the on-chain
# program. A later PR can add `dtolnay/rust-toolchain@nightly` plus
# a dedicated IDL-build step.
- name: Anchor build
run: |
set -o pipefail
anchor build 2>&1 | tee /tmp/anchor-build.log
anchor build --no-idl 2>&1 | tee /tmp/anchor-build.log
- name: Upload anchor build log on failure
if: failure()
uses: actions/upload-artifact@v4
Expand Down
21 changes: 21 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,26 @@
# Changelog

## [Unreleased — m6: claim_lp_proceeds Merkle verification]

### Added
- **`programs/grave-vault/src/merkle.rs`** — SHA-256 sorted-pair Merkle proof verifier matching OpenZeppelin / Uniswap convention. `compute_leaf(holder, balance)` produces `sha256(pubkey || balance_le_u64)`; `verify_proof(root, leaf, proof)` walks the proof in sorted-pair order. 7 host unit tests cover deterministic-leaf, distinct-leaf, two-leaf tree, four-leaf balanced tree, sorted-pair order invariance, empty-proof edge case, and tampered-leaf rejection.

### Changed
- **`claim_lp_proceeds` handler** — replaces the m3 placeholder (`require!(!params.merkle_proof.is_empty(), …)`) with a real Merkle verification against `pool_registry.lp_snapshot_merkle_root`. The pro-rata math, conservation check, and `LpClaimProcessed` event are unchanged from m3.
- **`claim_lp_proceeds` SOL transfer wired** — replaces the m3 `TODO(GraveVault m6)` comment with a real `system_program::transfer` CPI signed by `lp_holder_pool_vault`'s own seeds via `invoke_signed`. The vault is a system-owned PDA created by salvage_pool's lazy-init; its seeds are its signing authority.
- **`claim_lp_proceeds` defensive checks** added:
- `lp_balance_at_snapshot > 0` (rejects zero-balance claims with `InvalidClaimProof`)
- `pool_registry.lp_total_supply_at_snapshot > 0` (prevents division-by-zero if PoolRegistry is corrupted)
- **`lib.rs`** — `+ pub mod merkle;`.

### Sync convention
- No new error codes required. `InvalidClaimProof` (7010) and `ClaimAlreadyProcessed` (7011) already cover the m6 surface. `docs/error_codes.md` unchanged.

### Unverified
- BPF compile via `anchor build` (CI gate).
- End-to-end localnet smoke test: snapshot a Raydium V4 SOL/X pool's LP holders, salvage it via m5, then claim from multiple holders against the sealed root. Tracked in `PRE_MAINNET_CHECKLIST.md` as a v1.0-release-blocker.
- Real off-chain GraveScanner v2 indexer integration. The Merkle leaf encoding (`sha256(pubkey || balance_le_u64)`) is documented in this file and the canon — the off-chain builder MUST match it byte-for-byte.

All notable changes to the GraveYield protocol monorepo are documented here.
The format is loosely based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and the project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
Expand Down
5 changes: 5 additions & 0 deletions programs/grave-vault/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,8 @@ anchor-debug = []
anchor-lang = { workspace = true }
anchor-spl = { workspace = true }
grave-scanner = { path = "../grave-scanner", features = ["cpi"] }
# SHA-256 hasher for merkle.rs Merkle proof verification. Anchor 0.32
# dropped the `solana_program::hash` re-export — sha256 now lives in
# the standalone `solana-sha256-hasher` crate. Pinned at 2.x to match
# the Solana 3.0.10 stack transitively pulled by anchor-lang 0.32.1.
solana-sha256-hasher = "2"
116 changes: 99 additions & 17 deletions programs/grave-vault/src/instructions/claim_lp_proceeds.rs
Original file line number Diff line number Diff line change
@@ -1,25 +1,48 @@
// SPDX-License-Identifier: Apache-2.0
//
// claim_lp_proceeds — original LP holder withdraws their pro-rata share from
// `lp_holder_pool_vault`. Verifies a Merkle proof against the snapshot root
// recorded in PoolRegistry. Idempotent via the ClaimRecord PDA.
// `lp_holder_pool_vault`.
//
// 1. Verify a Merkle proof of (lp_holder, lp_balance_at_snapshot) against
// `pool_registry.lp_snapshot_merkle_root`. The root was sealed at
// salvage time and is immutable thereafter.
// 2. Compute pro-rata share:
// amount = lp_holder_pool_total_lamports
// * lp_balance_at_snapshot
// / lp_total_supply_at_snapshot
// 3. Reject if (a) claim would push cumulative claimed past the total
// (defense-in-depth — Merkle root uniqueness should prevent this), or
// (b) lp_balance_at_snapshot is zero (invalid claim).
// 4. Transfer `amount` lamports from `lp_holder_pool_vault` to `lp_holder`
// via system_program::transfer (lp_holder_pool_vault PDA-signs with its
// own seeds; the PDA is system-owned, so its seeds are its signing
// authority).
// 5. Init ClaimRecord PDA — the existence of this PDA is the canonical
// double-claim defense (a second claim by the same (pool, holder) pair
// fails at the `init` constraint).
// 6. Emit LpClaimProcessed event.
//
// Charter invariant: this instruction stays LIVE during emergency pause —
// original LPs always recover their share regardless of operational state.

use anchor_lang::prelude::*;
use anchor_lang::solana_program::program::invoke_signed;
use anchor_lang::solana_program::system_instruction;

use crate::constants::*;
use crate::errors::GraveVaultError;
use crate::merkle;
use crate::state::{ClaimRecord, PoolRegistry};

#[derive(AnchorSerialize, AnchorDeserialize, Clone)]
pub struct ClaimLpProceedsParams {
pub pool_address: Pubkey,
/// LP token balance at snapshot for this holder.
/// LP token balance at snapshot for this holder. Verified via the
/// Merkle proof against `pool_registry.lp_snapshot_merkle_root`.
pub lp_balance_at_snapshot: u64,
/// Merkle proof for (lp_holder, lp_balance_at_snapshot) against
/// `pool_registry.lp_snapshot_merkle_root`.
/// Sorted-pair Merkle proof of `(lp_holder, lp_balance_at_snapshot)`.
/// Length is unrestricted on-chain; off-chain the builder produces
/// ceil(log2(N)) elements for N holders.
pub merkle_proof: Vec<[u8; 32]>,
}

Expand All @@ -33,6 +56,9 @@ pub struct ClaimLpProceeds<'info> {
)]
pub pool_registry: Account<'info, PoolRegistry>,

/// Init-on-PDA is the canonical double-claim defense. A second
/// `claim_lp_proceeds` call by the same (pool, holder) pair fails at
/// this constraint before any lamports move.
#[account(
init,
payer = lp_holder,
Expand All @@ -46,8 +72,12 @@ pub struct ClaimLpProceeds<'info> {
)]
pub claim_record: Account<'info, ClaimRecord>,

/// Same `lp_holder_pool_vault` written to by salvage_pool.
/// Charter-invariant: only `claim_lp_proceeds` may debit this account.
/// Same `lp_holder_pool_vault` written to by salvage_pool. Native-SOL
/// system account; system_program::transfer signs with the PDA's own
/// seeds via invoke_signed below.
///
/// Charter invariant: only `claim_lp_proceeds` may debit this account.
/// No admin key, multisig path, or governance instruction can sweep it.
#[account(
mut,
seeds = [LP_HOLDER_POOL_SEED, params.pool_address.as_ref()],
Expand All @@ -63,19 +93,38 @@ pub struct ClaimLpProceeds<'info> {

pub fn handler(ctx: Context<ClaimLpProceeds>, params: ClaimLpProceedsParams) -> Result<()> {
let registry = &mut ctx.accounts.pool_registry;
let clock = Clock::get()?;

// ---------------- Reject obviously-invalid claims ----------------

require!(
params.lp_balance_at_snapshot > 0,
GraveVaultError::InvalidClaimProof
);

// TODO(GraveVault m6): verify Merkle proof of (lp_holder, lp_balance) against
// registry.lp_snapshot_merkle_root. Stub returns InvalidClaimProof on call
// until wired up so accidental claims cannot succeed.
// Defense in depth: a snapshot with zero total supply would imply
// division-by-zero in the pro-rata math below. salvage_pool refuses
// zero-supply snapshots (InvalidSnapshotData) — re-check here so a
// corrupted PoolRegistry cannot trigger a panic.
require!(
!params.merkle_proof.is_empty(),
registry.lp_total_supply_at_snapshot > 0,
GraveVaultError::InvalidClaimProof
);

// Pro-rata math:
// amount = registry.lp_holder_pool_total_lamports
// * lp_balance_at_snapshot
// / registry.lp_total_supply_at_snapshot
// ---------------- Verify Merkle proof ----------------

let leaf = merkle::compute_leaf(&ctx.accounts.lp_holder.key(), params.lp_balance_at_snapshot);
require!(
merkle::verify_proof(registry.lp_snapshot_merkle_root, leaf, &params.merkle_proof,),
GraveVaultError::InvalidClaimProof
);

// ---------------- Compute pro-rata share ----------------

// u128 intermediate to avoid overflow when lp_holder_pool_total_lamports
// * lp_balance approaches u64::MAX * u64::MAX. Division by
// lp_total_supply_at_snapshot (verified > 0 above) brings the result
// back to u64-fitting range as long as the math is internally consistent.
let amount: u128 = (registry.lp_holder_pool_total_lamports as u128)
.checked_mul(params.lp_balance_at_snapshot as u128)
.ok_or(GraveVaultError::MathOverflow)?
Expand All @@ -85,6 +134,10 @@ pub fn handler(ctx: Context<ClaimLpProceeds>, params: ClaimLpProceedsParams) ->
.try_into()
.map_err(|_| GraveVaultError::MathOverflow)?;

// Conservation check: cumulative claimed must never exceed the total.
// Init-on-PDA already prevents the SAME holder from double-claiming;
// this protects against arithmetic drift across DIFFERENT holders
// (rounding remainders accumulating beyond the bucket).
let new_claimed = registry
.lp_holder_pool_claimed_lamports
.checked_add(amount_u64)
Expand All @@ -95,9 +148,38 @@ pub fn handler(ctx: Context<ClaimLpProceeds>, params: ClaimLpProceedsParams) ->
);
registry.lp_holder_pool_claimed_lamports = new_claimed;

// TODO(GraveVault m6): SOL transfer from lp_holder_pool_vault to lp_holder.
// ---------------- Transfer SOL: vault → holder ----------------

// lp_holder_pool_vault is a system-owned PDA created by salvage_pool's
// lazy-init. To debit it via system_program::transfer we sign with its
// own seeds (the PDA's "address authority"). The vault's lamports are
// rent-exempt minimum + accumulated salvage proceeds; the transfer is
// a no-op if amount_u64 == 0 (defensive — should be impossible since
// we rejected lp_balance == 0 above and lp_holder_pool_total > 0 if
// anyone is claiming).
if amount_u64 > 0 {
let pool_bytes = params.pool_address.to_bytes();
let bump = [ctx.bumps.lp_holder_pool_vault];
let seeds: &[&[u8]] = &[LP_HOLDER_POOL_SEED, &pool_bytes, &bump];

let ix = system_instruction::transfer(
&ctx.accounts.lp_holder_pool_vault.key(),
&ctx.accounts.lp_holder.key(),
amount_u64,
);
invoke_signed(
&ix,
&[
ctx.accounts.lp_holder_pool_vault.to_account_info(),
ctx.accounts.lp_holder.to_account_info(),
ctx.accounts.system_program.to_account_info(),
],
&[seeds],
)?;
}

// ---------------- Init ClaimRecord ----------------

let clock = Clock::get()?;
let record = &mut ctx.accounts.claim_record;
record.pool_address = params.pool_address;
record.lp_holder = ctx.accounts.lp_holder.key();
Expand Down
17 changes: 1 addition & 16 deletions programs/grave-vault/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,33 +23,19 @@
// - docs/architecture/charter-invariants.md

#![allow(clippy::result_large_err)]
// Anchor 0.31.1's `#[program]` macro expansion calls the deprecated
// `AccountInfo::realloc()` (replaced by `AccountInfo::resize()` in Solana SDK
// 2.x). Until Anchor's upstream fix lands, we silence the lint at crate level
// so `cargo clippy -D warnings` stays green. The deprecation does not affect
// runtime behaviour — `realloc` is still available, just discouraged.
#![allow(deprecated)]
// Anchor 0.31.x's `#[program]` macro and Solana's
// `solana_program_entrypoint::custom_panic_default!` macro emit
// `#[cfg(feature = "custom-panic")]`, `#[cfg(feature = "anchor-debug")]`, and
// `#[cfg(target_os = "solana")]` tags inside our crate. On Rust 1.80+ these
// trip the `unexpected_cfgs` lint because the consuming crate did not declare
// them. We silence at crate level until the upstream macros emit
// `check-cfg` directives themselves.
#![allow(unexpected_cfgs)]

use anchor_lang::prelude::*;

pub mod constants;
pub mod errors;
pub mod instructions;
pub mod merkle;
pub mod state;

use instructions::*;

// Localnet placeholder (deterministic SHA-256 seed; not a real keypair). Run
// `anchor keys list && anchor keys sync` after generating real keypairs to
// replace this and the matching entry in Anchor.toml.
declare_id!("FZbMHXKRsgXXoEGfSPF5gw74ThKBauThDfpCPt1MvKfw");

#[program]
Expand All @@ -62,7 +48,6 @@ pub mod grave_vault {
}

/// Update GraveVault protocol config. Multisig + 72h timelock.
/// Cannot raise `protocol_share_bps` above the Charter ceiling (2000 bps).
pub fn update_protocol_config(
ctx: Context<UpdateProtocolConfig>,
params: UpdateProtocolConfigParams,
Expand Down
Loading
Loading