Skip to content

Fix RepairExtraMetas recovery and enforce portfolio provenance checks - #171

Closed
Bayyan16 wants to merge 1 commit into
dcccrypto:mainfrom
Bayyan16:fix/issues-167-170
Closed

Fix RepairExtraMetas recovery and enforce portfolio provenance checks#171
Bayyan16 wants to merge 1 commit into
dcccrypto:mainfrom
Bayyan16:fix/issues-167-170

Conversation

@Bayyan16

@Bayyan16 Bayyan16 commented Jun 30, 2026

Copy link
Copy Markdown
Contributor

Summary

This PR fixes four issues across RepairExtraMetas, GetPositionValue, SettleFunding, and TransferHook.

Fixes:

The changes focus on two security/integrity areas:

  1. safely recovering canonical System-owned ExtraAccountMetaList PDA accounts in RepairExtraMetas;
  2. enforcing decoded portfolio provenance binding before trusting portfolio data in valuation, funding settlement, and transfer-hook paths.

Changes

1. RepairExtraMetas recovery

RepairExtraMetas previously rejected a canonical ExtraAccountMetaList PDA when the account was System-owned / prefunded before the instruction could recover and rewrite it.

This PR updates the recovery flow so RepairExtraMetas can recover only the canonical empty System-owned PDA.

The updated flow now:

  • verifies the passed extra_metas account is the canonical PDA for the NFT mint;
  • accepts only program-owned accounts or empty System-owned canonical PDA accounts;
  • rejects System-owned accounts that already contain data;
  • tops up rent if needed;
  • allocates the canonical PDA using PDA signer seeds;
  • assigns the PDA to this program using PDA signer seeds;
  • resizes the account;
  • rewrites the deterministic ExtraAccountMetaList.

Non-canonical accounts, third-party-owned accounts, and non-empty System-owned accounts remain rejected. This keeps the recovery path permissionless but still fail-closed for unsafe account states.


2. Shared portfolio provenance binding helper

This PR adds a shared helper:

cpi_v16::verify_portfolio_account_id(...)

The helper verifies that decoded portfolio data is actually bound to the portfolio account passed into the current instruction:

p.provenance_header.portfolio_account_id == portfolio.key.to_bytes()

This check is intentionally applied at each portfolio-consuming handler. Portfolio decoding validates the internal v16 layout, while the handler must also verify that the decoded provenance matches the actual account it is about to trust.


3. GetPositionValue provenance validation

GetPositionValue now rejects decoded portfolio data when the internal provenance_header.portfolio_account_id does not match the passed portfolio.key.

This prevents the valuation path from emitting trusted POSITION_VALUE_V16 logs for provenance-mismatched portfolio data.

Affected issue:


4. SettleFunding provenance validation

SettleFunding now validates decoded portfolio provenance before reading the current leg funding snapshot and mutating:

PositionNftV16.f_snap_at_mint

This prevents the NFT funding snapshot from being updated using portfolio data whose internal provenance does not bind to the passed portfolio account.

Affected issue:


5. TransferHook provenance validation

TransferHook now validates decoded portfolio provenance before:

  • registry context checks;
  • bound-leg verification;
  • transfer-gate checks;
  • PositionNftV16.last_holder mutation.

This prevents transfer gating and holder-state mutation from using provenance-mismatched portfolio data.

Affected issue:


Security Impact

This PR fixes the following classes of issues:

  • canonical System-owned ExtraAccountMetaList PDA recovery failure;
  • valuation output integrity issue in GetPositionValue;
  • funding snapshot state-integrity issue in SettleFunding;
  • transfer-gating and last_holder state-integrity issue in TransferHook.

The portfolio-consuming paths now fail closed unless the decoded portfolio provenance matches the actual portfolio account passed to the instruction.

This PR does not loosen account validation. The recovery path for RepairExtraMetas is limited to the canonical PDA and only permits empty System-owned accounts to be recovered. Any non-canonical or unsafe account state remains rejected.


Validation

Tested locally:

cargo test --features no-entrypoint

Result:

44 passed; 0 failed

Final commit includes changes in:

  • src/cpi_v16.rs
  • src/processor.rs
  • src/transfer_hook.rs
  • src/valuation.rs

Recommended checks before merge:

cargo test
cargo test --features no-entrypoint
cargo fmt --all -- --check

Summary by CodeRabbit

  • Bug Fixes
    • Added extra validation to ensure portfolio data matches the provided account in multiple flows, reducing the chance of acting on mismatched or tampered account data.
    • Strengthened extra account metadata repair handling, including safer checks for empty accounts and improved resizing behavior.
    • Improved error logging when account validation fails, making issues easier to diagnose.

@coderabbitai

coderabbitai Bot commented Jun 30, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@Bayyan16, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 25 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 25ab01bb-ad9c-4a84-b0c7-e141cb5c0b13

📥 Commits

Reviewing files that changed from the base of the PR and between 33d1eda and a705212.

📒 Files selected for processing (4)
  • src/cpi_v16.rs
  • src/processor.rs
  • src/transfer_hook.rs
  • src/valuation.rs
📝 Walkthrough

Walkthrough

Adds verify_portfolio_account_id to cpi_v16.rs that validates the decoded portfolio's provenance_header.portfolio_account_id against the on-chain account public key. This check is then inserted into process_settle_funding, process_execute (transfer hook), and process_get_position_value. Separately, process_repair_extra_metas is updated to accept System-owned (empty) extra_metas accounts alongside program-owned ones.

Changes

Portfolio Provenance Validation + Repair Fix

Layer / File(s) Summary
verify_portfolio_account_id helper
src/cpi_v16.rs
Extends solana_program imports with msg and adds new exported verify_portfolio_account_id that compares provenance_header.portfolio_account_id to the passed Pubkey, logs context on mismatch, and returns NftError::InvalidNftPda.
Call sites in instruction handlers
src/processor.rs, src/transfer_hook.rs, src/valuation.rs
Inserts verify_portfolio_account_id immediately after portfolio decode in process_settle_funding, process_execute, and process_get_position_value.
process_repair_extra_metas ownership broadening
src/processor.rs
Extends ownership check to accept System-owned empty extra_metas accounts; introduces extra_metas_system_owned flag; conditionally runs allocate/assign only for System-owned accounts before resizing.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related issues

Possibly related PRs

  • dcccrypto/percolator-nft#43: Adds early fail-fast validation in the same handlers (process_settle_funding, process_execute, process_get_position_value) using a different helper (verify_pda_version).
  • dcccrypto/percolator-nft#58: Adds NftError::InvalidNftPda fail-fast account-key checks in the same process_settle_funding and process_execute paths.
  • dcccrypto/percolator-nft#52: Adds early account-identity validation in the same runtime entrypoints to prevent stale slot reuse.

Poem

🐇 Hop, hop, I check each key,
The portfolio must match what I see!
A mismatch found? I log and bail,
InvalidNftPda tells the tale.
No forged accounts shall slip past me! 🥕

🚥 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 summarizes the two main changes: RepairExtraMetas recovery fixes and added portfolio provenance checks.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ 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.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/processor.rs (1)

1485-1519: 🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Normalize oversized extra_metas accounts too.

Line 1486 only repairs accounts shorter than EXTRA_METAS_ACCOUNT_LEN; a program-owned oversized account keeps stale trailing bytes after the deterministic prefix is rewritten. Resize or reject data.len() > EXTRA_METAS_ACCOUNT_LEN so repair produces the canonical buffer.

Proposed fix
-    if data.len() < EXTRA_METAS_ACCOUNT_LEN {
+    if data.len() != EXTRA_METAS_ACCOUNT_LEN {
+        let needs_grow = data.len() < EXTRA_METAS_ACCOUNT_LEN;
         drop(data);
-        let rent = Rent::get()?;
-        let needed = rent.minimum_balance(EXTRA_METAS_ACCOUNT_LEN);
-        let current = extra_metas.lamports();
-        if needed > current {
-            let top_up = needed - current;
-            invoke(
-                &system_instruction::transfer(payer.key, extra_metas.key, top_up),
-                &[payer.clone(), extra_metas.clone(), system_program.clone()],
-            )?;
+        if needs_grow {
+            let rent = Rent::get()?;
+            let needed = rent.minimum_balance(EXTRA_METAS_ACCOUNT_LEN);
+            let current = extra_metas.lamports();
+            if needed > current {
+                let top_up = needed - current;
+                invoke(
+                    &system_instruction::transfer(payer.key, extra_metas.key, top_up),
+                    &[payer.clone(), extra_metas.clone(), system_program.clone()],
+                )?;
+            }
         }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/processor.rs` around lines 1485 - 1519, The repair path in
processor::process_extra_metas only handles undersized accounts, but oversized
program-owned extra_metas can keep stale trailing bytes after rewriting the
deterministic prefix. Update the normalization logic in the extra_metas handling
block to either resize oversized data down to EXTRA_METAS_ACCOUNT_LEN or reject
it before continuing, and make sure the final buffer is canonical after the
existing allocate/assign flow and extra_metas.resize call.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@src/processor.rs`:
- Around line 1485-1519: The repair path in processor::process_extra_metas only
handles undersized accounts, but oversized program-owned extra_metas can keep
stale trailing bytes after rewriting the deterministic prefix. Update the
normalization logic in the extra_metas handling block to either resize oversized
data down to EXTRA_METAS_ACCOUNT_LEN or reject it before continuing, and make
sure the final buffer is canonical after the existing allocate/assign flow and
extra_metas.resize call.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 5898c299-07dc-4e2a-b964-22806f3e85ca

📥 Commits

Reviewing files that changed from the base of the PR and between e693bac and 33d1eda.

📒 Files selected for processing (4)
  • src/cpi_v16.rs
  • src/processor.rs
  • src/transfer_hook.rs
  • src/valuation.rs

@dcccrypto

Copy link
Copy Markdown
Owner

Thanks @Bayyan16 — this is landing as #172, which keeps your commit with your authorship and adds the corrections as a follow-up commit on top.

Why a new PR rather than a push here: your branch was cut from an older base, so rebasing it cleanly meant cherry-picking your commit onto the current trunk on a branch in this repo. I don't force-push contributor forks.

What changed on top of your work:

The provenance checks are sound and break no live NFT (I verified against the deployed commit and live devnet portfolios). What needed fixing: the new ownership gate and the whole resize/recovery block were indented at column 0 inside the function body; the recovery path hardcoded b"extra-account-metas" instead of the imported EXTRA_METAS_SEED; and the PR shipped zero tests for four changed on-chain behaviours. Also worth noting — the linked #169/#170 "High" severities aren't reachable: percolator-prog cannot produce the required precondition.

Full detail and the verification are in #172. Closing this in favour of it — your change is in there, not discarded.

@dcccrypto dcccrypto closed this Aug 16, 2026
dcccrypto added a commit that referenced this pull request Aug 17, 2026
…ntation/seed/tests fixed (supersedes #171) (#172)

* fix: harden portfolio provenance and repair extra metas recovery

* fix(nft): repair the indentation, seed constant, and missing tests in #171

Follow-up to the provenance/repair commit. The three provenance checks are sound
and break no live NFT — the wrapper's only production initializer sets
portfolio_account_id = portfolio_ai.key, MintPositionNft at the deployed commit
f18da24 already enforces the same equality, and all live devnet portfolios carry
bytes[48..80] == their own address. The problems were in how it shipped.

1. Indentation. The new ownership gate and the entire resize/recovery block were
   written at column 0 inside the function body, so a security-critical
   account-ownership check read as though it were top-level. Re-indented to the
   surrounding block. (Only these regions — `main` does not pass
   `cargo fmt --check` and no workflow enforces it, so a repo-wide reformat would
   be unrelated churn.)

2. Hardcoded seed literal. The recovery path derived its signer seeds from
   b"extra-account-metas" while the mint path (:631) uses EXTRA_METAS_SEED, which
   is already imported at :28. Now both use the constant, so a seed change cannot
   desynchronise them.

3. Zero tests. The PR changed four on-chain behaviours and shipped no tests; CI
   exercised none of the new lines. Added the two arms of
   verify_portfolio_account_id plus a zeroed-provenance case (an all-zero header
   must not pass by accident). Negative control: stubbing the comparison to
   `false` fails two of the three.

4. Justified the System-owned recovery branch rather than leaving it unexplained.
   It is the never-created case: a nonexistent account is presented as
   System-owned with no data, so the old `owner != program_id -> reject` gate
   meant RepairExtraMetas could only fix a wrong-SIZED metas account, never a
   MISSING one — the case that actually bricks transfers. It is not the post-burn
   state: close_extra_metas (:747) zeroes lamports and data but never assigns back
   to System, and a burn drains nft_pda too, so a burned NFT cannot reach the
   checks. Safety rests on the address being pinned to our derivation, the account
   being required empty, and nft_pda being independently validated.

SEVERITY CORRECTION for the linked issues: #169/#170 are graded "High", but the
precondition — a wrapper-owned, decodable portfolio whose
provenance_header.portfolio_account_id differs from its own address — cannot be
produced by percolator-prog. These checks are defence-in-depth against a future
initializer, not fixes for a reachable exploit, and the issues should be re-graded
before anyone merges on that basis.

cargo build clean; 47 lib tests pass (44 before, +3 new).

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

* fix(nft): revert the permissionless shrink, correct the post-burn claim

Second-pass review findings. Two are substantive.

1. UNDISCLOSED DESTRUCTIVE CAPABILITY. The resize guard was changed from
   `data.len() < EXTRA_METAS_ACCOUNT_LEN` (main) to `!=`, which also SHRANK an
   oversized program-owned extra_metas back to 261 bytes — through a
   PERMISSIONLESS instruction, and with no mention in the PR. Reverted to
   grow-only. The recovery case is unaffected: a never-created account has len 0,
   which is already `<` the target, so `<` covers it. This also removes the now
   -redundant needs_grow branch.

2. FALSE PREMISE COMMITTED TO ON-CHAIN SOURCE. The comment asserted "this is NOT
   the post-burn state: close_extra_metas zeroes lamports and data but never
   assigns the account back to the System Program". The runtime reaps
   zero-lamport accounts at end of transaction, so on any LATER transaction a
   burned NFT's extra_metas DOES load as System-owned and empty and DOES reach
   this branch. The branch is still safe — but via the next gate, not this one:
   the same burn drains nft_pda, so `nft_pda.owner != program_id` rejects before
   anything is written. Corrected, with the old claim flagged as wrong so a future
   reader does not re-derive it.

3. INDENTATION FIX WAS INCOMPLETE — the irony of a commit titled "repair the
   indentation". The PR's own added line at transfer_hook.rs:474 sat at 4-space
   inside an 8-space block, with a stray blank line splitting it from the
   statement it depends on. Both fixed.

cargo build clean; 47 lib tests pass, full suite green.

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

---------

Co-authored-by: Bayyan16 <mpllanggeng16@gmail.com>
Co-authored-by: dcccrypto <dcccrypto@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
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.

2 participants