From 9e56c2db0f38a142761436f27a6bc7a0bd0ba039 Mon Sep 17 00:00:00 2001 From: 0X-SquidSol Date: Sun, 30 Aug 2026 12:51:24 -0400 Subject: [PATCH] fix(nft): make GetPositionValue actually fail-CLOSED on stale state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The module doc and README stated that GetPositionValue is fail-CLOSED on "stale/slot-reuse/no-active-leg". Slot-reuse and no-active-leg returned Err; staleness was never checked. The handler read none of `leg.stale`, `leg.b_stale`, `stale_state`, `b_stale_state`, `liquidation_lock`, `resolved_payout_receipt.present` or `close_progress`, emitted none of them, and emitted no `status` line on the success path — so a consumer had neither an error to catch nor a field to inspect. The program therefore contradicted itself on the same portfolio: the transfer hook refused to move an NFT that GetPositionValue reported to marketplaces and lending protocols as a healthy active leg with full economics. Before this change the hook's gate and the valuation disagreed on exactly seven states. Route the decision through `leg_transfer_gate` — documented as the "single consolidated gate" for the hook and the wrapper's B-3 — instead of reimplementing one of its five checks. Each verdict emits its own `status=` and returns the matching error; `no_active_leg` keeps `LegNotActive` so existing consumers are unaffected. Ordering matters and follows the hook (verify_bound_leg then transfer_gate_check): the slot-reuse check runs BEFORE the gate verdict is applied. Otherwise a portfolio that is both slot-reused and transiently stale would report the transient reason, masking a terminal signal that routes the holder to EmergencyBurn behind one that says "retry later". Blocked positions still report their economics, but under a separate `POSITION_BLOCKED_V16:` prefix. Emitting them under the existing prefix would let a parser that ignores `err` keep reading a real number off a liquidation-locked position — the very defect this closes. Withholding them entirely would be worse than it sounds: the engine sets `leg.b_stale` on any multi-chunk backing settlement, so that state is ordinary crank-paced operation, and `resolved` is terminal and carries the final settled value. The wrapper records the same principle for this gate — `UnwrapEscrowedPortfolio` is "deliberately NOT gated on active-leg / resolved_payout_receipt / liquidation_lock / stale / close-progress" because "gating on those would strand funds". The `status=` mapping is extracted as a pure `gate_status` and unit-tested exhaustively, because `msg!` output cannot be captured off-chain on this pin (solana-msg's non-BPF `sol_log` is a bare `println!` that bypasses `program_stubs`) and the four blocked states share one error code, so the string is the only thing distinguishing them. Also: adds `status=ok` to the success path, splits the compound `slot_reuse_detected` status line into one key per line, and documents the whole log contract — status vocabulary, prefixes, the simulateTransaction batching caveat — in the README, where nothing previously enumerated it. Closes #180 Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 34 ++- src/instruction.rs | 7 +- src/valuation.rs | 325 +++++++++++++++++------ tests/poc_valuation_ignores_staleness.rs | 260 ++++++++++++++++++ 4 files changed, 543 insertions(+), 83 deletions(-) create mode 100644 tests/poc_valuation_ignores_staleness.rs diff --git a/README.md b/README.md index f7cc44e..15964b0 100644 --- a/README.md +++ b/README.md @@ -86,9 +86,41 @@ percolator-nft (this program) is exposed. Gated by program-upgrade governance. - **no-entrypoint Feature**: Program entrypoint is gated behind a `no-entrypoint` cargo feature for library-style composition (e.g. embedding in test harnesses). -- **GetPositionValue is fail-CLOSED**: stale/slot-reuse/no-active-leg conditions return +- **GetPositionValue is fail-CLOSED**: every non-transferable condition returns errors, not `Ok(())`. Clients using `simulateTransaction` must check the error. +### GetPositionValue log contract + +The instruction returns nothing via CPI, so its logs are its API. Every response +emits `POSITION_VALUE_V16:portfolio=`, `POSITION_VALUE_V16:asset_index=` and +exactly one `POSITION_VALUE_V16:status=`: + +| `status=` | Meaning | Error | +|---|---|---| +| `ok` | Healthy bound leg; the economic fields follow under `POSITION_VALUE_V16:`. | — | +| `no_active_leg` | The position is closed or never existed; route to `EmergencyBurn`. | `LegNotActive` (22) | +| `leg_stale` | The bound leg owes chunked settlement. Transient — a crank clears it. | `TransferBlocked` (24) | +| `portfolio_locked_or_stale` | Portfolio-level liquidation lock or stale state. | `TransferBlocked` (24) | +| `resolved` | Terminal resolved-payout receipt present; claim rather than price it. | `TransferBlocked` (24) | +| `close_in_progress` | A close is mid-flight for this asset. Transient. | `TransferBlocked` (24) | +| `slot_reuse_detected` | The slot was reused by a different position instance; this NFT is dead. Accompanied by `market_id_at_mint=` and `current_market_id=`. | `MarketIdMismatch` (25) | + +Notes for integrators: + +- `simulateTransaction` returns `logs` alongside `err`, so the status line is + readable on a failed instruction. (`logs` is `null` only when simulation fails + *before* execution — bad blockhash, unloadable account, signature verification.) +- On a blocked status the economic fields are emitted under the separate + **`POSITION_BLOCKED_V16:`** prefix, not `POSITION_VALUE_V16:`. A parser + scanning for the latter therefore fails closed by construction; opt into + distressed pricing deliberately by reading the former. `slot_reuse_detected` + emits no economics at all — they would describe a different position. +- **Batching caveat:** a failing instruction aborts the whole transaction, so + packing many `GetPositionValue` calls into one simulation means a single + blocked position suppresses every instruction after it. Batch defensively, or + price positions individually. +- Logs are capped at 10,000 bytes per transaction and truncate silently. + ## v17 Layout Support The NFT program mirrors the converged v17 portfolio layout (`PortfolioAccountV16Account`, diff --git a/src/instruction.rs b/src/instruction.rs index 9ebfe41..2733b82 100644 --- a/src/instruction.rs +++ b/src/instruction.rs @@ -73,7 +73,12 @@ pub const TAG_SETTLE_FUNDING: u8 = 2; /// Read-only valuation diagnostics for marketplaces and lending protocols. /// Emits raw leg/valuation fields via transaction logs; does NOT return a /// value via CPI (no set_return_data). Clients use `simulateTransaction`. -/// Fail-CLOSED: stale/slot-reuse/no-active-leg conditions return an error. +/// Fail-CLOSED: every condition that makes the bound leg non-transferable +/// returns an error. `POSITION_VALUE_V16:status=` carries the reason, one of: +/// `ok`, `no_active_leg`, `leg_stale`, `portfolio_locked_or_stale`, `resolved`, +/// `close_in_progress`, `slot_reuse_detected`. Blocked positions still report +/// their economics under the separate `POSITION_BLOCKED_V16:` prefix (except +/// slot-reuse, whose fields would describe a different position instance). /// /// Accounts: /// 0. `[]` PositionNft PDA diff --git a/src/valuation.rs b/src/valuation.rs index 4b17922..3ff0d91 100644 --- a/src/valuation.rs +++ b/src/valuation.rs @@ -7,8 +7,19 @@ //! Clients use `simulateTransaction` to read the log output. //! //! This instruction does NOT return a value via CPI (no `set_return_data`). -//! It is fail-CLOSED: stale/slot-reuse/no-active-leg conditions return an -//! error rather than `Ok(())` so callers cannot silently observe invalid state. +//! +//! It is fail-CLOSED. Every condition that makes the bound leg non-transferable +//! returns an error rather than `Ok(())`, so callers cannot silently observe +//! invalid state. The full set is the `LegTransferGate` variants plus +//! slot-reuse: `no_active_leg`, `leg_stale`, `portfolio_locked_or_stale`, +//! `resolved`, `close_in_progress`, `slot_reuse_detected`. Each emits +//! `POSITION_VALUE_V16:status=`; the healthy path emits `status=ok`. +//! +//! A blocked position still reports its economics, but under the separate +//! `POSITION_BLOCKED_V16:` prefix, so a consumer scanning for +//! `POSITION_VALUE_V16:` fields fails closed while one that deliberately wants +//! distressed pricing can opt in. Slot-reuse is the sole exception: its fields +//! would describe a different position instance entirely, so they are withheld. use solana_program::{ account_info::{next_account_info, AccountInfo}, @@ -21,10 +32,43 @@ use solana_program::{ use crate::{ cpi_v16, error::NftError, - slab_types_v16, + slab_types_v16::{self, LegTransferGate}, state_v16::{verify_position_nft, PositionNftV16, POSITION_NFT_V16_LEN}, }; +/// Map a gate verdict to its `status=` string and the error the instruction +/// returns for it, if any. +/// +/// Extracted and `pub(crate)` purely so it can be exhaustively unit-tested. The +/// status string is this instruction's real API — it returns nothing via CPI, +/// and the four blocked states deliberately share one error code, so the string +/// is the ONLY thing distinguishing them to a consumer. `msg!` output cannot be +/// captured off-chain on this `solana-program` pin (`solana-msg`'s non-BPF +/// `sol_log` is a bare `println!`, bypassing `program_stubs`), so testing the +/// mapping directly is what keeps the vocabulary from silently rotting. +pub(crate) fn gate_status(gate: LegTransferGate) -> (&'static str, Option) { + match gate { + LegTransferGate::Transferable(_) => ("ok", None), + LegTransferGate::LegStale => ("leg_stale", Some(NftError::TransferBlocked)), + LegTransferGate::PortfolioLockedOrStale => { + ("portfolio_locked_or_stale", Some(NftError::TransferBlocked)) + } + LegTransferGate::Resolved => ("resolved", Some(NftError::TransferBlocked)), + LegTransferGate::CloseInProgress => { + ("close_in_progress", Some(NftError::TransferBlocked)) + } + LegTransferGate::NoActiveLeg => ("no_active_leg", Some(NftError::LegNotActive)), + } +} + +/// Emit the two identifying lines every response begins with. Each call site +/// then emits its own `status=` line, so the slot-reuse path can keep its extra +/// fields without duplicating these two. +fn emit_position_header(portfolio: &Pubkey, asset_index: u32) { + msg!("POSITION_VALUE_V16:portfolio={}", portfolio); + msg!("POSITION_VALUE_V16:asset_index={}", asset_index); +} + /// Process GetPositionValue instruction. /// /// Emits raw leg/valuation fields via transaction logs; does NOT return a @@ -90,87 +134,206 @@ pub fn process_get_position_value( cpi_v16::verify_portfolio_account_id(p, portfolio.key, "GetPositionValue")?; - // ── Find active leg for the bound asset_index ── - match p.active_leg_slot_for_asset(asset_index) { + // ── Consolidated gate ── + // Route through `leg_transfer_gate` — the same gate the transfer hook and + // the wrapper's B-3 use — so this instruction cannot disagree with them + // about whether a position is healthy. Previously only the no-active-leg + // arm was checked, which left the documented fail-CLOSED-on-stale contract + // untrue for the other four: a liquidation-locked, resolved, mid-close or + // stale position was reported to marketplaces and lending protocols as a + // healthy active leg, while the hook refused to move that same NFT. + // + // The gate decides the RESULT, not whether to answer — but a blocked + // position's economics are emitted under a DIFFERENT prefix. + // + // `simulateTransaction` returns logs alongside `err`, so the data does + // reach a caller either way. The prefix split is what makes that safe: an + // existing integrator scanning for `POSITION_VALUE_V16:capital=` finds + // nothing on a blocked position and fails closed, exactly as intended — + // emitting the economics under the SAME prefix would let a parser that + // ignores `err` keep reading a real number off a liquidation-locked + // position, which is precisely the defect this fix exists to close. + // A caller that wants distressed pricing opts in by reading + // `POSITION_BLOCKED_V16:`, and can only do so deliberately. + // + // Withholding the numbers entirely would be worse than it sounds: the + // engine sets `leg.b_stale` on any multi-chunk backing settlement, so + // `LegStale` is ordinary crank-paced operation, not an exceptional state — + // and `Resolved` is terminal and carries the position's final settled + // value. The wrapper records the same principle for this same gate: + // `UnwrapEscrowedPortfolio` is "deliberately NOT gated on active-leg / + // resolved_payout_receipt / liquidation_lock / stale / close-progress" + // because "gating on those would strand funds". + let (status, blocked) = gate_status(p.leg_transfer_gate(asset_index)); + + // #100/#118: no bound leg at all — there is no position to describe, so + // emit only the identifying lines and fail closed. + let slot = match p.active_leg_slot_for_asset(asset_index) { + Some(slot) => slot, None => { - // #100/#118: fail-CLOSED — no active leg means the NFT is stale - // or the position is gone. Return an error so callers cannot - // silently observe this as valid state. - msg!("POSITION_VALUE_V16:portfolio={}", portfolio.key); - msg!("POSITION_VALUE_V16:asset_index={}", asset_index); - msg!("POSITION_VALUE_V16:status=no_active_leg"); - return Err(NftError::LegNotActive.into()); - } - Some(slot) => { - let leg = &p.legs[slot]; - - // #118: fail-CLOSED on slot-reuse (market_id mismatch). A different - // market_id means the leg slot was closed and re-opened for a new - // position — the NFT is stale. Return an error rather than Ok so - // this diagnostic path is fail-closed, not fail-open. - let nft_market_id = { - let pda_data2 = nft_pda.try_borrow_data()?; - let ns = bytemuck::from_bytes::( - &pda_data2[..POSITION_NFT_V16_LEN], - ); - ns.market_id_at_mint.get() - }; - - if leg.market_id.get() != nft_market_id { - msg!( - "POSITION_VALUE_V16:portfolio={}", - portfolio.key - ); - msg!("POSITION_VALUE_V16:asset_index={}", asset_index); - msg!( - "POSITION_VALUE_V16:status=slot_reuse_detected market_id_at_mint={} current_market_id={}", - nft_market_id, - leg.market_id.get() - ); - return Err(NftError::MarketIdMismatch.into()); - } - - // ── Legitimate active bound leg — emit log fields ── - msg!("POSITION_VALUE_V16:portfolio={}", portfolio.key); - msg!("POSITION_VALUE_V16:asset_index={}", asset_index); - msg!("POSITION_VALUE_V16:market_id={}", leg.market_id.get()); - msg!("POSITION_VALUE_V16:side={}", leg.side); - msg!( - "POSITION_VALUE_V16:basis_pos_q={}", - leg.basis_pos_q.get() - ); - msg!("POSITION_VALUE_V16:f_snap={}", leg.f_snap.get()); - msg!( - "POSITION_VALUE_V16:epoch_snap={}", - leg.epoch_snap.get() - ); - msg!( - "POSITION_VALUE_V16:loss_weight={}", - leg.loss_weight.get() - ); - // #147: capital/pnl/reserved_pnl are RETAINED per-portfolio scalars in - // v17 (NOT moved per-asset and NOT replaced — the residual_* counters - // are additive and sit after them). Emit the retained scalars so a - // consumer can read the actual position economics; logged RAW (no equity - // re-derivation — see the module header). Units are atoms; `capital`/ - // `reserved_pnl` are unsigned, `pnl` is SIGNED (a loss prints with a - // leading '-'). They are portfolio-level, which IS this position's P&L - // since one NFT escrows the whole portfolio. - msg!("POSITION_VALUE_V16:capital={}", p.capital.get()); - msg!("POSITION_VALUE_V16:pnl={}", p.pnl.get()); - msg!("POSITION_VALUE_V16:reserved_pnl={}", p.reserved_pnl.get()); - // Additive residual-loss accounting counters (portfolio-wide totals), - // NOT the position's value. - msg!( - "POSITION_VALUE_V16:residual_crystallized={}", - p.residual_crystallized_loss_atoms_total.get() - ); - msg!( - "POSITION_VALUE_V16:residual_spent={}", - p.residual_spent_principal_atoms_total.get() - ); + emit_position_header(portfolio.key, asset_index); + msg!("POSITION_VALUE_V16:status={}", status); + return Err(blocked.unwrap_or(NftError::LegNotActive).into()); } + }; + let leg = &p.legs[slot]; + + // #118: fail-CLOSED on slot-reuse (market_id mismatch). A different + // market_id means the slot was closed and re-opened for a NEW position, so + // the fields below would describe someone else's position entirely. This is + // the one blocked case whose payload IS withheld — it would be actively + // wrong rather than merely stale. + let nft_market_id = { + let pda_data2 = nft_pda.try_borrow_data()?; + let ns = bytemuck::from_bytes::(&pda_data2[..POSITION_NFT_V16_LEN]); + ns.market_id_at_mint.get() + }; + if leg.market_id.get() != nft_market_id { + emit_position_header(portfolio.key, asset_index); + // One key per line: packing extra `k=v` pairs onto the status line (as + // this path previously did) makes `status` parse as a compound value. + msg!("POSITION_VALUE_V16:status=slot_reuse_detected"); + msg!("POSITION_VALUE_V16:market_id_at_mint={}", nft_market_id); + msg!("POSITION_VALUE_V16:current_market_id={}", leg.market_id.get()); + return Err(NftError::MarketIdMismatch.into()); } + // ── Emit the field block, then apply the gate's verdict ── + // Healthy positions report under POSITION_VALUE_V16; blocked ones report + // the same fields under POSITION_BLOCKED_V16 so no existing parser can + // mistake distressed data for a quote. `status` is always emitted under the + // well-known prefix so the reason is discoverable without opting in. + emit_position_header(portfolio.key, asset_index); + msg!("POSITION_VALUE_V16:status={}", status); + if blocked.is_some() { + msg!("POSITION_BLOCKED_V16:market_id={}", leg.market_id.get()); + msg!("POSITION_BLOCKED_V16:side={}", leg.side); + msg!("POSITION_BLOCKED_V16:basis_pos_q={}", leg.basis_pos_q.get()); + msg!("POSITION_BLOCKED_V16:f_snap={}", leg.f_snap.get()); + msg!("POSITION_BLOCKED_V16:epoch_snap={}", leg.epoch_snap.get()); + msg!("POSITION_BLOCKED_V16:loss_weight={}", leg.loss_weight.get()); + msg!("POSITION_BLOCKED_V16:capital={}", p.capital.get()); + msg!("POSITION_BLOCKED_V16:pnl={}", p.pnl.get()); + msg!("POSITION_BLOCKED_V16:reserved_pnl={}", p.reserved_pnl.get()); + msg!( + "POSITION_BLOCKED_V16:residual_crystallized={}", + p.residual_crystallized_loss_atoms_total.get() + ); + msg!( + "POSITION_BLOCKED_V16:residual_spent={}", + p.residual_spent_principal_atoms_total.get() + ); + return Err(blocked.unwrap_or(NftError::TransferBlocked).into()); + } + msg!("POSITION_VALUE_V16:market_id={}", leg.market_id.get()); + msg!("POSITION_VALUE_V16:side={}", leg.side); + msg!("POSITION_VALUE_V16:basis_pos_q={}", leg.basis_pos_q.get()); + msg!("POSITION_VALUE_V16:f_snap={}", leg.f_snap.get()); + msg!("POSITION_VALUE_V16:epoch_snap={}", leg.epoch_snap.get()); + msg!("POSITION_VALUE_V16:loss_weight={}", leg.loss_weight.get()); + // #147: capital/pnl/reserved_pnl are RETAINED per-portfolio scalars in + // v17 (NOT moved per-asset and NOT replaced — the residual_* counters + // are additive and sit after them). Emit the retained scalars so a + // consumer can read the actual position economics; logged RAW (no equity + // re-derivation — see the module header). Units are atoms; `capital`/ + // `reserved_pnl` are unsigned, `pnl` is SIGNED (a loss prints with a + // leading '-'). They are portfolio-level, which IS this position's P&L + // since one NFT escrows the whole portfolio. + msg!("POSITION_VALUE_V16:capital={}", p.capital.get()); + msg!("POSITION_VALUE_V16:pnl={}", p.pnl.get()); + msg!("POSITION_VALUE_V16:reserved_pnl={}", p.reserved_pnl.get()); + // Additive residual-loss accounting counters (portfolio-wide totals), + // NOT the position's value. + msg!( + "POSITION_VALUE_V16:residual_crystallized={}", + p.residual_crystallized_loss_atoms_total.get() + ); + msg!( + "POSITION_VALUE_V16:residual_spent={}", + p.residual_spent_principal_atoms_total.get() + ); + Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + /// Pins the `status=` vocabulary. These strings are the instruction's API: + /// `GetPositionValue` returns nothing via CPI, and the four blocked states + /// share error code 24, so a consumer distinguishes them by this string + /// alone. A rename or copy-paste here is a breaking change for every + /// integrator and must fail a test. + #[test] + fn gate_status_vocabulary_is_pinned() { + let cases = [ + (LegTransferGate::Transferable(0), "ok", None), + ( + LegTransferGate::LegStale, + "leg_stale", + Some(NftError::TransferBlocked), + ), + ( + LegTransferGate::PortfolioLockedOrStale, + "portfolio_locked_or_stale", + Some(NftError::TransferBlocked), + ), + ( + LegTransferGate::Resolved, + "resolved", + Some(NftError::TransferBlocked), + ), + ( + LegTransferGate::CloseInProgress, + "close_in_progress", + Some(NftError::TransferBlocked), + ), + ( + LegTransferGate::NoActiveLeg, + "no_active_leg", + Some(NftError::LegNotActive), + ), + ]; + for (gate, expected_status, expected_err) in cases { + let (status, err) = gate_status(gate); + assert_eq!(status, expected_status, "status for {gate:?}"); + assert_eq!(err, expected_err, "error for {gate:?}"); + } + } + + /// Every status string must be distinct from every other, so a consumer can + /// switch on it. (A copy-paste giving two states the same string would + /// otherwise pass the mapping test above.) + #[test] + fn every_gate_status_string_is_distinct() { + let all = [ + LegTransferGate::Transferable(0), + LegTransferGate::LegStale, + LegTransferGate::PortfolioLockedOrStale, + LegTransferGate::Resolved, + LegTransferGate::CloseInProgress, + LegTransferGate::NoActiveLeg, + ]; + let mut seen: Vec<&str> = all.iter().map(|g| gate_status(*g).0).collect(); + seen.sort_unstable(); + let before = seen.len(); + seen.dedup(); + assert_eq!(before, seen.len(), "duplicate status strings: {seen:?}"); + } + + /// Only `Transferable` yields Ok; everything else fails closed. + #[test] + fn only_transferable_is_not_an_error() { + assert!(gate_status(LegTransferGate::Transferable(3)).1.is_none()); + for g in [ + LegTransferGate::LegStale, + LegTransferGate::PortfolioLockedOrStale, + LegTransferGate::Resolved, + LegTransferGate::CloseInProgress, + LegTransferGate::NoActiveLeg, + ] { + assert!(gate_status(g).1.is_some(), "{g:?} must fail closed"); + } + } +} diff --git a/tests/poc_valuation_ignores_staleness.rs b/tests/poc_valuation_ignores_staleness.rs new file mode 100644 index 0000000..9c86836 --- /dev/null +++ b/tests/poc_valuation_ignores_staleness.rs @@ -0,0 +1,260 @@ +//! PoC: `GetPositionValue` documents itself as fail-CLOSED on stale state, but +//! reads no staleness field at all. +//! +//! `src/valuation.rs:9-11`: +//! +//! "This instruction does NOT return a value via CPI (no `set_return_data`). +//! It is fail-CLOSED: stale/slot-reuse/no-active-leg conditions return an +//! error rather than `Ok(())` so callers cannot silently observe invalid +//! state." +//! +//! Before the fix only two of those three were implemented — `no_active_leg` +//! and `slot_reuse` returned `Err`, the "stale" half did not. The handler never reads `leg.stale`, +//! `leg.b_stale`, `stale_state`, `b_stale_state`, `liquidation_lock`, +//! `resolved_payout_receipt.present` or `close_progress`, and emits none of +//! them either — nor does it emit a `status` line on the success path — so a +//! consumer has neither an error nor a field to check. +//! +//! The crate already has one consolidated gate for exactly this. Its doc +//! (`slab_types_v16.rs:559-561`) calls `leg_transfer_gate` the "single +//! consolidated gate for both the transfer-hook and the wrapper's B-3 +//! `TransferPortfolioOwnership`". Valuation now routes through it instead of +//! reimplementing one of its five checks, so the two can no longer disagree. +//! These tests pin that: for every state, the hook's gate and the valuation +//! reach the same verdict. + +use bytemuck::Zeroable; +use percolator_nft::{ + cpi_v16::{transfer_gate_check, PERCOLATOR_MAINNET}, + instruction::TAG_GET_POSITION_VALUE, + processor, + slab_types_v16::{self as sl, decode_portfolio}, + state_v16::{ + mint_authority_pda, position_nft_pda, PositionNftV16, POSITION_NFT_V16_MAGIC, + POSITION_NFT_V16_VERSION, + }, +}; +use solana_program::{account_info::AccountInfo, program_error::ProgramError, pubkey::Pubkey}; + +// NOTE on coverage: this instruction's `status=` vocabulary cannot be asserted +// from an integration test. `solana-msg`'s non-BPF `sol_log` is a bare +// `println!` that bypasses `program_stubs`, so `SyscallStubs` cannot observe +// `msg!` output on this solana-program pin. The vocabulary is instead pinned by +// unit tests on the pure `gate_status` mapping in `src/valuation.rs`, which is +// what the emission reads from. + +const PROG: Pubkey = Pubkey::new_from_array([9u8; 32]); +const ALICE: Pubkey = Pubkey::new_from_array([0xA1; 32]); +const PORTFOLIO: Pubkey = Pubkey::new_from_array([0x50; 32]); +const MARKET_GROUP: Pubkey = Pubkey::new_from_array([0x60; 32]); +const NFT_MINT: Pubkey = Pubkey::new_from_array([0x11; 32]); +const ASSET_INDEX: u32 = 7; +const MARKET_ID: u64 = 42; + +/// Which blocked state to stamp into an otherwise healthy portfolio. +#[derive(Clone, Copy, Debug)] +enum Blocked { + None, + LegStale, + LegBStale, + PortfolioStale, + PortfolioBStale, + LiquidationLock, + ResolvedReceipt, + CloseInProgress, + /// The control: this one valuation DOES fail closed on. + NoActiveLeg, +} + +fn leak(v: T) -> &'static mut T { + Box::leak(Box::new(v)) +} + +fn acct(key: Pubkey, owner: Pubkey, data: Vec) -> AccountInfo<'static> { + AccountInfo::new( + leak(key), + false, + false, + leak(1_000_000u64), + Box::leak(data.into_boxed_slice()), + leak(owner), + false, + 0, + ) +} + +fn portfolio_buf(state: Blocked) -> Vec { + portfolio_buf_with_market_id(state, MARKET_ID) +} + +fn portfolio_buf_with_market_id(state: Blocked, market_id: u64) -> Vec { + let (mint_auth, _) = mint_authority_pda(&PROG); + let mut a: sl::PortfolioAccountV16Account = Zeroable::zeroed(); + a.provenance_header.market_group_id = MARKET_GROUP.to_bytes(); + a.provenance_header.portfolio_account_id = PORTFOLIO.to_bytes(); + a.provenance_header.owner = mint_auth.to_bytes(); + a.provenance_header.version = sl::V16PodU16::new(sl::V16_ACCOUNT_VERSION); + a.provenance_header.layout_discriminator = sl::V16PodU16::new(sl::V16_LAYOUT_DISCRIMINATOR); + a.owner = mint_auth.to_bytes(); + + if !matches!(state, Blocked::NoActiveLeg) { + a.legs[0].active = 1; + a.legs[0].asset_index = sl::V16PodU32::new(ASSET_INDEX); + a.legs[0].market_id = sl::V16PodU64::new(market_id); + } + + match state { + Blocked::None | Blocked::NoActiveLeg => {} + Blocked::LegStale => a.legs[0].stale = 1, + Blocked::LegBStale => a.legs[0].b_stale = 1, + Blocked::PortfolioStale => a.stale_state = 1, + Blocked::PortfolioBStale => a.b_stale_state = 1, + Blocked::LiquidationLock => a.liquidation_lock = 1, + Blocked::ResolvedReceipt => a.resolved_payout_receipt.present = 1, + Blocked::CloseInProgress => { + a.close_progress.active = 1; + a.close_progress.asset_index = sl::V16PodU32::new(ASSET_INDEX); + } + } + + let mut buf = vec![0u8; sl::HEADER_LEN + sl::EXPECTED_PORTFOLIO_ACCOUNT_SIZE]; + buf[0..8].copy_from_slice(&sl::MAGIC.to_le_bytes()); + buf[8..10].copy_from_slice(&sl::VERSION.to_le_bytes()); + buf[10] = sl::KIND_PORTFOLIO; + buf[sl::HEADER_LEN..].copy_from_slice(bytemuck::bytes_of(&a)); + buf +} + +fn nft_pda_buf(bump: u8) -> Vec { + let mut s: PositionNftV16 = Zeroable::zeroed(); + s.magic = sl::V16PodU64::new(POSITION_NFT_V16_MAGIC); + s.version = POSITION_NFT_V16_VERSION; + s.bump = bump; + s.portfolio_account = PORTFOLIO.to_bytes(); + s.nft_mint = NFT_MINT.to_bytes(); + s.asset_index = sl::V16PodU32::new(ASSET_INDEX); + s.market_id_at_mint = sl::V16PodU64::new(MARKET_ID); + s.last_holder = ALICE.to_bytes(); + bytemuck::bytes_of(&s).to_vec() +} + +/// Run `GetPositionValue` (tag 3) over a portfolio in the given state. +fn run_valuation(state: Blocked) -> Result<(), ProgramError> { + run_valuation_with_market_id(state, MARKET_ID) +} + +fn run_valuation_with_market_id(state: Blocked, market_id: u64) -> Result<(), ProgramError> { + let (nft_pda_key, bump) = position_nft_pda(&PORTFOLIO, MARKET_ID, &PROG); + let accounts = vec![ + acct(nft_pda_key, PROG, nft_pda_buf(bump)), + acct( + PORTFOLIO, + PERCOLATOR_MAINNET, + portfolio_buf_with_market_id(state, market_id), + ), + ]; + processor::process(&PROG, &accounts, &[TAG_GET_POSITION_VALUE]) +} + +/// Ask the crate's own consolidated gate — the one the transfer hook uses — +/// whether this portfolio is transferable. +fn gate_says_transferable(state: Blocked) -> bool { + let buf = portfolio_buf(state); + let p = decode_portfolio(&buf).expect("fixture must decode"); + transfer_gate_check(p, ASSET_INDEX).is_ok() +} + +// -- 1. the fix ------------------------------------------------------------- + +#[test] +fn valuation_now_fails_closed_on_every_blocked_state() { + for state in [ + Blocked::LegStale, + Blocked::LegBStale, + Blocked::PortfolioStale, + Blocked::PortfolioBStale, + Blocked::LiquidationLock, + Blocked::ResolvedReceipt, + Blocked::CloseInProgress, + ] { + assert!( + !gate_says_transferable(state), + "{state:?}: fixture must actually be blocked by leg_transfer_gate", + ); + let r = run_valuation(state); + assert!( + matches!(r, Err(ProgramError::Custom(c)) if c == 24), // TransferBlocked + "{state:?}: valuation must now fail CLOSED as documented, got {r:?}", + ); + } +} + +// -- 2. controls ------------------------------------------------------------- + +#[test] +fn control_a_clean_portfolio_is_still_valued() { + // The fix must not break the normal path: a healthy position still reports. + assert!(gate_says_transferable(Blocked::None)); + assert!( + run_valuation(Blocked::None).is_ok(), + "a clean portfolio must still produce a valuation", + ); +} + +#[test] +fn control_no_active_leg_keeps_its_own_error_code() { + // #100/#118's arm is preserved verbatim rather than folded into the generic + // blocked case, so existing consumers keying on LegNotActive still work. + let r = run_valuation(Blocked::NoActiveLeg); + assert!( + matches!(r, Err(ProgramError::Custom(c)) if c == 22), // LegNotActive + "no_active_leg must still return LegNotActive, got {r:?}", + ); +} + +// -- 4. ordering: a terminal signal must not be masked by a transient one ---- + +#[test] +fn slot_reuse_outranks_a_transient_stale_flag() { + // A portfolio that is BOTH slot-reused and stale must report the slot reuse. + // MarketIdMismatch is TERMINAL and routes the holder to EmergencyBurn; + // b_stale is transient and merely says "retry later". Reporting the + // transient one would send the holder into an indefinite wait. This mirrors + // the transfer hook, which runs verify_bound_leg before transfer_gate_check. + let r = run_valuation_with_market_id(Blocked::PortfolioBStale, MARKET_ID + 57); + assert!( + matches!(r, Err(ProgramError::Custom(c)) if c == 25), // MarketIdMismatch + "slot reuse must outrank the transient stale flag, got {r:?}", + ); +} + +#[test] +fn control_slot_reuse_alone_is_unchanged() { + let r = run_valuation_with_market_id(Blocked::None, MARKET_ID + 57); + assert!(matches!(r, Err(ProgramError::Custom(c)) if c == 25)); +} + +// -- 5. the one-directional invariant that is actually true ------------------ + +#[test] +fn every_gate_blocked_state_is_also_valuation_blocked() { + // NOT an equivalence: valuation is deliberately STRICTER, because it also + // rejects a market_id mismatch that leg_transfer_gate never examines. The + // true property is the one-way implication. + for state in [ + Blocked::LegStale, + Blocked::LegBStale, + Blocked::PortfolioStale, + Blocked::PortfolioBStale, + Blocked::LiquidationLock, + Blocked::ResolvedReceipt, + Blocked::CloseInProgress, + Blocked::NoActiveLeg, + ] { + assert!(!gate_says_transferable(state), "{state:?}"); + assert!(run_valuation(state).is_err(), "{state:?} must also be blocked"); + } + // ...and the converse genuinely does not hold: + assert!(gate_says_transferable(Blocked::None)); + assert!(run_valuation_with_market_id(Blocked::None, MARKET_ID + 57).is_err()); +}