fix(nft): hand closed PDAs back to the System program (#178) - #179
Conversation
Every close path zeroed the account and drained its lamports but never handed it
back to the System program — no `resize(0)`, no `assign`. The runtime reaps
zero-lamport accounts only at the END of a transaction, so a later instruction in
the SAME transaction crediting lamports back left the account alive: still owned
by this program, still full length, all zero.
For the PositionNft PDA that state is a permanent deadlock. `MintPositionNft`
gates on `!nft_pda.data_is_empty()`, so a 199-byte zeroed account reads as
"already minted" forever, while every handler that could clear it runs
`verify_position_nft`, which rejects `magic == 0`. Nothing in the program can
undo it. `ReconcileBurnedNft` requires no signer at all, so any third party could
trigger it for the price of one rent-exempt balance:
ix0: ReconcileBurnedNft
ix1: System::transfer(attacker -> nft_pda, 2_275_920)
Because `market_id` is per-asset-slot rather than per-position-instance
(state_v16.rs), the PDA address is stable across close-and-reopen, so this
destroys a (portfolio, asset-slot) pair's ability to ever be wrapped again.
All four close sites — BurnPositionNft, EmergencyBurn, ReconcileBurnedNft and
close_extra_metas — now route through one shared helper so they cannot diverge.
Both halves are required: `assign` alone leaves the account full-length so
`data_is_empty()` still reads false, and `resize` alone leaves it program-owned
so dcccrypto#129's `system_instruction::allocate` would reject it.
The resize is guarded with a runtime `cfg!(target_os = "solana")`.
`AccountInfo::resize` writes the new length through `data_ptr.offset(-8)` and
reads `original_data_len()` through `key_ptr.offset(-4)`; both are valid only for
an AccountInfo the runtime serialized, so it must not run against host-built test
fixtures. SPL Token-2022 splits `delete_account` on the same cfg and
percolator-prog guards its own `realloc(0)` likewise. A runtime `cfg!` is used
rather than the attribute so the call stays compiled and lint-checked on host —
CI runs host tests only, so under the attribute form the resize would never be
type-checked at all.
Also corrects the RepairExtraMetas comment that relied on end-of-transaction
reaping to make a closed extra_metas System-owned; that is now structural.
Closes dcccrypto#178
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (2)
Included review availability: Your plan provides up to 1 included review per hour; 0 remain after this review. 📝 WalkthroughWalkthroughChangesNFT PDA close behavior
Estimated code review effort: 3 (Moderate) | ~25 minutes Merge Risk: 🔵 Low · up to Closed PDAs are now cleared, resized to zero, and returned to the System program so they can be recreated instead of remaining permanently unusable. The PR is mergeable with owner awareness that the on-chain resize, revival, and remint path still needs a runtime-backed regression test or explicit follow-up. Suggested reviewers: Sequence Diagram(s)sequenceDiagram
participant ReconcileBurnedNft
participant processor
participant nft_pda
participant SystemProgram
ReconcileBurnedNft->>processor: process reconciliation
processor->>nft_pda: clear and resize account data
processor->>SystemProgram: assign account ownership
processor-->>ReconcileBurnedNft: return close result
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
Full details: Linked Issues checkExplanation The PR addresses issue
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
…#186) CI on main has failed since `fix(nft): ReconcileBurnedNft reclaims mint and ExtraAccountMetaList rent (#182)` merged; the four commits before it are green. #182 changed ReconcileBurnedNft's account contract and two older PoC fixtures were not updated with it. Both are mine, from #179 and #175. Three changes, all in test fixtures -- no production code: 1. Two accounts added. #182 made `extra_metas` (7) and `token_program` (8) REQUIRED rather than optional, so the mint and ExtraAccountMetaList rent can be reclaimed while nft_pda is still alive. The fixtures passed 7 accounts and failed with NotEnoughAccountKeys. 2. `nft_mint` is now writable. Reconcile closes it to reclaim its rent and rejects a read-only one, which surfaced as InvalidAccountData once the account count was right. 3. The expected payout doubled. Reconcile now sweeps the ExtraAccountMetaList rent to `last_holder` alongside the nft_pda rent, so the fixtures' PDA_RENT expectation became PDA_RENT + PDA_RENT. Named as RECONCILE_PAYOUT with the reason, rather than repeating the arithmetic at three call sites. These are the fixtures catching up to a deliberate behaviour change, not a relaxation: each one now asserts MORE than it did (two extra accounts pinned, writability pinned, a larger payout pinned). Worth noting how this went unnoticed for two days: `cargo test` cannot build as shipped, because `solana-sdk` and `proptest` are declared as dev-dependencies, used nowhere, and drag in openssl-sys. So nobody running the suite locally would have seen it -- only CI, which nobody was watching. That is #184 item 4.1 and it is fixed separately. Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Closes #178.
Problem
Every close path zeroed the account and drained its lamports but never handed it back to the System program — no
resize(0), noassign. The runtime reaps zero-lamport accounts only at the end of a transaction, so a later instruction in the same transaction crediting lamports back left the account alive: still program-owned, still full length, all zero.For the
PositionNftPDA that is a permanent deadlock.MintPositionNftgates on!nft_pda.data_is_empty(), so a 199-byte zeroed account reads as "already minted" forever; and every handler that could clear it runsverify_position_nft, which rejectsmagic == 0.ReconcileBurnedNftrequires no signer at all, so any third party could do it for the price of one rent-exempt balance:Because
market_idis per-asset-slot rather than per-position-instance (state_v16.rs), the PDA address is stable across close-and-reopen — so this destroys a(portfolio, asset-slot)pair's ability to ever be wrapped again, not merely the current position.Worth noting that a comment in
RepairExtraMetasalready reasoned about this and relied on the assumption it breaks: "close_extra_metas zeroes its lamports and data without reassigning the owner, but the runtime reaps zero-lamport accounts at end of transaction, so on any LATER transaction it loads as System-owned and empty." That holds only if the account is actually reaped.Fix
All four close sites —
BurnPositionNft,EmergencyBurn,ReconcileBurnedNftandclose_extra_metas— route through one shared helper so they cannot diverge:Both halves are required, and I checked each:
assignalone leaves the account full-length sodata_is_empty()still reads false and the mint gate still bricks;resizealone leaves it program-owned, so #129'ssystem_instruction::allocaterejects it withAccountAlreadyInUse. Together they produce System-owned + empty, which is exactly what the #129 transfer→allocate→assign creation path consumes.Why the resize is cfg-guarded, and why as
cfg!rather than#[cfg]AccountInfo::resizewrites the new length throughdata_ptr.offset(-8)and readsoriginal_data_len()throughkey_ptr.offset(-4). Both are valid only for anAccountInfothe runtime serialized, so it must not run against a host-built fixture — doing so corrupts the heap. This is not theoretical: an unguarded version failed the existing suite withSTATUS_HEAP_CORRUPTIONwhile every individual test still printedok.That part follows precedent — SPL Token-2022 splits
delete_accounton exactly this cfg, and percolator-prog guards its ownrealloc(0)the same way.The runtime
cfg!rather than the#[cfg]attribute is a deliberate divergence: CI runscargo test --lib --testson a host target only, so under the attribute the resize line would never be compiled, type-checked or linted anywhere — and the resize is the half that actually removes the brick.cfg!folds to a constant false off-target, so it still never executes host-side, while keeping the call under the compiler. Verified by introducing a deliberate typo: the host build now fails, where the attribute form accepted it.Verification
tests/poc_pda_revival_brick.rs: the post-close state, plus a mint A/B usingRent::get()(processor.rs) as a sentinel — it is the first syscall and sits after thedata_is_empty()gate, soUnsupportedSysvarmeans every pre-syscall check passed. Three are controls: a properly-reaped PDA passes the same gate, a program-owned full-length PDA is still rejected (pinning that the fix is in the close path, not a loosened mint gate), and a live NFT still blocks re-mint.is_signerbefore invoking.cargo clippy --all-targets -- -D warningsclean;cargo build-sbf --tools-version v1.52succeeds.Known coverage gap, stated plainly: because the resize cannot execute off-target, no test exercises it.
reconcile_hands_the_pda_back_to_the_system_programobserves only theassign, anda_revived_closed_pda_no_longer_bricks_the_mint_slothand-constructs the System-owned empty account rather than obtaining it from a close — so the two halves are joined by reasoning rather than by a test. The right closure is a litesvm case that loads the real.so, runsReconcileBurnedNft, assertsowner == system_program && data.len() == 0, then performs the revival and a successful mint. percolator-prog and percolator-stake both already use litesvm; percolator-nft's[dev-dependencies]is currently empty, so adding it is a larger change than belongs in this PR. Happy to follow up.Notes
RepairExtraMetascomment that depended on end-of-transaction reaping is updated; that property is now structural. The change also makes both of its gates fire earlier and for the right reason.&program_idas both an account's owner slot and theprogram_idargument.AccountInfo::assignwrites through that reference, so the local would silently become the system program id. Given its own storage.ReconcileBurnedNftnever closesextra_metasat all, so its rent (~0.00207 SOL) is unreclaimable after a reconcile — a pre-existing leak, unchanged here, and more visible now thatnft_pdahands itself back on that path. Worth its own issue.ReconcileBurnedNftbeing permissionless is what turns this from a footgun into a griefing vector, but it is defensible on its own terms (a recovery crank whose recipient is pinned to the recordedlast_holder), so the fix is in the close paths rather than a new signer requirement.🤖 Generated with Claude Code
Summary by CodeRabbit
Bug Fixes
Tests