Skip to content

fix(nft): record last_holder on CPI-mediated transfers via Token-2022's in-flight flag (#174) - #175

Open
0x-SquidSol wants to merge 1 commit into
dcccrypto:mainfrom
0x-SquidSol:fix/last-holder-stale-on-cpi-transfers
Open

fix(nft): record last_holder on CPI-mediated transfers via Token-2022's in-flight flag (#174)#175
0x-SquidSol wants to merge 1 commit into
dcccrypto:mainfrom
0x-SquidSol:fix/last-holder-stale-on-cpi-transfers

Conversation

@0x-SquidSol

@0x-SquidSol 0x-SquidSol commented Aug 30, 2026

Copy link
Copy Markdown
Contributor

Closes #174.

Problem

ReconcileBurnedNft releases an entire escrowed portfolio — position and collateral — to nft_state.last_holder, and that field is its sole authorisation (processor.rs:1275, :1319, :1326). The transfer hook only wrote it when the top-level instruction was Token-2022 (transfer_hook.rs:166, gate at :542 pre-change). Every transfer routed through another program — marketplace escrow, orderbook fill, multisig, aggregator — skipped the write.

Since last_holder is initialised to the minter (processor.rs:500), an NFT traded only through programs kept last_holder = minter for its entire life, and it never self-corrected. A marketplace buyer was never recorded, so if any later holder burned out-of-band — the exact scenario #138 added ReconcileBurnedNft to recover from — the escrow went to a seller who had already been paid, while the real owner was refused by the same gate.

PR #159 named this as a known tradeoff ("routes to the prior genuine holder — no theft, no new drain"). That assessment does not hold: after a sale the prior holder has no remaining claim, the loss to the buyer is the whole position, and because #145 exists to enable program-mediated trading, the stale state is the steady state rather than a rare one.

Fix

Additionally authorise the write when Token-2022's own in-flight signal is set on both the source and the destination:

let transfer_in_flight =
    account_is_transferring(source_ata)? && account_is_transferring(dest_ata)?;
let holder_recorded = is_genuine_token2022_transfer || transfer_in_flight;
if holder_recorded { /* nft_state.last_holder = new_owner */ }

process_transfer calls set_transferring on both accounts immediately before invoke_execute and unset_transferring immediately after, so the flag is true for every real transfer regardless of routing and false during a spoofed direct Execute, where no transfer is running. That is exactly the discrimination the top-level heuristic was standing in for, without the marketplace blind spot.

Reading it needs no new dependency. The doc comment previously asserted this required the spl-token-2022 crate, which conflicts with the solana-program pin; that is not so — the flag is one byte of TLV, and this file already hand-parses the same accounts for mint/owner/state. That comment is corrected here.

Why #152/#153 stay closed

The gate is a disjunction, so it is monotonic: every case authorised before still is, and the new term only adds cases where a real transfer is provably in flight.

To set both flags an attacker must get Token-2022 to actually run process_transfer on this mint's source and destination, which moves the token; new_owner is then read from the account that actually received it. The flag bytes live in Token-2022-owned accounts, so they are not attacker-writable, and both accounts are already pinned to this mint (:369/:390, :404/:422) with the mint pinned to the PDA (:483). The window cannot be observed from outside, because set/unset bracket invoke_execute, a failing inner instruction aborts the whole transaction rather than committing a set-without-unset state, and process_execute performs no CPI. That last point is load-bearing and is now documented in the helper so a future CPI cannot silently break it.

verify_cpi_caller_is_token2022 is unchanged and still returns Err for a malformed Token-2022 outer instruction, so the #103 plain-Transfer rejection and the mint-match check are untouched.

Verification

Verified against the vendored spl-token-2022 sources: ExtensionType::TransferHookAccount == 15, stable across 1.0.0 / 6.0.0 / 8.0.1 / interface-2.1.0 (it is the on-chain wire format, and every version appended only at the tail); TransferHookAccount { transferring: PodBool } is one byte; PodBool → bool is != 0.

Behaviour change worth naming

last_holder now advances on every program-mediated hop. If a marketplace holds the NFT in a program-owned escrow account between listing and sale, last_holder becomes that escrow address, and an out-of-band burn while escrowed would release the portfolio there. That is the correct party by the program's own rule ("last_holder is the current holder") and is strictly better than paying an already-paid seller, but it is a case that did not arise before.

Notes

  • Comments falsified by this change are corrected in transfer_hook.rs, processor.rs and instruction.rs. Two pre-existing comments (state_v16.rs:80-85, processor.rs:1217-1219) claimed last_holder is "rewritten to the recipient on every transfer" — that was false before this change and is true after it.
  • The extra-meta entry [5] writability justification is updated rather than changed: the set of transfers requiring a writable nft_pda is now wider (it includes marketplace CPI). No new requirement is introduced — Token-2022 builds entry [5] writable on every path.
  • Deliberately not included, to keep this reviewable: the TransferHook extension authority is set to the mint-authority PDA rather than None (processor.rs:545), which would make the "no foreign code in the window" property structural instead of contingent; the unused solana-sdk / proptest dev-dependencies, which pull openssl-sys and block cargo test on Windows though nothing in src/ or tests/ references them; and the README instruction table omitting tag 7 ReconcileBurnedNft. Happy to file or fix any of these separately.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes

    • NFT ownership records now update correctly for both direct Token-2022 transfers and marketplace or orderbook transfers.
    • Burned-NFT escrow reconciliation now uses the latest recorded holder, preventing payouts to stale owners.
    • Added safeguards to reject spoofed or incomplete transfer signals.
  • Documentation

    • Clarified transfer tracking behavior and the writable account requirements for escrow reconciliation.

…'s in-flight flag

`last_holder` is the sole authorisation for ReconcileBurnedNft's release of an
escrowed portfolio, but the hook only wrote it when the TOP-LEVEL instruction was
Token-2022. Any transfer routed through another program — marketplace escrow,
orderbook fill, multisig — skipped the write, so the field stayed at its initial
value: the minter (processor.rs:500). A buyer who acquired the NFT through a
marketplace was never recorded, so an out-of-band burn released the position and
its collateral to the seller, who had already been paid, while the actual owner
was refused by the same gate.

Additionally authorise the write when Token-2022's own
`TransferHookAccount.transferring` flag is set on BOTH the source and the
destination. Token-2022 sets it immediately before `invoke_execute` and clears it
immediately after, inside `process_transfer`, so it is true for every real
transfer regardless of routing and false during a spoofed direct `Execute`.
Reading it needs no new dependency: it is one byte of TLV, and this file already
hand-parses the same accounts for mint/owner/state.

The gate is a disjunction and therefore monotonic — every case authorised before
still is — so dcccrypto#152/dcccrypto#153 stay closed, and behaviour degrades to exactly the prior
(fail-closed) one if an account somehow lacks the extension.

Also corrects the comments this falsifies, including the note claiming the flag
was unreadable without the spl-token-2022 crate, and the `new_holder_recorded`
log line, which reported only the first disjunct and so denied the write on
exactly the transfers this fix exists to record.

Closes dcccrypto#174

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

coderabbitai Bot commented Aug 30, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: ae9e13f1-8310-4555-aaf9-38b550cc94c7

📥 Commits

Reviewing files that changed from the base of the PR and between 215842e and 0ab0f4c.

📒 Files selected for processing (5)
  • README.md
  • src/instruction.rs
  • src/processor.rs
  • src/transfer_hook.rs
  • tests/poc_stale_last_holder.rs

Included review availability: Your plan provides up to 1 included review per hour; 0 remain after this review.


📝 Walkthrough

Walkthrough

The transfer hook now reads Token-2022’s in-flight transferring flag to record holders for marketplace-CPI transfers. It preserves spoof protection, updates reconciliation documentation, and adds end-to-end tests for stale holders, escrow release, malformed account data, and realistic ATA layouts.

Changes

Holder tracking and reconciliation

Layer / File(s) Summary
Transfer-state flag parsing
src/transfer_hook.rs
The hook parses Token-2022 account TLV data and accepts the transferring flag only for valid token accounts.
Holder update and writable-state wiring
src/transfer_hook.rs, src/instruction.rs, src/processor.rs, README.md
process_execute records last_holder for genuine transfers or transfers with both accounts marked in flight. Documentation states that the PositionNft entry must remain writable.
Regression harness and security coverage
tests/poc_stale_last_holder.rs
Tests cover marketplace transfers, reconciliation authorization, spoofed Execute calls, partial flags, invalid account types, and realistic ATA layouts.

Estimated code review effort: 3 (Moderate) | ~30 minutes

Merge Risk: 🔵 Low · up to 0ab0f

The PR records the current holder for program-mediated NFT transfers, improving who can recover an escrowed portfolio after a burn while preserving the existing account and anti-spoof checks. It is mergeable with explicit owner awareness that recovery authorization depends on Token-2022’s transient transfer flags being set and cleared correctly, which should be validated with an integration check.

Sequence Diagram(s)

sequenceDiagram
  participant Token2022
  participant process_execute
  participant PositionNft
  participant ReconcileBurnedNft
  Token2022->>process_execute: invoke transfer hook
  process_execute->>process_execute: verify caller and read paired transferring flags
  process_execute->>PositionNft: write last_holder when holder_recorded
  ReconcileBurnedNft->>PositionNft: read last_holder for authorization
Loading

Suggested reviewers: dcccrypto

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly identifies the primary change: recording last_holder for CPI-mediated transfers through Token-2022's in-flight flag.
Linked Issues check ✅ Passed The implementation addresses issue #174. It records last_holder for direct and CPI-mediated Token-2022 transfers, preserves the anti-forgery authorization path, and adds tests for spoofing, partial …
Out of Scope Changes check ✅ Passed The code, documentation, logging, and regression tests directly support the linked issue and the transfer-hook holder-recording fix. No unrelated changes are identified.
Docstring Coverage ✅ Passed Docstring coverage is 80.00% which is sufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 30 functions across 4 files. (1 skipped: 1 …
Full details: Linked Issues check

Explanation

The implementation addresses issue #174. It records last_holder for direct and CPI-mediated Token-2022 transfers, preserves the anti-forgery authorization path, and adds tests for spoofing, partial flags, malformed TLV data, and realistic account layouts. The documented reconciliation behavior also prevents payment to a stale previous holder.

Full details: Docstring Coverage

Explanation

Docstring coverage is 80.00% which is sufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 30 functions across 4 files. (1 skipped: 1 unsupported.)

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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.

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

Labels

None yet

Projects

None yet

1 participant