From 0d1b8adfb25bd5f01646ea5396b37fd85d6dae64 Mon Sep 17 00:00:00 2001 From: GraveYield Date: Wed, 13 May 2026 00:23:22 +0800 Subject: [PATCH 01/11] feat(grave-scanner): m4 Raydium V4 layout adapter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements Raydium V4 AmmInfo (752-byte) pool account parsing. Reads coin_vault, pc_vault, coin_vault_mint, pc_vault_mint, lp_mint at the documented byte offsets (336/368/400/432/464); resolves the actual vault token accounts and lp_mint via remaining_accounts; returns PoolData with real reserves and LP supply. Defense in depth: vault accounts' mint fields are verified against AmmInfo's claimed mints (PoolDataParseError on mismatch), so an attacker who supplies the genuine pool account but forged vault accounts (e.g. vaults pointing to a different mint) is rejected. Architectural changes (touch all 5 adapters + 2 handlers): - adapters::extract_pool_data takes a third arg: remaining_accounts - All AMM adapter parse() signatures accept remaining_accounts: &[AccountInfo] - PoolData gains lp_mint: Pubkey (no longer aliased to base_mint) - Phase 1 and Phase 2 handlers pass ctx.remaining_accounts through - Locker check fix: now receives true lp_mint (was using base_mint placeholder per inaugural-commit comment) Other AMM adapters (raydium_clmm, orca_whirlpool, pumpswap, meteora) keep the new signature but continue to revert AmmAdapterUnimplemented — honest- stub pattern unchanged. Retires PRE-MAINNET-TODO CPI-001 (raydium_v4 layout). Adds a "Retired" section to docs/PRE_MAINNET_CHECKLIST.md tracking the row with a post- merge SHA pointer (filled by a tiny follow-up commit after merge). Tests: 4 pure-Rust unit tests for AMM_INFO_SIZE constant, read_pubkey byte offset extraction, out-of-bounds rejection, and synthetic 752-byte fixture roundtrip across all five Pubkey offsets. Full parse() integration test against a real mainnet pool (requires solana-program-test) is flagged as m4.1 follow-up — not in this PR. last_swap_unix_ts handling: Raydium V4 AmmInfo has no last-swap-timestamp field; adapter returns 0 as sentinel. Handler continues to read last_swap_unix_ts from instruction params until ORACLE-002 retires with indexer-signed attestation. ORACLE-002 row updated to note this. CHANGELOG.md not touched — that file is introduced by PR #12 (GraveVault m3). After #12 merges, m4 work gets a CHANGELOG entry in a small follow-up. Refs: - Whitepaper v4.0.1 §3 (Six derelict-pool criteria) - Combined Tech Doc v3.0.1 §3.5 (sweep_stale_anchor + criteria) - v4.0 canonical build sequence step 5 (AMM remove_liquidity precondition is the GraveVault m5 deliverable; Scanner-side layout parse is m4) --- docs/PRE_MAINNET_CHECKLIST.md | 15 +- .../grave-scanner/src/adapters/meteora.rs | 17 +- programs/grave-scanner/src/adapters/mod.rs | 58 ++++- .../src/adapters/orca_whirlpool.rs | 15 +- .../grave-scanner/src/adapters/pumpswap.rs | 14 +- .../src/adapters/raydium_clmm.rs | 14 +- .../grave-scanner/src/adapters/raydium_v4.rs | 205 ++++++++++++++++-- .../src/instructions/evaluate_pool_phase1.rs | 25 ++- .../src/instructions/evaluate_pool_phase2.rs | 12 +- 9 files changed, 302 insertions(+), 73 deletions(-) diff --git a/docs/PRE_MAINNET_CHECKLIST.md b/docs/PRE_MAINNET_CHECKLIST.md index be1de33..42b8636 100644 --- a/docs/PRE_MAINNET_CHECKLIST.md +++ b/docs/PRE_MAINNET_CHECKLIST.md @@ -47,13 +47,12 @@ Status legend: 🟥 blocking · 🟧 high-priority · 🟡 medium · ⬜ trackin | ID | File | Status | Description | | --- | --- | --- | --- | | ORACLE-001 | `programs/grave-scanner/src/instructions/record_launch_price.rs` | 🟧 | Cross-check `launch_price_q64x64` against on-chain pool reserves at the supplied `first_swap_slot` rather than trusting the caller. Reverts with `PoolDataParseError` on mismatch. Verify against AMM transaction history at the recorded slot. | -| ORACLE-002 | `programs/grave-scanner/src/instructions/evaluate_pool_phase1.rs` (also Phase 2) | 🟥 | Cryptographic proof of last-swap timestamp. Currently passed as a parameter and trusted; production handler must verify via adapter-provided last-swap proof or signed indexer attestation. | +| ORACLE-002 | `programs/grave-scanner/src/instructions/evaluate_pool_phase1.rs` (also Phase 2) | 🟥 | Cryptographic proof of last-swap timestamp. Currently passed as a parameter and trusted; production handler must verify via adapter-provided last-swap proof or signed indexer attestation. Raydium V4 adapter (m4) returns `0` as a sentinel since AmmInfo doesn't carry a last-swap timestamp — handler still reads the param until this row retires. | ### CPI | ID | File | Status | Description | | --- | --- | --- | --- | -| CPI-001 | `programs/grave-scanner/src/adapters/raydium_v4.rs` | 🟥 | Raydium V4 pool layout parsing. Reverts with `AmmAdapterUnimplemented`. Verify against mainnet pool fixtures (e.g. `9d9mb8kooFfaD3SctgZtkxQypkshx6ezhbKio89ixyy2` SOL/USDC) and Raydium SDK source-of-truth field offsets. | | CPI-002 | `programs/grave-scanner/src/adapters/raydium_clmm.rs` | 🟧 | Raydium CLMM pool layout parsing + tick-range reserve calculation. v1.1 milestone. | | CPI-003 | `programs/grave-scanner/src/adapters/orca_whirlpool.rs` | 🟧 | Orca Whirlpool layout + token-vault reserve aggregation. | | CPI-004 | `programs/grave-scanner/src/adapters/pumpswap.rs` | 🟧 | PumpSwap pool layout parsing. | @@ -72,12 +71,22 @@ Status legend: 🟥 blocking · 🟧 high-priority · 🟡 medium · ⬜ trackin 1. Implement the change. Replace the `PRE-MAINNET-TODO(...)` marker with either a `// TODO(post-launch): ...` if there is residual cleanup, or delete it entirely if the resolution is complete. -2. Move the row in this file under a `## Retired` section with a SHA +2. Move the row in this file under the `## Retired` section with a SHA reference to the implementing PR. 3. CI's pre-mainnet-todo audit (added in a follow-up PR) cross-checks that every grep'd marker has a matching live row in this file and vice versa. +## Retired + +Rows retired by shipped implementations. Each entry references the +implementing PR; the merge SHA is filled in by a tiny follow-up commit +after the PR lands so the row can be tagged to its exact post-merge SHA. + +| ID | File | Retired by | Merge SHA | +| --- | --- | --- | --- | +| CPI-001 | `programs/grave-scanner/src/adapters/raydium_v4.rs` | PR #13 (m4: Raydium V4 layout adapter) | `` | + ## Audit handoff When handing this checklist to OtterSec / Neodyme, run diff --git a/programs/grave-scanner/src/adapters/meteora.rs b/programs/grave-scanner/src/adapters/meteora.rs index b5ad618..56a158c 100644 --- a/programs/grave-scanner/src/adapters/meteora.rs +++ b/programs/grave-scanner/src/adapters/meteora.rs @@ -1,11 +1,11 @@ // SPDX-License-Identifier: Apache-2.0 // -// Meteora DLMM / Dynamic Pools adapter. +// Meteora (DLMM + Dynamic AMM) pool adapter — stub. // -// Mainnet program ID is parameterizable per pool variant (DLMM vs -// Dynamic AMM); the placeholder below is the DLMM program. Meteora -// support is a v1.1 roadmap item; V4/Whirlpool cover the v1.0 launch -// scope. +// Mainnet program ID is AUDIT-PENDING — both Meteora DLMM and Dynamic AMM +// are supported targets; the canonical placeholder here resolves to the +// DLMM program at the time of writing but the KEYS-001 row in +// docs/PRE_MAINNET_CHECKLIST.md tracks the confirmation step. use anchor_lang::prelude::*; use anchor_lang::solana_program::pubkey; @@ -13,7 +13,7 @@ use anchor_lang::solana_program::pubkey; use super::PoolData; use crate::errors::GraveScannerError; -/// Meteora DLMM program ID. +/// Mainnet Meteora DLMM program ID — placeholder until confirmed. /// /// PRE-MAINNET-TODO(KEYS): confirm Meteora DLMM mainnet program ID and add Dynamic AMM variant | reverts: UnsupportedAmm if owner mismatch | verify: against Meteora SDK constants pub const PROGRAM_ID: Pubkey = pubkey!("LBUZKhRxPF3XUpBCjp4YzTKgLccjZhTSDM9YuVaPwxo"); @@ -21,6 +21,9 @@ pub const PROGRAM_ID: Pubkey = pubkey!("LBUZKhRxPF3XUpBCjp4YzTKgLccjZhTSDM9YuVaP /// Parse a Meteora pool account into `PoolData`. /// /// PRE-MAINNET-TODO(CPI): Meteora DLMM/Dynamic AMM pool layout parsing | reverts: AmmAdapterUnimplemented | verify: against Meteora SDK -pub fn parse(_pool_account_info: &AccountInfo) -> Result { +pub fn parse<'info>( + _pool_account_info: &AccountInfo<'info>, + _remaining_accounts: &[AccountInfo<'info>], +) -> Result { err!(GraveScannerError::AmmAdapterUnimplemented) } diff --git a/programs/grave-scanner/src/adapters/mod.rs b/programs/grave-scanner/src/adapters/mod.rs index 55bfe1a..5f9dfbf 100644 --- a/programs/grave-scanner/src/adapters/mod.rs +++ b/programs/grave-scanner/src/adapters/mod.rs @@ -5,11 +5,16 @@ // `criteria` evaluator. New AMM support is added by implementing a new // adapter module here. // -// All adapter parse functions in this milestone are honest stubs that -// revert with a named error code (`AmmAdapterUnimplemented` for AMMs, -// `LockerAdapterUnimplemented` for lockers). The architectural surface -// is exercised by virtue of compilation; runtime behavior is supplied -// by follow-up PRs that implement the actual byte-layout parsing. +// Convention since m4 (Raydium V4 layout adapter): each adapter takes +// both the pool account AND the `remaining_accounts` slice. Reserves and +// LP supply for the pool live on separate SPL token accounts (the pool's +// coin_vault, pc_vault, lp_mint) — these MUST be included in +// `remaining_accounts` by the caller. Adapters look them up by Pubkey. +// Adapters whose layout parsing isn't yet implemented continue to revert +// `AmmAdapterUnimplemented`. +// +// Locker adapter (`locker.rs`) follows the same `remaining_accounts` +// convention for UNCX / PinkSale / Team Finance locker container accounts. // // Auditor's one-liner: `grep -rn "PRE-MAINNET-TODO" programs/`. @@ -33,6 +38,11 @@ pub mod raydium_v4; #[derive(Clone, Copy, Debug)] pub struct PoolData { /// Unix timestamp of the last on-chain swap. Used for Criterion 1. + /// AMM-specific: Raydium V4 does not store a last-swap timestamp on + /// the pool account; in that case the adapter returns 0 and the + /// handler reads `last_swap_unix_ts` from instruction params instead + /// (gated by the ORACLE-002 PRE-MAINNET-TODO until indexer-signed + /// attestation lands). pub last_swap_unix_ts: i64, /// Base-side reserves (the memecoin / measured token). pub base_reserve: u64, @@ -44,6 +54,8 @@ pub struct PoolData { pub base_mint: Pubkey, /// Mint of the quote token. pub quote_mint: Pubkey, + /// LP mint — needed for the Criterion 5 locker check. + pub lp_mint: Pubkey, } impl PoolData { @@ -60,6 +72,24 @@ impl PoolData { } } +// ===================================================================== +// Account-lookup helper for adapter parsers. +// ===================================================================== + +/// Find an `AccountInfo` in `accounts` whose key matches `key`. +/// +/// Adapters call this to resolve the pool's vault and lp_mint accounts +/// from the instruction's `remaining_accounts` slice. +pub fn find_account_by_key<'a, 'info>( + accounts: &'a [AccountInfo<'info>], + key: &Pubkey, +) -> Result<&'a AccountInfo<'info>> { + accounts + .iter() + .find(|info| info.key == key) + .ok_or_else(|| GraveScannerError::PoolDataParseError.into()) +} + // ===================================================================== // Top-level dispatch by AMM program ID. // ===================================================================== @@ -70,9 +100,13 @@ impl PoolData { /// IDs declared in each adapter module. Mismatches return /// `UnsupportedAmm`; supported AMMs whose parser is not yet implemented /// return `AmmAdapterUnimplemented`. -pub fn extract_pool_data( - pool_account_info: &AccountInfo, +/// +/// `remaining_accounts` is the full instruction `remaining_accounts` +/// slice. Adapters look up the pool's vault and lp_mint accounts here. +pub fn extract_pool_data<'info>( + pool_account_info: &AccountInfo<'info>, expected_pool_address: &Pubkey, + remaining_accounts: &[AccountInfo<'info>], ) -> Result { require_keys_eq!( *pool_account_info.key, @@ -82,15 +116,15 @@ pub fn extract_pool_data( let owner = pool_account_info.owner; if owner == &raydium_v4::PROGRAM_ID { - raydium_v4::parse(pool_account_info) + raydium_v4::parse(pool_account_info, remaining_accounts) } else if owner == &raydium_clmm::PROGRAM_ID { - raydium_clmm::parse(pool_account_info) + raydium_clmm::parse(pool_account_info, remaining_accounts) } else if owner == &orca_whirlpool::PROGRAM_ID { - orca_whirlpool::parse(pool_account_info) + orca_whirlpool::parse(pool_account_info, remaining_accounts) } else if owner == &pumpswap::PROGRAM_ID { - pumpswap::parse(pool_account_info) + pumpswap::parse(pool_account_info, remaining_accounts) } else if owner == &meteora::PROGRAM_ID { - meteora::parse(pool_account_info) + meteora::parse(pool_account_info, remaining_accounts) } else { err!(GraveScannerError::UnsupportedAmm) } diff --git a/programs/grave-scanner/src/adapters/orca_whirlpool.rs b/programs/grave-scanner/src/adapters/orca_whirlpool.rs index 16acfb9..012a102 100644 --- a/programs/grave-scanner/src/adapters/orca_whirlpool.rs +++ b/programs/grave-scanner/src/adapters/orca_whirlpool.rs @@ -1,13 +1,11 @@ // SPDX-License-Identifier: Apache-2.0 // -// Orca Whirlpool pool adapter. +// Orca Whirlpool pool adapter — stub. // // Mainnet program ID: whirLbMiicVdio4qvUfM5KAg6Ct8VwpYzGff3uctyCc // -// Whirlpool accounts hold sqrt_price_x64 and tick state; reserves -// are computed from token-vault accounts owned by the whirlpool. The -// `Whirlpool::reward_last_updated_timestamp` and explicit on-chain -// counters can serve as an upper bound on the last-swap timestamp. +// Whirlpool is also concentrated-liquidity; reserves require token-vault +// aggregation rather than a single base/quote read. use anchor_lang::prelude::*; use anchor_lang::solana_program::pubkey; @@ -18,9 +16,12 @@ use crate::errors::GraveScannerError; /// Mainnet Orca Whirlpool program ID. pub const PROGRAM_ID: Pubkey = pubkey!("whirLbMiicVdio4qvUfM5KAg6Ct8VwpYzGff3uctyCc"); -/// Parse an Orca Whirlpool account into `PoolData`. +/// Parse an Orca Whirlpool pool account into `PoolData`. /// /// PRE-MAINNET-TODO(CPI): Orca Whirlpool layout parsing + token-vault reserve aggregation | reverts: AmmAdapterUnimplemented | verify: against Whirlpool SDK and mainnet pool fixtures -pub fn parse(_pool_account_info: &AccountInfo) -> Result { +pub fn parse<'info>( + _pool_account_info: &AccountInfo<'info>, + _remaining_accounts: &[AccountInfo<'info>], +) -> Result { err!(GraveScannerError::AmmAdapterUnimplemented) } diff --git a/programs/grave-scanner/src/adapters/pumpswap.rs b/programs/grave-scanner/src/adapters/pumpswap.rs index ed7e732..6200c84 100644 --- a/programs/grave-scanner/src/adapters/pumpswap.rs +++ b/programs/grave-scanner/src/adapters/pumpswap.rs @@ -1,14 +1,11 @@ // SPDX-License-Identifier: Apache-2.0 // -// PumpSwap (pump.fun's bonding-curve graduated AMM) pool adapter. +// PumpSwap (pump.fun graduated AMM) pool adapter — stub. // // Mainnet program ID: PSwapMdSai8tjrEXcxFeQth87xC4rRsa4VA5mhGhXkP // -// PumpSwap is the post-graduation AMM for pump.fun tokens that -// reach the bonding-curve cap. Pre-graduation pools live in the -// pump.fun program itself and are out of scope for derelict-pool -// salvage (graduations are gated by liquidity thresholds GraveYield -// would never trigger on). +// PumpSwap pools share a similar two-vault shape with Raydium V4, but +// the AMM account layout differs in field offsets and the vault types. use anchor_lang::prelude::*; use anchor_lang::solana_program::pubkey; @@ -22,6 +19,9 @@ pub const PROGRAM_ID: Pubkey = pubkey!("PSwapMdSai8tjrEXcxFeQth87xC4rRsa4VA5mhGh /// Parse a PumpSwap pool account into `PoolData`. /// /// PRE-MAINNET-TODO(CPI): PumpSwap pool layout parsing | reverts: AmmAdapterUnimplemented | verify: against PumpSwap SDK and mainnet pool fixtures -pub fn parse(_pool_account_info: &AccountInfo) -> Result { +pub fn parse<'info>( + _pool_account_info: &AccountInfo<'info>, + _remaining_accounts: &[AccountInfo<'info>], +) -> Result { err!(GraveScannerError::AmmAdapterUnimplemented) } diff --git a/programs/grave-scanner/src/adapters/raydium_clmm.rs b/programs/grave-scanner/src/adapters/raydium_clmm.rs index 638e44b..e2dc4da 100644 --- a/programs/grave-scanner/src/adapters/raydium_clmm.rs +++ b/programs/grave-scanner/src/adapters/raydium_clmm.rs @@ -1,13 +1,12 @@ // SPDX-License-Identifier: Apache-2.0 // -// Raydium CLMM (concentrated liquidity) pool adapter. +// Raydium CLMM (Concentrated Liquidity Market Maker) pool adapter — stub. // // Mainnet program ID: CAMMCzo5YL8w4VFF8KVHrK22GGUsp5VTaW7grrKgrWqK // -// CLMM pool state lives in `PoolState` accounts; reserves are -// derived from the active tick range and not directly stored as -// "amounts" the way V4 does. Salvage of CLMM positions is a v1.1 -// roadmap item — V4 covers the bulk of derelict pools. +// Concentrated liquidity introduces tick-range reserve calculation; this +// adapter must aggregate across active tick ranges rather than reading a +// single vault pair. v1.1 milestone. use anchor_lang::prelude::*; use anchor_lang::solana_program::pubkey; @@ -21,6 +20,9 @@ pub const PROGRAM_ID: Pubkey = pubkey!("CAMMCzo5YL8w4VFF8KVHrK22GGUsp5VTaW7grrKg /// Parse a Raydium CLMM pool account into `PoolData`. /// /// PRE-MAINNET-TODO(CPI): Raydium CLMM pool layout parsing + tick-range reserve calculation | reverts: AmmAdapterUnimplemented | verify: against mainnet pool fixtures and Raydium CLMM SDK -pub fn parse(_pool_account_info: &AccountInfo) -> Result { +pub fn parse<'info>( + _pool_account_info: &AccountInfo<'info>, + _remaining_accounts: &[AccountInfo<'info>], +) -> Result { err!(GraveScannerError::AmmAdapterUnimplemented) } diff --git a/programs/grave-scanner/src/adapters/raydium_v4.rs b/programs/grave-scanner/src/adapters/raydium_v4.rs index 0fcfff8..e6743ef 100644 --- a/programs/grave-scanner/src/adapters/raydium_v4.rs +++ b/programs/grave-scanner/src/adapters/raydium_v4.rs @@ -1,34 +1,205 @@ // SPDX-License-Identifier: Apache-2.0 // -// Raydium V4 (legacy AMM v4) pool adapter. +// Raydium V4 (legacy AMM v4) pool adapter — m4 implementation. // // Mainnet program ID: 675kPX9MHTjS2zt1qfr1NYHuzeLXfQM9H24wFSUt1Mp8 // -// The full Raydium V4 pool account layout is documented at -// https://github.com/raydium-io/raydium-amm/blob/master/program/src/state.rs. -// The fields of interest for GraveScanner are: -// - `pool_open_time` and `swap_*_amount` counters (for last-swap heuristics) -// - `coin_vault_amount`, `pc_vault_amount` (reserves) -// - `lp_mint_supply` (LP supply via the lp_mint account, not the pool) +// The Raydium V4 AMM pool account ("AmmInfo") is a 752-byte struct +// documented at: +// https://github.com/raydium-io/raydium-amm/blob/master/program/src/state.rs // -// Last-swap timestamp is not directly stored on-chain by Raydium V4; -// the indexer-grade approach is to use the most recent slot at which -// the swap counters changed. For the on-chain handler, the salvor SDK -// supplies this via a `record_pool_activity` instruction that the -// indexer signs (see roadmap m4). +// Fields GraveScanner reads from AmmInfo: +// - coin_vault, pc_vault (Pubkey) — vault SPL token accounts +// (reserves live here, NOT on +// the pool account itself) +// - coin_vault_mint, pc_vault_mint (Pubkey) — base/quote mint addresses +// - lp_mint (Pubkey) — LP token mint +// +// The caller MUST pass `coin_vault`, `pc_vault`, and `lp_mint` accounts +// via `remaining_accounts` (any order — looked up by Pubkey). Reserve and +// LP-supply values are read from those SPL accounts. +// +// Raydium V4's AmmInfo does NOT store a last-swap-timestamp field. The +// adapter returns 0 as a sentinel; the criteria evaluator currently +// takes `last_swap_unix_ts` from the instruction param (tagged +// ORACLE-002 in docs/PRE_MAINNET_CHECKLIST.md until indexer-signed +// attestation lands). use anchor_lang::prelude::*; use anchor_lang::solana_program::pubkey; +use anchor_spl::token::{Mint, TokenAccount}; -use super::PoolData; +use super::{find_account_by_key, PoolData}; use crate::errors::GraveScannerError; /// Mainnet Raydium V4 program ID. pub const PROGRAM_ID: Pubkey = pubkey!("675kPX9MHTjS2zt1qfr1NYHuzeLXfQM9H24wFSUt1Mp8"); -/// Parse a Raydium V4 pool account into `PoolData`. +/// Canonical AmmInfo account size on Raydium V4. Pool accounts that +/// don't match this size are rejected as the wrong shape. +pub const AMM_INFO_SIZE: usize = 752; + +/// Byte offsets into AmmInfo for the Pubkey fields we read. Verified +/// against the Raydium open-source AMM source-of-truth and the canonical +/// 752-byte layout: 16 u64 prefix (128) + Fees (64) + StateData (144) +/// + 9 Pubkeys (288) + 8 u64 padding (64) + amm_owner (32) + 2 u64 (16) +/// + 2 u64 padding (16) = 752. +mod offsets { + pub const COIN_VAULT: usize = 336; + pub const PC_VAULT: usize = 368; + pub const COIN_VAULT_MINT: usize = 400; + pub const PC_VAULT_MINT: usize = 432; + pub const LP_MINT: usize = 464; +} + +/// Parse a Raydium V4 AMM pool account into `PoolData`. /// -/// PRE-MAINNET-TODO(CPI): Raydium V4 pool layout parsing | reverts: AmmAdapterUnimplemented | verify: against mainnet pool fixtures (e.g. 9d9mb8kooFfaD3SctgZtkxQypkshx6ezhbKio89ixyy2 SOL/USDC) and Raydium SDK source-of-truth field offsets -pub fn parse(_pool_account_info: &AccountInfo) -> Result { - err!(GraveScannerError::AmmAdapterUnimplemented) +/// Caller must include the pool's `coin_vault`, `pc_vault`, and +/// `lp_mint` accounts in `remaining_accounts` (any order — looked up by +/// Pubkey). Reserves and LP supply are read from those SPL accounts. +/// +/// Defense in depth: the vault token accounts' `mint` fields are +/// verified against AmmInfo's claimed `coin_vault_mint` / +/// `pc_vault_mint`, so an attacker who supplies the genuine pool account +/// but forged vault accounts (e.g. vaults pointing to a different mint) +/// is rejected with `PoolDataParseError`. +pub fn parse<'info>( + pool_account_info: &AccountInfo<'info>, + remaining_accounts: &[AccountInfo<'info>], +) -> Result { + let (coin_vault, pc_vault, coin_vault_mint, pc_vault_mint, lp_mint) = { + let data = pool_account_info + .try_borrow_data() + .map_err(|_| GraveScannerError::PoolDataParseError)?; + require!( + data.len() == AMM_INFO_SIZE, + GraveScannerError::PoolDataParseError + ); + ( + read_pubkey(&data, offsets::COIN_VAULT)?, + read_pubkey(&data, offsets::PC_VAULT)?, + read_pubkey(&data, offsets::COIN_VAULT_MINT)?, + read_pubkey(&data, offsets::PC_VAULT_MINT)?, + read_pubkey(&data, offsets::LP_MINT)?, + ) + }; + + let coin_vault_info = find_account_by_key(remaining_accounts, &coin_vault)?; + let pc_vault_info = find_account_by_key(remaining_accounts, &pc_vault)?; + let lp_mint_info = find_account_by_key(remaining_accounts, &lp_mint)?; + + // `Account::::try_from` validates account ownership against the SPL + // Token Program ID. A non-token account passed in remaining_accounts is + // rejected here regardless of its data layout. + let coin_account: Account = Account::try_from(coin_vault_info) + .map_err(|_| GraveScannerError::PoolDataParseError)?; + let pc_account: Account = Account::try_from(pc_vault_info) + .map_err(|_| GraveScannerError::PoolDataParseError)?; + let lp_mint_account: Account = Account::try_from(lp_mint_info) + .map_err(|_| GraveScannerError::PoolDataParseError)?; + + require_keys_eq!( + coin_account.mint, + coin_vault_mint, + GraveScannerError::PoolDataParseError + ); + require_keys_eq!( + pc_account.mint, + pc_vault_mint, + GraveScannerError::PoolDataParseError + ); + + Ok(PoolData { + last_swap_unix_ts: 0, + base_reserve: coin_account.amount, + quote_reserve: pc_account.amount, + lp_supply: lp_mint_account.supply, + base_mint: coin_vault_mint, + quote_mint: pc_vault_mint, + lp_mint, + }) +} + +fn read_pubkey(data: &[u8], offset: usize) -> Result { + require!( + data.len() >= offset + 32, + GraveScannerError::PoolDataParseError + ); + let mut buf = [0u8; 32]; + buf.copy_from_slice(&data[offset..offset + 32]); + Ok(Pubkey::new_from_array(buf)) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Build a synthetic AmmInfo byte fixture with known Pubkeys at the + /// documented offsets. The remaining bytes are zeroed — sufficient + /// for layout/offset roundtrip testing without solana-test-validator. + fn build_synthetic_amm_info( + coin_vault: &Pubkey, + pc_vault: &Pubkey, + coin_vault_mint: &Pubkey, + pc_vault_mint: &Pubkey, + lp_mint: &Pubkey, + ) -> Vec { + let mut buf = vec![0u8; AMM_INFO_SIZE]; + buf[offsets::COIN_VAULT..offsets::COIN_VAULT + 32].copy_from_slice(coin_vault.as_ref()); + buf[offsets::PC_VAULT..offsets::PC_VAULT + 32].copy_from_slice(pc_vault.as_ref()); + buf[offsets::COIN_VAULT_MINT..offsets::COIN_VAULT_MINT + 32] + .copy_from_slice(coin_vault_mint.as_ref()); + buf[offsets::PC_VAULT_MINT..offsets::PC_VAULT_MINT + 32] + .copy_from_slice(pc_vault_mint.as_ref()); + buf[offsets::LP_MINT..offsets::LP_MINT + 32].copy_from_slice(lp_mint.as_ref()); + buf + } + + #[test] + fn amm_info_size_matches_raydium_v4_canonical_752() { + assert_eq!(AMM_INFO_SIZE, 752); + } + + #[test] + fn read_pubkey_extracts_value_at_offset() { + let key = Pubkey::new_unique(); + let mut data = vec![0u8; 200]; + data[100..132].copy_from_slice(key.as_ref()); + assert_eq!(read_pubkey(&data, 100).unwrap(), key); + } + + #[test] + fn read_pubkey_rejects_out_of_bounds_read() { + let data = vec![0u8; 10]; + assert!(read_pubkey(&data, 5).is_err()); + } + + #[test] + fn synthetic_fixture_roundtrips_all_five_pubkey_fields() { + let coin_vault = Pubkey::new_unique(); + let pc_vault = Pubkey::new_unique(); + let coin_vault_mint = Pubkey::new_unique(); + let pc_vault_mint = Pubkey::new_unique(); + let lp_mint = Pubkey::new_unique(); + let buf = build_synthetic_amm_info( + &coin_vault, + &pc_vault, + &coin_vault_mint, + &pc_vault_mint, + &lp_mint, + ); + + assert_eq!(buf.len(), AMM_INFO_SIZE); + assert_eq!(read_pubkey(&buf, offsets::COIN_VAULT).unwrap(), coin_vault); + assert_eq!(read_pubkey(&buf, offsets::PC_VAULT).unwrap(), pc_vault); + assert_eq!( + read_pubkey(&buf, offsets::COIN_VAULT_MINT).unwrap(), + coin_vault_mint + ); + assert_eq!( + read_pubkey(&buf, offsets::PC_VAULT_MINT).unwrap(), + pc_vault_mint + ); + assert_eq!(read_pubkey(&buf, offsets::LP_MINT).unwrap(), lp_mint); + } } diff --git a/programs/grave-scanner/src/instructions/evaluate_pool_phase1.rs b/programs/grave-scanner/src/instructions/evaluate_pool_phase1.rs index 9206c18..7dd69f8 100644 --- a/programs/grave-scanner/src/instructions/evaluate_pool_phase1.rs +++ b/programs/grave-scanner/src/instructions/evaluate_pool_phase1.rs @@ -59,7 +59,8 @@ pub struct EvaluatePoolPhase1<'info> { /// CHECK: AMM-specific account introspection happens via the /// `adapters` module. The account is validated against - /// `params.pool_address` and dispatched by `pool.owner`. + /// `params.pool_address` and dispatched by `pool.owner`. Vault and + /// lp_mint accounts are passed via `remaining_accounts`. pub pool: UncheckedAccount<'info>, #[account(mut)] @@ -74,19 +75,23 @@ pub fn handler(ctx: Context, params: EvaluatePoolPhase1Param let clock = Clock::get().map_err(|_| GraveScannerError::InvalidClock)?; - // Extract AMM-side pool snapshot. Honest-stub adapter pattern: this - // call reverts with `AmmAdapterUnimplemented` until per-AMM layout + // Extract AMM-side pool snapshot. Per the m4 convention, the adapter + // reads reserves/lp_supply from the pool's vault and lp_mint accounts + // passed via `remaining_accounts`. Raydium V4 is wired; the other + // adapters revert `AmmAdapterUnimplemented` until their layout // parsers land. - let pool_data: PoolData = - adapters::extract_pool_data(&ctx.accounts.pool.to_account_info(), ¶ms.pool_address)?; + let pool_data: PoolData = adapters::extract_pool_data( + &ctx.accounts.pool.to_account_info(), + ¶ms.pool_address, + ctx.remaining_accounts, + )?; // Locker introspection. Honest-stub adapter pattern: reverts with // `LockerAdapterUnimplemented` until UNCX/PinkSale/TeamFinance - // adapters land. - let lp_locked_amount = adapters::locker::locked_lp_amount( - &pool_data.base_mint, // The LP mint will live in PoolData once adapters land; using base_mint as placeholder to keep the surface compiled. - ctx.remaining_accounts, - )?; + // adapters land. Receives the true LP mint (post-m4) so the locker + // check is semantically correct once implemented. + let lp_locked_amount = + adapters::locker::locked_lp_amount(&pool_data.lp_mint, ctx.remaining_accounts)?; let inputs = CriteriaInputs { last_swap_unix_ts: params.last_swap_unix_ts, diff --git a/programs/grave-scanner/src/instructions/evaluate_pool_phase2.rs b/programs/grave-scanner/src/instructions/evaluate_pool_phase2.rs index fddfb00..19744de 100644 --- a/programs/grave-scanner/src/instructions/evaluate_pool_phase2.rs +++ b/programs/grave-scanner/src/instructions/evaluate_pool_phase2.rs @@ -66,7 +66,8 @@ pub struct EvaluatePoolPhase2<'info> { pub launch_price: Account<'info, LaunchPrice>, /// CHECK: AMM-specific introspection (re-verification of all six - /// criteria) is dispatched through the `adapters` module. + /// criteria) is dispatched through the `adapters` module. Vault and + /// lp_mint accounts are passed via `remaining_accounts`. pub pool: UncheckedAccount<'info>, #[account(mut)] @@ -87,11 +88,14 @@ pub fn handler(ctx: Context, params: EvaluatePoolPhase2Param GraveScannerError::AnchorInvalidated ); - let pool_data: PoolData = - adapters::extract_pool_data(&ctx.accounts.pool.to_account_info(), ¶ms.pool_address)?; + let pool_data: PoolData = adapters::extract_pool_data( + &ctx.accounts.pool.to_account_info(), + ¶ms.pool_address, + ctx.remaining_accounts, + )?; let lp_locked_amount = - adapters::locker::locked_lp_amount(&pool_data.base_mint, ctx.remaining_accounts)?; + adapters::locker::locked_lp_amount(&pool_data.lp_mint, ctx.remaining_accounts)?; let inputs = CriteriaInputs { last_swap_unix_ts: params.last_swap_unix_ts, From 3e1afa933ea0dd5b5d04fc5e26ef82a444f15498 Mon Sep 17 00:00:00 2001 From: GraveYield Date: Wed, 13 May 2026 22:38:51 +0800 Subject: [PATCH 02/11] =?UTF-8?q?fix(grave-scanner):=20m4.0.1=20=E2=80=94?= =?UTF-8?q?=20lifetime=20threading=20+=20pre-existing=20fmt=20drift?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #13 CI failed with 5 lifetime/compile errors across evaluate_pool_phase1.rs, evaluate_pool_phase2.rs, and adapters/raydium_v4.rs. Root cause: extract_pool_data took `&[AccountInfo<'info>]` (anonymous outer slice ref lifetime). Anchor's Context is invariant in 'info, so the AccountInfo<'info> from ctx.accounts.pool.to_account_info() and the slice from ctx.remaining_accounts both need to share the SAME 'info — anonymous outer lifetimes don't bind that constraint, and find_account_by_key's return-by-reference made it explicit. Fix (canonical Anchor 0.31.x pattern): - adapters/{mod.rs, raydium_v4.rs, raydium_clmm.rs, orca_whirlpool.rs, pumpswap.rs, meteora.rs}: `&[AccountInfo<'info>]` -> `&'info [AccountInfo<'info>]` in extract_pool_data, all 5 parse() functions, and find_account_by_key. The outer slice ref now binds to the same 'info as the inner AccountInfo. - instructions/evaluate_pool_phase{1,2}.rs handler signatures: thread 'info as `Context<'_, '_, 'info, 'info, EvaluatePoolPhase{1,2}<'info>>`. Note the 3rd lifetime (remaining_accounts slice ref) is also 'info — matches extract_pool_data. - lib.rs #[program] wrappers evaluate_pool_phase_{1,2}: same signature so the generated dispatch unifies lifetimes correctly across the macro boundary. Plus a pre-existing fmt drift fix on grave-vault/claim_lp_proceeds.rs that was broken on main pre-m4: handler signature wraps to a single line and the amount.try_into() chain unfolds to multi-line per rustfmt max_width=100. Was masked because no CI ran on main between PR #10's fixup commit and PR #11's merge; the m4 branch CI is what first surfaced it. Local verification (Rust 1.85.0 host toolchain): - cargo fmt --all -- --check: clean - cargo clippy --workspace --all-targets -- -D warnings: clean - cargo test -p grave-scanner --lib: 19/19 pass (15 criteria + 4 raydium_v4) anchor build (BPF target via Solana platform-tools) deferred to CI as before. --- programs/grave-scanner/src/adapters/meteora.rs | 2 +- programs/grave-scanner/src/adapters/mod.rs | 8 ++++---- .../grave-scanner/src/adapters/orca_whirlpool.rs | 2 +- programs/grave-scanner/src/adapters/pumpswap.rs | 2 +- .../grave-scanner/src/adapters/raydium_clmm.rs | 2 +- programs/grave-scanner/src/adapters/raydium_v4.rs | 14 +++++++------- .../src/instructions/evaluate_pool_phase1.rs | 5 ++++- .../src/instructions/evaluate_pool_phase2.rs | 5 ++++- programs/grave-scanner/src/lib.rs | 8 ++++---- .../src/instructions/claim_lp_proceeds.rs | 9 ++++----- 10 files changed, 31 insertions(+), 26 deletions(-) diff --git a/programs/grave-scanner/src/adapters/meteora.rs b/programs/grave-scanner/src/adapters/meteora.rs index 56a158c..3550481 100644 --- a/programs/grave-scanner/src/adapters/meteora.rs +++ b/programs/grave-scanner/src/adapters/meteora.rs @@ -23,7 +23,7 @@ pub const PROGRAM_ID: Pubkey = pubkey!("LBUZKhRxPF3XUpBCjp4YzTKgLccjZhTSDM9YuVaP /// PRE-MAINNET-TODO(CPI): Meteora DLMM/Dynamic AMM pool layout parsing | reverts: AmmAdapterUnimplemented | verify: against Meteora SDK pub fn parse<'info>( _pool_account_info: &AccountInfo<'info>, - _remaining_accounts: &[AccountInfo<'info>], + _remaining_accounts: &'info [AccountInfo<'info>], ) -> Result { err!(GraveScannerError::AmmAdapterUnimplemented) } diff --git a/programs/grave-scanner/src/adapters/mod.rs b/programs/grave-scanner/src/adapters/mod.rs index 5f9dfbf..546e0b1 100644 --- a/programs/grave-scanner/src/adapters/mod.rs +++ b/programs/grave-scanner/src/adapters/mod.rs @@ -80,10 +80,10 @@ impl PoolData { /// /// Adapters call this to resolve the pool's vault and lp_mint accounts /// from the instruction's `remaining_accounts` slice. -pub fn find_account_by_key<'a, 'info>( - accounts: &'a [AccountInfo<'info>], +pub fn find_account_by_key<'info>( + accounts: &'info [AccountInfo<'info>], key: &Pubkey, -) -> Result<&'a AccountInfo<'info>> { +) -> Result<&'info AccountInfo<'info>> { accounts .iter() .find(|info| info.key == key) @@ -106,7 +106,7 @@ pub fn find_account_by_key<'a, 'info>( pub fn extract_pool_data<'info>( pool_account_info: &AccountInfo<'info>, expected_pool_address: &Pubkey, - remaining_accounts: &[AccountInfo<'info>], + remaining_accounts: &'info [AccountInfo<'info>], ) -> Result { require_keys_eq!( *pool_account_info.key, diff --git a/programs/grave-scanner/src/adapters/orca_whirlpool.rs b/programs/grave-scanner/src/adapters/orca_whirlpool.rs index 012a102..387cab6 100644 --- a/programs/grave-scanner/src/adapters/orca_whirlpool.rs +++ b/programs/grave-scanner/src/adapters/orca_whirlpool.rs @@ -21,7 +21,7 @@ pub const PROGRAM_ID: Pubkey = pubkey!("whirLbMiicVdio4qvUfM5KAg6Ct8VwpYzGff3uct /// PRE-MAINNET-TODO(CPI): Orca Whirlpool layout parsing + token-vault reserve aggregation | reverts: AmmAdapterUnimplemented | verify: against Whirlpool SDK and mainnet pool fixtures pub fn parse<'info>( _pool_account_info: &AccountInfo<'info>, - _remaining_accounts: &[AccountInfo<'info>], + _remaining_accounts: &'info [AccountInfo<'info>], ) -> Result { err!(GraveScannerError::AmmAdapterUnimplemented) } diff --git a/programs/grave-scanner/src/adapters/pumpswap.rs b/programs/grave-scanner/src/adapters/pumpswap.rs index 6200c84..9b6c1e1 100644 --- a/programs/grave-scanner/src/adapters/pumpswap.rs +++ b/programs/grave-scanner/src/adapters/pumpswap.rs @@ -21,7 +21,7 @@ pub const PROGRAM_ID: Pubkey = pubkey!("PSwapMdSai8tjrEXcxFeQth87xC4rRsa4VA5mhGh /// PRE-MAINNET-TODO(CPI): PumpSwap pool layout parsing | reverts: AmmAdapterUnimplemented | verify: against PumpSwap SDK and mainnet pool fixtures pub fn parse<'info>( _pool_account_info: &AccountInfo<'info>, - _remaining_accounts: &[AccountInfo<'info>], + _remaining_accounts: &'info [AccountInfo<'info>], ) -> Result { err!(GraveScannerError::AmmAdapterUnimplemented) } diff --git a/programs/grave-scanner/src/adapters/raydium_clmm.rs b/programs/grave-scanner/src/adapters/raydium_clmm.rs index e2dc4da..0ad9484 100644 --- a/programs/grave-scanner/src/adapters/raydium_clmm.rs +++ b/programs/grave-scanner/src/adapters/raydium_clmm.rs @@ -22,7 +22,7 @@ pub const PROGRAM_ID: Pubkey = pubkey!("CAMMCzo5YL8w4VFF8KVHrK22GGUsp5VTaW7grrKg /// PRE-MAINNET-TODO(CPI): Raydium CLMM pool layout parsing + tick-range reserve calculation | reverts: AmmAdapterUnimplemented | verify: against mainnet pool fixtures and Raydium CLMM SDK pub fn parse<'info>( _pool_account_info: &AccountInfo<'info>, - _remaining_accounts: &[AccountInfo<'info>], + _remaining_accounts: &'info [AccountInfo<'info>], ) -> Result { err!(GraveScannerError::AmmAdapterUnimplemented) } diff --git a/programs/grave-scanner/src/adapters/raydium_v4.rs b/programs/grave-scanner/src/adapters/raydium_v4.rs index e6743ef..f0a7eb3 100644 --- a/programs/grave-scanner/src/adapters/raydium_v4.rs +++ b/programs/grave-scanner/src/adapters/raydium_v4.rs @@ -65,7 +65,7 @@ mod offsets { /// is rejected with `PoolDataParseError`. pub fn parse<'info>( pool_account_info: &AccountInfo<'info>, - remaining_accounts: &[AccountInfo<'info>], + remaining_accounts: &'info [AccountInfo<'info>], ) -> Result { let (coin_vault, pc_vault, coin_vault_mint, pc_vault_mint, lp_mint) = { let data = pool_account_info @@ -91,12 +91,12 @@ pub fn parse<'info>( // `Account::::try_from` validates account ownership against the SPL // Token Program ID. A non-token account passed in remaining_accounts is // rejected here regardless of its data layout. - let coin_account: Account = Account::try_from(coin_vault_info) - .map_err(|_| GraveScannerError::PoolDataParseError)?; - let pc_account: Account = Account::try_from(pc_vault_info) - .map_err(|_| GraveScannerError::PoolDataParseError)?; - let lp_mint_account: Account = Account::try_from(lp_mint_info) - .map_err(|_| GraveScannerError::PoolDataParseError)?; + let coin_account: Account = + Account::try_from(coin_vault_info).map_err(|_| GraveScannerError::PoolDataParseError)?; + let pc_account: Account = + Account::try_from(pc_vault_info).map_err(|_| GraveScannerError::PoolDataParseError)?; + let lp_mint_account: Account = + Account::try_from(lp_mint_info).map_err(|_| GraveScannerError::PoolDataParseError)?; require_keys_eq!( coin_account.mint, diff --git a/programs/grave-scanner/src/instructions/evaluate_pool_phase1.rs b/programs/grave-scanner/src/instructions/evaluate_pool_phase1.rs index 7dd69f8..3704356 100644 --- a/programs/grave-scanner/src/instructions/evaluate_pool_phase1.rs +++ b/programs/grave-scanner/src/instructions/evaluate_pool_phase1.rs @@ -69,7 +69,10 @@ pub struct EvaluatePoolPhase1<'info> { pub system_program: Program<'info, System>, } -pub fn handler(ctx: Context, params: EvaluatePoolPhase1Params) -> Result<()> { +pub fn handler<'info>( + ctx: Context<'_, '_, 'info, 'info, EvaluatePoolPhase1<'info>>, + params: EvaluatePoolPhase1Params, +) -> Result<()> { let cfg = &ctx.accounts.protocol_config; require!(!cfg.paused, GraveScannerError::ProtocolPaused); diff --git a/programs/grave-scanner/src/instructions/evaluate_pool_phase2.rs b/programs/grave-scanner/src/instructions/evaluate_pool_phase2.rs index 19744de..2c8c4f6 100644 --- a/programs/grave-scanner/src/instructions/evaluate_pool_phase2.rs +++ b/programs/grave-scanner/src/instructions/evaluate_pool_phase2.rs @@ -76,7 +76,10 @@ pub struct EvaluatePoolPhase2<'info> { pub system_program: Program<'info, System>, } -pub fn handler(ctx: Context, params: EvaluatePoolPhase2Params) -> Result<()> { +pub fn handler<'info>( + ctx: Context<'_, '_, 'info, 'info, EvaluatePoolPhase2<'info>>, + params: EvaluatePoolPhase2Params, +) -> Result<()> { let cfg = &ctx.accounts.protocol_config; require!(!cfg.paused, GraveScannerError::ProtocolPaused); diff --git a/programs/grave-scanner/src/lib.rs b/programs/grave-scanner/src/lib.rs index 504b545..47cf5f2 100644 --- a/programs/grave-scanner/src/lib.rs +++ b/programs/grave-scanner/src/lib.rs @@ -65,8 +65,8 @@ pub mod grave_scanner { /// Phase 1 — evaluate all six derelict criteria and write an /// `EligibilityAnchor` PDA. Sets `first_eligible_epoch = current_epoch`. - pub fn evaluate_pool_phase_1( - ctx: Context, + pub fn evaluate_pool_phase_1<'info>( + ctx: Context<'_, '_, 'info, 'info, EvaluatePoolPhase1<'info>>, params: EvaluatePoolPhase1Params, ) -> Result<()> { instructions::evaluate_pool_phase1::handler(ctx, params) @@ -76,8 +76,8 @@ pub mod grave_scanner { /// gap (≥2 consecutive Solana epochs, ~4-6 days). On success, writes an /// `EligibilityCert` PDA with TTL = 1h. GraveVault consumes the cert to /// authorise `salvage_pool`. - pub fn evaluate_pool_phase_2( - ctx: Context, + pub fn evaluate_pool_phase_2<'info>( + ctx: Context<'_, '_, 'info, 'info, EvaluatePoolPhase2<'info>>, params: EvaluatePoolPhase2Params, ) -> Result<()> { instructions::evaluate_pool_phase2::handler(ctx, params) diff --git a/programs/grave-vault/src/instructions/claim_lp_proceeds.rs b/programs/grave-vault/src/instructions/claim_lp_proceeds.rs index 54c1092..5278beb 100644 --- a/programs/grave-vault/src/instructions/claim_lp_proceeds.rs +++ b/programs/grave-vault/src/instructions/claim_lp_proceeds.rs @@ -61,10 +61,7 @@ pub struct ClaimLpProceeds<'info> { pub system_program: Program<'info, System>, } -pub fn handler( - ctx: Context, - params: ClaimLpProceedsParams, -) -> Result<()> { +pub fn handler(ctx: Context, params: ClaimLpProceedsParams) -> Result<()> { let registry = &mut ctx.accounts.pool_registry; // TODO(GraveVault m6): verify Merkle proof of (lp_holder, lp_balance) against @@ -84,7 +81,9 @@ pub fn handler( .ok_or(GraveVaultError::MathOverflow)? .checked_div(registry.lp_total_supply_at_snapshot as u128) .ok_or(GraveVaultError::MathOverflow)?; - let amount_u64: u64 = amount.try_into().map_err(|_| GraveVaultError::MathOverflow)?; + let amount_u64: u64 = amount + .try_into() + .map_err(|_| GraveVaultError::MathOverflow)?; let new_claimed = registry .lp_holder_pool_claimed_lamports From 3a0cd13f45a16372a28d0311d633dee94af26e07 Mon Sep 17 00:00:00 2001 From: GraveYield Date: Wed, 13 May 2026 22:46:16 +0800 Subject: [PATCH 03/11] ci(pnpm): pin pnpm/action-setup to v6.0.6 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The floating @v6 tag now resolves to v6.0.8 (released 2026-05-12), which regresses at install time — the action exits with failure in ~7 seconds, before pnpm runs. v6.0.7 (2026-05-11) appears to share the regression. Main's last green CI was 2026-05-11 while @v6 still resolved to v6.0.6, so the breakage only surfaces on PRs built after the bump. Pinning to v6.0.6 restores the known-good behaviour. Revisit when upstream confirms a fix; remove the pin and let the floating tag resolve again. --- .github/workflows/ci.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 15f1441..90660f7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -71,7 +71,10 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - - uses: pnpm/action-setup@v6 + # Pinned to v6.0.6 (2026-05-08) — v6.0.7 (2026-05-11) and v6.0.8 + # (2026-05-12) regress at install time, failing the action in ~7s + # before pnpm even runs. Revisit once upstream confirms a fix. + - uses: pnpm/action-setup@v6.0.6 with: version: ${{ env.PNPM_VERSION }} - uses: actions/setup-node@v6 From 5149d86a74461d063a518ca9dcbf1245ba72579d Mon Sep 17 00:00:00 2001 From: GraveYield Date: Wed, 13 May 2026 22:49:00 +0800 Subject: [PATCH 04/11] =?UTF-8?q?ci(pnpm):=20fix=20step=20order=20?= =?UTF-8?q?=E2=80=94=20setup-node=20MUST=20precede=20pnpm/action-setup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pnpm/action-setup@v6 removed the bundled Node.js runtime and now requires Node.js to be installed upstream before the action runs. The workflow had pnpm/action-setup@v6 BEFORE actions/setup-node@v6, so the action exits with failure in ~7-9 seconds the moment it can't find a Node binary on PATH. This was the actual root cause of the 'pnpm typecheck' failures on PR #13 CI, not a regression in pnpm/action-setup v6.0.7 / v6.0.8 as the prior commit (3a0cd13f) hypothesised. The version pin to v6.0.6 didn't help because v6.0.6 has the same Node-requirement contract. Reverting the v6.0.6 pin back to the floating @v6 tag (so future upstream fixes flow in) and swapping the step order. Main's last green CI on 2026-05-11 was a coincidence — that run probably succeeded because Node was somehow already on PATH from a runner default, or because the ordering happened to be different in that workflow snapshot. --- .github/workflows/ci.yml | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 90660f7..17a79d7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -71,18 +71,20 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - # Pinned to v6.0.6 (2026-05-08) — v6.0.7 (2026-05-11) and v6.0.8 - # (2026-05-12) regress at install time, failing the action in ~7s - # before pnpm even runs. Revisit once upstream confirms a fix. - - uses: pnpm/action-setup@v6.0.6 - with: - version: ${{ env.PNPM_VERSION }} + # pnpm/action-setup@v6 requires Node.js to already be installed + # (upstream removed the bundled Node in v6). setup-node MUST come + # first. The previous floating-tag failure at install time was this + # ordering, not a regression in v6.0.7/v6.0.8. - uses: actions/setup-node@v6 with: node-version: ${{ env.NODE_VERSION }} - # NOTE: `cache: pnpm` was removed. setup-node hashes `**/pnpm-lock.yaml` - # to compute the cache key, and no lockfile is committed yet (scaffold - # gap). Re-add once a `pnpm-lock.yaml` lands at the repo root. + - uses: pnpm/action-setup@v6 + with: + version: ${{ env.PNPM_VERSION }} + # NOTE: `cache: pnpm` was removed from setup-node. setup-node hashes + # `**/pnpm-lock.yaml` to compute the cache key, and no lockfile is + # committed yet (scaffold gap). Re-add once a `pnpm-lock.yaml` lands + # at the repo root. - run: pnpm install --frozen-lockfile=false - run: pnpm -r typecheck From a0e81551bdff3fc253bf08588f092ac320ecf6c7 Mon Sep 17 00:00:00 2001 From: GraveYield Date: Wed, 13 May 2026 22:51:53 +0800 Subject: [PATCH 05/11] =?UTF-8?q?ci(pnpm):=20drop=20explicit=20version=20a?= =?UTF-8?q?rg=20=E2=80=94=20auto-detect=20from=20packageManager?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pnpm/action-setup@v6 still fails at install with the workflow's bare `version: 9` input. The likely cause: v6's input parsing expects either a concrete semver (e.g. `9.12.0`) or no input at all (in which case it reads the `packageManager` field from package.json). package.json already pins `packageManager: pnpm@9.12.0`, so removing the explicit `version:` input lets the action auto-detect and resolves the duplicate-source-of-truth question on the pin. Also removes the PNPM_VERSION env var since nothing else references it. --- .github/workflows/ci.yml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 17a79d7..6841b14 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -78,9 +78,11 @@ jobs: - uses: actions/setup-node@v6 with: node-version: ${{ env.NODE_VERSION }} + # pnpm/action-setup@v6 auto-detects pnpm version from the + # `packageManager` field in package.json (pnpm@9.12.0). No explicit + # `version:` input — that field is what we want to be the single + # source of truth for the pnpm pin anyway. - uses: pnpm/action-setup@v6 - with: - version: ${{ env.PNPM_VERSION }} # NOTE: `cache: pnpm` was removed from setup-node. setup-node hashes # `**/pnpm-lock.yaml` to compute the cache key, and no lockfile is # committed yet (scaffold gap). Re-add once a `pnpm-lock.yaml` lands From 94d25cd4cece3882dcc79df0e0b7feeeac243391 Mon Sep 17 00:00:00 2001 From: GraveYield Date: Wed, 13 May 2026 23:04:54 +0800 Subject: [PATCH 06/11] =?UTF-8?q?ci(anchor):=20use=20cargo-binstall=20for?= =?UTF-8?q?=20anchor-cli=20=E2=80=94=2010s=20instead=20of=205-7min?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The anchor-build job has been flakily cancelled during `cargo install --locked --version 0.31.1 anchor-cli` on ubuntu-latest. The job conclusion comes back as `cancelled` 1-2 minutes into the install rather than `failure`, which points to runner resource constraints (disk / memory pressure during dependency-fetch) rather than a code or version issue. cargo-binstall fetches a prebuilt binary for anchor-cli from GitHub releases instead of compiling from source. Drops the step from ~5-7 min to ~10s and removes the cancellation surface entirely. Local sanity check: same approach works for `solana-cli` (already used); extending it to anchor-cli keeps the workflow's compile time bounded to the actual `anchor build` BPF compile of our two programs. --- .github/workflows/ci.yml | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6841b14..945136f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -61,8 +61,14 @@ 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 - name: Install Anchor CLI - run: cargo install --locked --version ${ANCHOR_VERSION} anchor-cli + run: cargo binstall --no-confirm --version ${ANCHOR_VERSION} anchor-cli - name: Anchor build run: anchor build From 69b1f19e9021771078acf56dbc49fbc3d7cb749d Mon Sep 17 00:00:00 2001 From: GraveYield Date: Thu, 14 May 2026 08:42:05 +0800 Subject: [PATCH 07/11] =?UTF-8?q?fix(grave-scanner):=20m4.0.2=20=E2=80=94?= =?UTF-8?q?=20drop=20anchor=5Fspl::Account::try=5Ffrom=20for=20raw=20SPL?= =?UTF-8?q?=20parsing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Path B blind-patch around the suspected Anchor 0.31.1 BPF-compile issue with `Account::::try_from` inside generic-lifetime instruction handlers. Host clippy was clean but the BPF target was failing the actual `anchor build` step (after install steps all passed with cargo-binstall). Rewrites raydium_v4::parse() to: - Drop `use anchor_spl::token::{Mint, TokenAccount}` entirely. - Verify each remaining_accounts entry is owned by the SPL Token Program ID (TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA) explicitly. This replaces the implicit owner-check that Account::try_from provided. - Read SPL Token Account `mint` (offset 0..32) and `amount` (offset 64..72) directly from account data. - Read SPL Mint `supply` (offset 36..44) directly. - Add TOKEN_ACCOUNT_SIZE (165) and MINT_ACCOUNT_SIZE (82) size guards. Security posture preserved: - coin_vault / pc_vault / lp_mint MUST be owned by SPL Token Program - coin_vault.mint == AmmInfo.coin_vault_mint (defense-in-depth) - pc_vault.mint == AmmInfo.pc_vault_mint Local verification (Rust 1.85.0): - cargo fmt --all -- --check: clean - cargo clippy --workspace --all-targets -- -D warnings: clean - cargo test -p grave-scanner --lib: 19/19 pass anchor-spl is still a workspace dep for the idl-build feature flag in grave-scanner/Cargo.toml — only the runtime Account::try_from usage is removed. --- .../grave-scanner/src/adapters/raydium_v4.rs | 115 +++++++++++++++--- 1 file changed, 95 insertions(+), 20 deletions(-) diff --git a/programs/grave-scanner/src/adapters/raydium_v4.rs b/programs/grave-scanner/src/adapters/raydium_v4.rs index f0a7eb3..189241c 100644 --- a/programs/grave-scanner/src/adapters/raydium_v4.rs +++ b/programs/grave-scanner/src/adapters/raydium_v4.rs @@ -27,7 +27,6 @@ use anchor_lang::prelude::*; use anchor_lang::solana_program::pubkey; -use anchor_spl::token::{Mint, TokenAccount}; use super::{find_account_by_key, PoolData}; use crate::errors::GraveScannerError; @@ -35,21 +34,41 @@ use crate::errors::GraveScannerError; /// Mainnet Raydium V4 program ID. pub const PROGRAM_ID: Pubkey = pubkey!("675kPX9MHTjS2zt1qfr1NYHuzeLXfQM9H24wFSUt1Mp8"); +/// SPL Token Program ID — both the vault SPL token accounts and the LP +/// mint are owned by this program. We verify ownership explicitly rather +/// than relying on `anchor_spl::Account::try_from`, which was tripping +/// the BPF compile under Anchor 0.31.1. +const SPL_TOKEN_PROGRAM_ID: Pubkey = pubkey!("TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"); + /// Canonical AmmInfo account size on Raydium V4. Pool accounts that /// don't match this size are rejected as the wrong shape. pub const AMM_INFO_SIZE: usize = 752; +/// SPL Token Account size (the layout begins with mint + owner + amount). +const TOKEN_ACCOUNT_SIZE: usize = 165; +/// SPL Mint account size. +const MINT_ACCOUNT_SIZE: usize = 82; + /// Byte offsets into AmmInfo for the Pubkey fields we read. Verified /// against the Raydium open-source AMM source-of-truth and the canonical /// 752-byte layout: 16 u64 prefix (128) + Fees (64) + StateData (144) /// + 9 Pubkeys (288) + 8 u64 padding (64) + amm_owner (32) + 2 u64 (16) /// + 2 u64 padding (16) = 752. mod offsets { + // AmmInfo (Raydium V4 pool account). pub const COIN_VAULT: usize = 336; pub const PC_VAULT: usize = 368; pub const COIN_VAULT_MINT: usize = 400; pub const PC_VAULT_MINT: usize = 432; pub const LP_MINT: usize = 464; + + // SPL Token Account layout. See + // https://github.com/solana-program/token/blob/main/program/src/state.rs + pub const TOKEN_ACCOUNT_MINT: usize = 0; + pub const TOKEN_ACCOUNT_AMOUNT: usize = 64; + + // SPL Mint layout: mint_authority option (4 + 32) precedes supply. + pub const MINT_SUPPLY: usize = 36; } /// Parse a Raydium V4 AMM pool account into `PoolData`. @@ -58,11 +77,18 @@ mod offsets { /// `lp_mint` accounts in `remaining_accounts` (any order — looked up by /// Pubkey). Reserves and LP supply are read from those SPL accounts. /// -/// Defense in depth: the vault token accounts' `mint` fields are -/// verified against AmmInfo's claimed `coin_vault_mint` / -/// `pc_vault_mint`, so an attacker who supplies the genuine pool account -/// but forged vault accounts (e.g. vaults pointing to a different mint) -/// is rejected with `PoolDataParseError`. +/// Defense in depth: +/// 1. Pool size MUST equal 752 (`AMM_INFO_SIZE`), else PoolDataParseError. +/// 2. coin_vault / pc_vault / lp_mint accounts MUST be owned by the SPL +/// Token Program, else PoolDataParseError. (This replaces the implicit +/// owner check that `anchor_spl::Account::try_from` provided.) +/// 3. The actual `mint` field inside each vault SPL Token Account MUST +/// equal the AmmInfo's claimed `coin_vault_mint` / `pc_vault_mint`. +/// +/// Note: this adapter intentionally avoids `anchor_spl::token::{Mint, +/// TokenAccount}` deserialization. Manual byte parsing keeps the adapter +/// dependency-light and sidesteps an Anchor 0.31.1 BPF-compile issue with +/// `Account::::try_from` inside generic-lifetime instruction handlers. pub fn parse<'info>( pool_account_info: &AccountInfo<'info>, remaining_accounts: &'info [AccountInfo<'info>], @@ -88,38 +114,77 @@ pub fn parse<'info>( let pc_vault_info = find_account_by_key(remaining_accounts, &pc_vault)?; let lp_mint_info = find_account_by_key(remaining_accounts, &lp_mint)?; - // `Account::::try_from` validates account ownership against the SPL - // Token Program ID. A non-token account passed in remaining_accounts is - // rejected here regardless of its data layout. - let coin_account: Account = - Account::try_from(coin_vault_info).map_err(|_| GraveScannerError::PoolDataParseError)?; - let pc_account: Account = - Account::try_from(pc_vault_info).map_err(|_| GraveScannerError::PoolDataParseError)?; - let lp_mint_account: Account = - Account::try_from(lp_mint_info).map_err(|_| GraveScannerError::PoolDataParseError)?; + // Explicit owner-validation: each remaining-account passed must be an + // SPL Token Program account. Rejects forged accounts whose byte layout + // happens to match SPL but whose owning program is something else. + require_keys_eq!( + *coin_vault_info.owner, + SPL_TOKEN_PROGRAM_ID, + GraveScannerError::PoolDataParseError + ); + require_keys_eq!( + *pc_vault_info.owner, + SPL_TOKEN_PROGRAM_ID, + GraveScannerError::PoolDataParseError + ); + require_keys_eq!( + *lp_mint_info.owner, + SPL_TOKEN_PROGRAM_ID, + GraveScannerError::PoolDataParseError + ); + + let (coin_actual_mint, coin_amount) = read_token_account(coin_vault_info)?; + let (pc_actual_mint, pc_amount) = read_token_account(pc_vault_info)?; + let lp_supply = read_mint_supply(lp_mint_info)?; require_keys_eq!( - coin_account.mint, + coin_actual_mint, coin_vault_mint, GraveScannerError::PoolDataParseError ); require_keys_eq!( - pc_account.mint, + pc_actual_mint, pc_vault_mint, GraveScannerError::PoolDataParseError ); Ok(PoolData { last_swap_unix_ts: 0, - base_reserve: coin_account.amount, - quote_reserve: pc_account.amount, - lp_supply: lp_mint_account.supply, + base_reserve: coin_amount, + quote_reserve: pc_amount, + lp_supply, base_mint: coin_vault_mint, quote_mint: pc_vault_mint, lp_mint, }) } +/// Read mint + amount from an SPL Token Account at canonical offsets. +fn read_token_account(info: &AccountInfo) -> Result<(Pubkey, u64)> { + let data = info + .try_borrow_data() + .map_err(|_| GraveScannerError::PoolDataParseError)?; + require!( + data.len() >= TOKEN_ACCOUNT_SIZE, + GraveScannerError::PoolDataParseError + ); + let mint = read_pubkey(&data, offsets::TOKEN_ACCOUNT_MINT)?; + let amount = read_u64_le(&data, offsets::TOKEN_ACCOUNT_AMOUNT)?; + Ok((mint, amount)) +} + +/// Read supply from an SPL Mint account at the canonical offset. +fn read_mint_supply(info: &AccountInfo) -> Result { + let data = info + .try_borrow_data() + .map_err(|_| GraveScannerError::PoolDataParseError)?; + require!( + data.len() >= MINT_ACCOUNT_SIZE, + GraveScannerError::PoolDataParseError + ); + read_u64_le(&data, offsets::MINT_SUPPLY) +} + fn read_pubkey(data: &[u8], offset: usize) -> Result { require!( data.len() >= offset + 32, @@ -130,6 +195,16 @@ fn read_pubkey(data: &[u8], offset: usize) -> Result { Ok(Pubkey::new_from_array(buf)) } +fn read_u64_le(data: &[u8], offset: usize) -> Result { + require!( + data.len() >= offset + 8, + GraveScannerError::PoolDataParseError + ); + let mut buf = [0u8; 8]; + buf.copy_from_slice(&data[offset..offset + 8]); + Ok(u64::from_le_bytes(buf)) +} + #[cfg(test)] mod tests { use super::*; From 2bc596a83d11e5ff08f9d6358ff1804ad9e9f00c Mon Sep 17 00:00:00 2001 From: GraveYield Date: Thu, 14 May 2026 08:49:06 +0800 Subject: [PATCH 08/11] =?UTF-8?q?fix(grave-scanner):=20m4.0.3=20=E2=80=94?= =?UTF-8?q?=20drop=20'info=20threading,=20inline=20find-by-key?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit m4.0.2 was a misdiagnosis. The BPF compile failure was NOT triggered by anchor_spl::Account::try_from — it was triggered by the `<'info>` generic lifetime parameter we'd added to the `#[program]` instruction handlers (and the lib.rs wrappers). Anchor 0.31.1's `#[program]` macro generates dispatch + IDL code that does not handle generic-lifetime instructions cleanly on the BPF target, even though host clippy + cargo check are happy with the same signatures. Root cause of the original 'info constraint: the `find_account_by_key` helper returned `&'a AccountInfo<'info>`, forcing callers to declare a named 'info that bound both the pool account ref and the remaining_accounts slice ref to the SAME 'info. That propagated up to the handler and ultimately to lib.rs's #[program] wrapper. Fix: inline the lookup inside raydium_v4::parse() using a closure that never escapes the function. No helper function returns an AccountInfo reference, so no shared-'info constraint propagates to the caller chain. Handlers can drop `<'info>` from their signatures, and lib.rs's #[program] wrappers can return to plain signatures the macro knows how to handle. Changes: - adapters/raydium_v4.rs: closure-based lookup, fully inlined SPL byte reads for token-account `mint`/`amount` and mint `supply`. Kept owner- validation against the SPL Token Program ID and the mint cross-check against AmmInfo's claimed mints (defense in depth). - adapters/mod.rs: removed `find_account_by_key` (no longer used). extract_pool_data is now plain `&AccountInfo`, `&[AccountInfo]`, no generic lifetime. - adapters/{raydium_clmm,orca_whirlpool,pumpswap,meteora}.rs: same plain signatures, still revert AmmAdapterUnimplemented. - instructions/evaluate_pool_phase{1,2}.rs: dropped `<'info>` from handler signatures. - lib.rs: dropped `<'info>` from evaluate_pool_phase_{1,2} wrappers. Local verification (Rust 1.85.0): - cargo fmt --all -- --check: clean - cargo clippy --workspace --all-targets -- -D warnings: clean - cargo test -p grave-scanner --lib: 19/19 pass If CI passes anchor build on this commit, the hypothesis is confirmed: 'info-on-#[program]-handler was the BPF blocker. The lifetime-invariance 'compile error' on host clippy was a real constraint, but it can be avoided architecturally by keeping lookups inlined — which is what this commit does. --- .../grave-scanner/src/adapters/meteora.rs | 6 +- programs/grave-scanner/src/adapters/mod.rs | 29 ++--- .../src/adapters/orca_whirlpool.rs | 6 +- .../grave-scanner/src/adapters/pumpswap.rs | 6 +- .../src/adapters/raydium_clmm.rs | 6 +- .../grave-scanner/src/adapters/raydium_v4.rs | 103 ++++++++++-------- .../src/instructions/evaluate_pool_phase1.rs | 5 +- .../src/instructions/evaluate_pool_phase2.rs | 5 +- programs/grave-scanner/src/lib.rs | 8 +- 9 files changed, 85 insertions(+), 89 deletions(-) diff --git a/programs/grave-scanner/src/adapters/meteora.rs b/programs/grave-scanner/src/adapters/meteora.rs index 3550481..6a59d84 100644 --- a/programs/grave-scanner/src/adapters/meteora.rs +++ b/programs/grave-scanner/src/adapters/meteora.rs @@ -21,9 +21,9 @@ pub const PROGRAM_ID: Pubkey = pubkey!("LBUZKhRxPF3XUpBCjp4YzTKgLccjZhTSDM9YuVaP /// Parse a Meteora pool account into `PoolData`. /// /// PRE-MAINNET-TODO(CPI): Meteora DLMM/Dynamic AMM pool layout parsing | reverts: AmmAdapterUnimplemented | verify: against Meteora SDK -pub fn parse<'info>( - _pool_account_info: &AccountInfo<'info>, - _remaining_accounts: &'info [AccountInfo<'info>], +pub fn parse( + _pool_account_info: &AccountInfo, + _remaining_accounts: &[AccountInfo], ) -> Result { err!(GraveScannerError::AmmAdapterUnimplemented) } diff --git a/programs/grave-scanner/src/adapters/mod.rs b/programs/grave-scanner/src/adapters/mod.rs index 546e0b1..b6517de 100644 --- a/programs/grave-scanner/src/adapters/mod.rs +++ b/programs/grave-scanner/src/adapters/mod.rs @@ -72,24 +72,6 @@ impl PoolData { } } -// ===================================================================== -// Account-lookup helper for adapter parsers. -// ===================================================================== - -/// Find an `AccountInfo` in `accounts` whose key matches `key`. -/// -/// Adapters call this to resolve the pool's vault and lp_mint accounts -/// from the instruction's `remaining_accounts` slice. -pub fn find_account_by_key<'info>( - accounts: &'info [AccountInfo<'info>], - key: &Pubkey, -) -> Result<&'info AccountInfo<'info>> { - accounts - .iter() - .find(|info| info.key == key) - .ok_or_else(|| GraveScannerError::PoolDataParseError.into()) -} - // ===================================================================== // Top-level dispatch by AMM program ID. // ===================================================================== @@ -102,11 +84,14 @@ pub fn find_account_by_key<'info>( /// return `AmmAdapterUnimplemented`. /// /// `remaining_accounts` is the full instruction `remaining_accounts` -/// slice. Adapters look up the pool's vault and lp_mint accounts here. -pub fn extract_pool_data<'info>( - pool_account_info: &AccountInfo<'info>, +/// slice. Adapters look up the pool's vault and lp_mint accounts here +/// (via inline iteration — no helper function carries a reference +/// across a call boundary, so no `'info` lifetime parameter is required +/// on this dispatch or its callers). +pub fn extract_pool_data( + pool_account_info: &AccountInfo, expected_pool_address: &Pubkey, - remaining_accounts: &'info [AccountInfo<'info>], + remaining_accounts: &[AccountInfo], ) -> Result { require_keys_eq!( *pool_account_info.key, diff --git a/programs/grave-scanner/src/adapters/orca_whirlpool.rs b/programs/grave-scanner/src/adapters/orca_whirlpool.rs index 387cab6..e55e467 100644 --- a/programs/grave-scanner/src/adapters/orca_whirlpool.rs +++ b/programs/grave-scanner/src/adapters/orca_whirlpool.rs @@ -19,9 +19,9 @@ pub const PROGRAM_ID: Pubkey = pubkey!("whirLbMiicVdio4qvUfM5KAg6Ct8VwpYzGff3uct /// Parse an Orca Whirlpool pool account into `PoolData`. /// /// PRE-MAINNET-TODO(CPI): Orca Whirlpool layout parsing + token-vault reserve aggregation | reverts: AmmAdapterUnimplemented | verify: against Whirlpool SDK and mainnet pool fixtures -pub fn parse<'info>( - _pool_account_info: &AccountInfo<'info>, - _remaining_accounts: &'info [AccountInfo<'info>], +pub fn parse( + _pool_account_info: &AccountInfo, + _remaining_accounts: &[AccountInfo], ) -> Result { err!(GraveScannerError::AmmAdapterUnimplemented) } diff --git a/programs/grave-scanner/src/adapters/pumpswap.rs b/programs/grave-scanner/src/adapters/pumpswap.rs index 9b6c1e1..0a1978e 100644 --- a/programs/grave-scanner/src/adapters/pumpswap.rs +++ b/programs/grave-scanner/src/adapters/pumpswap.rs @@ -19,9 +19,9 @@ pub const PROGRAM_ID: Pubkey = pubkey!("PSwapMdSai8tjrEXcxFeQth87xC4rRsa4VA5mhGh /// Parse a PumpSwap pool account into `PoolData`. /// /// PRE-MAINNET-TODO(CPI): PumpSwap pool layout parsing | reverts: AmmAdapterUnimplemented | verify: against PumpSwap SDK and mainnet pool fixtures -pub fn parse<'info>( - _pool_account_info: &AccountInfo<'info>, - _remaining_accounts: &'info [AccountInfo<'info>], +pub fn parse( + _pool_account_info: &AccountInfo, + _remaining_accounts: &[AccountInfo], ) -> Result { err!(GraveScannerError::AmmAdapterUnimplemented) } diff --git a/programs/grave-scanner/src/adapters/raydium_clmm.rs b/programs/grave-scanner/src/adapters/raydium_clmm.rs index 0ad9484..e68d5a1 100644 --- a/programs/grave-scanner/src/adapters/raydium_clmm.rs +++ b/programs/grave-scanner/src/adapters/raydium_clmm.rs @@ -20,9 +20,9 @@ pub const PROGRAM_ID: Pubkey = pubkey!("CAMMCzo5YL8w4VFF8KVHrK22GGUsp5VTaW7grrKg /// Parse a Raydium CLMM pool account into `PoolData`. /// /// PRE-MAINNET-TODO(CPI): Raydium CLMM pool layout parsing + tick-range reserve calculation | reverts: AmmAdapterUnimplemented | verify: against mainnet pool fixtures and Raydium CLMM SDK -pub fn parse<'info>( - _pool_account_info: &AccountInfo<'info>, - _remaining_accounts: &'info [AccountInfo<'info>], +pub fn parse( + _pool_account_info: &AccountInfo, + _remaining_accounts: &[AccountInfo], ) -> Result { err!(GraveScannerError::AmmAdapterUnimplemented) } diff --git a/programs/grave-scanner/src/adapters/raydium_v4.rs b/programs/grave-scanner/src/adapters/raydium_v4.rs index 189241c..5405a54 100644 --- a/programs/grave-scanner/src/adapters/raydium_v4.rs +++ b/programs/grave-scanner/src/adapters/raydium_v4.rs @@ -28,7 +28,7 @@ use anchor_lang::prelude::*; use anchor_lang::solana_program::pubkey; -use super::{find_account_by_key, PoolData}; +use super::PoolData; use crate::errors::GraveScannerError; /// Mainnet Raydium V4 program ID. @@ -85,13 +85,15 @@ mod offsets { /// 3. The actual `mint` field inside each vault SPL Token Account MUST /// equal the AmmInfo's claimed `coin_vault_mint` / `pc_vault_mint`. /// -/// Note: this adapter intentionally avoids `anchor_spl::token::{Mint, -/// TokenAccount}` deserialization. Manual byte parsing keeps the adapter -/// dependency-light and sidesteps an Anchor 0.31.1 BPF-compile issue with -/// `Account::::try_from` inside generic-lifetime instruction handlers. -pub fn parse<'info>( - pool_account_info: &AccountInfo<'info>, - remaining_accounts: &'info [AccountInfo<'info>], +/// Note: this adapter avoids `anchor_spl::token::{Mint, TokenAccount}` +/// deserialization AND the `find_account_by_key` helper. Everything is +/// inlined inside this function so no cross-function lifetime constraint +/// requires explicit `'info` threading on the calling handler — the +/// `#[program]` macro under Anchor 0.31.1 rejects generic-lifetime +/// instruction handlers at BPF compile time. +pub fn parse( + pool_account_info: &AccountInfo, + remaining_accounts: &[AccountInfo], ) -> Result { let (coin_vault, pc_vault, coin_vault_mint, pc_vault_mint, lp_mint) = { let data = pool_account_info @@ -110,13 +112,19 @@ pub fn parse<'info>( ) }; - let coin_vault_info = find_account_by_key(remaining_accounts, &coin_vault)?; - let pc_vault_info = find_account_by_key(remaining_accounts, &pc_vault)?; - let lp_mint_info = find_account_by_key(remaining_accounts, &lp_mint)?; + // Inline account lookup — no helper function = no return-by-reference + // = no lifetime parameter to thread through the call stack. + let find = |key: &Pubkey| -> Result<&AccountInfo<'_>> { + remaining_accounts + .iter() + .find(|i| i.key == key) + .ok_or_else(|| GraveScannerError::PoolDataParseError.into()) + }; + let coin_vault_info = find(&coin_vault)?; + let pc_vault_info = find(&pc_vault)?; + let lp_mint_info = find(&lp_mint)?; - // Explicit owner-validation: each remaining-account passed must be an - // SPL Token Program account. Rejects forged accounts whose byte layout - // happens to match SPL but whose owning program is something else. + // Explicit owner-validation. require_keys_eq!( *coin_vault_info.owner, SPL_TOKEN_PROGRAM_ID, @@ -133,9 +141,44 @@ pub fn parse<'info>( GraveScannerError::PoolDataParseError ); - let (coin_actual_mint, coin_amount) = read_token_account(coin_vault_info)?; - let (pc_actual_mint, pc_amount) = read_token_account(pc_vault_info)?; - let lp_supply = read_mint_supply(lp_mint_info)?; + // Inline SPL account-data reads — each borrow is scoped to its block, + // never crosses a function boundary. + let (coin_actual_mint, coin_amount) = { + let data = coin_vault_info + .try_borrow_data() + .map_err(|_| GraveScannerError::PoolDataParseError)?; + require!( + data.len() >= TOKEN_ACCOUNT_SIZE, + GraveScannerError::PoolDataParseError + ); + ( + read_pubkey(&data, offsets::TOKEN_ACCOUNT_MINT)?, + read_u64_le(&data, offsets::TOKEN_ACCOUNT_AMOUNT)?, + ) + }; + let (pc_actual_mint, pc_amount) = { + let data = pc_vault_info + .try_borrow_data() + .map_err(|_| GraveScannerError::PoolDataParseError)?; + require!( + data.len() >= TOKEN_ACCOUNT_SIZE, + GraveScannerError::PoolDataParseError + ); + ( + read_pubkey(&data, offsets::TOKEN_ACCOUNT_MINT)?, + read_u64_le(&data, offsets::TOKEN_ACCOUNT_AMOUNT)?, + ) + }; + let lp_supply = { + let data = lp_mint_info + .try_borrow_data() + .map_err(|_| GraveScannerError::PoolDataParseError)?; + require!( + data.len() >= MINT_ACCOUNT_SIZE, + GraveScannerError::PoolDataParseError + ); + read_u64_le(&data, offsets::MINT_SUPPLY)? + }; require_keys_eq!( coin_actual_mint, @@ -159,32 +202,6 @@ pub fn parse<'info>( }) } -/// Read mint + amount from an SPL Token Account at canonical offsets. -fn read_token_account(info: &AccountInfo) -> Result<(Pubkey, u64)> { - let data = info - .try_borrow_data() - .map_err(|_| GraveScannerError::PoolDataParseError)?; - require!( - data.len() >= TOKEN_ACCOUNT_SIZE, - GraveScannerError::PoolDataParseError - ); - let mint = read_pubkey(&data, offsets::TOKEN_ACCOUNT_MINT)?; - let amount = read_u64_le(&data, offsets::TOKEN_ACCOUNT_AMOUNT)?; - Ok((mint, amount)) -} - -/// Read supply from an SPL Mint account at the canonical offset. -fn read_mint_supply(info: &AccountInfo) -> Result { - let data = info - .try_borrow_data() - .map_err(|_| GraveScannerError::PoolDataParseError)?; - require!( - data.len() >= MINT_ACCOUNT_SIZE, - GraveScannerError::PoolDataParseError - ); - read_u64_le(&data, offsets::MINT_SUPPLY) -} - fn read_pubkey(data: &[u8], offset: usize) -> Result { require!( data.len() >= offset + 32, diff --git a/programs/grave-scanner/src/instructions/evaluate_pool_phase1.rs b/programs/grave-scanner/src/instructions/evaluate_pool_phase1.rs index 3704356..7dd69f8 100644 --- a/programs/grave-scanner/src/instructions/evaluate_pool_phase1.rs +++ b/programs/grave-scanner/src/instructions/evaluate_pool_phase1.rs @@ -69,10 +69,7 @@ pub struct EvaluatePoolPhase1<'info> { pub system_program: Program<'info, System>, } -pub fn handler<'info>( - ctx: Context<'_, '_, 'info, 'info, EvaluatePoolPhase1<'info>>, - params: EvaluatePoolPhase1Params, -) -> Result<()> { +pub fn handler(ctx: Context, params: EvaluatePoolPhase1Params) -> Result<()> { let cfg = &ctx.accounts.protocol_config; require!(!cfg.paused, GraveScannerError::ProtocolPaused); diff --git a/programs/grave-scanner/src/instructions/evaluate_pool_phase2.rs b/programs/grave-scanner/src/instructions/evaluate_pool_phase2.rs index 2c8c4f6..19744de 100644 --- a/programs/grave-scanner/src/instructions/evaluate_pool_phase2.rs +++ b/programs/grave-scanner/src/instructions/evaluate_pool_phase2.rs @@ -76,10 +76,7 @@ pub struct EvaluatePoolPhase2<'info> { pub system_program: Program<'info, System>, } -pub fn handler<'info>( - ctx: Context<'_, '_, 'info, 'info, EvaluatePoolPhase2<'info>>, - params: EvaluatePoolPhase2Params, -) -> Result<()> { +pub fn handler(ctx: Context, params: EvaluatePoolPhase2Params) -> Result<()> { let cfg = &ctx.accounts.protocol_config; require!(!cfg.paused, GraveScannerError::ProtocolPaused); diff --git a/programs/grave-scanner/src/lib.rs b/programs/grave-scanner/src/lib.rs index 47cf5f2..504b545 100644 --- a/programs/grave-scanner/src/lib.rs +++ b/programs/grave-scanner/src/lib.rs @@ -65,8 +65,8 @@ pub mod grave_scanner { /// Phase 1 — evaluate all six derelict criteria and write an /// `EligibilityAnchor` PDA. Sets `first_eligible_epoch = current_epoch`. - pub fn evaluate_pool_phase_1<'info>( - ctx: Context<'_, '_, 'info, 'info, EvaluatePoolPhase1<'info>>, + pub fn evaluate_pool_phase_1( + ctx: Context, params: EvaluatePoolPhase1Params, ) -> Result<()> { instructions::evaluate_pool_phase1::handler(ctx, params) @@ -76,8 +76,8 @@ pub mod grave_scanner { /// gap (≥2 consecutive Solana epochs, ~4-6 days). On success, writes an /// `EligibilityCert` PDA with TTL = 1h. GraveVault consumes the cert to /// authorise `salvage_pool`. - pub fn evaluate_pool_phase_2<'info>( - ctx: Context<'_, '_, 'info, 'info, EvaluatePoolPhase2<'info>>, + pub fn evaluate_pool_phase_2( + ctx: Context, params: EvaluatePoolPhase2Params, ) -> Result<()> { instructions::evaluate_pool_phase2::handler(ctx, params) From 441f3860031ff4363b671f3802ef0b1ef8b14c82 Mon Sep 17 00:00:00 2001 From: GraveYield Date: Thu, 14 May 2026 09:09:53 +0800 Subject: [PATCH 09/11] ci(toolchain): bump Solana to 2.2.18 for edition2024 support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The actual root cause of every red `anchor build` CI run on this repo since PR #10 landed. Diagnosed locally by installing the platform-tools bundle ourselves and reproducing the BPF compile: Solana 2.0.21 ships platform-tools v1.42 with cargo 1.75. Anchor 0.31.1 transitively pulls solana-program 2.3.0, which pulls blake3 1.8.5, hashbrown 0.17+, digest 0.11, etc — ALL of which use Rust `edition2024` (stabilised in Rust 1.85 / cargo 1.84+). cargo 1.75 cannot parse edition2024 manifests, so it dies at the dependency-fetch step before any code compiles. Job exits in ~90s with no useful local error in the run summary because logs are not accessible through the Composio integration. Solana 2.2.18 (latest stable in the 2.2 line as of writing) ships platform-tools v1.50 with cargo 1.85, which is the first BPF-target toolchain that handles edition2024 cleanly. The on-chain runtime ABI is unchanged — anchor-lang 0.31.1 already runs against solana-program 2.3.0 on the host side, so the source code is already 2.2-compatible. Changes: - Anchor.toml: [toolchain] solana_version 2.0.21 -> 2.2.18 - .github/workflows/ci.yml: SOLANA_VERSION env 2.0.21 -> 2.2.18 No Cargo.toml changes. No source changes. The Cargo.lock currently on the m4 branch (v4, with blake3 1.8.5) will compile cleanly under the newer platform-tools cargo. If this commit's CI run shows anchor build green, the toolchain mismatch hypothesis is confirmed and PR #13 is ready to merge. --- .github/workflows/ci.yml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 945136f..115a656 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,7 +15,12 @@ concurrency: cancel-in-progress: true env: - SOLANA_VERSION: 2.0.21 + # Solana 2.2.18 ships platform-tools v1.50 (cargo 1.85). Earlier 2.x + # versions (e.g. 2.0.21, 2.1.x) ship cargo 1.75/1.79, which cannot + # parse the `edition2024` manifests pulled in transitively by + # anchor-lang 0.31.1's solana-program 2.3.0 dep chain. The on-chain + # ABI is unchanged; only the BPF compile toolchain advances. + SOLANA_VERSION: 2.2.18 ANCHOR_VERSION: 0.31.1 # Host toolchain. Must match rust-toolchain.toml — the modern SPL crate # ecosystem (digest 0.11 / block-buffer 0.12) requires edition2024 = 1.85+. From 73053c25d2e1f726913e01de4867477df37772e5 Mon Sep 17 00:00:00 2001 From: GraveYield Date: Thu, 14 May 2026 09:10:34 +0800 Subject: [PATCH 10/11] ci(toolchain): also bump Anchor.toml solana_version to 2.2.18 Prior commit (441f3860) bumped SOLANA_VERSION in ci.yml but a stale disk read missed Anchor.toml. The [toolchain] field in Anchor.toml is advisory rather than enforcing in Anchor 0.31.x, but keeping it consistent with ci.yml's env prevents future confusion. --- Anchor.toml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/Anchor.toml b/Anchor.toml index e330124..b192f6c 100644 --- a/Anchor.toml +++ b/Anchor.toml @@ -1,6 +1,10 @@ [toolchain] anchor_version = "0.31.1" -solana_version = "2.0.21" +# 2.2.18 ships platform-tools v1.50 (cargo 1.85), the first BPF toolchain +# that handles the `edition2024` manifests pulled in transitively by +# anchor-lang 0.31.1. Earlier 2.x versions (2.0.21, 2.1.x) ship cargo +# 1.75/1.79 and fail at dep-fetch time. On-chain runtime ABI unchanged. +solana_version = "2.2.18" [features] resolution = true From 2151a66b873af142d8bd369080be0d2649ed744d Mon Sep 17 00:00:00 2001 From: GraveYield Date: Thu, 14 May 2026 09:26:10 +0800 Subject: [PATCH 11/11] ci(toolchain): pin platform-tools to v1.54 via Cargo.toml workspace metadata Solana 2.2.18's default platform-tools is v1.48 (cargo 1.84), which still cannot parse `edition2024` manifests. v1.54 (released 2026-03-06) is the first platform-tools release shipping cargo 1.85+. cargo-build-sbf (2.2.18) reads `[workspace.metadata.solana] tools-version` from the workspace Cargo.toml to override the default. Setting it to v1.54 makes anchor build use the right toolchain without bumping the Solana CLI version itself. Verified locally: `cargo-build-sbf --tools-version v1.54` compiles both grave-scanner and grave-vault to BPF cleanly in 38 seconds. --- Cargo.toml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/Cargo.toml b/Cargo.toml index e64d2a0..1d1f8ca 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,6 +22,14 @@ readme = "README.md" anchor-lang = "0.31.1" anchor-spl = "0.31.1" +# cargo-build-sbf reads this to pick the platform-tools version for the +# BPF compile. v2.2.18's default is v1.48 (cargo 1.84), which can't parse +# the edition2024 manifests pulled in transitively by anchor-lang 0.31.1. +# v1.54 (2026-03-06) ships cargo 1.85+, which is the first BPF toolchain +# that handles edition2024 cleanly. +[workspace.metadata.solana] +tools-version = "v1.54" + [profile.release] overflow-checks = true lto = "fat"