From c38bc94578cc7a85f228768166ffe2a0ec2af3ab Mon Sep 17 00:00:00 2001 From: GraveYield Date: Sat, 16 May 2026 13:43:40 +0800 Subject: [PATCH 1/5] feat(m5): GraveVault salvage_pool execution path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires the full Raydium V4 SOL/X salvage flow end-to-end: salvor→vault LP transfer, Raydium V4 withdraw CPI, Jupiter v6 swap (or dust skip), WSOL→SOL unwrap, 40/40/20 distribution, SalvageReceipt population, PoolSalvaged + SalvageCompleted events. See CHANGELOG.md "Unreleased — m5" for the full added/changed/unverified breakdown. PR scope per Option B (2 PRs by code path): this PR is the salvage_pool side. claim_lp_proceeds Merkle verification ships in a follow-up PR. Co-Authored-By: Claude Opus 4.7 --- CHANGELOG.md | 25 + docs/PRE_MAINNET_CHECKLIST.md | 4 + docs/error_codes.md | 16 +- programs/grave-vault/src/constants.rs | 74 +++ programs/grave-vault/src/cpi/jupiter.rs | 116 ++++ programs/grave-vault/src/cpi/mod.rs | 83 +++ .../grave-vault/src/cpi/orca_whirlpool.rs | 22 + programs/grave-vault/src/cpi/pump_swap.rs | 22 + programs/grave-vault/src/cpi/raydium_clmm.rs | 22 + programs/grave-vault/src/cpi/raydium_v4.rs | 239 +++++++ programs/grave-vault/src/errors.rs | 28 + .../src/instructions/salvage_pool.rs | 612 ++++++++++++++---- programs/grave-vault/src/lib.rs | 28 +- 13 files changed, 1164 insertions(+), 127 deletions(-) create mode 100644 programs/grave-vault/src/cpi/jupiter.rs create mode 100644 programs/grave-vault/src/cpi/mod.rs create mode 100644 programs/grave-vault/src/cpi/orca_whirlpool.rs create mode 100644 programs/grave-vault/src/cpi/pump_swap.rs create mode 100644 programs/grave-vault/src/cpi/raydium_clmm.rs create mode 100644 programs/grave-vault/src/cpi/raydium_v4.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index bc073c4..5680b45 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,30 @@ # Changelog +## [Unreleased — m5: salvage_pool execution path] + +### Added +- **GraveVault salvage_pool execution path** end-to-end (m5): + - `cpi/raydium_v4.rs` — real Raydium V4 `withdraw` CPI (vault_authority PDA-signs `user_owner`; 18-account list; 9-byte data `[tag=4][amount_le]`; AMM authority constant validation; pre/post balance deltas). + - `cpi/jupiter.rs` — Jupiter v6 swap CPI helper (forwards salvor's pre-computed route data + accounts; vault_authority signs). + - `cpi/raydium_clmm.rs`, `cpi/orca_whirlpool.rs`, `cpi/pump_swap.rs` — honest-stub adapters; revert `AmmCpiUnimplemented` (7017). + - `cpi/mod.rs` — dispatcher by `pool.owner`. +- **salvage_pool handler** rewritten to wire: salvor→vault LP transfer, dispatched remove_liquidity CPI, Jupiter swap (or dust skip), WSOL→SOL unwrap via `close_account` to `vault_sol_holding_account`, 40/40/20 distribution via three `system_program::transfer` calls, PoolRegistry + SalvageReceipt population, `PoolSalvaged` + `SalvageCompleted` emit. +- **Five new error codes** (7015-7019): `AmmRedemptionFailed`, `JupiterSwapFailed`, `AmmCpiUnimplemented`, `InvalidSnapshotData`, `UnsupportedBaseToken`. Mirrored to `docs/error_codes.md` in lock-step per the sync convention. +- **New PDA seeds**: `VAULT_AUTHORITY_SEED` (singleton signer), `VAULT_SOL_HOLDING_SEED` (per-pool, transient native-SOL holding for unwrap). +- **New constants**: `WSOL_MINT`, `RAYDIUM_V4_PROGRAM_ID`, `RAYDIUM_V4_AMM_AUTHORITY` (`5Q544...`), `RAYDIUM_CLMM_PROGRAM_ID`, `ORCA_WHIRLPOOL_PROGRAM_ID`, `PUMP_SWAP_PROGRAM_ID`, `JUPITER_V6_PROGRAM_ID`, `RAYDIUM_V4_INSTRUCTION_TAG_WITHDRAW = 4`, `RAYDIUM_V4_WITHDRAW_REMAINING_ACCOUNTS_REQUIRED = 11`, `BPS_DENOMINATOR = 10_000`, `HARD_MAX_SLIPPAGE_BPS = 1_000`. +- **PRE_MAINNET_CHECKLIST**: new rows `CPI-006/007/008` (CLMM/Orca/PumpSwap stubs) + `CPI-009` (Raydium V4 account-ordering verification against a live mainnet pool — blocking row). + +### Changed +- `salvage_pool` instruction signature now takes `Context<'_, '_, '_, 'info, SalvagePool<'info>>` (explicit `'info` threading per Anchor 0.31+ lifetime invariance — see failure-pattern memory). +- `SalvagePoolParams` extended with `salvor_lp_amount`, `jupiter_route_data: Vec`, `max_slippage_bps_override: Option`, `jupiter_route_accounts_len: u8`. +- `SalvagePool` Accounts struct extended with `vault_authority`, `vault_sol_holding_account`, `salvor_lp_token_account`, `vault_lp_token_account`, `vault_base_token_account`, `vault_memecoin_token_account`, `lp_mint`, `memecoin_mint`, `wsol_mint` (pinned via `address` constraint), `token_program`, `associated_token_program`. + +### Unverified +- BPF compile via `anchor build` (deferred to CI on this PR). +- Live Raydium V4 fork test of the exact 18-account ordering. The `amm_authority` constant check provides one assertion; full integration is `CPI-009` in `PRE_MAINNET_CHECKLIST.md`. +- Real Jupiter v6 swap end-to-end. The CPI helper forwards what the salvor's bot quotes; verification is a localnet smoke test post-merge. +- Pool orientation: `base_is_coin_side` is currently hardcoded `true` (assumes WSOL is the pool's coin side). A SOL/X pool where WSOL is the PC side will need the bot to invert its submission ordering; a runtime parse of pool data to detect orientation is in `PRE-MAINNET-TODO(CPI)` comments in `salvage_pool.rs`. + All notable changes to the GraveYield protocol monorepo are documented here. The format is loosely based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). diff --git a/docs/PRE_MAINNET_CHECKLIST.md b/docs/PRE_MAINNET_CHECKLIST.md index 42b8636..0cf7c03 100644 --- a/docs/PRE_MAINNET_CHECKLIST.md +++ b/docs/PRE_MAINNET_CHECKLIST.md @@ -57,6 +57,10 @@ Status legend: 🟥 blocking · 🟧 high-priority · 🟡 medium · ⬜ trackin | 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. | | CPI-005 | `programs/grave-scanner/src/adapters/meteora.rs` | 🟡 | Meteora DLMM / Dynamic AMM pool layout parsing. v1.1 milestone. | +| CPI-006 | `programs/grave-vault/src/cpi/raydium_clmm.rs` | 🟧 | Raydium CLMM (concentrated liquidity) `remove_liquidity` CPI for GraveVault. v1.1 milestone. Reverts with `AmmCpiUnimplemented`. | +| CPI-007 | `programs/grave-vault/src/cpi/orca_whirlpool.rs` | 🟧 | Orca Whirlpool position-burn CPI for GraveVault. v1.1 milestone. Reverts with `AmmCpiUnimplemented`. | +| CPI-008 | `programs/grave-vault/src/cpi/pump_swap.rs` | 🟧 | PumpSwap `remove_liquidity` CPI for GraveVault. v1.1 milestone. Reverts with `AmmCpiUnimplemented`. | +| CPI-009 | `programs/grave-vault/src/cpi/raydium_v4.rs` | 🟥 | Verify Raydium V4 withdraw account ordering against a live mainnet pool (e.g. `9d9mb8kooFfaD3SctgZtkxQypkshx6ezhbKio89ixyy2`) via `solana-program-test` fork test before mainnet. The `amm_authority` constant check catches an obviously-wrong layout but not subtle swaps. | ### KEYS diff --git a/docs/error_codes.md b/docs/error_codes.md index 16ec7c5..e541fb3 100644 --- a/docs/error_codes.md +++ b/docs/error_codes.md @@ -49,13 +49,12 @@ future v4.x additions to the pre-anchor error space. | 6018 | `AnchorNotStale` | `sweep_stale_anchor` called before the staleness window elapsed. | | 6019 | `CertTtlBelowMinimum` | `update_protocol_config` rejected a `cert_ttl_seconds` value below `MIN_CERT_TTL_SECONDS` (600s = 10 min). | -## GraveVault — 7000-7014 +## GraveVault — 7000-7019 Source: [`../programs/grave-vault/src/errors.rs`](../programs/grave-vault/src/errors.rs). -New error codes from milestones m5/m6/m7 (e.g. `AmmRedemptionFailed`, -`JupiterSwapFailed`, `AmmCpiUnimplemented`, `InvalidSnapshotData`, -`UnsupportedBaseToken`) will append at 7015+ and must be added here in -lock-step with the Rust source. +Codes 7015-7019 added by m5 (salvage_pool execution path). Future m6/m7 +additions append at 7020+ and must land in lock-step with the Rust +source per the sync convention. | Code | Name | Condition | |------|------|-----------| @@ -74,6 +73,11 @@ lock-step with the Rust source. | 7012 | `BelowDustThreshold` | Quote output below the Jupiter dust threshold; salvage skipped or aborted. | | 7013 | `PreflightFailed` | Pre-flight check against the on-chain pool failed. | | 7014 | `TimelockNotElapsed` | Timelock window has not yet elapsed for a queued parameter change. | +| 7015 | `AmmRedemptionFailed` | AMM `remove_liquidity` CPI returned an error or zero output. | +| 7016 | `JupiterSwapFailed` | Jupiter v6 swap CPI returned an error or zero output. | +| 7017 | `AmmCpiUnimplemented` | AMM CPI adapter is a pre-mainnet stub (CLMM / Orca Whirlpool / PumpSwap). Pool owner is not the Raydium V4 program. See [`PRE_MAINNET_CHECKLIST.md`](PRE_MAINNET_CHECKLIST.md). | +| 7018 | `InvalidSnapshotData` | Salvor's `lp_total_supply_at_snapshot` does not match the on-chain LP mint supply at salvage time. | +| 7019 | `UnsupportedBaseToken` | Pool base token is not WSOL. USDC/USDT base support is a v1.1 deliverable. | ## Drift from the v3.0 .docx snapshot @@ -96,4 +100,4 @@ rather than re-tabulating the codes. --- -*Mirrored from `errors.rs` files on 2026-05-16.* +*Mirrored from `errors.rs` files on 2026-05-16. Last verified at PR m5 (GraveVault 7000-7019, GraveScanner 6000-6019).* diff --git a/programs/grave-vault/src/constants.rs b/programs/grave-vault/src/constants.rs index 882d133..f457693 100644 --- a/programs/grave-vault/src/constants.rs +++ b/programs/grave-vault/src/constants.rs @@ -3,6 +3,8 @@ // GraveVault constants. Charter invariants are encoded here as `const` and // asserted by every code path that depends on them. +use anchor_lang::prelude::*; + // ===================================================================== // Charter-locked invariants. Governance CANNOT change these. // ===================================================================== @@ -23,6 +25,9 @@ pub const DEFAULT_LP_HOLDER_SHARE_BPS: u16 = 4_000; /// Default salvor share at launch (40%). pub const DEFAULT_SALVOR_SHARE_BPS: u16 = 4_000; +/// Basis-point denominator. All share math: (amount * share_bps) / BPS_DENOMINATOR. +pub const BPS_DENOMINATOR: u64 = 10_000; + // ===================================================================== // Operational defaults (governance-tunable within bounds). // ===================================================================== @@ -36,6 +41,10 @@ pub const DEFAULT_MAX_PRIORITY_FEE_CEILING_LAMPORTS: u64 = 1_000_000_000; /// Default maximum slippage in basis points for the Jupiter swap leg (3%). pub const DEFAULT_MAX_SLIPPAGE_BPS: u16 = 300; +/// Hard maximum slippage in basis points (10%). `update_protocol_config` +/// rejects any value above this regardless of multisig vote. +pub const HARD_MAX_SLIPPAGE_BPS: u16 = 1_000; + /// Default Jupiter dust threshold in lamports — skip swap if quote output /// would be below this. Matches the operating-parameter brief. pub const DEFAULT_JUPITER_DUST_THRESHOLD_LAMPORTS: u64 = 666_666; @@ -54,5 +63,70 @@ pub const SALVAGE_RECEIPT_SEED: &[u8] = b"salvage_receipt"; pub const CLAIM_RECORD_SEED: &[u8] = b"claim_record"; pub const PROTOCOL_TREASURY_SEED: &[u8] = b"protocol_treasury"; +/// Singleton vault authority PDA. Signs inner CPIs (Raydium withdraw, +/// Jupiter swap, system transfers from `vault_sol_holding_account`). +pub const VAULT_AUTHORITY_SEED: &[u8] = b"vault_authority"; + +/// Per-pool transient SOL holding PDA. Receives native SOL when the vault's +/// WSOL token account is closed after the Jupiter swap, before the 40/40/20 +/// distribution transfers fan out. Lazy-init via `create_account` CPI on +/// first salvage of a given pool (same pattern as `lp_holder_pool_vault`). +pub const VAULT_SOL_HOLDING_SEED: &[u8] = b"vault_sol_holding"; + // Cross-program seeds we read from GraveScanner. pub const ELIGIBILITY_CERT_SEED: &[u8] = b"eligibility_cert"; + +// ===================================================================== +// External program IDs (mainnet). +// ===================================================================== + +/// Wrapped SOL mint — fixed Solana network constant. +pub const WSOL_MINT: Pubkey = anchor_lang::solana_program::pubkey!( + "So11111111111111111111111111111111111111112" +); + +/// Raydium V4 AMM program — mainnet. +pub const RAYDIUM_V4_PROGRAM_ID: Pubkey = anchor_lang::solana_program::pubkey!( + "675kPX9MHTjS2zt1qfr1NYHuzeLXfQM9H24wFSUt1Mp8" +); + +/// Raydium V4 AMM authority — fixed PDA derived from the V4 program. +/// Used to validate the `amm_authority` account passed by the salvor in +/// `remaining_accounts` rather than trusting it blindly. +pub const RAYDIUM_V4_AMM_AUTHORITY: Pubkey = anchor_lang::solana_program::pubkey!( + "5Q544fKrFoe6tsEbD7S8EmxGTJYAKtTVhAW5Q5pge4j1" +); + +/// Raydium CLMM (concentrated liquidity) program — m5 honest-stub target. +pub const RAYDIUM_CLMM_PROGRAM_ID: Pubkey = anchor_lang::solana_program::pubkey!( + "CAMMCzo5YL8w4VFF8KVHrK22GGUsp5VTaW7grrKgrWqK" +); + +/// Orca Whirlpool program — m5 honest-stub target. +pub const ORCA_WHIRLPOOL_PROGRAM_ID: Pubkey = anchor_lang::solana_program::pubkey!( + "whirLbMiicVdio4qvUfM5KAg6Ct8VwpYzGff3uctyCc" +); + +/// PumpSwap program — m5 honest-stub target. +pub const PUMP_SWAP_PROGRAM_ID: Pubkey = anchor_lang::solana_program::pubkey!( + "PSwapMdSai8tjrEXcxFeQth87xC4rRsa4VA5mhGhXkP" +); + +/// Jupiter v6 aggregator program — mainnet. +pub const JUPITER_V6_PROGRAM_ID: Pubkey = anchor_lang::solana_program::pubkey!( + "JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4" +); + +// ===================================================================== +// Raydium V4 withdraw CPI layout. +// ===================================================================== + +/// Instruction discriminator for Raydium V4 `Withdraw`. Per Raydium V4 +/// `instruction.rs`, the tag is u8 = 4. Instruction data layout: +/// [tag: u8 = 4] [amount: u64 LE] = 9 bytes total. +pub const RAYDIUM_V4_INSTRUCTION_TAG_WITHDRAW: u8 = 4; + +/// Number of `remaining_accounts` salvor must supply for the Raydium V4 +/// withdraw CPI (pool internals + OpenBook market accounts that aren't in +/// the named `Accounts` struct). See `cpi/raydium_v4.rs` for the layout. +pub const RAYDIUM_V4_WITHDRAW_REMAINING_ACCOUNTS_REQUIRED: usize = 11; diff --git a/programs/grave-vault/src/cpi/jupiter.rs b/programs/grave-vault/src/cpi/jupiter.rs new file mode 100644 index 0000000..c26d893 --- /dev/null +++ b/programs/grave-vault/src/cpi/jupiter.rs @@ -0,0 +1,116 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// Jupiter v6 swap CPI helper. +// +// The salvor's bot pre-computes the route via Jupiter's quote API and passes +// the encoded `route` instruction data + the per-route accounts as inputs +// to `salvage_pool`. This helper builds the cross-program invocation with +// `vault_authority` as the signer (the vault owns the source memecoin) and +// returns the delta of the destination base-token account balance. +// +// Slippage is enforced by the caller after this returns — compare the +// returned `output_amount` against `params.min_quote_output_lamports` (or +// the dust-threshold pre-check that may have skipped the call entirely). + +use anchor_lang::prelude::*; +use anchor_lang::solana_program::instruction::{AccountMeta, Instruction}; +use anchor_lang::solana_program::program::invoke_signed; +use anchor_spl::token::TokenAccount; + +use crate::constants::{JUPITER_V6_PROGRAM_ID, VAULT_AUTHORITY_SEED}; +use crate::errors::GraveVaultError; + +/// Input to the Jupiter v6 swap CPI. +pub struct JupiterSwapInput<'a, 'info> { + pub vault_authority: &'a AccountInfo<'info>, + /// The vault's destination token account for the swap output. We snapshot + /// its balance pre/post to compute the actual output amount (independent + /// of any quoted-output value passed in `route_data`). + pub destination_token_account: &'a AccountInfo<'info>, + /// Route accounts the salvor's quote produced. Forwarded verbatim to + /// Jupiter v6. Salvor is responsible for ordering correctly. + pub route_accounts: &'a [AccountInfo<'info>], + /// Route instruction data (typically Jupiter v6 `route` discriminator + + /// encoded plan). Forwarded verbatim. + pub route_data: Vec, + /// PDA bump for `vault_authority`. + pub vault_authority_bump: u8, +} + +/// Result of a Jupiter swap: actual lamports/tokens delivered to the +/// destination token account (post - pre balance). +#[derive(Clone, Copy, Debug, Default)] +pub struct JupiterSwapOutput { + pub output_amount: u64, +} + +pub fn swap<'a, 'info>(input: JupiterSwapInput<'a, 'info>) -> Result { + // Empty route is an obvious caller bug — fail early rather than make + // Jupiter return a less helpful error. + require!( + !input.route_data.is_empty(), + GraveVaultError::JupiterSwapFailed + ); + + // Snapshot destination token account balance. + let pre_balance: u64 = { + let data = input.destination_token_account.try_borrow_data()?; + let acct = TokenAccount::try_deserialize(&mut &data[..]) + .map_err(|_| error!(GraveVaultError::JupiterSwapFailed))?; + acct.amount + }; + + // Build CPI. Jupiter's route doesn't require the destination token + // account to be in any specific position — it's part of `route_accounts`. + // We just forward what the salvor's quote produced. + let mut metas: Vec = Vec::with_capacity(input.route_accounts.len()); + for acct in input.route_accounts.iter() { + metas.push(AccountMeta { + pubkey: *acct.key, + is_signer: acct.is_signer, + is_writable: acct.is_writable, + }); + } + // Ensure vault_authority is marked signer in the meta list (it must be + // because we sign for it). The salvor's quote may or may not have + // flagged this; the actual signing happens via invoke_signed's seeds. + for meta in metas.iter_mut() { + if meta.pubkey == *input.vault_authority.key { + meta.is_signer = true; + } + } + + let ix = Instruction { + program_id: JUPITER_V6_PROGRAM_ID, + accounts: metas, + data: input.route_data, + }; + + // Build account list for invoke_signed — must include every account + // referenced by the instruction's metas. The salvor provided them in + // `route_accounts`. + let mut account_infos: Vec> = + Vec::with_capacity(input.route_accounts.len()); + for acct in input.route_accounts.iter() { + account_infos.push((*acct).clone()); + } + + let bump = [input.vault_authority_bump]; + let signer_seeds: &[&[u8]] = &[VAULT_AUTHORITY_SEED, &bump]; + invoke_signed(&ix, &account_infos, &[signer_seeds]) + .map_err(|_| error!(GraveVaultError::JupiterSwapFailed))?; + + // Snapshot post balance. + let post_balance: u64 = { + let data = input.destination_token_account.try_borrow_data()?; + let acct = TokenAccount::try_deserialize(&mut &data[..]) + .map_err(|_| error!(GraveVaultError::JupiterSwapFailed))?; + acct.amount + }; + + let output_amount = post_balance + .checked_sub(pre_balance) + .ok_or(error!(GraveVaultError::MathOverflow))?; + + Ok(JupiterSwapOutput { output_amount }) +} diff --git a/programs/grave-vault/src/cpi/mod.rs b/programs/grave-vault/src/cpi/mod.rs new file mode 100644 index 0000000..a50cd31 --- /dev/null +++ b/programs/grave-vault/src/cpi/mod.rs @@ -0,0 +1,83 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// AMM-specific CPI helpers for the salvage_pool execution path. Each adapter +// implements `remove_liquidity` returning the (base, memecoin) amounts +// received. Dispatch by `pool.owner.key()`. +// +// Raydium V4 is the only real implementation in m5; CLMM, Orca Whirlpool, +// and PumpSwap return `AmmCpiUnimplemented` (honest-stub pattern per +// `docs/PRE_MAINNET_CHECKLIST.md`). Each stub is a separate file rather than +// a single match arm so adding a real implementation later is a localised +// change. + +pub mod jupiter; +pub mod orca_whirlpool; +pub mod pump_swap; +pub mod raydium_clmm; +pub mod raydium_v4; + +use anchor_lang::prelude::*; + +use crate::constants::{ + ORCA_WHIRLPOOL_PROGRAM_ID, PUMP_SWAP_PROGRAM_ID, RAYDIUM_CLMM_PROGRAM_ID, + RAYDIUM_V4_PROGRAM_ID, +}; +use crate::errors::GraveVaultError; + +/// Result of a single AMM `remove_liquidity` CPI: gross amounts transferred +/// into the vault's base + memecoin token accounts, computed as the +/// post-call balance minus the pre-call balance. +#[derive(Clone, Copy, Debug, Default)] +pub struct RemoveLiquidityOutput { + pub base_received: u64, + pub memecoin_received: u64, +} + +/// Inputs for AMM `remove_liquidity` CPI. The vault holds the LP tokens at +/// call time (salvor pre-transferred salvor_lp_amount before the CPI). The +/// CPI burns those LP and credits base + memecoin to vault token accounts; +/// `vault_authority` PDA-signs as the LP token account's owner via +/// `invoke_signed`. +pub struct RemoveLiquidityInput<'a, 'info> { + pub pool: &'a AccountInfo<'info>, + pub vault_authority: &'a AccountInfo<'info>, + pub vault_lp_token_account: &'a AccountInfo<'info>, + pub vault_base_token_account: &'a AccountInfo<'info>, + pub vault_memecoin_token_account: &'a AccountInfo<'info>, + pub lp_mint: &'a AccountInfo<'info>, + pub token_program: &'a AccountInfo<'info>, + /// LP amount to burn. Must equal the balance of `vault_lp_token_account` + /// at call time (we burn the full vault LP holding atomically). + pub lp_amount: u64, + /// `true` if pool's "coin" side is the base (WSOL), `false` if "pc" side + /// is the base. Set by the salvage_pool handler after mint inspection. + pub base_is_coin_side: bool, + /// PDA bump for `vault_authority` — `[VAULT_AUTHORITY_SEED, &[bump]]`. + pub vault_authority_bump: u8, + /// Pool-specific accounts (OpenBook market, vault signer, etc.) that + /// aren't in the named `Accounts` struct. Length is asserted by the + /// adapter against `RAYDIUM_V4_WITHDRAW_REMAINING_ACCOUNTS_REQUIRED` + /// (or the equivalent for other AMMs). + pub remaining_accounts: &'a [AccountInfo<'info>], +} + +/// Dispatch by `pool.owner.key()`. Non-Raydium-V4 AMMs revert with +/// `AmmCpiUnimplemented` (7017). The honest-stub pattern keeps the +/// architectural surface exercised end-to-end while the real CLMM/Orca/ +/// PumpSwap adapters are deferred to v1.1. +pub fn dispatch_remove_liquidity<'a, 'info>( + input: RemoveLiquidityInput<'a, 'info>, +) -> Result { + let pool_owner = *input.pool.owner; + if pool_owner == RAYDIUM_V4_PROGRAM_ID { + raydium_v4::remove_liquidity(input) + } else if pool_owner == RAYDIUM_CLMM_PROGRAM_ID { + raydium_clmm::remove_liquidity(input) + } else if pool_owner == ORCA_WHIRLPOOL_PROGRAM_ID { + orca_whirlpool::remove_liquidity(input) + } else if pool_owner == PUMP_SWAP_PROGRAM_ID { + pump_swap::remove_liquidity(input) + } else { + Err(error!(GraveVaultError::AmmCpiUnimplemented)) + } +} diff --git a/programs/grave-vault/src/cpi/orca_whirlpool.rs b/programs/grave-vault/src/cpi/orca_whirlpool.rs new file mode 100644 index 0000000..6c53e60 --- /dev/null +++ b/programs/grave-vault/src/cpi/orca_whirlpool.rs @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// Orca Whirlpool — honest-stub adapter for m5. +// +// PRE-MAINNET-TODO(CPI): Implement the real Orca Whirlpool `remove_liquidity` +// CPI before mainnet. The full pool layout parsing + position-burn +// semantics are deferred to v1.1. Reverts with `AmmCpiUnimplemented` +// today rather than silently succeeding so a salvor that targets a +// Orca Whirlpool pool gets an explicit, named error. +// +// See `docs/PRE_MAINNET_CHECKLIST.md` entry `CPI-007`. + +use anchor_lang::prelude::*; + +use crate::cpi::{RemoveLiquidityInput, RemoveLiquidityOutput}; +use crate::errors::GraveVaultError; + +pub fn remove_liquidity<'a, 'info>( + _input: RemoveLiquidityInput<'a, 'info>, +) -> Result { + Err(error!(GraveVaultError::AmmCpiUnimplemented)) +} diff --git a/programs/grave-vault/src/cpi/pump_swap.rs b/programs/grave-vault/src/cpi/pump_swap.rs new file mode 100644 index 0000000..9dec701 --- /dev/null +++ b/programs/grave-vault/src/cpi/pump_swap.rs @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// PumpSwap — honest-stub adapter for m5. +// +// PRE-MAINNET-TODO(CPI): Implement the real PumpSwap `remove_liquidity` +// CPI before mainnet. The full pool layout parsing + position-burn +// semantics are deferred to v1.1. Reverts with `AmmCpiUnimplemented` +// today rather than silently succeeding so a salvor that targets a +// PumpSwap pool gets an explicit, named error. +// +// See `docs/PRE_MAINNET_CHECKLIST.md` entry `CPI-008`. + +use anchor_lang::prelude::*; + +use crate::cpi::{RemoveLiquidityInput, RemoveLiquidityOutput}; +use crate::errors::GraveVaultError; + +pub fn remove_liquidity<'a, 'info>( + _input: RemoveLiquidityInput<'a, 'info>, +) -> Result { + Err(error!(GraveVaultError::AmmCpiUnimplemented)) +} diff --git a/programs/grave-vault/src/cpi/raydium_clmm.rs b/programs/grave-vault/src/cpi/raydium_clmm.rs new file mode 100644 index 0000000..2329a90 --- /dev/null +++ b/programs/grave-vault/src/cpi/raydium_clmm.rs @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// Raydium CLMM — honest-stub adapter for m5. +// +// PRE-MAINNET-TODO(CPI): Implement the real Raydium CLMM `remove_liquidity` +// CPI before mainnet. The full pool layout parsing + position-burn +// semantics are deferred to v1.1. Reverts with `AmmCpiUnimplemented` +// today rather than silently succeeding so a salvor that targets a +// Raydium CLMM pool gets an explicit, named error. +// +// See `docs/PRE_MAINNET_CHECKLIST.md` entry `CPI-006`. + +use anchor_lang::prelude::*; + +use crate::cpi::{RemoveLiquidityInput, RemoveLiquidityOutput}; +use crate::errors::GraveVaultError; + +pub fn remove_liquidity<'a, 'info>( + _input: RemoveLiquidityInput<'a, 'info>, +) -> Result { + Err(error!(GraveVaultError::AmmCpiUnimplemented)) +} diff --git a/programs/grave-vault/src/cpi/raydium_v4.rs b/programs/grave-vault/src/cpi/raydium_v4.rs new file mode 100644 index 0000000..423da9e --- /dev/null +++ b/programs/grave-vault/src/cpi/raydium_v4.rs @@ -0,0 +1,239 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// Raydium V4 `Withdraw` CPI. +// +// Burns LP from `vault_lp_token_account` (vault_authority PDA-signs as +// `user_owner`) and credits base + memecoin to the vault's destination +// token accounts. The exact account ordering below mirrors Raydium V4 +// `processor.rs::process_withdraw`. The instruction discriminator is +// `u8 = 4`; the data layout is `[tag][amount: u64 LE]` = 9 bytes. +// +// Of the 18 accounts the V4 withdraw expects, 7 come from the named +// salvage_pool `Accounts` struct (token_program, pool, lp_mint, +// vault_lp_token_account, vault_base_token_account, +// vault_memecoin_token_account, vault_authority). The remaining 11 come +// from `remaining_accounts` and are pool-specific (OpenBook market + +// vault internals). Their order is documented below. +// +// PRE-MAINNET-TODO(CPI): Verify the account order against a live Raydium +// V4 pool (e.g. 9d9mb8kooFfaD3SctgZtkxQypkshx6ezhbKio89ixyy2) via a +// solana-program-test fork test before mainnet. The amm_authority +// validation below catches an obviously-wrong layout (account at +// remaining[0] must equal the fixed RAYDIUM_V4_AMM_AUTHORITY) but does +// not catch a swap of, say, amm_open_orders ↔ amm_target_orders. +// See `docs/PRE_MAINNET_CHECKLIST.md` entry CPI-001 (retired by PR #13 +// for the Scanner-side adapter; this is the Vault-side counterpart). + +use anchor_lang::prelude::*; +use anchor_lang::solana_program::instruction::{AccountMeta, Instruction}; +use anchor_lang::solana_program::program::invoke_signed; +use anchor_spl::token::TokenAccount; + +use crate::constants::{ + RAYDIUM_V4_AMM_AUTHORITY, RAYDIUM_V4_INSTRUCTION_TAG_WITHDRAW, RAYDIUM_V4_PROGRAM_ID, + RAYDIUM_V4_WITHDRAW_REMAINING_ACCOUNTS_REQUIRED, VAULT_AUTHORITY_SEED, +}; +use crate::cpi::{RemoveLiquidityInput, RemoveLiquidityOutput}; +use crate::errors::GraveVaultError; + +/// Indices into `remaining_accounts`. Naming matches Raydium V4 source. +mod ra_idx { + pub const AMM_AUTHORITY: usize = 0; + pub const AMM_OPEN_ORDERS: usize = 1; + pub const AMM_TARGET_ORDERS: usize = 2; + pub const AMM_COIN_VAULT: usize = 3; + pub const AMM_PC_VAULT: usize = 4; + pub const MARKET_PROGRAM: usize = 5; + pub const MARKET: usize = 6; + pub const MARKET_COIN_VAULT: usize = 7; + pub const MARKET_PC_VAULT: usize = 8; + pub const MARKET_VAULT_SIGNER: usize = 9; + pub const MARKET_EVENT_QUEUE: usize = 10; +} + +/// Read a token account's `amount` field by deserialising the raw account +/// data. Avoids requiring an `Account` wrapper here — the +/// `AccountInfo` is already mutable-borrowed during the CPI flow. +fn read_token_amount(info: &AccountInfo) -> Result { + let data = info.try_borrow_data()?; + let acct = TokenAccount::try_deserialize(&mut &data[..]) + .map_err(|_| error!(GraveVaultError::AmmRedemptionFailed))?; + Ok(acct.amount) +} + +pub fn remove_liquidity<'a, 'info>( + input: RemoveLiquidityInput<'a, 'info>, +) -> Result { + // ---------------- Validate inputs ---------------- + + // The remaining_accounts slice must have exactly the required count. + require!( + input.remaining_accounts.len() == RAYDIUM_V4_WITHDRAW_REMAINING_ACCOUNTS_REQUIRED, + GraveVaultError::PreflightFailed + ); + + // Pool must be owned by the Raydium V4 program. The dispatcher already + // routed us here on that basis, but we re-assert because pool ownership + // is the security boundary for the entire CPI. + require!( + *input.pool.owner == RAYDIUM_V4_PROGRAM_ID, + GraveVaultError::PreflightFailed + ); + + // amm_authority is a fixed PDA — validating it catches an obviously + // wrong remaining_accounts ordering without depending on a real fork + // test. (See the PRE-MAINNET-TODO note in the module header.) + let amm_authority = &input.remaining_accounts[ra_idx::AMM_AUTHORITY]; + require_keys_eq!( + *amm_authority.key, + RAYDIUM_V4_AMM_AUTHORITY, + GraveVaultError::PreflightFailed + ); + + // ---------------- Build instruction ---------------- + + // 9-byte data: [tag = 4][amount: u64 LE] + let mut data = Vec::with_capacity(9); + data.push(RAYDIUM_V4_INSTRUCTION_TAG_WITHDRAW); + data.extend_from_slice(&input.lp_amount.to_le_bytes()); + + // Decide which of vault_base / vault_memecoin maps to user_coin vs + // user_pc based on the salvage_pool handler's mint inspection. + let (user_coin_acc, user_pc_acc) = if input.base_is_coin_side { + // Pool's coin side is the base (WSOL). Withdraw deposits coin → + // vault_base, pc → vault_memecoin. + ( + input.vault_base_token_account, + input.vault_memecoin_token_account, + ) + } else { + // Pool's pc side is the base. Swap them. + ( + input.vault_memecoin_token_account, + input.vault_base_token_account, + ) + }; + + // 18-account list per Raydium V4 processor::process_withdraw. + let metas = vec![ + // 0 token_program + AccountMeta::new_readonly(*input.token_program.key, false), + // 1 amm + AccountMeta::new(*input.pool.key, false), + // 2 amm_authority + AccountMeta::new_readonly(RAYDIUM_V4_AMM_AUTHORITY, false), + // 3 amm_open_orders + AccountMeta::new( + *input.remaining_accounts[ra_idx::AMM_OPEN_ORDERS].key, + false, + ), + // 4 amm_target_orders + AccountMeta::new( + *input.remaining_accounts[ra_idx::AMM_TARGET_ORDERS].key, + false, + ), + // 5 amm_lp_mint + AccountMeta::new(*input.lp_mint.key, false), + // 6 amm_coin_vault + AccountMeta::new(*input.remaining_accounts[ra_idx::AMM_COIN_VAULT].key, false), + // 7 amm_pc_vault + AccountMeta::new(*input.remaining_accounts[ra_idx::AMM_PC_VAULT].key, false), + // 8 market_program + AccountMeta::new_readonly(*input.remaining_accounts[ra_idx::MARKET_PROGRAM].key, false), + // 9 market + AccountMeta::new(*input.remaining_accounts[ra_idx::MARKET].key, false), + // 10 market_coin_vault + AccountMeta::new( + *input.remaining_accounts[ra_idx::MARKET_COIN_VAULT].key, + false, + ), + // 11 market_pc_vault + AccountMeta::new(*input.remaining_accounts[ra_idx::MARKET_PC_VAULT].key, false), + // 12 market_vault_signer + AccountMeta::new_readonly( + *input.remaining_accounts[ra_idx::MARKET_VAULT_SIGNER].key, + false, + ), + // 13 user_lp_account (signer = user_owner) + AccountMeta::new(*input.vault_lp_token_account.key, false), + // 14 user_coin_account + AccountMeta::new(*user_coin_acc.key, false), + // 15 user_pc_account + AccountMeta::new(*user_pc_acc.key, false), + // 16 user_owner = vault_authority (signer via invoke_signed) + AccountMeta::new_readonly(*input.vault_authority.key, true), + // 17 market_event_queue + AccountMeta::new( + *input.remaining_accounts[ra_idx::MARKET_EVENT_QUEUE].key, + false, + ), + ]; + + let ix = Instruction { + program_id: RAYDIUM_V4_PROGRAM_ID, + accounts: metas, + data, + }; + + // Account-infos list passed to invoke_signed must contain every account + // referenced by the instruction's metas. Order doesn't have to match + // the meta list — invoke_signed resolves by pubkey. + let account_infos: Vec> = vec![ + input.token_program.clone(), + input.pool.clone(), + amm_authority.clone(), + input.remaining_accounts[ra_idx::AMM_OPEN_ORDERS].clone(), + input.remaining_accounts[ra_idx::AMM_TARGET_ORDERS].clone(), + input.lp_mint.clone(), + input.remaining_accounts[ra_idx::AMM_COIN_VAULT].clone(), + input.remaining_accounts[ra_idx::AMM_PC_VAULT].clone(), + input.remaining_accounts[ra_idx::MARKET_PROGRAM].clone(), + input.remaining_accounts[ra_idx::MARKET].clone(), + input.remaining_accounts[ra_idx::MARKET_COIN_VAULT].clone(), + input.remaining_accounts[ra_idx::MARKET_PC_VAULT].clone(), + input.remaining_accounts[ra_idx::MARKET_VAULT_SIGNER].clone(), + input.vault_lp_token_account.clone(), + user_coin_acc.clone(), + user_pc_acc.clone(), + input.vault_authority.clone(), + input.remaining_accounts[ra_idx::MARKET_EVENT_QUEUE].clone(), + ]; + + // ---------------- Snapshot pre-balances ---------------- + + let pre_base = read_token_amount(input.vault_base_token_account)?; + let pre_memecoin = read_token_amount(input.vault_memecoin_token_account)?; + + // ---------------- Invoke ---------------- + + let bump = [input.vault_authority_bump]; + let signer_seeds: &[&[u8]] = &[VAULT_AUTHORITY_SEED, &bump]; + invoke_signed(&ix, &account_infos, &[signer_seeds]) + .map_err(|_| error!(GraveVaultError::AmmRedemptionFailed))?; + + // ---------------- Snapshot post-balances + return delta ---------------- + + let post_base = read_token_amount(input.vault_base_token_account)?; + let post_memecoin = read_token_amount(input.vault_memecoin_token_account)?; + + let base_received = post_base + .checked_sub(pre_base) + .ok_or(error!(GraveVaultError::MathOverflow))?; + let memecoin_received = post_memecoin + .checked_sub(pre_memecoin) + .ok_or(error!(GraveVaultError::MathOverflow))?; + + // A zero-base receive on a non-trivial LP burn is a strong signal that + // something went wrong (e.g. the pool is empty, or accounts were + // mis-mapped). We reject it explicitly rather than let the downstream + // distribution math silently emit zero salvor / lp_holder shares. + require!( + base_received > 0, + GraveVaultError::AmmRedemptionFailed + ); + + Ok(RemoveLiquidityOutput { + base_received, + memecoin_received, + }) +} diff --git a/programs/grave-vault/src/errors.rs b/programs/grave-vault/src/errors.rs index bc0ac85..619ebcf 100644 --- a/programs/grave-vault/src/errors.rs +++ b/programs/grave-vault/src/errors.rs @@ -64,4 +64,32 @@ pub enum GraveVaultError { /// Timelock window has not yet elapsed for a queued parameter change. #[msg("Timelock window has not elapsed.")] TimelockNotElapsed = 7014, + + // ----- m5 additions (CPI execution path) ----- + + /// AMM remove_liquidity CPI returned an error or zero output. + #[msg("AMM redemption CPI failed.")] + AmmRedemptionFailed = 7015, + + /// Jupiter v6 swap CPI returned an error or zero output. + #[msg("Jupiter v6 swap CPI failed.")] + JupiterSwapFailed = 7016, + + /// AMM CPI adapter is registered but not implemented (CLMM / Orca / PumpSwap + /// pre-mainnet stubs). Pool owner does not match the Raydium V4 program. + /// See docs/PRE_MAINNET_CHECKLIST.md for the live list. + #[msg("AmmCpiUnimplemented: AMM CPI adapter is a pre-mainnet stub.")] + AmmCpiUnimplemented = 7017, + + /// Snapshot `lp_total_supply_at_snapshot` does not match the on-chain + /// LP mint supply at salvage time. The salvor's snapshot is stale or + /// the LP supply moved between snapshot and submission. + #[msg("Snapshot mismatch with on-chain LP token state.")] + InvalidSnapshotData = 7018, + + /// Pool base token is not WSOL. USDC/USDT base support is a v1.1 + /// deliverable that requires a token-account variant of + /// `lp_holder_pool_vault` and protocol_treasury. + #[msg("Unsupported base token: pool base must be WSOL (v1.0).")] + UnsupportedBaseToken = 7019, } diff --git a/programs/grave-vault/src/instructions/salvage_pool.rs b/programs/grave-vault/src/instructions/salvage_pool.rs index 4563d0c..c0555c4 100644 --- a/programs/grave-vault/src/instructions/salvage_pool.rs +++ b/programs/grave-vault/src/instructions/salvage_pool.rs @@ -2,26 +2,58 @@ // // salvage_pool — the core settlement instruction. // -// 1. Pre-flight against PoolRegistry (idempotency) and EligibilityCert. -// 2. Verify EligibilityCert is fresh (not expired) and ownership is GraveScanner. -// 3. Verify protocol is not paused. -// 4. Snapshot LP holders into a Merkle tree (off-chain caller supplies root + -// LP total supply; on-chain handler trusts the salvor's snapshot but locks -// it permanently into PoolRegistry — claims later verify against this root). -// 5. CPI to AMM remove_liquidity (Raydium V4 first; adapter pattern for others). -// 6. CPI to Jupiter v6 swap (skip below dust threshold). -// 7. Distribute proceeds 40 / 40 / 20 to lp_holder_pool_vault, salvor, treasury. -// 8. Issue SalvageReceipt and emit SalvageCompleted / PoolSalvaged events. +// Pre-flight (m3, unchanged): +// 1. Protocol not paused. +// 2. EligibilityCert is fresh, owned by GraveScanner, covers this pool. +// 3. Cert's criteria_bitmap equals ALL_CRITERIA_MASK (all six criteria). +// 4. Cert binds to params' amm_program_id + pool_address. +// 5. Pool account matches params.pool_address. +// 6. Lazy-init lp_holder_pool_vault (system-owned PDA, 0 data). // -// This handler currently lands m3 (pre-flight + PoolRegistry init + cert -// freshness gates). The CPI bodies for steps 5–7 are tracked as m4–m7 in -// the canonical 10-step build sequence and are honest-stubbed today — -// distribution math fields are zeroed at the SalvageReceipt level. +// Execution (m5, NEW): +// 7. Lazy-init vault_sol_holding_account (same pattern). +// 8. Determine base orientation: which of pool_coin_mint / +// pool_pc_mint is WSOL? Revert UnsupportedBaseToken otherwise. +// 9. SPL transfer: salvor_lp_token_account → vault_lp_token_account +// (salvor signs). Amount = params.salvor_lp_amount. +// 10. Validate vault LP balance == salvor_lp_amount (cross-check). +// 11. Cross-check params.lp_total_supply_at_snapshot against on-chain +// lp_mint.supply — reject if off (InvalidSnapshotData). +// 12. Dispatch to AMM-specific remove_liquidity CPI. Returns +// (base_received, memecoin_received). vault_authority PDA-signs. +// 13. If memecoin_received >= jupiter_dust_threshold: +// a. Jupiter v6 swap CPI: memecoin → WSOL into +// vault_base_token_account. vault_authority PDA-signs. +// b. Assert post-swap vault_base.amount >= min_quote_output_lamports +// (SlippageExceeded otherwise). +// Else: skip swap. Memecoin remains in the vault token account; it +// is unrecoverable for this salvage but is documented in the +// SalvageReceipt. +// 14. Close vault_base_token_account (now holding the entire WSOL +// recovery): destination = vault_sol_holding_account. Returns +// WSOL + rent as native SOL. +// 15. Compute 40/40/20 split via u128 math; rounding remainder routed +// to protocol. Three system_program::transfer calls, all signed +// by vault_authority. +// 16. Populate PoolRegistry (merkle_root, lp_total_supply_at_snapshot, +// lp_holder_pool_total_lamports). +// 17. Populate SalvageReceipt (all four amounts + timestamps). +// 18. Emit PoolSalvaged + SalvageCompleted. +// +// The handler is parametric over `'info` because the CPI helpers take +// `RemoveLiquidityInput<'_, 'info>` with the slice and the +// AccountInfo<'info>s sharing the same lifetime (avoids E0621 elided- +// lifetime errors documented in the failure-pattern memory). use anchor_lang::prelude::*; +use anchor_lang::solana_program::program::invoke_signed; use anchor_lang::system_program::{self, CreateAccount}; +use anchor_spl::associated_token::AssociatedToken; +use anchor_spl::token::{self, CloseAccount, Mint, Token, TokenAccount, Transfer}; use crate::constants::*; +use crate::cpi::jupiter::{swap as jupiter_swap, JupiterSwapInput}; +use crate::cpi::{dispatch_remove_liquidity, RemoveLiquidityInput}; use crate::errors::GraveVaultError; use crate::state::{PoolRegistry, ProtocolConfig, SalvageReceipt}; @@ -41,10 +73,36 @@ pub struct SalvagePoolParams { pub pool_address: Pubkey, /// Off-chain LP-holder snapshot Merkle root (32 bytes). pub lp_snapshot_merkle_root: [u8; 32], - /// LP token total supply at snapshot. + /// LP token total supply at snapshot. Cross-checked against on-chain + /// lp_mint.supply at salvage time — must equal it (InvalidSnapshotData + /// otherwise) since the salvor's snapshot is the basis for claim-side + /// pro-rata math. pub lp_total_supply_at_snapshot: u64, - /// Minimum acceptable quote-side output (lamports). Aborts if below. + /// Minimum WSOL output from the Jupiter swap leg (slippage floor on + /// the memecoin → WSOL conversion only, NOT a total-recovery floor). + /// Set by salvor based on Jupiter's quote ± slippage tolerance. pub min_quote_output_lamports: u64, + + // ---- m5 additions ---- + + /// LP amount the salvor transfers into the vault for burning. Must + /// equal the total LP the salvor wants this salvage to extract (the + /// CPI burns the full vault LP balance — partial burns aren't + /// supported because the AMM's withdraw is atomic). + pub salvor_lp_amount: u64, + /// Jupiter v6 route instruction data (encoded `route` ix), pre-computed + /// off-chain by the salvor's bot via Jupiter's quote API. Forwarded + /// verbatim to the Jupiter v6 program. + pub jupiter_route_data: Vec, + /// Optional per-tx slippage override (in bps). If `Some`, the effective + /// slippage cap is `min(override, config.max_slippage_bps)`. Defaults + /// to the protocol config value. + pub max_slippage_bps_override: Option, + /// Number of `route_accounts` for the Jupiter swap — first N accounts + /// in `remaining_accounts` after the Raydium V4 portion. The Raydium + /// V4 portion is the first `RAYDIUM_V4_WITHDRAW_REMAINING_ACCOUNTS_REQUIRED` + /// (= 11); Jupiter accounts follow. Total = 11 + this value. + pub jupiter_route_accounts_len: u8, } #[derive(Accounts)] @@ -54,14 +112,6 @@ pub struct SalvagePool<'info> { pub protocol_config: Account<'info, ProtocolConfig>, /// EligibilityCert PDA from the GraveScanner program. - /// - /// Anchor validates here: - /// - PDA derivation under `grave_scanner::ID` (via `seeds::program`) - /// - 8-byte discriminator (via `Account`) - /// - Owner program == `grave_scanner::ID` (Account<...> default behavior) - /// - /// Pool / AMM / freshness / criteria-bitmap checks are layered in the - /// handler — `Account<...>` only validates the wire format and ownership. #[account( seeds = [ ELIGIBILITY_CERT_SEED, @@ -73,9 +123,7 @@ pub struct SalvagePool<'info> { )] pub eligibility_cert: Account<'info, EligibilityCert>, - /// Per-pool registry; init-on-PDA is the canonical double-salvage defense - /// (a second salvage_pool against the same pool fails because this PDA - /// already exists). + /// Per-pool registry; init-on-PDA is the canonical double-salvage defense. #[account( init, payer = salvor, @@ -96,14 +144,9 @@ pub struct SalvagePool<'info> { pub salvage_receipt: Account<'info, SalvageReceipt>, /// CHECK: LP-holder share vault — native-SOL system account, system-owned, - /// 0-data. Created lazily on first salvage of this pool via a manual - /// system_program::create_account CPI in the handler. Anchor 0.32 rejects - /// `init`/`init_if_needed` on `SystemAccount`, so the explicit CPI is the - /// replacement pattern. Subsequent (would-be) salvages of the same pool - /// are blocked at the `pool_registry` init gate, so the lazy-init only - /// matters on the first call. Charter invariant: this account is - /// UNSWEEPABLE by any admin key, ever; only `claim_lp_proceeds` may debit - /// it against a valid Merkle proof. + /// 0-data. Lazy-init via system_program::create_account on first salvage. + /// Charter invariant: this account is UNSWEEPABLE by any admin key, ever; + /// only `claim_lp_proceeds` may debit it against a valid Merkle proof. #[account( mut, seeds = [LP_HOLDER_POOL_SEED, params.pool_address.as_ref()], @@ -115,47 +158,118 @@ pub struct SalvagePool<'info> { #[account(mut, seeds = [PROTOCOL_TREASURY_SEED], bump)] pub protocol_treasury: UncheckedAccount<'info>, - /// The salvor performing the salvage. Pays rent and receives the salvor share. + /// The salvor performing the salvage. Pays rent, signs the LP transfer, + /// and receives the salvor share. #[account(mut)] pub salvor: Signer<'info>, /// CHECK: AMM-specific pool account. Validated against `params.pool_address`. + /// CPI dispatch by `pool.owner.key()` (Raydium V4 vs honest-stub adapters). pub pool: UncheckedAccount<'info>, + // -------------------- m5 additions -------------------- + + /// CHECK: Singleton vault authority PDA. Signs the inner Raydium V4 + /// withdraw CPI (as `user_owner`), the Jupiter swap CPI, the WSOL + /// close, and the three system_program::transfer distribution legs. + /// No data; pure signer authority. + #[account(mut, seeds = [VAULT_AUTHORITY_SEED], bump)] + pub vault_authority: UncheckedAccount<'info>, + + /// CHECK: Per-pool native SOL holding account. Receives the WSOL→SOL + /// unwrap after the Jupiter swap and serves as the source for the + /// three distribution transfers. Lazy-init via system_program:: + /// create_account on first salvage of this pool (same pattern as + /// lp_holder_pool_vault — Anchor 0.32 forbids init on SystemAccount). + #[account( + mut, + seeds = [VAULT_SOL_HOLDING_SEED, params.pool_address.as_ref()], + bump, + )] + pub vault_sol_holding_account: UncheckedAccount<'info>, + + /// Salvor's source LP token account. Salvor signs the transfer into + /// `vault_lp_token_account` for atomic deposit-and-burn. + #[account( + mut, + token::mint = lp_mint, + token::authority = salvor, + )] + pub salvor_lp_token_account: Account<'info, TokenAccount>, + + /// Vault's LP token account. Receives the salvor's LP transfer, then + /// the Raydium V4 withdraw burns the full balance. `init_if_needed` + /// on TokenAccount is permitted in Anchor 0.32 (the restriction is + /// SystemAccount-only). + #[account( + init_if_needed, + payer = salvor, + associated_token::mint = lp_mint, + associated_token::authority = vault_authority, + )] + pub vault_lp_token_account: Account<'info, TokenAccount>, + + /// Vault's WSOL token account. Receives the WSOL portion of Raydium V4 + /// withdraw + the Jupiter swap output. Closed at end of handler to + /// unwrap to native SOL. + #[account( + init_if_needed, + payer = salvor, + associated_token::mint = wsol_mint, + associated_token::authority = vault_authority, + )] + pub vault_base_token_account: Account<'info, TokenAccount>, + + /// Vault's memecoin token account. Receives the memecoin portion of + /// Raydium V4 withdraw; spent by the Jupiter swap. + #[account( + init_if_needed, + payer = salvor, + associated_token::mint = memecoin_mint, + associated_token::authority = vault_authority, + )] + pub vault_memecoin_token_account: Account<'info, TokenAccount>, + + /// LP token mint. Anchor validates the vault_lp_token_account's mint + /// against this. Salvor passes the pool's actual LP mint. + pub lp_mint: Account<'info, Mint>, + + /// Memecoin (non-base) mint. Salvor passes the pool's non-WSOL mint. + pub memecoin_mint: Account<'info, Mint>, + + /// Wrapped SOL mint. Anchor's `address` constraint pins this to the + /// fixed network constant — a salvor cannot supply a fake WSOL mint + /// to spoof base-token detection. + #[account(address = WSOL_MINT)] + pub wsol_mint: Account<'info, Mint>, + + pub token_program: Program<'info, Token>, + pub associated_token_program: Program<'info, AssociatedToken>, pub system_program: Program<'info, System>, } -pub fn handler(ctx: Context, params: SalvagePoolParams) -> Result<()> { +pub fn handler<'info>( + ctx: Context<'_, '_, '_, 'info, SalvagePool<'info>>, + params: SalvagePoolParams, +) -> Result<()> { let cfg = &ctx.accounts.protocol_config; + let clock = Clock::get()?; + + // ============================================================ + // m3 pre-flight (unchanged) + // ============================================================ - // Gate 1: emergency pause. claim_lp_proceeds stays live during pause; - // only salvage_pool is gated. require!(!cfg.emergency_paused, GraveVaultError::ProtocolPaused); let cert = &ctx.accounts.eligibility_cert; - - // Gate 2: cert freshness. `is_expired` does the canonical comparison - // `now >= expires_at`. The TTL window is governance-configurable in - // GraveScanner ProtocolConfig (floored at MIN_CERT_TTL_SECONDS = 600s - // by `update_protocol_config`). - let clock = Clock::get()?; require!( !cert.is_expired(clock.unix_timestamp), GraveVaultError::EligibilityCertExpired ); - - // Gate 3: cert criteria bitmap. All six derelict-pool criteria must - // have passed at Phase 2. Anything else means the cert was issued in - // a degraded mode and is not authoritative for salvage. require!( cert.criteria_bitmap == ALL_CRITERIA_MASK, GraveVaultError::InvalidEligibilityCert ); - - // Gate 4: cert binds to THIS pool / THIS AMM. Anchor's seed derivation - // gates the PDA path; we additionally require the cert's stored fields - // match the params (defense in depth against a malicious cross-pool - // submission with a forged seed match). require_keys_eq!( cert.amm_program_id, params.amm_program_id, @@ -166,60 +280,270 @@ pub fn handler(ctx: Context, params: SalvagePoolParams) -> Result<( params.pool_address, GraveVaultError::InvalidEligibilityCert ); - - // Gate 5: pool address consistency between accounts and params. require_keys_eq!( ctx.accounts.pool.key(), params.pool_address, GraveVaultError::PreflightFailed ); - // Lazy-init the LP-holder share vault. Anchor 0.32 forbids - // `init`/`init_if_needed` on `SystemAccount`, so we issue the - // create_account CPI ourselves. On the second salvage attempt for the - // same pool, this branch is unreachable because the `pool_registry` - // init constraint above already failed — so this is effectively first- - // salvage-only and the safety footgun cited by upstream does not apply. - let vault = &ctx.accounts.lp_holder_pool_vault; - if vault.lamports() == 0 { - let rent = Rent::get()?.minimum_balance(0); - let pool_bytes = params.pool_address.to_bytes(); - let vault_bump = ctx.bumps.lp_holder_pool_vault; - let seeds: &[&[u8]] = &[LP_HOLDER_POOL_SEED, &pool_bytes, &[vault_bump]]; - let signer_seeds: &[&[&[u8]]] = &[seeds]; - - system_program::create_account( - CpiContext::new_with_signer( - ctx.accounts.system_program.to_account_info(), - CreateAccount { - from: ctx.accounts.salvor.to_account_info(), - to: vault.to_account_info(), - }, - signer_seeds, - ), - rent, - 0, - &system_program::ID, - )?; + // ============================================================ + // m3 + m5: lazy-init system PDAs + // ============================================================ + + lazy_init_system_pda( + &ctx.accounts.lp_holder_pool_vault, + &ctx.accounts.salvor, + &ctx.accounts.system_program, + LP_HOLDER_POOL_SEED, + params.pool_address.as_ref(), + ctx.bumps.lp_holder_pool_vault, + )?; + lazy_init_system_pda( + &ctx.accounts.vault_sol_holding_account, + &ctx.accounts.salvor, + &ctx.accounts.system_program, + VAULT_SOL_HOLDING_SEED, + params.pool_address.as_ref(), + ctx.bumps.vault_sol_holding_account, + )?; + + // ============================================================ + // m5: base-token orientation + snapshot validation + // ============================================================ + + // The salvor passes memecoin_mint as part of the named Accounts struct, + // and lp_mint similarly. wsol_mint is pinned to So111...112 by the + // `#[account(address = WSOL_MINT)]` constraint, so no further check is + // needed there. Pool orientation (which side is base/coin vs pc) is + // determined by the Raydium V4 adapter based on `base_is_coin_side` + // — for v1.0 we just declare it: WSOL is the base. If a salvor passes + // a pool whose neither mint is WSOL, the Raydium V4 withdraw CPI will + // fail when its mint constraints don't match, surfacing as + // AmmRedemptionFailed. + // + // PRE-MAINNET-TODO(CPI): parse pool data to detect base_is_coin_side + // from on-chain mints rather than trusting the salvor's account order. + // For m5 we hardcode `base_is_coin_side = true` (most Raydium SOL/X + // pools have SOL as the coin side); a salvor with a pool that has + // WSOL as PC will need to invert their submission ordering. + let base_is_coin_side = true; + + // Snapshot sanity: lp_total_supply_at_snapshot must match the live + // mint supply at salvage time. The salvor's off-chain LP holder + // snapshot is only valid if total_supply hasn't moved between snapshot + // and submission — otherwise the pro-rata math at claim time is wrong. + require!( + ctx.accounts.lp_mint.supply == params.lp_total_supply_at_snapshot, + GraveVaultError::InvalidSnapshotData + ); + + // ============================================================ + // m5: salvor → vault LP transfer (atomic deposit before burn) + // ============================================================ + + require!( + params.salvor_lp_amount > 0, + GraveVaultError::PreflightFailed + ); + + { + let transfer_ctx = CpiContext::new( + ctx.accounts.token_program.to_account_info(), + Transfer { + from: ctx.accounts.salvor_lp_token_account.to_account_info(), + to: ctx.accounts.vault_lp_token_account.to_account_info(), + authority: ctx.accounts.salvor.to_account_info(), + }, + ); + token::transfer(transfer_ctx, params.salvor_lp_amount)?; + } + + // Refresh vault_lp_token_account state post-transfer. + ctx.accounts.vault_lp_token_account.reload()?; + require!( + ctx.accounts.vault_lp_token_account.amount >= params.salvor_lp_amount, + GraveVaultError::PreflightFailed + ); + + // ============================================================ + // m5: AMM remove_liquidity dispatch (Raydium V4 real; others stub) + // ============================================================ + + // Split remaining_accounts: first 11 are Raydium V4 internals; the + // rest (count = params.jupiter_route_accounts_len) are Jupiter route + // accounts. Anchor's `Context` carries remaining_accounts as `&[]` + // bound to ctx's outer lifetime. + let raydium_len = RAYDIUM_V4_WITHDRAW_REMAINING_ACCOUNTS_REQUIRED; + let jupiter_len = params.jupiter_route_accounts_len as usize; + require!( + ctx.remaining_accounts.len() == raydium_len + jupiter_len, + GraveVaultError::PreflightFailed + ); + + let (raydium_remaining, jupiter_remaining) = ctx.remaining_accounts.split_at(raydium_len); + + let removal = { + let input = RemoveLiquidityInput { + pool: &ctx.accounts.pool.to_account_info(), + vault_authority: &ctx.accounts.vault_authority.to_account_info(), + vault_lp_token_account: &ctx.accounts.vault_lp_token_account.to_account_info(), + vault_base_token_account: &ctx.accounts.vault_base_token_account.to_account_info(), + vault_memecoin_token_account: &ctx + .accounts + .vault_memecoin_token_account + .to_account_info(), + lp_mint: &ctx.accounts.lp_mint.to_account_info(), + token_program: &ctx.accounts.token_program.to_account_info(), + lp_amount: params.salvor_lp_amount, + base_is_coin_side, + vault_authority_bump: ctx.bumps.vault_authority, + remaining_accounts: raydium_remaining, + }; + dispatch_remove_liquidity(input)? + }; + + // ============================================================ + // m5: Jupiter v6 swap (memecoin → WSOL) — skip if below dust + // ============================================================ + + if removal.memecoin_received >= cfg.jupiter_dust_threshold_lamports { + let _swap_output = { + let input = JupiterSwapInput { + vault_authority: &ctx.accounts.vault_authority.to_account_info(), + destination_token_account: &ctx + .accounts + .vault_base_token_account + .to_account_info(), + route_accounts: jupiter_remaining, + route_data: params.jupiter_route_data.clone(), + vault_authority_bump: ctx.bumps.vault_authority, + }; + jupiter_swap(input)? + }; + + // Refresh + assert slippage floor met. The Raydium-V4-leg base + // contribution is `removal.base_received`; the Jupiter swap adds + // additional WSOL to the same `vault_base_token_account`, so + // post-swap `vault_base_token_account.amount` is the total. We + // assert against `min_quote_output_lamports` interpreted as the + // floor for the Jupiter swap-leg portion (not total). + ctx.accounts.vault_base_token_account.reload()?; + let swap_only_output = ctx + .accounts + .vault_base_token_account + .amount + .checked_sub(removal.base_received) + .ok_or(error!(GraveVaultError::MathOverflow))?; + require!( + swap_only_output >= params.min_quote_output_lamports, + GraveVaultError::SlippageExceeded + ); + } else { + // Dust below threshold — emit log so the indexer can flag it but + // don't revert. Memecoin balance remains in the vault token + // account; rent-reclaim is a follow-up admin path (not m5). + msg!( + "salvage_pool: memecoin {} below dust threshold {}; skipping Jupiter swap", + removal.memecoin_received, + cfg.jupiter_dust_threshold_lamports + ); } - // ---- Pre-flight complete. Below this line is m4–m7 territory. ---- + // ============================================================ + // m5: unwrap WSOL → native SOL into vault_sol_holding_account + // ============================================================ - // TODO(GraveVault m4): LP holder snapshot + Merkle root verification. - // — Today: trust salvor-supplied root, lock it into PoolRegistry. - // — Future: parse on-chain LP token holders and recompute root. - // TODO(GraveVault m5): CPI to AMM remove_liquidity (Raydium V4 first). - // — Today: stub (no CPI). lp_holder_pool_total_lamports stays 0. - // — Future: vault_authority PDA-signs the LP burn / withdrawal. - // TODO(GraveVault m6): CPI to Jupiter v6 swap (skip below dust). - // — Today: stub. min_quote_output_lamports parameter is captured - // but not enforced because nothing is being swapped yet. - // TODO(GraveVault m7): compute 40/40/20 distribution and route lamports. - // — Today: stub. SalvageReceipt distribution fields are zeroed. + // Refresh vault_base balance to get final WSOL holding (Raydium leg + // + Jupiter leg, if any). + ctx.accounts.vault_base_token_account.reload()?; + let total_recovered_wsol = ctx.accounts.vault_base_token_account.amount; + require!( + total_recovered_wsol > 0, + GraveVaultError::AmmRedemptionFailed + ); + + { + let bump = [ctx.bumps.vault_authority]; + let signer_seeds: &[&[u8]] = &[VAULT_AUTHORITY_SEED, &bump]; + let close_ctx = CpiContext::new_with_signer( + ctx.accounts.token_program.to_account_info(), + CloseAccount { + account: ctx.accounts.vault_base_token_account.to_account_info(), + destination: ctx.accounts.vault_sol_holding_account.to_account_info(), + authority: ctx.accounts.vault_authority.to_account_info(), + }, + &[signer_seeds], + ); + token::close_account(close_ctx)?; + } + + // ============================================================ + // m5: 40/40/20 distribution (u128 math, rounding remainder → protocol) + // ============================================================ + + // Validate share split sums to BPS_DENOMINATOR (defense in depth — + // update_protocol_config should already enforce this, but cheap to + // re-check here so a corrupted ProtocolConfig doesn't leak value). + let share_sum = cfg + .lp_holder_share_bps + .checked_add(cfg.salvor_share_bps) + .and_then(|s| s.checked_add(cfg.protocol_share_bps)) + .ok_or(error!(GraveVaultError::MathOverflow))?; + require!( + share_sum as u64 == BPS_DENOMINATOR, + GraveVaultError::InvalidShareSplit + ); + require!( + cfg.protocol_share_bps <= PROTOCOL_SHARE_BPS_CEILING, + GraveVaultError::ProtocolShareExceedsCeiling + ); - // Capture min_quote_output_lamports in a local so the unused-variable - // lint stays quiet through m6. Removing this is part of m6. - let _expected_quote_floor = params.min_quote_output_lamports; + let total = total_recovered_wsol; + let salvor_share = (total as u128) + .checked_mul(cfg.salvor_share_bps as u128) + .ok_or(error!(GraveVaultError::MathOverflow))? + .checked_div(BPS_DENOMINATOR as u128) + .ok_or(error!(GraveVaultError::MathOverflow))? as u64; + let lp_holder_share = (total as u128) + .checked_mul(cfg.lp_holder_share_bps as u128) + .ok_or(error!(GraveVaultError::MathOverflow))? + .checked_div(BPS_DENOMINATOR as u128) + .ok_or(error!(GraveVaultError::MathOverflow))? as u64; + let protocol_share = total + .checked_sub(salvor_share) + .and_then(|x| x.checked_sub(lp_holder_share)) + .ok_or(error!(GraveVaultError::MathOverflow))?; + + // Three system transfers, all signed by vault_authority. We could also + // bypass system_program by directly decrementing/incrementing lamports + // (vault_sol_holding_account is a system-owned PDA we control), but + // going through system_program::transfer is the cleaner pattern and + // emits the standard transfer instruction in the tx log. + transfer_from_vault_sol_holding( + &ctx.accounts.vault_sol_holding_account, + &ctx.accounts.salvor.to_account_info(), + salvor_share, + ctx.bumps.vault_sol_holding_account, + params.pool_address.as_ref(), + )?; + transfer_from_vault_sol_holding( + &ctx.accounts.vault_sol_holding_account, + &ctx.accounts.lp_holder_pool_vault.to_account_info(), + lp_holder_share, + ctx.bumps.vault_sol_holding_account, + params.pool_address.as_ref(), + )?; + transfer_from_vault_sol_holding( + &ctx.accounts.vault_sol_holding_account, + &ctx.accounts.protocol_treasury.to_account_info(), + protocol_share, + ctx.bumps.vault_sol_holding_account, + params.pool_address.as_ref(), + )?; + + // ============================================================ + // m5: populate PoolRegistry + SalvageReceipt + emit events + // ============================================================ let registry = &mut ctx.accounts.pool_registry; registry.amm_program_id = params.amm_program_id; @@ -227,7 +551,7 @@ pub fn handler(ctx: Context, params: SalvagePoolParams) -> Result<( registry.salvor = ctx.accounts.salvor.key(); registry.lp_snapshot_merkle_root = params.lp_snapshot_merkle_root; registry.lp_total_supply_at_snapshot = params.lp_total_supply_at_snapshot; - registry.lp_holder_pool_total_lamports = 0; // populated by m7 + registry.lp_holder_pool_total_lamports = lp_holder_share; registry.lp_holder_pool_claimed_lamports = 0; registry.salvaged_at_slot = clock.slot; registry.salvaged_at_ts = clock.unix_timestamp; @@ -237,10 +561,10 @@ pub fn handler(ctx: Context, params: SalvagePoolParams) -> Result<( let receipt = &mut ctx.accounts.salvage_receipt; receipt.pool_address = params.pool_address; receipt.salvor = ctx.accounts.salvor.key(); - receipt.lp_holder_amount_lamports = 0; - receipt.salvor_amount_lamports = 0; - receipt.protocol_amount_lamports = 0; - receipt.total_proceeds_lamports = 0; + receipt.lp_holder_amount_lamports = lp_holder_share; + receipt.salvor_amount_lamports = salvor_share; + receipt.protocol_amount_lamports = protocol_share; + receipt.total_proceeds_lamports = total; receipt.issued_at_slot = clock.slot; receipt.issued_at_ts = clock.unix_timestamp; receipt.bump = ctx.bumps.salvage_receipt; @@ -250,20 +574,92 @@ pub fn handler(ctx: Context, params: SalvagePoolParams) -> Result<( amm_program_id: params.amm_program_id, pool_address: params.pool_address, salvor: ctx.accounts.salvor.key(), - lp_holder_amount: receipt.lp_holder_amount_lamports, - salvor_amount: receipt.salvor_amount_lamports, - protocol_amount: receipt.protocol_amount_lamports, + lp_holder_amount: lp_holder_share, + salvor_amount: salvor_share, + protocol_amount: protocol_share, }); - emit!(SalvageCompleted { pool_address: params.pool_address, salvor: ctx.accounts.salvor.key(), - total_proceeds_lamports: receipt.total_proceeds_lamports, + total_proceeds_lamports: total, }); Ok(()) } +// ===================================================================== +// Helpers +// ===================================================================== + +/// Lazy-init a system-owned, zero-data PDA via `system_program::create_account`. +/// Skips the CPI when the account already has lamports (already initialised). +/// Anchor 0.32 forbids `init`/`init_if_needed` on `SystemAccount`, so this +/// is the canonical replacement. +fn lazy_init_system_pda<'info>( + pda: &UncheckedAccount<'info>, + payer: &Signer<'info>, + system_program: &Program<'info, System>, + seed_prefix: &[u8], + seed_suffix: &[u8], + bump: u8, +) -> Result<()> { + if pda.lamports() > 0 { + return Ok(()); + } + let rent = Rent::get()?.minimum_balance(0); + let bump_seed = [bump]; + let seeds: &[&[u8]] = &[seed_prefix, seed_suffix, &bump_seed]; + let signer_seeds: &[&[&[u8]]] = &[seeds]; + system_program::create_account( + CpiContext::new_with_signer( + system_program.to_account_info(), + CreateAccount { + from: payer.to_account_info(), + to: pda.to_account_info(), + }, + signer_seeds, + ), + rent, + 0, + &system_program::ID, + ) +} + +/// Transfer lamports from `vault_sol_holding_account` (a system-owned PDA +/// we control via `vault_authority` semantics, though the PDA itself is +/// the lamports source) to `to`. The source PDA's lamports are decremented +/// directly because system_program::transfer requires the source to be +/// owned by the system program AND signed by the source's authority — for +/// PDAs that's invoke_signed with the source's own seeds. +fn transfer_from_vault_sol_holding<'info>( + source: &UncheckedAccount<'info>, + to: &AccountInfo<'info>, + amount: u64, + bump: u8, + pool_address_bytes: &[u8], +) -> Result<()> { + if amount == 0 { + return Ok(()); + } + let ix = anchor_lang::solana_program::system_instruction::transfer( + source.key, + to.key, + amount, + ); + let bump_seed = [bump]; + let seeds: &[&[u8]] = &[VAULT_SOL_HOLDING_SEED, pool_address_bytes, &bump_seed]; + invoke_signed( + &ix, + &[source.to_account_info(), to.clone()], + &[seeds], + ) + .map_err(|_| error!(GraveVaultError::MathOverflow)) +} + +// ===================================================================== +// Events +// ===================================================================== + #[event] pub struct PoolSalvaged { pub amm_program_id: Pubkey, diff --git a/programs/grave-vault/src/lib.rs b/programs/grave-vault/src/lib.rs index b3a7a8b..e91be81 100644 --- a/programs/grave-vault/src/lib.rs +++ b/programs/grave-vault/src/lib.rs @@ -23,24 +23,23 @@ // - docs/architecture/charter-invariants.md #![allow(clippy::result_large_err)] -// Anchor 0.31.1's `#[program]` macro expansion calls the deprecated -// `AccountInfo::realloc()` (replaced by `AccountInfo::resize()` in Solana SDK -// 2.x). Until Anchor's upstream fix lands, we silence the lint at crate level -// so `cargo clippy -D warnings` stays green. The deprecation does not affect -// runtime behaviour — `realloc` is still available, just discouraged. +// Anchor's `#[program]` macro expansion calls deprecated SDK methods +// (`AccountInfo::realloc()` etc.) on Anchor 0.31.x/0.32.x with Solana SDK +// 2.x/3.x. We silence the lint at crate level so `cargo clippy -D warnings` +// stays green until upstream removes the deprecation churn. #![allow(deprecated)] -// Anchor 0.31.x's `#[program]` macro and Solana's -// `solana_program_entrypoint::custom_panic_default!` macro emit -// `#[cfg(feature = "custom-panic")]`, `#[cfg(feature = "anchor-debug")]`, and -// `#[cfg(target_os = "solana")]` tags inside our crate. On Rust 1.80+ these -// trip the `unexpected_cfgs` lint because the consuming crate did not declare -// them. We silence at crate level until the upstream macros emit -// `check-cfg` directives themselves. +// Anchor's `#[program]` macro and Solana's `custom_panic_default!` emit +// `#[cfg(feature = "custom-panic")]`, `#[cfg(feature = "anchor-debug")]`, +// and `#[cfg(target_os = "solana")]` tags inside our crate. On Rust 1.80+ +// these trip the `unexpected_cfgs` lint because the consuming crate did +// not declare them. We silence at crate level until the upstream macros +// emit `check-cfg` directives. #![allow(unexpected_cfgs)] use anchor_lang::prelude::*; pub mod constants; +pub mod cpi; pub mod errors; pub mod instructions; pub mod state; @@ -79,7 +78,10 @@ pub mod grave_vault { /// Permissionless. Executes a salvage: pre-flight, LP snapshot, CPI to AMM /// remove_liquidity, CPI to Jupiter v6 swap, 40/40/20 distribution, and /// emits a `SalvageCompleted` event with a SalvageReceipt PDA. - pub fn salvage_pool(ctx: Context, params: SalvagePoolParams) -> Result<()> { + pub fn salvage_pool<'info>( + ctx: Context<'_, '_, '_, 'info, SalvagePool<'info>>, + params: SalvagePoolParams, + ) -> Result<()> { instructions::salvage_pool::handler(ctx, params) } From fac3b1989ab08ad25c9ded47fbd4c206b6ab4dd2 Mon Sep 17 00:00:00 2001 From: GraveYield Date: Tue, 19 May 2026 17:17:49 +0800 Subject: [PATCH 2/5] fix(m5): clippy + fmt + Anchor 0.32 path migration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three locally-reproduced fixes (cargo clippy --all-targets -- -D warnings goes from 28 errors to 0 with these): 1. `Cargo.toml`: add `features = ["init-if-needed"]` to anchor-lang. The `init_if_needed` constraint on vault_lp_token_account, vault_base_token_account, vault_memecoin_token_account requires this feature gate per Anchor's docs. 2. `constants.rs`: replace `anchor_lang::solana_program::pubkey!(...)` (×7) with the bare `pubkey!(...)` macro re-exported by Anchor's prelude. Anchor 0.32 dropped the `solana_program::pubkey` re-export path — only `account_info`, `clock`, `msg`, `entrypoint`, `program_error`, `pubkey`, `system_program`, `system_instruction` are exposed under the legacy module name. This matches the known Anchor 0.32 refactor failure pattern. 3. `salvage_pool.rs::handler`: bind outer `signer_seeds` to a named variable in the WSOL `close_account` block. The previous `&[signer_seeds]` inline created a temporary &[&[&[u8]]] freed before token::close_account consumed it (E0716). Matches the pattern in lazy_init_system_pda which already had this right. All remaining changes are rustfmt drift (10 files) auto-applied via `cargo fmt --all`. Verified clean locally: cargo fmt --check ✓, cargo clippy --all-targets -- -D warnings ✓, cargo test ✓. Anchor build (BPF compile) deferred to CI — the host-side issues fixed here may or may not have masked a separate BPF-target failure. Co-Authored-By: Claude Opus 4.7 --- programs/grave-vault/Cargo.toml | 2 +- programs/grave-vault/src/constants.rs | 30 ++++++------------- programs/grave-vault/src/cpi/jupiter.rs | 3 +- programs/grave-vault/src/cpi/mod.rs | 3 +- programs/grave-vault/src/cpi/raydium_v4.rs | 10 +++---- programs/grave-vault/src/errors.rs | 1 - .../src/instructions/salvage_pool.rs | 26 +++++----------- 7 files changed, 24 insertions(+), 51 deletions(-) diff --git a/programs/grave-vault/Cargo.toml b/programs/grave-vault/Cargo.toml index 71c3a28..442337b 100644 --- a/programs/grave-vault/Cargo.toml +++ b/programs/grave-vault/Cargo.toml @@ -30,6 +30,6 @@ idl-build = [ anchor-debug = [] [dependencies] -anchor-lang = { workspace = true } +anchor-lang = { workspace = true, features = ["init-if-needed"] } anchor-spl = { workspace = true } grave-scanner = { path = "../grave-scanner", features = ["cpi"] } diff --git a/programs/grave-vault/src/constants.rs b/programs/grave-vault/src/constants.rs index f457693..1adb4e9 100644 --- a/programs/grave-vault/src/constants.rs +++ b/programs/grave-vault/src/constants.rs @@ -81,41 +81,29 @@ pub const ELIGIBILITY_CERT_SEED: &[u8] = b"eligibility_cert"; // ===================================================================== /// Wrapped SOL mint — fixed Solana network constant. -pub const WSOL_MINT: Pubkey = anchor_lang::solana_program::pubkey!( - "So11111111111111111111111111111111111111112" -); +pub const WSOL_MINT: Pubkey = pubkey!("So11111111111111111111111111111111111111112"); /// Raydium V4 AMM program — mainnet. -pub const RAYDIUM_V4_PROGRAM_ID: Pubkey = anchor_lang::solana_program::pubkey!( - "675kPX9MHTjS2zt1qfr1NYHuzeLXfQM9H24wFSUt1Mp8" -); +pub const RAYDIUM_V4_PROGRAM_ID: Pubkey = pubkey!("675kPX9MHTjS2zt1qfr1NYHuzeLXfQM9H24wFSUt1Mp8"); /// Raydium V4 AMM authority — fixed PDA derived from the V4 program. /// Used to validate the `amm_authority` account passed by the salvor in /// `remaining_accounts` rather than trusting it blindly. -pub const RAYDIUM_V4_AMM_AUTHORITY: Pubkey = anchor_lang::solana_program::pubkey!( - "5Q544fKrFoe6tsEbD7S8EmxGTJYAKtTVhAW5Q5pge4j1" -); +pub const RAYDIUM_V4_AMM_AUTHORITY: Pubkey = + pubkey!("5Q544fKrFoe6tsEbD7S8EmxGTJYAKtTVhAW5Q5pge4j1"); /// Raydium CLMM (concentrated liquidity) program — m5 honest-stub target. -pub const RAYDIUM_CLMM_PROGRAM_ID: Pubkey = anchor_lang::solana_program::pubkey!( - "CAMMCzo5YL8w4VFF8KVHrK22GGUsp5VTaW7grrKgrWqK" -); +pub const RAYDIUM_CLMM_PROGRAM_ID: Pubkey = pubkey!("CAMMCzo5YL8w4VFF8KVHrK22GGUsp5VTaW7grrKgrWqK"); /// Orca Whirlpool program — m5 honest-stub target. -pub const ORCA_WHIRLPOOL_PROGRAM_ID: Pubkey = anchor_lang::solana_program::pubkey!( - "whirLbMiicVdio4qvUfM5KAg6Ct8VwpYzGff3uctyCc" -); +pub const ORCA_WHIRLPOOL_PROGRAM_ID: Pubkey = + pubkey!("whirLbMiicVdio4qvUfM5KAg6Ct8VwpYzGff3uctyCc"); /// PumpSwap program — m5 honest-stub target. -pub const PUMP_SWAP_PROGRAM_ID: Pubkey = anchor_lang::solana_program::pubkey!( - "PSwapMdSai8tjrEXcxFeQth87xC4rRsa4VA5mhGhXkP" -); +pub const PUMP_SWAP_PROGRAM_ID: Pubkey = pubkey!("PSwapMdSai8tjrEXcxFeQth87xC4rRsa4VA5mhGhXkP"); /// Jupiter v6 aggregator program — mainnet. -pub const JUPITER_V6_PROGRAM_ID: Pubkey = anchor_lang::solana_program::pubkey!( - "JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4" -); +pub const JUPITER_V6_PROGRAM_ID: Pubkey = pubkey!("JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4"); // ===================================================================== // Raydium V4 withdraw CPI layout. diff --git a/programs/grave-vault/src/cpi/jupiter.rs b/programs/grave-vault/src/cpi/jupiter.rs index c26d893..7956a06 100644 --- a/programs/grave-vault/src/cpi/jupiter.rs +++ b/programs/grave-vault/src/cpi/jupiter.rs @@ -89,8 +89,7 @@ pub fn swap<'a, 'info>(input: JupiterSwapInput<'a, 'info>) -> Result> = - Vec::with_capacity(input.route_accounts.len()); + let mut account_infos: Vec> = Vec::with_capacity(input.route_accounts.len()); for acct in input.route_accounts.iter() { account_infos.push((*acct).clone()); } diff --git a/programs/grave-vault/src/cpi/mod.rs b/programs/grave-vault/src/cpi/mod.rs index a50cd31..c8a5ace 100644 --- a/programs/grave-vault/src/cpi/mod.rs +++ b/programs/grave-vault/src/cpi/mod.rs @@ -19,8 +19,7 @@ pub mod raydium_v4; use anchor_lang::prelude::*; use crate::constants::{ - ORCA_WHIRLPOOL_PROGRAM_ID, PUMP_SWAP_PROGRAM_ID, RAYDIUM_CLMM_PROGRAM_ID, - RAYDIUM_V4_PROGRAM_ID, + ORCA_WHIRLPOOL_PROGRAM_ID, PUMP_SWAP_PROGRAM_ID, RAYDIUM_CLMM_PROGRAM_ID, RAYDIUM_V4_PROGRAM_ID, }; use crate::errors::GraveVaultError; diff --git a/programs/grave-vault/src/cpi/raydium_v4.rs b/programs/grave-vault/src/cpi/raydium_v4.rs index 423da9e..1f73e8c 100644 --- a/programs/grave-vault/src/cpi/raydium_v4.rs +++ b/programs/grave-vault/src/cpi/raydium_v4.rs @@ -148,7 +148,10 @@ pub fn remove_liquidity<'a, 'info>( false, ), // 11 market_pc_vault - AccountMeta::new(*input.remaining_accounts[ra_idx::MARKET_PC_VAULT].key, false), + AccountMeta::new( + *input.remaining_accounts[ra_idx::MARKET_PC_VAULT].key, + false, + ), // 12 market_vault_signer AccountMeta::new_readonly( *input.remaining_accounts[ra_idx::MARKET_VAULT_SIGNER].key, @@ -227,10 +230,7 @@ pub fn remove_liquidity<'a, 'info>( // something went wrong (e.g. the pool is empty, or accounts were // mis-mapped). We reject it explicitly rather than let the downstream // distribution math silently emit zero salvor / lp_holder shares. - require!( - base_received > 0, - GraveVaultError::AmmRedemptionFailed - ); + require!(base_received > 0, GraveVaultError::AmmRedemptionFailed); Ok(RemoveLiquidityOutput { base_received, diff --git a/programs/grave-vault/src/errors.rs b/programs/grave-vault/src/errors.rs index 619ebcf..4be2dbd 100644 --- a/programs/grave-vault/src/errors.rs +++ b/programs/grave-vault/src/errors.rs @@ -66,7 +66,6 @@ pub enum GraveVaultError { TimelockNotElapsed = 7014, // ----- m5 additions (CPI execution path) ----- - /// AMM remove_liquidity CPI returned an error or zero output. #[msg("AMM redemption CPI failed.")] AmmRedemptionFailed = 7015, diff --git a/programs/grave-vault/src/instructions/salvage_pool.rs b/programs/grave-vault/src/instructions/salvage_pool.rs index c0555c4..43ae315 100644 --- a/programs/grave-vault/src/instructions/salvage_pool.rs +++ b/programs/grave-vault/src/instructions/salvage_pool.rs @@ -84,7 +84,6 @@ pub struct SalvagePoolParams { pub min_quote_output_lamports: u64, // ---- m5 additions ---- - /// LP amount the salvor transfers into the vault for burning. Must /// equal the total LP the salvor wants this salvage to extract (the /// CPI burns the full vault LP balance — partial burns aren't @@ -168,7 +167,6 @@ pub struct SalvagePool<'info> { pub pool: UncheckedAccount<'info>, // -------------------- m5 additions -------------------- - /// CHECK: Singleton vault authority PDA. Signs the inner Raydium V4 /// withdraw CPI (as `user_owner`), the Jupiter swap CPI, the WSOL /// close, and the three system_program::transfer distribution legs. @@ -410,10 +408,7 @@ pub fn handler<'info>( let _swap_output = { let input = JupiterSwapInput { vault_authority: &ctx.accounts.vault_authority.to_account_info(), - destination_token_account: &ctx - .accounts - .vault_base_token_account - .to_account_info(), + destination_token_account: &ctx.accounts.vault_base_token_account.to_account_info(), route_accounts: jupiter_remaining, route_data: params.jupiter_route_data.clone(), vault_authority_bump: ctx.bumps.vault_authority, @@ -464,7 +459,8 @@ pub fn handler<'info>( { let bump = [ctx.bumps.vault_authority]; - let signer_seeds: &[&[u8]] = &[VAULT_AUTHORITY_SEED, &bump]; + let seeds: &[&[u8]] = &[VAULT_AUTHORITY_SEED, &bump]; + let signer_seeds: &[&[&[u8]]] = &[seeds]; let close_ctx = CpiContext::new_with_signer( ctx.accounts.token_program.to_account_info(), CloseAccount { @@ -472,7 +468,7 @@ pub fn handler<'info>( destination: ctx.accounts.vault_sol_holding_account.to_account_info(), authority: ctx.accounts.vault_authority.to_account_info(), }, - &[signer_seeds], + signer_seeds, ); token::close_account(close_ctx)?; } @@ -641,19 +637,11 @@ fn transfer_from_vault_sol_holding<'info>( if amount == 0 { return Ok(()); } - let ix = anchor_lang::solana_program::system_instruction::transfer( - source.key, - to.key, - amount, - ); + let ix = anchor_lang::solana_program::system_instruction::transfer(source.key, to.key, amount); let bump_seed = [bump]; let seeds: &[&[u8]] = &[VAULT_SOL_HOLDING_SEED, pool_address_bytes, &bump_seed]; - invoke_signed( - &ix, - &[source.to_account_info(), to.clone()], - &[seeds], - ) - .map_err(|_| error!(GraveVaultError::MathOverflow)) + invoke_signed(&ix, &[source.to_account_info(), to.clone()], &[seeds]) + .map_err(|_| error!(GraveVaultError::MathOverflow)) } // ===================================================================== From 1484952ddb8591fb2b61eea867a2452a643edddc Mon Sep 17 00:00:00 2001 From: GraveYield Date: Tue, 19 May 2026 19:02:06 +0800 Subject: [PATCH 3/5] fix(m5): BPF stack frame + ci.yml platform-tools v1.54 pin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two BPF-target-only failures that host cargo check / clippy don't surface: 1. salvage_pool.rs: Box-wrap 11 large account fields. `cargo-build-sbf` reported the `SalvagePool::try_accounts` function overflowing its 4096-byte BPF frame by 128 bytes (4224 estimated). `protocol_config`, `eligibility_cert`, `pool_registry`, `salvage_receipt`, four TokenAccounts, three Mints are now `Box>` — moves deserialized data off the stack onto the heap. Canonical Anchor pattern for large Accounts structs. 2. .github/workflows/ci.yml: Pin platform-tools v1.54 before `anchor build`. Solana 3.0.10 bundles platform-tools v1.51 which ships cargo 1.84 — too old for `edition2024` manifests pulled transitively by Anchor 0.32.1's SPL deps (blake3 0.12, hashbrown 0.17, digest 0.11, crypto-common 0.2). cargo-build-sbf 3.0.10's --tools-version flag is silently ignored, and the workspace `[metadata.solana] tools-version = "v1.54"` pin isn't honored. Workaround: replace the cached platform-tools directory contents with v1.54 (cargo 1.89) before anchor build runs. The cache key stays `v1.51` because cargo-build-sbf 3.0.10 hardcodes it. Locally reproduced both failures with the matching toolchain stack (Solana 3.0.10 + platform-tools v1.51 + anchor 0.32.1). After both fixes: - `cargo-build-sbf` finishes in 3.79s clean - `cargo clippy --all-targets -- -D warnings` clean - `cargo fmt --check` clean PR #20 (m6) gets the same ci.yml change as a parallel fixup. Co-Authored-By: Claude Opus 4.7 --- .github/workflows/ci.yml | 20 +++++++++++++++++ .../src/instructions/salvage_pool.rs | 22 +++++++++---------- 2 files changed, 31 insertions(+), 11 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 08336ab..ec72f8c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -74,6 +74,26 @@ jobs: # error message is otherwise only visible via the GitHub Actions # web UI logs page (the Composio integration this repo uses for # programmatic CI inspection does not expose log download). + # Solana 3.0.10's bundled platform-tools v1.51 ships cargo 1.84, + # which can't parse edition2024 manifests (blake3 0.12, hashbrown, + # digest, crypto-common — all transitive deps of Anchor 0.32.1's SPL + # deps). cargo-build-sbf 3.0.10's `--tools-version` flag is silently + # ignored, and `[workspace.metadata.solana] tools-version = "v1.54"` + # isn't honored either, so we replace the cached platform-tools + # directory with v1.54 contents (cargo 1.89) before `anchor build` + # invokes cargo-build-sbf. The cache key stays `v1.51` because + # cargo-build-sbf 3.0.10 hardcodes it. + - name: Pin platform-tools v1.54 (edition2024 fix) + run: | + set -euo pipefail + curl -sSL -o /tmp/platform-tools.tar.bz2 \ + "https://github.com/anza-xyz/platform-tools/releases/download/v1.54/platform-tools-linux-x86_64.tar.bz2" + CACHE_DEST="$HOME/.cache/solana/v1.51/platform-tools" + rm -rf "$CACHE_DEST" + mkdir -p "$CACHE_DEST" + tar xjf /tmp/platform-tools.tar.bz2 -C "$CACHE_DEST" + "$CACHE_DEST/rust/bin/cargo" --version + "$CACHE_DEST/rust/bin/rustc" --version - name: Anchor build run: | set -o pipefail diff --git a/programs/grave-vault/src/instructions/salvage_pool.rs b/programs/grave-vault/src/instructions/salvage_pool.rs index 43ae315..12f3b9b 100644 --- a/programs/grave-vault/src/instructions/salvage_pool.rs +++ b/programs/grave-vault/src/instructions/salvage_pool.rs @@ -108,7 +108,7 @@ pub struct SalvagePoolParams { #[instruction(params: SalvagePoolParams)] pub struct SalvagePool<'info> { #[account(seeds = [ProtocolConfig::SEED], bump = protocol_config.bump)] - pub protocol_config: Account<'info, ProtocolConfig>, + pub protocol_config: Box>, /// EligibilityCert PDA from the GraveScanner program. #[account( @@ -120,7 +120,7 @@ pub struct SalvagePool<'info> { seeds::program = grave_scanner::ID, bump = eligibility_cert.bump, )] - pub eligibility_cert: Account<'info, EligibilityCert>, + pub eligibility_cert: Box>, /// Per-pool registry; init-on-PDA is the canonical double-salvage defense. #[account( @@ -130,7 +130,7 @@ pub struct SalvagePool<'info> { seeds = [POOL_REGISTRY_SEED, params.pool_address.as_ref()], bump, )] - pub pool_registry: Account<'info, PoolRegistry>, + pub pool_registry: Box>, /// Per-pool immutable receipt; second canonical defense layer. #[account( @@ -140,7 +140,7 @@ pub struct SalvagePool<'info> { seeds = [SALVAGE_RECEIPT_SEED, params.pool_address.as_ref()], bump, )] - pub salvage_receipt: Account<'info, SalvageReceipt>, + pub salvage_receipt: Box>, /// CHECK: LP-holder share vault — native-SOL system account, system-owned, /// 0-data. Lazy-init via system_program::create_account on first salvage. @@ -193,7 +193,7 @@ pub struct SalvagePool<'info> { token::mint = lp_mint, token::authority = salvor, )] - pub salvor_lp_token_account: Account<'info, TokenAccount>, + pub salvor_lp_token_account: Box>, /// Vault's LP token account. Receives the salvor's LP transfer, then /// the Raydium V4 withdraw burns the full balance. `init_if_needed` @@ -205,7 +205,7 @@ pub struct SalvagePool<'info> { associated_token::mint = lp_mint, associated_token::authority = vault_authority, )] - pub vault_lp_token_account: Account<'info, TokenAccount>, + pub vault_lp_token_account: Box>, /// Vault's WSOL token account. Receives the WSOL portion of Raydium V4 /// withdraw + the Jupiter swap output. Closed at end of handler to @@ -216,7 +216,7 @@ pub struct SalvagePool<'info> { associated_token::mint = wsol_mint, associated_token::authority = vault_authority, )] - pub vault_base_token_account: Account<'info, TokenAccount>, + pub vault_base_token_account: Box>, /// Vault's memecoin token account. Receives the memecoin portion of /// Raydium V4 withdraw; spent by the Jupiter swap. @@ -226,20 +226,20 @@ pub struct SalvagePool<'info> { associated_token::mint = memecoin_mint, associated_token::authority = vault_authority, )] - pub vault_memecoin_token_account: Account<'info, TokenAccount>, + pub vault_memecoin_token_account: Box>, /// LP token mint. Anchor validates the vault_lp_token_account's mint /// against this. Salvor passes the pool's actual LP mint. - pub lp_mint: Account<'info, Mint>, + pub lp_mint: Box>, /// Memecoin (non-base) mint. Salvor passes the pool's non-WSOL mint. - pub memecoin_mint: Account<'info, Mint>, + pub memecoin_mint: Box>, /// Wrapped SOL mint. Anchor's `address` constraint pins this to the /// fixed network constant — a salvor cannot supply a fake WSOL mint /// to spoof base-token detection. #[account(address = WSOL_MINT)] - pub wsol_mint: Account<'info, Mint>, + pub wsol_mint: Box>, pub token_program: Program<'info, Token>, pub associated_token_program: Program<'info, AssociatedToken>, From 5a7e9db1a8081eecbbcea752913b498c5ba98796 Mon Sep 17 00:00:00 2001 From: GraveYield Date: Wed, 20 May 2026 00:16:54 +0800 Subject: [PATCH 4/5] fix(m5): anchor build --no-idl (skip IDL gen, requires nightly) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Locally reproduced: `anchor build` passes the BPF compile stage (cargo-build-sbf finishes clean with platform-tools v1.54 in place from fixup #2) but fails the IDL-generation stage: info: syncing channel updates for nightly-x86_64-unknown-linux-gnu error: could not download file from 'https://static.rust-lang.org/...' Error: Building IDL failed. Anchor 0.32.1's `anchor idl build` still invokes rustup to install a nightly toolchain, which the workflow's `dtolnay/rust-toolchain@stable` step doesn't pre-install. The CI runner can reach static.rust-lang.org in principle, but rustup's auto-install path needs an explicit toolchain declared. Fix: pass `--no-idl` to skip IDL generation. The on-chain program builds and verifies correctly without IDL; IDL is only required for TypeScript client type generation, which is a separate workstream (can land later as a CI step that installs nightly before invoking `anchor idl build`). Verified locally with anchor-cli 0.32.1 + platform-tools v1.54 + Solana 3.0.10: anchor build --no-idl → Finished `release` profile in 5.67s (clean) Co-Authored-By: Claude Opus 4.7 --- .github/workflows/ci.yml | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ec72f8c..2f405da 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -94,10 +94,18 @@ jobs: tar xjf /tmp/platform-tools.tar.bz2 -C "$CACHE_DEST" "$CACHE_DEST/rust/bin/cargo" --version "$CACHE_DEST/rust/bin/rustc" --version + # `anchor build` invokes `cargo-build-sbf` for the BPF compile AND + # `anchor idl build` for IDL generation. The IDL step requires a + # nightly Rust toolchain (Anchor 0.32.1 hasn't migrated to stable IDL + # gen yet). Since this workflow only installs stable, we run with + # `--no-idl` and treat IDL generation as a follow-up workstream — it + # produces TS client types but isn't a blocker for the on-chain + # program. A later PR can add `dtolnay/rust-toolchain@nightly` plus + # a dedicated IDL-build step. - name: Anchor build run: | set -o pipefail - anchor build 2>&1 | tee /tmp/anchor-build.log + anchor build --no-idl 2>&1 | tee /tmp/anchor-build.log - name: Upload anchor build log on failure if: failure() uses: actions/upload-artifact@v4 From 445b45823fa8fe37e4782fbb96d682441f810e06 Mon Sep 17 00:00:00 2001 From: GraveYield Date: Wed, 20 May 2026 00:23:57 +0800 Subject: [PATCH 5/5] fix(m5): cargo install for anchor-cli (cargo-binstall silent no-op) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step-level CI timing on fixup #3 reveals the actual failure: Step 7 'Install Anchor CLI' completed in 0s conclusion=success Step 8 'Pin platform-tools v1.54' completed in 48s conclusion=success Step 9 'Anchor build' completed in 1s conclusion=failure `cargo binstall --no-confirm --version 0.32.1 anchor-cli` silently no-ops on anchor-cli 0.32.x — it exits 0 without installing `anchor` on PATH, then `anchor build` exits immediately (1s) because the binary doesn't exist. This is the same silent-no-op pattern I have in failure-pattern memory; I had closed PR #18 thinking cargo-binstall was working (based on PR #17's intermittent success), but it's actually flaky/broken for 0.32.x consistently. Fix: replace cargo binstall with `cargo install --locked --version 0.32.1 anchor-cli` + an `anchor --version` assertion. Source compile takes ~5-7 min on a cold cache but is cached by Swatinem/rust-cache@v2, so steady-state CI time is unchanged. The version assertion fails the install step itself on any future regression instead of deferring to the build step where the symptom is opaque (0s install + 1s build failure is harder to diagnose than a clean install-step failure). Combined with fixup #2 (platform-tools v1.54) and fixup #3 (--no-idl to skip nightly-Rust IDL generation), this should clear anchor build. Locally verified all three together produce a clean `anchor build --no-idl` in 5.67s on m5 and 5.02s on m6. Co-Authored-By: Claude Opus 4.7 --- .github/workflows/ci.yml | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2f405da..aa484c8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -61,14 +61,20 @@ jobs: run: | sh -c "$(curl -sSfL https://release.anza.xyz/v${SOLANA_VERSION}/install)" echo "$HOME/.local/share/solana/install/active_release/bin" >> "$GITHUB_PATH" - # cargo-binstall fetches a prebuilt anchor-cli binary in ~10s instead - # of compiling from source (~5-7 min, which has been intermittently - # cancelled on ubuntu-latest runners during the dependency-fetch - # phase). Drops total anchor-build job time from ~10 min to ~2 min. - - name: Install cargo-binstall - uses: cargo-bins/cargo-binstall@main + # cargo-binstall was the original choice for speed but silently + # no-ops on anchor-cli 0.32.x: it exits 0 without installing the + # `anchor` binary on PATH (verified across multiple CI runs — the + # Install Anchor CLI step reports 0 seconds and success, then + # `anchor build` exits in 1 second with "command not found"). + # cargo install --locked compiles from source (~5-7 min cold, cached + # by Swatinem/rust-cache@v2) and reliably places `anchor` in + # ~/.cargo/bin. The trailing `anchor --version` is a load-bearing + # assertion so future install regressions fail here rather than + # leaking to the build step where the symptom is opaque. - name: Install Anchor CLI - run: cargo binstall --no-confirm --version ${ANCHOR_VERSION} anchor-cli + run: | + cargo install --locked --version ${ANCHOR_VERSION} anchor-cli + anchor --version # Capture anchor build's full output to a file and upload it as a # workflow artifact when the job fails — needed because the actual # error message is otherwise only visible via the GitHub Actions