From f3f74a176accb546ec3222ae389e611c7411bf99 Mon Sep 17 00:00:00 2001 From: v1ktorrrr Date: Thu, 25 Jun 2026 01:18:58 +0530 Subject: [PATCH 1/3] =?UTF-8?q?fix(audit):=20M-1/L-1/L-2=20=E2=80=94=20doc?= =?UTF-8?q?s=20correctness,=20read-only=20hook=20accounts,=20EmergencyBurn?= =?UTF-8?q?=20decode=20fallback?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit M-1 (docs): remove false claim that LiteSVM integration tests are the runtime ground truth for the vendored portfolio layout. The const_assert! macros verify the mirror struct's internal consistency only; alignment with the live engine layout requires runtime validation. Corrected in cpi_v16.rs, slab_types_v16.rs, and README.md. L-1 (writable flags): flip PositionNft PDA (entry 5) and Portfolio (entry 6) from writable to read-only in the ExtraAccountMetaList for both MintPositionNft and RepairExtraMetas. Post-#105 the transfer hook is validation-only and never writes either account; the writable flags imposed unnecessary write-locks on portfolio accounts during transfers. RepairExtraMetas rationale updated to reflect the new correct flags. L-2 (#110B): EmergencyBurn no longer hard-reverts when a wrapper-owned portfolio is present but fails decode (e.g. future layout migration). Decode failure now skips the eligibility check and falls through to the unwrap CPI, which the wrapper handles on its own terms. The existing portfolio_gone path (#131) is unchanged. --- README.md | 5 +++-- src/cpi_v16.rs | 3 +-- src/instruction.rs | 11 +++++----- src/processor.rs | 51 +++++++++++++++++++++++++------------------ src/slab_types_v16.rs | 6 ++--- 5 files changed, 42 insertions(+), 34 deletions(-) diff --git a/README.md b/README.md index b523325..2e6e77f 100644 --- a/README.md +++ b/README.md @@ -92,8 +92,9 @@ percolator-nft (this program) ## v17 Layout Support The NFT program mirrors the converged v17 portfolio layout (`PortfolioAccountV16Account`, -9227 bytes) to read position state directly without CPI. Struct offsets are validated at -compile-time with size and offset assertions. +9227 bytes) to read position state directly without CPI. Struct field offsets are verified +for internal consistency via compile-time `const_assert!` macros; alignment with the live +engine layout requires runtime validation against real on-chain data (deferred, see #110B). ## Transfer Hook diff --git a/src/cpi_v16.rs b/src/cpi_v16.rs index 7bd54b6..4946b48 100644 --- a/src/cpi_v16.rs +++ b/src/cpi_v16.rs @@ -10,8 +10,7 @@ //! The decision functions take an already-decoded `&PortfolioAccountV16Account` //! (+ `&PositionNftV16` where relevant) so they are pure and exhaustively //! unit-testable WITHOUT a Solana runtime. The on-chain handlers do account -//! plumbing + the (unchanged) Token-2022 CPIs around these functions; the -//! end-to-end path is verified by the A.5 LiteSVM suite. +//! plumbing + the (unchanged) Token-2022 CPIs around these functions. //! //! ## v16 slot-reuse anchor = `market_id` (per-asset-slot incarnation id) //! diff --git a/src/instruction.rs b/src/instruction.rs index 7c1c47c..900f43f 100644 --- a/src/instruction.rs +++ b/src/instruction.rs @@ -116,12 +116,11 @@ pub const TAG_EMERGENCY_BURN: u8 = 5; /// its flags match the current processor's `build_extra_account_metas` /// output — most importantly, marking the portfolio account writable. /// -/// Historical mints produced an ExtraAccountMetaList where the portfolio was -/// declared read-only. That was wrong — the transfer hook CPIs into -/// percolator-prog with `TransferPortfolioOwnership` (tag 72), which mutates -/// `owner` in the portfolio. Without portfolio writable, the CPI fails with -/// `writable privilege escalated` and every transfer bounces. Burn + remint -/// is not a workaround: burn requires the position already be closed. +/// Historical mints (pre-#105) produced an ExtraAccountMetaList where the +/// portfolio was declared writable for the transfer-hook's B-3 ownership CPI. +/// Post-#105 (escrow-at-mint), the hook is validation-only and no longer CPIs; +/// both PositionNft PDA and Portfolio are now read-only in newly minted lists. +/// `RepairExtraMetas` rewrites old lists to the correct (read-only) flags. /// /// Permissionless by design. The only data written to the PDA is /// deterministic from the on-chain state of `nft_mint` + its `nft_pda` diff --git a/src/processor.rs b/src/processor.rs index 09d5b0f..d41d6c3 100644 --- a/src/processor.rs +++ b/src/processor.rs @@ -585,8 +585,8 @@ fn process_mint_position_nft( // Atomic ExtraAccountMetaList PDA initialization // // TLV layout (7 entries): - // [5] PositionNft PDA — writable (hook updates f_snap_at_mint) - // [6] Portfolio account — WRITABLE (B-3 CPI mutates portfolio.owner) + // [5] PositionNft PDA — read-only (#105: hook is validation-only) + // [6] Portfolio account — read-only (#105: B-3 CPI moved to mint/burn, not hook) // [7] Percolator program — read-only (from portfolio.owner, allowlist-verified) // [8] Mint authority PDA — read-only // [9] Instructions sysvar — read-only @@ -669,10 +669,10 @@ fn process_mint_position_nft( data[12..16].copy_from_slice(&(EXTRA_META_COUNT as u32).to_le_bytes()); let entries: [(Pubkey, bool, bool); EXTRA_META_COUNT] = [ - // 5: PositionNft PDA — writable (hook updates f_snap_at_mint on transfer) - (*nft_pda.key, false, true), - // 6: Portfolio account — WRITABLE (B-3 CPI mutates portfolio.owner) - (*portfolio.key, false, true), + // 5: PositionNft PDA — read-only (#105: hook is validation-only, no longer writes f_snap_at_mint) + (*nft_pda.key, false, false), + // 6: Portfolio account — read-only (#105: B-3 ownership CPI moved to mint/burn; hook only reads) + (*portfolio.key, false, false), // 7: Percolator program — read-only, from verified portfolio.owner (percolator_prog_id, false, false), // 8: Mint authority PDA — read-only @@ -1056,22 +1056,31 @@ fn process_emergency_burn(program_id: &Pubkey, accounts: &[AccountInfo]) -> Prog let portfolio_gone = portfolio.data_is_empty() || portfolio.lamports() == 0; // ── Check emergency burn eligibility (position flat / no active leg) ── - if !portfolio_gone { + if portfolio_gone { + msg!("EmergencyBurn: bound portfolio already closed/reclaimed by the core — skipping eligibility + unwrap (#131)"); + } else { cpi_v16::verify_portfolio_program(portfolio)?; let portfolio_data = portfolio.try_borrow_data()?; - let p = slab_types_v16::decode_portfolio(&portfolio_data) - .map_err(cpi_v16::map_decode_err)?; - // #110C: apply the same provenance check that MintPositionNft (line 304) - // enforces. Without this, a portfolio with a mismatched portfolio_account_id - // passes decode_portfolio at emergency-burn even though it would be rejected at mint. - if p.provenance_header.portfolio_account_id != portfolio.key.to_bytes() { - msg!("EmergencyBurn: portfolio_account_id mismatch (#110C)"); - return Err(NftError::InvalidNftPda.into()); + match slab_types_v16::decode_portfolio(&portfolio_data) { + Ok(p) => { + // #110C (from main): apply the same provenance check MintPositionNft + // (line 304) enforces. The PR's version dropped it. + if p.provenance_header.portfolio_account_id != portfolio.key.to_bytes() { + msg!("EmergencyBurn: portfolio_account_id mismatch (#110C)"); + return Err(NftError::InvalidNftPda.into()); + } + cpi_v16::emergency_burn_ok(p, &nft_state_copy) + .map_err(ProgramError::from)?; + } + Err(e) => { + // #110B: portfolio present and wrapper-owned but undecodable (e.g. a + // future layout migration changed magic/version). We cannot verify + // eligibility, but the position cannot be operated while escrowed, so + // skip the check and fall through to the unwrap CPI which the wrapper + // handles on its own terms. + msg!("EmergencyBurn: portfolio present but undecodable ({:?}) — skipping eligibility, proceeding to unwrap (#110B)", e); + } } - cpi_v16::emergency_burn_ok(p, &nft_state_copy) - .map_err(ProgramError::from)?; - } else { - msg!("EmergencyBurn: bound portfolio already closed/reclaimed by the core — skipping eligibility + unwrap (#131)"); } // ── Verify holder owns the NFT via the canonical Token-2022 ATA ── @@ -1495,8 +1504,8 @@ fn process_repair_extra_metas( data[12..16].copy_from_slice(&(EXTRA_META_COUNT as u32).to_le_bytes()); let entries: [(Pubkey, bool, bool); EXTRA_META_COUNT] = [ - (*nft_pda.key, false, true), // 5: PositionNft PDA — writable - (*portfolio.key, false, true), // 6: Portfolio account — WRITABLE (B-3 CPI) + (*nft_pda.key, false, false), // 5: PositionNft PDA — read-only (#105) + (*portfolio.key, false, false), // 6: Portfolio account — read-only (#105) (percolator_prog_id, false, false), // 7: Percolator program — read-only (*mint_auth.key, false, false), // 8: Mint authority PDA — read-only (sysvar_instructions::id(), false, false), // 9: Instructions sysvar — read-only diff --git a/src/slab_types_v16.rs b/src/slab_types_v16.rs index 3a85c3e..9644fc3 100644 --- a/src/slab_types_v16.rs +++ b/src/slab_types_v16.rs @@ -39,9 +39,9 @@ //! //! ## Compile-time guards //! -//! `const_assert!` on every sub-struct size + key field offsets fail to compile -//! if the vendored layout drifts from the engine. The LiteSVM integration test -//! (cross-cut phase) is the runtime ground truth. +//! `const_assert!` on every sub-struct size + key field offsets verify internal +//! consistency of the mirror struct. They do NOT verify alignment with the live +//! engine's layout — that requires runtime validation against real on-chain data. #![allow(dead_code)] // wired into cpi.rs / processor.rs From 2c8aec22a5e86adb9a00c11ed1ff1ae09fce3f7d Mon Sep 17 00:00:00 2001 From: dcccrypto Date: Sun, 16 Aug 2026 11:19:04 +0100 Subject: [PATCH 2/3] fix(nft): keep meta entry [5] writable and narrow the EmergencyBurn fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to the M-1/L-1/L-2 commit. M-1 (doc corrections) is accurate and kept as-is. The other two needed work. L-1 — entry [5] must stay WRITABLE. The PR flipped both hook accounts to read-only on the stated basis that "#105: hook is validation-only". That was true when the PR was opened, but main has since gained #152/#153, which added a NEW write: process_transfer_hook does `nft_state.last_holder = new_owner` at transfer_hook.rs:542-545 on every genuine Token-2022 transfer. A read-only meta for the PositionNft PDA therefore makes every direct TransferChecked fail — and since RepairExtraMetas is PERMISSIONLESS, that is a free brick-any-NFT vector. Both sites are restored to writable (the repair path had the same flip, which is the dangerous one). Entry [6] (Portfolio) read-only is KEPT, argued separately as the review asked: verified against this build that the hook performs no invoke/invoke_signed at all and never mutably borrows `portfolio` — it only reads it to check the NFT PDA binding. The old "B-3 CPI mutates portfolio.owner" rationale moved to mint/burn in #105. Read-only is correct here and narrows the write lock. To stop the two entry tables drifting apart again, the flags are extracted into a single `EXTRA_META_ENTRY_FLAGS` table used by both the mint path and RepairExtraMetas, so divergence is now structurally impossible rather than a convention. Four tests pin the flags; the 34 pre-existing tests all passed with entry [5] read-only, so nothing in the repo would have caught this. Negative control: flipping entry 5 back to read-only fails `position_nft_pda_meta_entry_is_writable`. L-2 — narrowed. Skipping the eligibility gate on ANY PortfolioDecodeError falls through to UnwrapEscrowedPortfolio, which is not itself leg-gated, so it could emergency-burn a position never proven flat. The fallback now admits only BadVersion / BadAccountVersion / BadLayoutDiscriminator — the "future layout migration" case the PR justified it with, on an account already verified wrapper-owned. TooShort / BadMagic / BadKind / Cast / OwnerMismatch now propagate as errors; OwnerMismatch is a violated engine invariant and must never widen burn eligibility. Conflict resolution note: main's #110C provenance check is preserved on the Ok arm — the PR's version dropped it. cargo build clean; 48 lib tests pass (44 before, +4 new). Co-Authored-By: Claude Opus 5 (1M context) --- src/processor.rs | 172 +++++++++++++++++++++++++++++++++++++---------- 1 file changed, 136 insertions(+), 36 deletions(-) diff --git a/src/processor.rs b/src/processor.rs index d41d6c3..f3b444b 100644 --- a/src/processor.rs +++ b/src/processor.rs @@ -241,6 +241,30 @@ fn registry_registers_program(data: &[u8], program_id: &Pubkey) -> bool { } } +/// `(is_signer, is_writable)` for the seven ExtraAccountMetaList entries, which the +/// Token-2022 transfer hook receives at account indices 5..=11. +/// +/// Shared by the mint path and by RepairExtraMetas so the two can never disagree. +/// RepairExtraMetas is PERMISSIONLESS, so a divergence would let any caller rewrite a +/// live NFT's metas into a shape the hook cannot use. +/// +/// Entry 5 (PositionNft PDA) must stay writable: #105 removed the f_snap_at_mint write, +/// but #152/#153 added `nft_state.last_holder = new_owner` in process_transfer_hook +/// (transfer_hook.rs:542-545), which runs on every genuine Token-2022 transfer. A +/// read-only meta there makes each such TransferChecked fail. +/// +/// Entry 6 (Portfolio) is read-only: the hook performs no invoke/invoke_signed and never +/// mutably borrows `portfolio` — it only reads it to check the NFT PDA binding. +pub(crate) const EXTRA_META_ENTRY_FLAGS: [(bool, bool); 7] = [ + (false, true), // 5: PositionNft PDA — writable (hook writes last_holder) + (false, false), // 6: Portfolio account — read-only (hook only reads) + (false, false), // 7: Percolator program — read-only + (false, false), // 8: Mint authority PDA — read-only + (false, false), // 9: Instructions sysvar — read-only + (false, false), // 10: NFT program (self) — read-only + (false, false), // 11: NFT registry PDA — read-only +]; + fn process_mint_position_nft( program_id: &Pubkey, accounts: &[AccountInfo], @@ -585,8 +609,8 @@ fn process_mint_position_nft( // Atomic ExtraAccountMetaList PDA initialization // // TLV layout (7 entries): - // [5] PositionNft PDA — read-only (#105: hook is validation-only) - // [6] Portfolio account — read-only (#105: B-3 CPI moved to mint/burn, not hook) + // [5] PositionNft PDA — WRITABLE (hook records last_holder, #152/#153) + // [6] Portfolio account — read-only (hook only reads it; B-3 CPI moved to mint/burn per #105) // [7] Percolator program — read-only (from portfolio.owner, allowlist-verified) // [8] Mint authority PDA — read-only // [9] Instructions sysvar — read-only @@ -668,22 +692,20 @@ fn process_mint_position_nft( data[8..12].copy_from_slice(&tlv_value_len.to_le_bytes()); data[12..16].copy_from_slice(&(EXTRA_META_COUNT as u32).to_le_bytes()); - let entries: [(Pubkey, bool, bool); EXTRA_META_COUNT] = [ - // 5: PositionNft PDA — read-only (#105: hook is validation-only, no longer writes f_snap_at_mint) - (*nft_pda.key, false, false), - // 6: Portfolio account — read-only (#105: B-3 ownership CPI moved to mint/burn; hook only reads) - (*portfolio.key, false, false), - // 7: Percolator program — read-only, from verified portfolio.owner - (percolator_prog_id, false, false), - // 8: Mint authority PDA — read-only - (*mint_auth.key, false, false), - // 9: Instructions sysvar — read-only - (sysvar_instructions::id(), false, false), - // 10: NFT program (self) — read-only - (*program_id, false, false), - // 11: Per-market NFT registry PDA — read-only, derived under wrapper_program_id - (registry_pda, false, false), + let keys: [Pubkey; EXTRA_META_COUNT] = [ + *nft_pda.key, + *portfolio.key, + percolator_prog_id, + *mint_auth.key, + sysvar_instructions::id(), + *program_id, + registry_pda, ]; + // Flags come from the shared table so mint and RepairExtraMetas cannot diverge. + let entries: [(Pubkey, bool, bool); EXTRA_META_COUNT] = core::array::from_fn(|i| { + let (is_signer, is_writable) = EXTRA_META_ENTRY_FLAGS[i]; + (keys[i], is_signer, is_writable) + }); for (i, (key, is_signer, is_writable)) in entries.iter().enumerate() { let off = 16 + i * EXTRA_META_ENTRY_LEN; @@ -1063,23 +1085,43 @@ fn process_emergency_burn(program_id: &Pubkey, accounts: &[AccountInfo]) -> Prog let portfolio_data = portfolio.try_borrow_data()?; match slab_types_v16::decode_portfolio(&portfolio_data) { Ok(p) => { - // #110C (from main): apply the same provenance check MintPositionNft - // (line 304) enforces. The PR's version dropped it. + // #110C: apply the same provenance check that MintPositionNft (line 304) + // enforces. Without this, a portfolio with a mismatched portfolio_account_id + // passes decode_portfolio at emergency-burn even though it would be rejected at mint. if p.provenance_header.portfolio_account_id != portfolio.key.to_bytes() { msg!("EmergencyBurn: portfolio_account_id mismatch (#110C)"); return Err(NftError::InvalidNftPda.into()); } - cpi_v16::emergency_burn_ok(p, &nft_state_copy) - .map_err(ProgramError::from)?; + cpi_v16::emergency_burn_ok(p, &nft_state_copy).map_err(ProgramError::from)?; } - Err(e) => { - // #110B: portfolio present and wrapper-owned but undecodable (e.g. a - // future layout migration changed magic/version). We cannot verify - // eligibility, but the position cannot be operated while escrowed, so - // skip the check and fall through to the unwrap CPI which the wrapper - // handles on its own terms. - msg!("EmergencyBurn: portfolio present but undecodable ({:?}) — skipping eligibility, proceeding to unwrap (#110B)", e); + // #110B, NARROWED: only a genuine layout migration may bypass the + // eligibility gate. + // + // Skipping the gate lets a position be emergency-burned without proving it + // is flat, and this falls through to UnwrapEscrowedPortfolio, which is not + // itself leg-gated — so the set of errors that reach it must be exactly the + // set this fallback was justified by ("a future layout migration changed + // magic/version"), not every decode failure. + // + // Admitted: BadVersion / BadAccountVersion / BadLayoutDiscriminator — the + // account IS ours (wrapper-owned, verified above by verify_portfolio_program) + // and is a shape this build predates. + // + // Rejected: TooShort, BadMagic, BadKind, Cast, OwnerMismatch — on a + // wrapper-owned account these mean corruption or a wrong account, not a + // migration. OwnerMismatch in particular is a violated engine invariant and + // must never widen burn eligibility. + Err( + e @ (slab_types_v16::PortfolioDecodeError::BadVersion + | slab_types_v16::PortfolioDecodeError::BadAccountVersion + | slab_types_v16::PortfolioDecodeError::BadLayoutDiscriminator), + ) => { + msg!( + "EmergencyBurn: portfolio present but from an unknown layout version ({:?}) — skipping eligibility, proceeding to unwrap (#110B)", + e + ); } + Err(e) => return Err(cpi_v16::map_decode_err(e)), } } @@ -1503,15 +1545,20 @@ fn process_repair_extra_metas( data[8..12].copy_from_slice(&tlv_value_len.to_le_bytes()); data[12..16].copy_from_slice(&(EXTRA_META_COUNT as u32).to_le_bytes()); - let entries: [(Pubkey, bool, bool); EXTRA_META_COUNT] = [ - (*nft_pda.key, false, false), // 5: PositionNft PDA — read-only (#105) - (*portfolio.key, false, false), // 6: Portfolio account — read-only (#105) - (percolator_prog_id, false, false), // 7: Percolator program — read-only - (*mint_auth.key, false, false), // 8: Mint authority PDA — read-only - (sysvar_instructions::id(), false, false), // 9: Instructions sysvar — read-only - (*program_id, false, false), // 10: NFT program (self) — read-only - (registry_pda, false, false), // 11: Per-market NFT registry PDA — read-only + let keys: [Pubkey; EXTRA_META_COUNT] = [ + *nft_pda.key, + *portfolio.key, + percolator_prog_id, + *mint_auth.key, + sysvar_instructions::id(), + *program_id, + registry_pda, ]; + // Same shared table as the mint path — see EXTRA_META_ENTRY_FLAGS. + let entries: [(Pubkey, bool, bool); EXTRA_META_COUNT] = core::array::from_fn(|i| { + let (is_signer, is_writable) = EXTRA_META_ENTRY_FLAGS[i]; + (keys[i], is_signer, is_writable) + }); for (i, (key, is_signer, is_writable)) in entries.iter().enumerate() { let off = HEADER_LEN + i * EXTRA_META_ENTRY_LEN; data[off] = 0; @@ -1756,3 +1803,56 @@ fn rent_recipient_guard_accepts_writable_holder() { assert!(result.is_ok()); } + +#[cfg(test)] +mod extra_meta_flag_tests { + use super::*; + + /// Entry 5 is the PositionNft PDA and the transfer hook WRITES it + /// (`nft_state.last_holder = new_owner`, transfer_hook.rs:542-545, gated on + /// `is_genuine_token2022_transfer`). Flipping it read-only makes every genuine + /// Token-2022 TransferChecked fail, and because RepairExtraMetas is permissionless + /// that is a free brick-any-NFT vector. This pins the flag so the regression cannot + /// return silently — the 34 pre-existing tests all passed with it read-only. + #[test] + fn position_nft_pda_meta_entry_is_writable() { + assert_eq!( + EXTRA_META_ENTRY_FLAGS[0], + (false, true), + "entry 5 (PositionNft PDA) must be non-signer and WRITABLE: the transfer hook \ + writes last_holder to it on every genuine Token-2022 transfer" + ); + } + + /// Entry 6 is the Portfolio account. The hook performs no invoke/invoke_signed and + /// never mutably borrows it, so read-only is correct and narrows the write lock. + #[test] + fn portfolio_meta_entry_is_read_only() { + assert_eq!( + EXTRA_META_ENTRY_FLAGS[1], + (false, false), + "entry 6 (Portfolio) is read-only: the hook only reads it to check the NFT \ + PDA binding" + ); + } + + /// Everything from entry 7 on is a program/sysvar/PDA the hook only reads. + #[test] + fn remaining_meta_entries_are_read_only_non_signers() { + for (i, flags) in EXTRA_META_ENTRY_FLAGS.iter().enumerate().skip(2) { + assert_eq!( + *flags, + (false, false), + "entry {} must be a read-only non-signer", + i + 5 + ); + } + } + + /// No entry is ever a signer — the hook is invoked by Token-2022, which cannot + /// produce signatures for these accounts. + #[test] + fn no_meta_entry_is_a_signer() { + assert!(EXTRA_META_ENTRY_FLAGS.iter().all(|(s, _)| !*s)); + } +} From 9add823f7e903feade8acbddc95a6e68acaf27f1 Mon Sep 17 00:00:00 2001 From: dcccrypto Date: Mon, 17 Aug 2026 00:18:50 +0100 Subject: [PATCH 3/3] fix(nft): update the statements this PR made false, and derive the meta count MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second-pass review findings. All of these were pre-existing text that THIS change turned into a lie — the failure mode the PR itself set out to fix. 1. ON-CHAIN LOG. RepairExtraMetas emitted `msg!("... (portfolio now writable)")`. That was accurate on main; this PR makes portfolio read-only, so the deployed program would log a false statement on every permissionless repair. Now reports the actual shape ("nft_pda writable, portfolio read-only"). 2. instruction.rs doc claimed "Post-#105 the hook is validation-only and no longer CPIs; both PositionNft PDA and Portfolio are now read-only". The first half is the exact reasoning that made L-1 dangerous — #152/#153 re-added a write, so entry [5] is writable. Rewritten to state the current flags and WHY each is what it is. 3. transfer_hook.rs:284 still labelled the portfolio account "(writable)". 4. README M-1 cited "#110B" for deferred runtime layout validation. Per the issue, item B is the EmergencyBurn wedge (what L-2 addresses) and item H is the layout cross-check. Corrected to #110H. 5. EXTRA_META_COUNT was re-declared as a literal `7` in BOTH call sites with nothing tying it to EXTRA_META_ENTRY_FLAGS — the table that actually decides the entries. Extracting the flags removed one drift risk and introduced another. Both are now `EXTRA_META_ENTRY_FLAGS.len()`. Verified: shrinking the table to 6 entries is a compile error ("expected an array with a size of 6, found one with a size of 7") rather than a silent mismatch. cargo build clean; 48 lib tests pass. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 2 +- src/instruction.rs | 21 +++++++++++++-------- src/processor.rs | 10 +++++++--- src/transfer_hook.rs | 2 +- 4 files changed, 22 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 2e6e77f..f7cc44e 100644 --- a/README.md +++ b/README.md @@ -94,7 +94,7 @@ percolator-nft (this program) The NFT program mirrors the converged v17 portfolio layout (`PortfolioAccountV16Account`, 9227 bytes) to read position state directly without CPI. Struct field offsets are verified for internal consistency via compile-time `const_assert!` macros; alignment with the live -engine layout requires runtime validation against real on-chain data (deferred, see #110B). +engine layout requires runtime validation against real on-chain data (deferred, see #110H). ## Transfer Hook diff --git a/src/instruction.rs b/src/instruction.rs index 900f43f..9ebfe41 100644 --- a/src/instruction.rs +++ b/src/instruction.rs @@ -112,15 +112,20 @@ pub const TAG_EMERGENCY_BURN: u8 = 5; /// Tag 6: RepairExtraAccountMetas /// -/// Rewrite the ExtraAccountMetaList PDA data for an existing NFT mint so -/// its flags match the current processor's `build_extra_account_metas` -/// output — most importantly, marking the portfolio account writable. +/// Rewrite the ExtraAccountMetaList PDA data for an existing NFT mint so its +/// flags match the current processor's `EXTRA_META_ENTRY_FLAGS`. /// -/// Historical mints (pre-#105) produced an ExtraAccountMetaList where the -/// portfolio was declared writable for the transfer-hook's B-3 ownership CPI. -/// Post-#105 (escrow-at-mint), the hook is validation-only and no longer CPIs; -/// both PositionNft PDA and Portfolio are now read-only in newly minted lists. -/// `RepairExtraMetas` rewrites old lists to the correct (read-only) flags. +/// Current flags (see EXTRA_META_ENTRY_FLAGS in processor.rs): +/// [5] PositionNft PDA — WRITABLE. #105 removed the f_snap_at_mint write, but +/// #152/#153 added a new one: the hook writes `nft_state.last_holder` on +/// every genuine Token-2022 transfer, so this must stay writable or such a +/// transfer fails. +/// [6] Portfolio — read-only. The hook performs no invoke/invoke_signed +/// and never mutably borrows it; the pre-#105 B-3 ownership CPI that once +/// required write access moved to mint/burn. +/// +/// Historical mints may carry either shape; `RepairExtraMetas` rewrites them to +/// the current one. /// /// Permissionless by design. The only data written to the PDA is /// deterministic from the on-chain state of `nft_mint` + its `nft_pda` diff --git a/src/processor.rs b/src/processor.rs index f3b444b..a8e3442 100644 --- a/src/processor.rs +++ b/src/processor.rs @@ -646,7 +646,10 @@ fn process_mint_position_nft( } const EXTRA_META_ENTRY_LEN: usize = 35; - const EXTRA_META_COUNT: usize = 7; + // Derived from the shared flags table, never re-typed: EXTRA_META_ENTRY_FLAGS + // is what actually decides the entries, so a count declared independently + // could silently disagree with it. + const EXTRA_META_COUNT: usize = EXTRA_META_ENTRY_FLAGS.len(); const EXTRA_METAS_ACCOUNT_LEN: usize = 8 /* TLV type */ + 4 /* TLV length */ + 4 /* entry count */ + EXTRA_META_ENTRY_LEN * EXTRA_META_COUNT; @@ -1518,7 +1521,8 @@ fn process_repair_extra_metas( } const EXTRA_META_ENTRY_LEN: usize = 35; - const EXTRA_META_COUNT: usize = 7; + // Derived from the shared flags table — see the note at the mint path. + const EXTRA_META_COUNT: usize = EXTRA_META_ENTRY_FLAGS.len(); const HEADER_LEN: usize = 16; const EXTRA_METAS_ACCOUNT_LEN: usize = HEADER_LEN + EXTRA_META_ENTRY_LEN * EXTRA_META_COUNT; @@ -1568,7 +1572,7 @@ fn process_repair_extra_metas( } msg!( - "RepairExtraMetas: rewrote ExtraAccountMetaList for mint {} (portfolio now writable)", + "RepairExtraMetas: rewrote ExtraAccountMetaList for mint {} (nft_pda writable, portfolio read-only)", nft_mint.key ); Ok(()) diff --git a/src/transfer_hook.rs b/src/transfer_hook.rs index 95de242..0fadd0f 100644 --- a/src/transfer_hook.rs +++ b/src/transfer_hook.rs @@ -281,7 +281,7 @@ pub fn process_execute( let _source_authority = next_account_info(accounts_iter)?; // 3: source authority (unused per spec) let extra_metas = next_account_info(accounts_iter)?; // 4: ExtraAccountMetaList PDA let nft_pda = next_account_info(accounts_iter)?; // 5: PositionNft PDA (writable) - let portfolio = next_account_info(accounts_iter)?; // 6: Portfolio account (writable) + let portfolio = next_account_info(accounts_iter)?; // 6: Portfolio account (read-only — hook only reads it) let percolator_prog = next_account_info(accounts_iter)?; // 7: Percolator program let mint_auth = next_account_info(accounts_iter)?; // 8: Mint authority PDA let sysvar_ix = next_account_info(accounts_iter)?; // 9: Instructions sysvar