diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 08336ab..aa484c8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index bc073c4..1379e43 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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). diff --git a/programs/grave-vault/Cargo.toml b/programs/grave-vault/Cargo.toml index 71c3a28..193ad7b 100644 --- a/programs/grave-vault/Cargo.toml +++ b/programs/grave-vault/Cargo.toml @@ -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" diff --git a/programs/grave-vault/src/instructions/claim_lp_proceeds.rs b/programs/grave-vault/src/instructions/claim_lp_proceeds.rs index 3308853..512f687 100644 --- a/programs/grave-vault/src/instructions/claim_lp_proceeds.rs +++ b/programs/grave-vault/src/instructions/claim_lp_proceeds.rs @@ -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]>, } @@ -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, @@ -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()], @@ -63,19 +93,38 @@ pub struct ClaimLpProceeds<'info> { pub fn handler(ctx: Context, 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, ¶ms.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)? @@ -85,6 +134,10 @@ pub fn handler(ctx: Context, 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) @@ -95,9 +148,38 @@ pub fn handler(ctx: Context, 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(); diff --git a/programs/grave-vault/src/lib.rs b/programs/grave-vault/src/lib.rs index b3a7a8b..2556cfb 100644 --- a/programs/grave-vault/src/lib.rs +++ b/programs/grave-vault/src/lib.rs @@ -23,19 +23,7 @@ // - 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::*; @@ -43,13 +31,11 @@ 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] @@ -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, params: UpdateProtocolConfigParams, diff --git a/programs/grave-vault/src/merkle.rs b/programs/grave-vault/src/merkle.rs new file mode 100644 index 0000000..50a7451 --- /dev/null +++ b/programs/grave-vault/src/merkle.rs @@ -0,0 +1,184 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// SHA-256 sorted-pair Merkle proof verifier for the LP-holder snapshot. +// +// Matches the OpenZeppelin / Uniswap `MerkleProof.verify` convention: +// * Leaf = SHA256(pubkey || balance_le_u64) (40 bytes) +// * Parent = SHA256(min(a, b) || max(a, b)) (64 bytes) +// * Odd leaf-out at a tree level promotes unchanged (handled implicitly: +// the off-chain builder pads odd levels by duplicating; we don't need +// special on-chain logic — the proof itself reflects the structure). +// +// The off-chain GraveScanner v2 indexer builds the tree using the same +// rules and submits proofs that this function verifies. The Merkle root +// is recorded in `PoolRegistry.lp_snapshot_merkle_root` by salvage_pool +// at salvage time and is immutable thereafter. + +use anchor_lang::prelude::Pubkey; +use solana_sha256_hasher::hash; + +/// Compute the canonical leaf hash for an LP-holder snapshot entry. +/// +/// Leaf bytes = `pubkey (32 bytes) || lp_balance.to_le_bytes() (8 bytes)`. +/// Returned hash is `sha256` of those 40 bytes. +pub fn compute_leaf(holder: &Pubkey, lp_balance: u64) -> [u8; 32] { + let mut buf = [0u8; 40]; + buf[..32].copy_from_slice(&holder.to_bytes()); + buf[32..].copy_from_slice(&lp_balance.to_le_bytes()); + hash(&buf).to_bytes() +} + +/// Verify a Merkle proof of `leaf` against `root`, using sorted-pair hashing +/// (OZ/Uniswap convention). At each level the proof element is hashed with +/// the running `current` value in canonical (min, max) byte order so the +/// off-chain builder doesn't need to track which side of the tree a leaf is on. +/// +/// Returns `true` iff the proof is valid. Constant-time-ish in proof length; +/// short-circuiting reveals length only, which the proof itself reveals. +pub fn verify_proof(root: [u8; 32], leaf: [u8; 32], proof: &[[u8; 32]]) -> bool { + let mut current = leaf; + for sibling in proof { + let (lo, hi) = if current <= *sibling { + (current, *sibling) + } else { + (*sibling, current) + }; + let mut buf = [0u8; 64]; + buf[..32].copy_from_slice(&lo); + buf[32..].copy_from_slice(&hi); + current = hash(&buf).to_bytes(); + } + current == root +} + +#[cfg(test)] +mod tests { + use super::*; + + // Two distinct, deterministic pubkeys for test purposes. + fn alice() -> Pubkey { + Pubkey::new_from_array([1u8; 32]) + } + fn bob() -> Pubkey { + Pubkey::new_from_array([2u8; 32]) + } + fn carol() -> Pubkey { + Pubkey::new_from_array([3u8; 32]) + } + fn dave() -> Pubkey { + Pubkey::new_from_array([4u8; 32]) + } + + /// Helper that hashes two 32-byte nodes in sorted order (matches the + /// verifier's internal step). Used to construct expected roots in tests. + fn sorted_pair_hash(a: [u8; 32], b: [u8; 32]) -> [u8; 32] { + let (lo, hi) = if a <= b { (a, b) } else { (b, a) }; + let mut buf = [0u8; 64]; + buf[..32].copy_from_slice(&lo); + buf[32..].copy_from_slice(&hi); + hash(&buf).to_bytes() + } + + #[test] + fn leaf_is_deterministic() { + let a = compute_leaf(&alice(), 100); + let b = compute_leaf(&alice(), 100); + assert_eq!(a, b); + } + + #[test] + fn leaf_differs_when_balance_differs() { + let a = compute_leaf(&alice(), 100); + let b = compute_leaf(&alice(), 101); + assert_ne!(a, b); + } + + #[test] + fn leaf_differs_when_pubkey_differs() { + let a = compute_leaf(&alice(), 100); + let b = compute_leaf(&bob(), 100); + assert_ne!(a, b); + } + + #[test] + fn verify_two_leaf_tree() { + // Two-leaf tree: root = H(min(la, lb) || max(la, lb)) + let la = compute_leaf(&alice(), 100); + let lb = compute_leaf(&bob(), 200); + let root = sorted_pair_hash(la, lb); + + // Proof for alice: just [lb] + assert!(verify_proof(root, la, &[lb])); + // Proof for bob: just [la] + assert!(verify_proof(root, lb, &[la])); + // Wrong leaf fails + let lc = compute_leaf(&carol(), 50); + assert!(!verify_proof(root, lc, &[lb])); + // Wrong proof fails + assert!(!verify_proof(root, la, &[lc])); + } + + #[test] + fn verify_four_leaf_tree() { + // Four-leaf balanced tree: + // + // root + // / \ + // n01 n23 + // / \ / \ + // la lb lc ld + // + let la = compute_leaf(&alice(), 100); + let lb = compute_leaf(&bob(), 200); + let lc = compute_leaf(&carol(), 300); + let ld = compute_leaf(&dave(), 400); + + let n01 = sorted_pair_hash(la, lb); + let n23 = sorted_pair_hash(lc, ld); + let root = sorted_pair_hash(n01, n23); + + // Alice's proof: [lb, n23] + assert!(verify_proof(root, la, &[lb, n23])); + // Bob's proof: [la, n23] + assert!(verify_proof(root, lb, &[la, n23])); + // Carol's proof: [ld, n01] + assert!(verify_proof(root, lc, &[ld, n01])); + // Dave's proof: [lc, n01] + assert!(verify_proof(root, ld, &[lc, n01])); + + // Wrong proof order fails (because pair hashing is sorted, but + // the sibling at the wrong tree level still won't match). + assert!(!verify_proof(root, la, &[n23, lb])); + + // Tampered leaf fails. + let fake = compute_leaf(&alice(), 999); + assert!(!verify_proof(root, fake, &[lb, n23])); + + // Empty proof against a non-leaf root fails. + assert!(!verify_proof(root, la, &[])); + } + + #[test] + fn empty_proof_verifies_leaf_as_root() { + // Edge case: a single-element "tree" where the leaf IS the root. + // verify_proof with empty proof returns leaf == root. + let la = compute_leaf(&alice(), 100); + assert!(verify_proof(la, la, &[])); + let lb = compute_leaf(&bob(), 200); + assert!(!verify_proof(la, lb, &[])); + } + + #[test] + fn sorted_pair_order_invariant() { + // verify_proof must produce the same result regardless of the + // off-chain builder's choice of left/right at each level — the + // sibling can be on either side and we sort canonically. + let la = compute_leaf(&alice(), 100); + let lb = compute_leaf(&bob(), 200); + let root = sorted_pair_hash(la, lb); + // Proving alice with sibling lb works the same as proving bob with + // sibling la — both arrive at the same root after one sorted-pair step. + assert!(verify_proof(root, la, &[lb])); + assert!(verify_proof(root, lb, &[la])); + } +}