Skip to content

feat(nft)!: ACCOUNTS_NFT_RECONCILE 7 -> 9 accounts (percolator-nft#182) — stacked on #377 - #378

Open
0x-SquidSol wants to merge 3 commits into
dcccrypto:mainfrom
0x-SquidSol:fix/nft-reconcile-nine-accounts
Open

feat(nft)!: ACCOUNTS_NFT_RECONCILE 7 -> 9 accounts (percolator-nft#182) — stacked on #377#378
0x-SquidSol wants to merge 3 commits into
dcccrypto:mainfrom
0x-SquidSol:fix/nft-reconcile-nine-accounts

Conversation

@0x-SquidSol

Copy link
Copy Markdown
Contributor

Companion to dcccrypto/percolator-nft#183 (issue: dcccrypto/percolator-nft#182).

Stacked on #377 — review that one first; this branch contains its commit.

What changes

ACCOUNTS_NFT_RECONCILE goes from 7 to 9 accounts:

-  "w", "r", "w", "r", "r", "r", "w",
+  "w", "w", "w", "r", "r", "r", "w", "w", "r",
  • index 7 — ExtraAccountMetaList PDA, writable (closed)
  • index 8 — Token-2022 program (mint-close CPI target)
  • index 1 — the NFT mint becomes writable, because the program now closes it

percolator-nft#182: Reconcile abandoned the NFT mint and the ExtraAccountMetaList PDA — 7,676,880 lamports per NFT, unrecoverable, because it closes the PositionNft PDA and every path that could later reclaim those two requires it to still be live. The program-side accounts are required rather than optional: Reconcile is permissionless, irreversible and runs at most once, so an opt-in could be defeated permanently by whoever called first.

Sequencing — this can ship before the program, and should

I initially assumed the reverse and want to correct that explicitly, because it determines the merge order.

A nine-account call is safe against the currently deployed programs. Their process_reconcile_burned_nft pulls seven accounts off an iterator; there is no accounts.len() check anywhere in processor.rs, and NotEnoughAccountKeys is the too-few error, so surplus accounts cannot trigger it — indices 7 and 8 are simply never read. The writable flag on index 1 is equally inert: the deployed reconcile path checks writability only on nft_pda, portfolio and last_holder_ai (processor.rs:1243), never on the mint, and never writes it. So this behaves identically on FqhKJT9gtScjrmfUuRMjeg7cXNpif1fqsy5Jh65tJmTS (mainnet) and CNGBPZRALk9Xu8BdgWNyrLJ7daQ9eJYFf1GnEEC7YCU3 (devnet).

The reverse order is what breaks. Deploy percolator-nft#183 while the SDK still says seven, and every Reconcile fails with NotEnoughAccountKeys — the program's two new next_account_info calls are explicitly required.

So SDK-first is strictly safer, and the gate is not the merge:

  • .github/workflows/ci.yml:26-28 publishes only on a release:-prefixed commit on main. Merging an ordinary commit runs lint/build/test and publishes nothing.
  • Hold the release: commit until percolator-nft#183 is at least devnet-deployed. Until then the docstring promises rent reclamation the chain does not yet perform — which costs nothing, since that rent is already lost on every Reconcile today.

I deliberately did not add a legacy 7-account export. It would solve a compatibility problem that does not exist, become a permanent trap once #183 deploys, and — worse — hand every stale client and griefer a ready-made short form to defeat the reclamation, which is precisely the opt-in the program PR made impossible.

Breaking surface

Marked ! because buildNftAccountMetas hard-throws on a count mismatch: a caller passing seven keys now gets account count mismatch: expected 9, got 7, loudly and at the call site. deriveExtraAccountMetas already exists to supply key 7.

Blast radius today is nil: seven repos depend on this package, all by exact version or git SHA rather than a floating ^5, and grepping their sources for reconcile / encodeNftReconcile / ACCOUNTS_NFT_RECONCILE returns zero hits. No consumer calls this instruction.

Also included

Verification

npx tsc --noEmit clean; full suite 1013 passing across 29 files. New drift assertions round-trip the template through buildNftAccountMetas and check the {isSigner, isWritable} booleans the runtime actually sees, including that Reconcile remains permissionless (no account may be a signer).

🤖 Generated with Claude Code

0x-SquidSol and others added 2 commits August 31, 2026 09:54
`ACCOUNTS_NFT_BURN` and `ACCOUNTS_NFT_EMERGENCY_BURN` marked account 0, the NFT
holder, as "s": signer but NOT writable. The program requires signer AND
writable, because the holder receives the rent from every account those
instructions close — the ATA, the mint, the PositionNft PDA and the
ExtraAccountMetaList.

percolator-nft rejects a read-only holder outright:

    fn require_writable_rent_recipient(holder: &AccountInfo) -> ProgramResult {
        if !holder.is_writable { return Err(ProgramError::InvalidAccountData); }
        Ok(())
    }

called from BurnPositionNft (processor.rs:825) and EmergencyBurn (:1000). The
program's own ABI table documents account 0 as `[signer, writable]`
(instruction.rs:44 and :99).

So every burn instruction built from these templates via buildNftAccountMetas
went on the wire with isWritable: false and was rejected with
InvalidAccountData. This is a live break against the deployed programs.

It went unnoticed because nothing in this repo consumes the templates — there is
no in-repo call site of buildNftAccountMetas or of any ACCOUNTS_NFT_* array, so
only external callers ever exercised them. The existing drift tests could not
have caught it either: they assert the shorthand string codes, and the defect is
only visible once those become {isSigner, isWritable} booleans.

Add account-list tests that round-trip through the real builder and assert those
booleans. That is the assertion that makes this class visible, and it also covers
the historical wrong-builder bug documented above buildNftAccountMetas, where
every flag silently became `undefined`.

Closes dcccrypto#376

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Mirrors dcccrypto/percolator-nft#183, which gives ReconcileBurnedNft the rent
reclamation the two burn paths have had since dcccrypto#102: extra_metas (writable) at
index 7 and the Token-2022 program at index 8. The NFT mint at index 1 becomes
writable because the program now closes it.

Reconcile previously abandoned the NFT mint and the ExtraAccountMetaList PDA —
7,676,880 lamports per NFT, unrecoverable, because it closes the PositionNft PDA
and every path that could later reclaim those two requires it to still be live.

Ahead of chain, but forward-compatible, so this can ship before the program
rather than after it. The deployed handlers pull seven accounts off an iterator,
have no `accounts.len()` check anywhere in processor.rs, and never check
`nft_mint.is_writable` in the reconcile path — so the two extra metas are unread
and a nine-account call behaves identically on the deployed programs. The
reverse order is the one that breaks: deploying dcccrypto#183 while the SDK still says
seven fails every Reconcile with NotEnoughAccountKeys.

Marked `!` because `buildNftAccountMetas` hard-throws on a count mismatch, so a
caller passing seven keys now gets `account count mismatch: expected 9, got 7`
at the call site. That is the intended failure — loud and local.

Also corrects two module-header defects found alongside: the instruction list
omitted tags 6 and 7, and the PositionNft PDA seed was documented as
`asset_index_u16_LE`, the pre-dcccrypto#108 scheme dcccrypto#108 existed to remove. The code was
always correct.

Refs dcccrypto/percolator-nft#182

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Aug 31, 2026

Copy link
Copy Markdown

Warning

Review limit reached

Next included review available in 49 minutes.

View limit details

Limit details: You’ve used the included review currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 6e5f6391-827b-4896-8632-9d80974f97e6

📥 Commits

Reviewing files that changed from the base of the PR and between 5b0aa77 and 7140360.

⛔ Files ignored due to path filters (3)
  • dist/abi/nft.d.ts is excluded by !**/dist/**
  • dist/index.js is excluded by !**/dist/**
  • dist/index.js.map is excluded by !**/dist/**, !**/*.map
📒 Files selected for processing (3)
  • CHANGELOG.md
  • src/abi/nft.ts
  • test/drift-check.test.ts

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Bytes [167..199] were documented as `_reserved`. percolator-nft#138 claimed them
for `last_holder: [u8; 32]`, the final field of PositionNftV16 and the one the
transfer hook rewrites on every transfer.

It matters here because ReconcileBurnedNft reads it as the sole authorisation:
the program releases the escrowed portfolio and all rent to whichever account
matches, and refuses any other. It is account 6 of ACCOUNTS_NFT_RECONCILE and
cannot be derived — only read from the PositionNft account.

So without this the SDK shipped the Reconcile account template while giving a
caller no way to obtain the one key in it that is not a PDA or a program id.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@0x-SquidSol

Copy link
Copy Markdown
Contributor Author

Added PositionNftState.lastHolder — the template was unusable without it

A review sweep caught that this PR shipped ACCOUNTS_NFT_RECONCILE requiring account 6, the recorded last-holder wallet, while the SDK had no way to produce it.

parsePositionNftAccount documented bytes [167..199] as _reserved. They are not: percolator-nft#138 claimed them for last_holder: [u8; 32], the final field of PositionNftV16 and the one the transfer hook rewrites on every transfer. ReconcileBurnedNft reads it as the sole authorisation — the program releases the escrowed portfolio and all rent to whichever account matches it and refuses any other — and it cannot be derived, only read.

So the instruction was unbuildable from this SDK: every other account in the template is a PDA, a program id, or the portfolio, and this one had no source.

Now exposed as PositionNftState.lastHolder, with a test asserting it parses from [167..199] and is distinct from positionOwnerAtMint (the two differ as soon as the NFT is transferred).

Full suite 1014 passing, tsc --noEmit clean, dist/ rebuilt.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant