Skip to content

[LOW] ReconcileBurnedNft abandons the NFT mint and ExtraAccountMetaList PDA — ~0.0068 SOL per NFT, unrecoverable (the #102 leak, on the path added later by #138) #182

Description

@0x-SquidSol

Summary

ReconcileBurnedNft (tag 7) returns the PositionNft PDA's rent to the recorded last holder but abandons the NFT mint and the ExtraAccountMetaList PDA. Once it has run, neither can ever be reclaimed by anyone.

#102 closed exactly this leak — for the two burn instructions that existed at the time. BurnPositionNft and EmergencyBurn both call close_extra_metas (processor.rs:968, :1198) and close the mint under the mint_auth PDA. ReconcileBurnedNft was added later by #138 and takes seven accounts:

0 nft_pda   1 nft_mint   2 portfolio   3 mint_auth
4 nft_registry   5 percolator_prog   6 last_holder

There is no extra_metas slot and no token-program slot, so it structurally cannot close either account.

Why it is permanent

Reconcile closes nft_pda (processor.rs:1329-1333), and every path that could reclaim the two accounts requires nft_pda to still be program-owned and to hold a valid PositionNftV16:

  • BurnPositionNft / EmergencyBurn are the only callers of close_extra_metas and the only signers of a mint close. Both also require verify_holder_ata_account with amount == 1 — but Reconcile only runs when supply is already 0, so neither can ever run again.
  • RepairExtraMetas requires nft_pda.owner == program_id (processor.rs:1534).

Both are dead the moment Reconcile returns.

Cost

Rent-exempt minimums, (128 + len) * 3480 * 2:

Account Length Stranded
ExtraAccountMetaList PDA 261 2,707,440 lamports
NFT mint (post-realloc) 458 4,078,560 lamports
Total 6,786,000 lamports ≈ 0.0068 SOL per reconciled NFT

No security impact — nothing is stolen and no position is at risk. It is an unrecoverable loss of the holder's own rent, on the recovery path that exists to make them whole after an out-of-band burn.

Proof of concept

tests/poc_reconcile_rent_leak.rs, against main at 215842e. Host-side, driving the real processor::process.

Reproduction note: cargo test does not build as shipped — the solana-sdk dev-dependency pulls openssl-sys, which needs a system OpenSSL. Neither it nor proptest is referenced anywhere in src/; removing both from [dev-dependencies] makes the suite build. Results are the 51 pre-existing tests plus these 4.

//! PoC: `ReconcileBurnedNft` closes the PositionNft PDA but abandons the NFT
//! mint and the ExtraAccountMetaList PDA, and once it has run neither can ever
//! be reclaimed.
//!
//! `#102` closed exactly this leak — for the two burn instructions that existed
//! at the time, `BurnPositionNft` and `EmergencyBurn`, both of which now call
//! `close_extra_metas` and close the mint. `ReconcileBurnedNft` (tag 7) was
//! added later by `#138` and takes only seven accounts: `nft_pda`, `nft_mint`,
//! `portfolio`, `mint_auth`, `nft_registry`, `percolator_prog`, `last_holder`.
//! There is no `extra_metas` slot and no token-program slot, so it structurally
//! cannot close either account.
//!
//! The leak is permanent, not merely deferred. Reconcile closes `nft_pda`, and
//! every path that could reclaim the two accounts requires `nft_pda` to still be
//! program-owned and to hold a valid `PositionNftV16`:
//!   * `BurnPositionNft` / `EmergencyBurn` — the only callers of
//!     `close_extra_metas` and the only signers of a mint close — additionally
//!     require the holder's ATA to hold `amount == 1`, but supply is already 0.
//!   * `RepairExtraMetas` requires `nft_pda.owner == program_id`.
//! Both are dead the moment Reconcile returns.

use bytemuck::Zeroable;
use percolator_nft::{
    cpi_v16::{derive_nft_registry, PERCOLATOR_MAINNET},
    instruction::{TAG_EMERGENCY_BURN, TAG_RECONCILE_BURNED_NFT, TAG_REPAIR_EXTRA_METAS},
    processor,
    slab_types_v16 as sl,
    state_v16::{
        mint_authority_pda, position_nft_pda, PositionNftV16, POSITION_NFT_V16_MAGIC,
        POSITION_NFT_V16_VERSION,
    },
    token2022::{get_associated_token_address, TOKEN_2022_PROGRAM_ID},
    transfer_hook::extra_account_metas_pda,
};
use solana_program::{account_info::AccountInfo, program_error::ProgramError, pubkey::Pubkey};

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;

/// Rent-exempt minimums, `(128 + len) * 3480 * 2`.
const PDA_RENT: u64 = 2_275_920; // 199 bytes
const METAS_RENT: u64 = 2_707_440; // 261 bytes
const MINT_RENT: u64 = 4_078_560; // 458 bytes, the mint's post-realloc length

fn leak<T>(v: T) -> &'static mut T {
    Box::leak(Box::new(v))
}

fn acct(
    key: Pubkey,
    owner: Pubkey,
    data: Vec<u8>,
    lamports: u64,
    writable: bool,
    signer: bool,
) -> AccountInfo<'static> {
    AccountInfo::new(
        leak(key),
        signer,
        writable,
        leak(lamports),
        Box::leak(data.into_boxed_slice()),
        leak(owner),
        false,
        0,
    )
}

fn token_account(mint: &Pubkey, owner: &Pubkey, amount: u64) -> Vec<u8> {
    let mut d = vec![0u8; 165];
    d[0..32].copy_from_slice(mint.as_ref());
    d[32..64].copy_from_slice(owner.as_ref());
    d[64..72].copy_from_slice(&amount.to_le_bytes());
    d[108] = 1;
    d
}

fn mint_account(supply: u64) -> Vec<u8> {
    let mut d = vec![0u8; 200];
    d[36..44].copy_from_slice(&supply.to_le_bytes());
    d[45] = 1;
    d
}

fn portfolio_buf() -> Vec<u8> {
    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();
    // Terminal: no active leg — the EmergencyBurn-eligible shape.
    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<u8> {
    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()
}

/// The three accounts whose fate this test is about, shared across a scenario so
/// their post-state can be inspected.
struct Fixture {
    nft_pda: AccountInfo<'static>,
    nft_mint: AccountInfo<'static>,
    extra_metas: AccountInfo<'static>,
}

fn fixture(mint_supply: u64) -> Fixture {
    let (nft_pda_key, bump) = position_nft_pda(&PORTFOLIO, MARKET_ID, &PROG);
    let (metas, _) = extra_account_metas_pda(&NFT_MINT, &PROG);
    Fixture {
        nft_pda: acct(nft_pda_key, PROG, nft_pda_buf(bump), PDA_RENT, true, false),
        nft_mint: acct(
            NFT_MINT,
            TOKEN_2022_PROGRAM_ID,
            mint_account(mint_supply),
            MINT_RENT,
            true,
            false,
        ),
        extra_metas: acct(metas, PROG, vec![7u8; 261], METAS_RENT, true, false),
    }
}

fn run_reconcile(f: &Fixture) -> Result<(), ProgramError> {
    let (mint_auth, _) = mint_authority_pda(&PROG);
    let (registry, _) = derive_nft_registry(&PERCOLATOR_MAINNET, &MARKET_GROUP);
    let accounts = vec![
        f.nft_pda.clone(),
        f.nft_mint.clone(),
        acct(PORTFOLIO, PERCOLATOR_MAINNET, portfolio_buf(), 0, true, false),
        acct(mint_auth, Pubkey::default(), vec![], 0, false, false),
        acct(registry, PERCOLATOR_MAINNET, vec![], 0, false, false),
        acct(PERCOLATOR_MAINNET, Pubkey::default(), vec![], 0, false, false),
        acct(ALICE, Pubkey::default(), vec![], 0, true, false),
    ];
    processor::process(&PROG, &accounts, &[TAG_RECONCILE_BURNED_NFT])
}

/// EmergencyBurn is the only other path that closes BOTH the mint and the
/// extra-metas PDA. Run it against the post-reconcile state.
fn run_emergency_burn(f: &Fixture) -> Result<(), ProgramError> {
    let (mint_auth, _) = mint_authority_pda(&PROG);
    let (registry, _) = derive_nft_registry(&PERCOLATOR_MAINNET, &MARKET_GROUP);
    let accounts = vec![
        acct(ALICE, Pubkey::default(), vec![], 0, true, true),
        f.nft_pda.clone(),
        f.nft_mint.clone(),
        acct(
            get_associated_token_address(&ALICE, &NFT_MINT),
            TOKEN_2022_PROGRAM_ID,
            token_account(&NFT_MINT, &ALICE, 1),
            0,
            true,
            false,
        ),
        acct(PORTFOLIO, PERCOLATOR_MAINNET, portfolio_buf(), 0, true, false),
        acct(mint_auth, Pubkey::default(), vec![], 0, false, false),
        acct(TOKEN_2022_PROGRAM_ID, Pubkey::default(), vec![], 0, false, false),
        f.extra_metas.clone(),
        acct(registry, PERCOLATOR_MAINNET, vec![], 0, false, false),
        acct(PERCOLATOR_MAINNET, Pubkey::default(), vec![], 0, false, false),
    ];
    processor::process(&PROG, &accounts, &[TAG_EMERGENCY_BURN])
}

fn run_repair_extra_metas(f: &Fixture) -> Result<(), ProgramError> {
    let (mint_auth, _) = mint_authority_pda(&PROG);
    let accounts = vec![
        acct(ALICE, Pubkey::default(), vec![], 1_000_000_000, true, true),
        f.nft_pda.clone(),
        f.nft_mint.clone(),
        f.extra_metas.clone(),
        acct(PORTFOLIO, PERCOLATOR_MAINNET, portfolio_buf(), 0, false, false),
        acct(mint_auth, Pubkey::default(), vec![], 0, false, false),
        acct(Pubkey::default(), Pubkey::default(), vec![], 0, false, false),
        acct(PERCOLATOR_MAINNET, Pubkey::default(), vec![], 0, false, false),
    ];
    processor::process(&PROG, &accounts, &[TAG_REPAIR_EXTRA_METAS])
}

// -- 1. the leak -------------------------------------------------------------

#[test]
fn reconcile_closes_the_pda_but_abandons_the_mint_and_extra_metas() {
    let f = fixture(0); // supply 0 — the out-of-band-burn state Reconcile handles
    assert!(run_reconcile(&f).is_ok(), "reconcile must succeed");

    assert_eq!(
        **f.nft_pda.lamports.borrow(),
        0,
        "the PositionNft PDA rent IS returned to the last holder",
    );
    assert_eq!(
        **f.nft_mint.lamports.borrow(),
        MINT_RENT,
        "...but the mint keeps its rent — Reconcile has no slot to close it",
    );
    assert_eq!(
        **f.extra_metas.lamports.borrow(),
        METAS_RENT,
        "...and so does the ExtraAccountMetaList PDA",
    );
    assert!(
        !f.extra_metas.data.borrow().iter().all(|b| *b == 0),
        "extra_metas is untouched, still carrying its meta list",
    );
}

#[test]
fn the_abandoned_rent_is_what_102_valued_plus_the_mint() {
    // Recorded so the cost is explicit rather than implied.
    assert_eq!(METAS_RENT + MINT_RENT, 6_786_000); // ≈ 0.00679 SOL per reconciled NFT
}

// -- 2. and it is unrecoverable ----------------------------------------------

#[test]
fn after_reconcile_no_path_can_reclaim_them() {
    let f = fixture(0);
    assert!(run_reconcile(&f).is_ok());

    // EmergencyBurn is the only other instruction that closes BOTH accounts.
    let eb = run_emergency_burn(&f);
    assert!(
        eb.is_err(),
        "EmergencyBurn must be dead once nft_pda is closed, got {eb:?}",
    );

    // RepairExtraMetas is permissionless but equally gated on nft_pda.
    let rp = run_repair_extra_metas(&f);
    assert!(
        rp.is_err(),
        "RepairExtraMetas must be dead too, got {rp:?}",
    );

    // The lamports are still sitting there, now unreachable.
    assert_eq!(**f.nft_mint.lamports.borrow(), MINT_RENT);
    assert_eq!(**f.extra_metas.lamports.borrow(), METAS_RENT);
}

// -- 3. control: the burn paths DO reclaim both ------------------------------

#[test]
fn control_emergency_burn_reclaims_both_when_the_pda_is_alive() {
    // Same accounts, but Reconcile has NOT run — so nft_pda is still live and
    // EmergencyBurn closes the mint and the metas. This isolates "Reconcile
    // omitted them" from "these accounts are never closeable".
    let f = fixture(1); // a live NFT: supply 1, holder holds it
    let r = run_emergency_burn(&f);
    assert!(r.is_ok(), "EmergencyBurn on a live NFT must succeed: {r:?}");
    assert_eq!(
        **f.extra_metas.lamports.borrow(),
        0,
        "EmergencyBurn DOES reclaim the extra-metas rent (#102)",
    );
}
running 4 tests
test the_abandoned_rent_is_what_102_valued_plus_the_mint ... ok
test reconcile_closes_the_pda_but_abandons_the_mint_and_extra_metas ... ok
test control_emergency_burn_reclaims_both_when_the_pda_is_alive ... ok
test after_reconcile_no_path_can_reclaim_them ... ok

control_emergency_burn_reclaims_both_when_the_pda_is_alive is the one that carries the argument: the identical accounts, with Reconcile simply not run, let EmergencyBurn close the metas PDA and return its rent. So the accounts are perfectly closeable — Reconcile just has no way to reach them. after_reconcile_no_path_can_reclaim_them then shows both recovery routes fail once Reconcile has closed nft_pda, with the lamports still sitting there unreachable.

Suggested fix

Extend Reconcile to close both, mirroring what EmergencyBurn already does, and pay the rent to last_holder_ai exactly as the PDA rent already is.

Worth doing this as optional trailing accounts rather than required ones, so it is not an ABI break: process_reconcile_burned_nft reads its accounts positionally and ignores extras today, so accounts.get(7) / accounts.get(8) for extra_metas and the token program lets existing 7-account callers keep working while a crank that supplies them recovers the rent. Reconcile is permissionless, and the recipient is already pinned to the recorded last_holder, so admitting the two extra accounts grants no new authority — the close targets are both re-derived from nft_mint, which is itself pinned to nft_state.nft_mint.

If an ABI break is acceptable, making them required is simpler and guarantees the rent is always recovered rather than only when the cranker bothers.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions