diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 08336ab..aa484c8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -61,23 +61,57 @@ jobs: run: | sh -c "$(curl -sSfL https://release.anza.xyz/v${SOLANA_VERSION}/install)" echo "$HOME/.local/share/solana/install/active_release/bin" >> "$GITHUB_PATH" - # cargo-binstall fetches a prebuilt anchor-cli binary in ~10s instead - # of compiling from source (~5-7 min, which has been intermittently - # cancelled on ubuntu-latest runners during the dependency-fetch - # phase). Drops total anchor-build job time from ~10 min to ~2 min. - - name: Install cargo-binstall - uses: cargo-bins/cargo-binstall@main + # cargo-binstall was the original choice for speed but silently + # no-ops on anchor-cli 0.32.x: it exits 0 without installing the + # `anchor` binary on PATH (verified across multiple CI runs — the + # Install Anchor CLI step reports 0 seconds and success, then + # `anchor build` exits in 1 second with "command not found"). + # cargo install --locked compiles from source (~5-7 min cold, cached + # by Swatinem/rust-cache@v2) and reliably places `anchor` in + # ~/.cargo/bin. The trailing `anchor --version` is a load-bearing + # assertion so future install regressions fail here rather than + # leaking to the build step where the symptom is opaque. - name: Install Anchor CLI - run: cargo binstall --no-confirm --version ${ANCHOR_VERSION} anchor-cli + run: | + cargo install --locked --version ${ANCHOR_VERSION} anchor-cli + anchor --version # Capture anchor build's full output to a file and upload it as a # workflow artifact when the job fails — needed because the actual # error message is otherwise only visible via the GitHub Actions # web UI logs page (the Composio integration this repo uses for # programmatic CI inspection does not expose log download). + # Solana 3.0.10's bundled platform-tools v1.51 ships cargo 1.84, + # which can't parse edition2024 manifests (blake3 0.12, hashbrown, + # digest, crypto-common — all transitive deps of Anchor 0.32.1's SPL + # deps). cargo-build-sbf 3.0.10's `--tools-version` flag is silently + # ignored, and `[workspace.metadata.solana] tools-version = "v1.54"` + # isn't honored either, so we replace the cached platform-tools + # directory with v1.54 contents (cargo 1.89) before `anchor build` + # invokes cargo-build-sbf. The cache key stays `v1.51` because + # cargo-build-sbf 3.0.10 hardcodes it. + - name: Pin platform-tools v1.54 (edition2024 fix) + run: | + set -euo pipefail + curl -sSL -o /tmp/platform-tools.tar.bz2 \ + "https://github.com/anza-xyz/platform-tools/releases/download/v1.54/platform-tools-linux-x86_64.tar.bz2" + CACHE_DEST="$HOME/.cache/solana/v1.51/platform-tools" + rm -rf "$CACHE_DEST" + mkdir -p "$CACHE_DEST" + tar xjf /tmp/platform-tools.tar.bz2 -C "$CACHE_DEST" + "$CACHE_DEST/rust/bin/cargo" --version + "$CACHE_DEST/rust/bin/rustc" --version + # `anchor build` invokes `cargo-build-sbf` for the BPF compile AND + # `anchor idl build` for IDL generation. The IDL step requires a + # nightly Rust toolchain (Anchor 0.32.1 hasn't migrated to stable IDL + # gen yet). Since this workflow only installs stable, we run with + # `--no-idl` and treat IDL generation as a follow-up workstream — it + # produces TS client types but isn't a blocker for the on-chain + # program. A later PR can add `dtolnay/rust-toolchain@nightly` plus + # a dedicated IDL-build step. - name: Anchor build run: | set -o pipefail - anchor build 2>&1 | tee /tmp/anchor-build.log + anchor build --no-idl 2>&1 | tee /tmp/anchor-build.log - name: Upload anchor build log on failure if: failure() uses: actions/upload-artifact@v4 diff --git a/CHANGELOG.md b/CHANGELOG.md index bc073c4..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/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 882d133..1adb4e9 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,58 @@ 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 = pubkey!("So11111111111111111111111111111111111111112"); + +/// Raydium V4 AMM program — mainnet. +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 = + pubkey!("5Q544fKrFoe6tsEbD7S8EmxGTJYAKtTVhAW5Q5pge4j1"); + +/// Raydium CLMM (concentrated liquidity) program — m5 honest-stub target. +pub const RAYDIUM_CLMM_PROGRAM_ID: Pubkey = pubkey!("CAMMCzo5YL8w4VFF8KVHrK22GGUsp5VTaW7grrKgrWqK"); + +/// Orca Whirlpool program — m5 honest-stub target. +pub const ORCA_WHIRLPOOL_PROGRAM_ID: Pubkey = + pubkey!("whirLbMiicVdio4qvUfM5KAg6Ct8VwpYzGff3uctyCc"); + +/// PumpSwap program — m5 honest-stub target. +pub const PUMP_SWAP_PROGRAM_ID: Pubkey = pubkey!("PSwapMdSai8tjrEXcxFeQth87xC4rRsa4VA5mhGhXkP"); + +/// Jupiter v6 aggregator program — mainnet. +pub const JUPITER_V6_PROGRAM_ID: Pubkey = 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..7956a06 --- /dev/null +++ b/programs/grave-vault/src/cpi/jupiter.rs @@ -0,0 +1,115 @@ +// 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..c8a5ace --- /dev/null +++ b/programs/grave-vault/src/cpi/mod.rs @@ -0,0 +1,82 @@ +// 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..1f73e8c --- /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..4be2dbd 100644 --- a/programs/grave-vault/src/errors.rs +++ b/programs/grave-vault/src/errors.rs @@ -64,4 +64,31 @@ 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..12f3b9b 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,27 +73,44 @@ 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)] #[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. - /// - /// 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, @@ -71,11 +120,9 @@ 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 - /// (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, @@ -83,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( @@ -93,17 +140,12 @@ 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. 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 +157,117 @@ 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: Box>, + + /// 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: Box>, + + /// 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: Box>, + + /// 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: 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: Box>, + + /// Memecoin (non-base) mint. Salvor passes the pool's non-WSOL 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: Box>, + + 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 +278,268 @@ 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]; + // ============================================================ + // 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) + // ============================================================ - 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, - )?; + 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)?; } - // ---- Pre-flight complete. Below this line is m4–m7 territory. ---- + // 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) + // ============================================================ - // 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. + // 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 + ); - // 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 (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 + ); + } + + // ============================================================ + // m5: unwrap WSOL → native SOL into vault_sol_holding_account + // ============================================================ + + // 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 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 { + 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 + ); + + 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 +547,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 +557,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 +570,84 @@ 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) }