diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 15f1441..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+. @@ -61,8 +66,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 @@ -71,15 +82,22 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - - uses: pnpm/action-setup@v6 - 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. + # 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 + # 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 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 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" 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..6a59d84 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( + _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 55bfe1a..b6517de 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 { @@ -70,9 +82,16 @@ impl PoolData { /// IDs declared in each adapter module. Mismatches return /// `UnsupportedAmm`; supported AMMs whose parser is not yet implemented /// return `AmmAdapterUnimplemented`. +/// +/// `remaining_accounts` is the full instruction `remaining_accounts` +/// 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: &[AccountInfo], ) -> Result { require_keys_eq!( *pool_account_info.key, @@ -82,15 +101,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..e55e467 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( + _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 ed7e732..0a1978e 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( + _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 638e44b..e68d5a1 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( + _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 0fcfff8..5405a54 100644 --- a/programs/grave-scanner/src/adapters/raydium_v4.rs +++ b/programs/grave-scanner/src/adapters/raydium_v4.rs @@ -1,21 +1,29 @@ // 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; @@ -26,9 +34,264 @@ use crate::errors::GraveScannerError; /// Mainnet Raydium V4 program ID. pub const PROGRAM_ID: Pubkey = pubkey!("675kPX9MHTjS2zt1qfr1NYHuzeLXfQM9H24wFSUt1Mp8"); -/// Parse a Raydium V4 pool account into `PoolData`. +/// 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`. +/// +/// 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: +/// 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`. /// -/// 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) +/// 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 + .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)?, + ) + }; + + // 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. + 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 + ); + + // 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, + coin_vault_mint, + GraveScannerError::PoolDataParseError + ); + require_keys_eq!( + pc_actual_mint, + pc_vault_mint, + GraveScannerError::PoolDataParseError + ); + + Ok(PoolData { + last_swap_unix_ts: 0, + base_reserve: coin_amount, + quote_reserve: pc_amount, + lp_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)) +} + +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::*; + + /// 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, 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