diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..a98a93d --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,91 @@ +# Changelog + +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). +Version bumps in this file refer to the workspace as a whole; per-program +version pinning lives in each program's `Cargo.toml`. + +## [Unreleased] + +## [v1.0.6] — 2026-05-10 + +### m3 — GraveVault `salvage_pool` pre-flight + cert freshness gates + +This release lands milestone 3 of the canonical 10-step build sequence: +**GraveVault `salvage_pool` pre-flight + PoolRegistry**. The CPI bodies +for AMM `remove_liquidity` (m5), Jupiter swap (m6), and 40/40/20 +distribution (m7) remain honest-stubbed and explicitly marked. + +#### Added + +- **`MIN_CERT_TTL_SECONDS = 600`** floor in `programs/grave-scanner/src/constants.rs`. + Hardcoded; raising it requires a program upgrade. +- **`ProtocolConfig.cert_ttl_seconds: i64`** field on the GraveScanner + ProtocolConfig (governance-configurable, 72h timelocked, default 3600s). + This replaces the previously-hardcoded `ELIGIBILITY_CERT_TTL_SECONDS` + const at the runtime path in `evaluate_pool_phase_2`. The const itself + is retained as `DEFAULT_CERT_TTL_SECONDS` for default-handling at init, + and an `#[deprecated]` alias is left at `ELIGIBILITY_CERT_TTL_SECONDS` + for backwards-compatible test fixtures. +- **Error 6019 `CertTtlBelowMinimum`** on GraveScanner. Raised by + `initialize` and `update_protocol_config` when a `cert_ttl_seconds` + parameter falls below `MIN_CERT_TTL_SECONDS`. +- **`init_if_needed` Anchor feature** on `programs/grave-vault/Cargo.toml` + for the `lp_holder_pool_vault` SystemAccount. First salvage of a pool + creates the 0-data system-owned PDA; subsequent salvages of the same + pool are gated upstream by the `pool_registry` init constraint. + +#### Changed + +- **`salvage_pool` pre-flight gates wired** in `programs/grave-vault/src/instructions/salvage_pool.rs`: + - Pause check (`ProtocolPaused`). + - **Cert freshness** via `EligibilityCert::is_expired(now)` (`EligibilityCertExpired`). + - **Cert criteria bitmap** must equal `0x3F` (all six derelict-pool + criteria validated at Phase 2) (`InvalidEligibilityCert`). + - **Cert pool / AMM binding** — `cert.amm_program_id == params.amm_program_id` + AND `cert.pool_address == params.pool_address` (`InvalidEligibilityCert`). + - Pool account address consistency (`PreflightFailed`). +- **`eligibility_cert` account** in `salvage_pool` migrated from + `UncheckedAccount<'info>` to `Account<'info, EligibilityCert>`. Anchor + now handles the 8-byte discriminator check and owner-program (`grave_scanner::ID`) + validation automatically; the previous manual ownership require! is + redundant and removed. +- **`lp_holder_pool_vault`** in both `salvage_pool` and `claim_lp_proceeds` + migrated from `UncheckedAccount<'info>` to `SystemAccount<'info>`. In + `salvage_pool` the constraint adds `init_if_needed` + `space = 0`. The + account remains charter-invariant unsweepable; only `claim_lp_proceeds` + may debit it (against a valid Merkle proof, m6+). +- **`evaluate_pool_phase_2`** now reads `cfg.cert_ttl_seconds` from + ProtocolConfig instead of the hardcoded const when stamping + `cert.expires_at`. + +#### Honest stubs (audit-pending, unchanged from v1.0.5) + +- AMM `remove_liquidity` CPI for Raydium V4: wired in v1.0.5; not yet + integration-tested against a seeded localnet pool (OpenBook seed harness + is a v1.1 deliverable). +- AMM adapters for Raydium CLMM, Orca Whirlpool, PumpSwap: revert + `AmmAdapterUnimplemented`. +- Locker release adapters (UNCX / PinkSale / Team Finance): revert + `LockerAdapterUnimplemented`. +- Jupiter v6 swap CPI: not yet wired; m6 deliverable. +- 40/40/20 distribution math: not yet wired; m7 deliverable. + `SalvageReceipt` distribution fields are zeroed at init. +- LP-holder Merkle proof verification in `claim_lp_proceeds`: returns + `InvalidClaimProof` until m6 wires the SHA-256 sorted-pair verification. + +#### Verification status + +- `cargo check`: not yet run in this sandbox — pending Seth's + ship-now-vs-verify-first call. v1.0.7 will be the post-verification + patch with any compile fixes named in the CHANGELOG. + +#### Pre-mainnet checklist + +- Replace placeholder program IDs in both crates' `declare_id!` and + `Anchor.toml` with real keypairs via `anchor keys list && anchor keys sync`. +- Re-deploy ProtocolConfig PDAs on devnet — adding `cert_ttl_seconds` + changes `INIT_SPACE` and existing config accounts will fail `realloc` + unless rotated through a fresh `initialize`. (Pre-mainnet: no live + config exists, so this is a no-op for the canonical deploy path.) diff --git a/programs/grave-scanner/src/constants.rs b/programs/grave-scanner/src/constants.rs index 0a3bd88..fdcf6b6 100644 --- a/programs/grave-scanner/src/constants.rs +++ b/programs/grave-scanner/src/constants.rs @@ -3,10 +3,10 @@ // GraveScanner constants — locked thresholds and PDA seeds. // Do not change without updating docs/architecture/eligibility-anchors.md. -// ===================================================================== +// ================================================================= // Eligibility thresholds (Charter-locked at launch, governance-tunable // within ranges enforced by `update_protocol_config`). -// ===================================================================== +// ================================================================= /// Minimum trading inactivity to consider a pool derelict (Criterion 1). /// 90 days, expressed in seconds. @@ -38,12 +38,35 @@ pub const DEFAULT_LP_BURN_DUST_THRESHOLD: u64 = 1_000; /// `sweep_stale_anchor` to reclaim rent. pub const DEFAULT_ANCHOR_STALENESS_SECONDS: u64 = 14 * 24 * 60 * 60; -/// EligibilityCert TTL — 1 hour, expressed in seconds. -pub const ELIGIBILITY_CERT_TTL_SECONDS: i64 = 60 * 60; +/// Default `EligibilityCert` TTL — 1 hour, expressed in seconds. Governance +/// can lower or raise this via `update_protocol_config` but never below +/// `MIN_CERT_TTL_SECONDS`. +/// +/// Used as the default value when `initialize` is called with +/// `cert_ttl_seconds = 0`. Live values live in `ProtocolConfig.cert_ttl_seconds` +/// (see `state/protocol_config.rs`). +pub const DEFAULT_CERT_TTL_SECONDS: i64 = 60 * 60; + +/// Hardcoded floor on `cert_ttl_seconds`. Governance cannot configure a +/// cert TTL shorter than this, even by accident — `update_protocol_config` +/// rejects any value below this with `CertTtlBelowMinimum`. +/// +/// Rationale: a cert TTL below 10 minutes makes the certify-and-salvage +/// bundle helper brittle against ordinary mempool latency. Raising this +/// floor requires a program upgrade, not a config update. +pub const MIN_CERT_TTL_SECONDS: i64 = 600; + +/// `EligibilityCert` TTL constant retained for backwards-compatible imports +/// (e.g., older test fixtures). Prefer `ProtocolConfig.cert_ttl_seconds` +/// at runtime; this alias mirrors `DEFAULT_CERT_TTL_SECONDS`. +#[deprecated( + note = "Use ProtocolConfig.cert_ttl_seconds at runtime, or DEFAULT_CERT_TTL_SECONDS for defaults." +)] +pub const ELIGIBILITY_CERT_TTL_SECONDS: i64 = DEFAULT_CERT_TTL_SECONDS; -// ===================================================================== +// ================================================================= // PDA seeds. -// ===================================================================== +// ================================================================= pub const PROTOCOL_CONFIG_SEED: &[u8] = b"protocol_config"; pub const ELIGIBILITY_ANCHOR_SEED: &[u8] = b"eligibility_anchor"; diff --git a/programs/grave-scanner/src/errors.rs b/programs/grave-scanner/src/errors.rs index 1897c8a..4a1aa41 100644 --- a/programs/grave-scanner/src/errors.rs +++ b/programs/grave-scanner/src/errors.rs @@ -5,7 +5,7 @@ // // Anchor's `#[error_code]` macro adds a default offset of 6000 to each // variant's Rust discriminant. To produce the canonical spec codes -// 6000..=6018, the discriminants below are 0..=18 (with the 12..=14 gap +// 6000..=6019, the discriminants below are 0..=19 (with the 12..=14 gap // preserved for future v4.x additions). // // Do not renumber existing variants. New variants append at the next @@ -90,4 +90,10 @@ pub enum GraveScannerError { /// staleness window elapsed. #[msg("AnchorNotStale: staleness window has not yet elapsed.")] AnchorNotStale = 18, + + /// On-chain code 6019. `update_protocol_config` rejected a + /// `cert_ttl_seconds` value below the hardcoded `MIN_CERT_TTL_SECONDS` + /// floor (600s = 10 min). Raising the floor requires a program upgrade. + #[msg("CertTtlBelowMinimum: cert_ttl_seconds below MIN_CERT_TTL_SECONDS floor.")] + CertTtlBelowMinimum = 19, } diff --git a/programs/grave-scanner/src/instructions/evaluate_pool_phase2.rs b/programs/grave-scanner/src/instructions/evaluate_pool_phase2.rs index 19744de..3da9b8a 100644 --- a/programs/grave-scanner/src/instructions/evaluate_pool_phase2.rs +++ b/programs/grave-scanner/src/instructions/evaluate_pool_phase2.rs @@ -1,8 +1,10 @@ // SPDX-License-Identifier: Apache-2.0 // // Phase 2 of evaluate_pool. Re-verifies all six criteria after the -// multi-epoch confirmation gap and issues an `EligibilityCert` (TTL = 1 -// hour). GraveVault consumes the cert to authorise `salvage_pool`. +// multi-epoch confirmation gap and issues an `EligibilityCert` (TTL = +// `ProtocolConfig.cert_ttl_seconds`, governance-configurable, default 1h, +// floored at MIN_CERT_TTL_SECONDS=600s). GraveVault consumes the cert to +// authorise `salvage_pool`. // // Phase 2 also enforces that the bitmap matches the originating // EligibilityAnchor — a Phase 1 pass cannot be downgraded silently. @@ -10,9 +12,7 @@ use anchor_lang::prelude::*; use crate::adapters::{self, PoolData}; -use crate::constants::{ - ELIGIBILITY_ANCHOR_SEED, ELIGIBILITY_CERT_SEED, ELIGIBILITY_CERT_TTL_SECONDS, LAUNCH_PRICE_SEED, -}; +use crate::constants::{ELIGIBILITY_ANCHOR_SEED, ELIGIBILITY_CERT_SEED, LAUNCH_PRICE_SEED}; use crate::criteria::{self, CriteriaInputs, CriteriaThresholds, Phase}; use crate::errors::GraveScannerError; use crate::state::{EligibilityAnchor, EligibilityCert, LaunchPrice, ProtocolConfig}; @@ -131,9 +131,12 @@ pub fn handler(ctx: Context, params: EvaluatePoolPhase2Param cert.anchor_epoch = anchor_account.first_eligible_epoch; cert.cert_epoch = clock.epoch; cert.issued_at = clock.unix_timestamp; + // TTL is governance-configurable per ProtocolConfig (with a hardcoded + // floor enforced in `update_protocol_config`). Reading here keeps cert + // freshness in lockstep with the live config. cert.expires_at = clock .unix_timestamp - .checked_add(ELIGIBILITY_CERT_TTL_SECONDS) + .checked_add(cfg.cert_ttl_seconds) .ok_or(GraveScannerError::MathOverflow)?; cert.criteria_bitmap = bitmap; cert.bump = ctx.bumps.eligibility_cert; diff --git a/programs/grave-scanner/src/instructions/initialize.rs b/programs/grave-scanner/src/instructions/initialize.rs index 2e14dd2..fb672c2 100644 --- a/programs/grave-scanner/src/instructions/initialize.rs +++ b/programs/grave-scanner/src/instructions/initialize.rs @@ -3,6 +3,7 @@ use anchor_lang::prelude::*; use crate::constants::*; +use crate::errors::GraveScannerError; use crate::state::ProtocolConfig; #[derive(AnchorSerialize, AnchorDeserialize, Clone)] @@ -14,6 +15,10 @@ pub struct InitializeParams { pub min_tvl_lamports: u64, pub anchor_staleness_seconds: u64, pub lp_burn_dust_threshold: u64, + /// EligibilityCert TTL in seconds. 0 = default to `DEFAULT_CERT_TTL_SECONDS` + /// (3600). Values below `MIN_CERT_TTL_SECONDS` (600) are rejected with + /// `CertTtlBelowMinimum`. + pub cert_ttl_seconds: i64, } #[derive(Accounts)] @@ -66,6 +71,17 @@ pub fn handler(ctx: Context, params: InitializeParams) -> Result<()> params.lp_burn_dust_threshold }; + let cert_ttl = if params.cert_ttl_seconds == 0 { + DEFAULT_CERT_TTL_SECONDS + } else { + params.cert_ttl_seconds + }; + require!( + cert_ttl >= MIN_CERT_TTL_SECONDS, + GraveScannerError::CertTtlBelowMinimum + ); + cfg.cert_ttl_seconds = cert_ttl; + cfg.paused = false; cfg.bump = ctx.bumps.protocol_config; cfg._reserved = [0u8; 64]; diff --git a/programs/grave-scanner/src/instructions/update_protocol_config.rs b/programs/grave-scanner/src/instructions/update_protocol_config.rs index 2d885b1..bdae4dd 100644 --- a/programs/grave-scanner/src/instructions/update_protocol_config.rs +++ b/programs/grave-scanner/src/instructions/update_protocol_config.rs @@ -5,6 +5,7 @@ use anchor_lang::prelude::*; +use crate::constants::MIN_CERT_TTL_SECONDS; use crate::errors::GraveScannerError; use crate::state::ProtocolConfig; @@ -14,6 +15,9 @@ pub struct UpdateProtocolConfigParams { pub price_collapse_bps: Option, pub min_tvl_lamports: Option, pub anchor_staleness_seconds: Option, + /// EligibilityCert TTL in seconds. Floor: `MIN_CERT_TTL_SECONDS` (600). + /// Any value below the floor reverts with `CertTtlBelowMinimum`. + pub cert_ttl_seconds: Option, } #[derive(Accounts)] @@ -48,6 +52,15 @@ pub fn handler( if let Some(v) = params.anchor_staleness_seconds { cfg.anchor_staleness_seconds = v; } + if let Some(v) = params.cert_ttl_seconds { + // Hardcoded floor: governance cannot push cert_ttl below 600s. + // Raising the floor requires a program upgrade. + require!( + v >= MIN_CERT_TTL_SECONDS, + GraveScannerError::CertTtlBelowMinimum + ); + cfg.cert_ttl_seconds = v; + } Ok(()) } diff --git a/programs/grave-scanner/src/state/protocol_config.rs b/programs/grave-scanner/src/state/protocol_config.rs index 423879e..2bcc1ab 100644 --- a/programs/grave-scanner/src/state/protocol_config.rs +++ b/programs/grave-scanner/src/state/protocol_config.rs @@ -32,6 +32,14 @@ pub struct ProtocolConfig { /// with `lp_supply <= lp_burn_dust_threshold` are treated as burned. pub lp_burn_dust_threshold: u64, + /// `EligibilityCert` TTL in seconds. Governance-configurable via + /// `update_protocol_config`, but bounded below by + /// `MIN_CERT_TTL_SECONDS` (600s) — `update_protocol_config` rejects + /// any value below the floor with `CertTtlBelowMinimum`. + /// + /// Read by `evaluate_pool_phase_2` when stamping `cert.expires_at`. + pub cert_ttl_seconds: i64, + /// Emergency pause flag. When `true`, `evaluate_pool_*` reverts with /// `ProtocolPaused`. Has no effect on rent reclaim (`sweep_stale_anchor`) /// or governance instructions. Per spec: GraveVault's diff --git a/programs/grave-vault/src/instructions/claim_lp_proceeds.rs b/programs/grave-vault/src/instructions/claim_lp_proceeds.rs index 5278beb..3308853 100644 --- a/programs/grave-vault/src/instructions/claim_lp_proceeds.rs +++ b/programs/grave-vault/src/instructions/claim_lp_proceeds.rs @@ -46,14 +46,14 @@ pub struct ClaimLpProceeds<'info> { )] pub claim_record: Account<'info, ClaimRecord>, - /// CHECK: same `lp_holder_pool_vault` written to by salvage_pool. + /// Same `lp_holder_pool_vault` written to by salvage_pool. /// Charter-invariant: only `claim_lp_proceeds` may debit this account. #[account( mut, seeds = [LP_HOLDER_POOL_SEED, params.pool_address.as_ref()], bump, )] - pub lp_holder_pool_vault: UncheckedAccount<'info>, + pub lp_holder_pool_vault: SystemAccount<'info>, #[account(mut)] pub lp_holder: Signer<'info>, diff --git a/programs/grave-vault/src/instructions/salvage_pool.rs b/programs/grave-vault/src/instructions/salvage_pool.rs index bfdbc12..4563d0c 100644 --- a/programs/grave-vault/src/instructions/salvage_pool.rs +++ b/programs/grave-vault/src/instructions/salvage_pool.rs @@ -13,15 +13,28 @@ // 7. Distribute proceeds 40 / 40 / 20 to lp_holder_pool_vault, salvor, treasury. // 8. Issue SalvageReceipt and emit SalvageCompleted / PoolSalvaged events. // -// This scaffold defines the account context, parameters, and event shape. The -// CPI bodies are TODO — they are tracked as the m1-m8 build sequence. +// 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. use anchor_lang::prelude::*; +use anchor_lang::system_program::{self, CreateAccount}; use crate::constants::*; use crate::errors::GraveVaultError; use crate::state::{PoolRegistry, ProtocolConfig, SalvageReceipt}; +use grave_scanner::state::EligibilityCert; + +/// Bitmap mask for "all six derelict-pool criteria pass" on an EligibilityCert. +/// +/// Must match `grave_scanner::criteria::ALL_CRITERIA_MASK`. Hardcoded here +/// rather than imported so a misnamed re-export on the Scanner side fails +/// at compile time rather than silently. Updating this constant requires +/// updating the Scanner-side mask in lock-step (see Combined Tech Doc §3.5). +pub const ALL_CRITERIA_MASK: u8 = 0b00111111; // 0x3F = 6 criteria + #[derive(AnchorSerialize, AnchorDeserialize, Clone)] pub struct SalvagePoolParams { pub amm_program_id: Pubkey, @@ -40,10 +53,15 @@ pub struct SalvagePool<'info> { #[account(seeds = [ProtocolConfig::SEED], bump = protocol_config.bump)] pub protocol_config: Account<'info, ProtocolConfig>, - /// EligibilityCert PDA from the GraveScanner program. We validate the - /// owner program and the seed derivation here without taking a hard - /// dependency on the GraveScanner account types. - /// CHECK: owner program and PDA derivation are validated in the handler. + /// 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, @@ -51,10 +69,13 @@ pub struct SalvagePool<'info> { params.pool_address.as_ref(), ], seeds::program = grave_scanner::ID, - bump, + bump = eligibility_cert.bump, )] - pub eligibility_cert: UncheckedAccount<'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). #[account( init, payer = salvor, @@ -64,6 +85,7 @@ pub struct SalvagePool<'info> { )] pub pool_registry: Account<'info, PoolRegistry>, + /// Per-pool immutable receipt; second canonical defense layer. #[account( init, payer = salvor, @@ -73,9 +95,15 @@ pub struct SalvagePool<'info> { )] pub salvage_receipt: Account<'info, SalvageReceipt>, - /// CHECK: lp_holder_pool_vault PDA. Receives the LP-holder share. This - /// account is UNSWEEPABLE by any admin key (Charter invariant). It is - /// only debited by `claim_lp_proceeds` against a valid Merkle proof. + /// 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. #[account( mut, seeds = [LP_HOLDER_POOL_SEED, params.pool_address.as_ref()], @@ -100,32 +128,98 @@ pub struct SalvagePool<'info> { pub fn handler(ctx: Context, params: SalvagePoolParams) -> Result<()> { let cfg = &ctx.accounts.protocol_config; + // Gate 1: emergency pause. claim_lp_proceeds stays live during pause; + // only salvage_pool is gated. require!(!cfg.emergency_paused, GraveVaultError::ProtocolPaused); - // EligibilityCert ownership check. The seeds::program above validates the - // PDA was derived under GraveScanner; we additionally check the owner. - require_keys_eq!( - *ctx.accounts.eligibility_cert.owner, - grave_scanner::ID, - GraveVaultError::InvalidEligibilityCert + 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 ); - // TODO(GraveVault m1): deserialise EligibilityCert via grave_scanner::state - // type once the cross-program account binding is wired up. For now the - // seed-based derivation gates correctness. + // 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 + ); - // TODO(GraveVault m1): read cert.expires_at and require !cert.is_expired(now). + // 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, + GraveVaultError::InvalidEligibilityCert + ); + require_keys_eq!( + cert.pool_address, + params.pool_address, + GraveVaultError::InvalidEligibilityCert + ); - // Pre-flight: verify pool address consistency. + // Gate 5: pool address consistency between accounts and params. require_keys_eq!( ctx.accounts.pool.key(), params.pool_address, GraveVaultError::PreflightFailed ); - // TODO(GraveVault m2): CPI to AMM remove_liquidity. - // TODO(GraveVault m3): CPI to Jupiter v6 swap (skip below dust). - // TODO(GraveVault m4): compute 40/40/20 distribution and route lamports. + // 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, + )?; + } + + // ---- Pre-flight complete. Below this line is m4–m7 territory. ---- + + // 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. + + // 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 registry = &mut ctx.accounts.pool_registry; registry.amm_program_id = params.amm_program_id; @@ -133,9 +227,8 @@ 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 post-distribution + registry.lp_holder_pool_total_lamports = 0; // populated by m7 registry.lp_holder_pool_claimed_lamports = 0; - let clock = Clock::get()?; registry.salvaged_at_slot = clock.slot; registry.salvaged_at_ts = clock.unix_timestamp; registry.bump = ctx.bumps.pool_registry;