Skip to content

fix(nft): gate the devnet wrapper id behind a devnet feature (#176) - #177

Merged
dcccrypto merged 1 commit into
dcccrypto:mainfrom
0x-SquidSol:fix/gate-devnet-wrapper-id-behind-feature
Aug 31, 2026
Merged

fix(nft): gate the devnet wrapper id behind a devnet feature (#176)#177
dcccrypto merged 1 commit into
dcccrypto:mainfrom
0x-SquidSol:fix/gate-devnet-wrapper-id-behind-feature

Conversation

@0x-SquidSol

Copy link
Copy Markdown
Contributor

Closes #176.

Problem

PERCOLATOR_DEVNET compiled unconditionally into every build — including the artifact a mainnet deploy produces — and was trusted at all three sites that consult the wrapper allowlist (cpi_v16.rs:98, processor.rs:628, transfer_hook.rs:400). The crate exposed no feature that could exclude it.

A program's address is its deploy keypair's public key and is not cluster-scoped. DhSkE7u… is currently unclaimed on mainnet-beta (getAccountInfonull) and live on devnet, so whoever holds the devnet deploy keypair could deploy an arbitrary program at that same address on mainnet and be trusted automatically.

percolator-prog's own Cargo.toml states the rule this broke, on its devnet feature:

Mirrors percolator-stake's own devnet feature and its N-3 rationale: a devnet id must never compile into a mainnet binary, so that a compromised devnet deploy keypair cannot inherit mainnet authority.

Fix

This is not a new design — percolator-stake already carries the identical allowlist, with the identical two program ids, correctly gated (percolator-stake/src/processor.rs:508-523). The two crates' feature lists differed by exactly this one entry. Applied verbatim, including stake's shadowing idiom:

let is_valid = portfolio_ai.owner == &PERCOLATOR_MAINNET;
#[cfg(feature = "devnet")]
let is_valid = is_valid || portfolio_ai.owner == &PERCOLATOR_DEVNET;

Verification

Artifact level — the devnet key is not in a mainnet build at all. Neither pubkey appears as contiguous bytes in the .so (SBF materialises them as 32-bit lddw immediate halves), so scanning for the eight 4-byte halves of each id:

artifact devnet id, per-chunk hits mainnet id
default (mainnet) [0,0,0,0,0,0,0,0] [10,10,10,10,10,10,10,10]
--features devnet [3,3,3,3,3,3,3,3] [3,3,3,3,3,3,3,3]

Behaviour — 4 tests on a default build (devnet rejected at the predicate and through the complete transfer hook, mainnet accepted, an unrelated program rejected) and 3 on --features devnet (the devnet id trusted again, so devnet deploys keep working). Both controls matter: the mainnet control shows the rejection is specific to the devnet id rather than a harness failing for everything, and the stranger control shows acceptance is allowlist membership rather than a missing check.

51 pre-existing tests pass in both configurations. cargo clippy --all-targets -- -D warnings clean in default, devnet and release. cargo build-sbf succeeds in both configurations.

Coverage completenessverify_portfolio_program is the single chokepoint; all 9 call sites route through it, covering every dispatch arm plus the transfer hook and valuation. The crate contains only four pubkey! literals; the other two (Token-2022, ATA) are cluster-invariant, so there is no second leak of this kind.

CI

.github/workflows/test.yml now runs both feature configurations. This is load-bearing rather than tidiness: the crate has a crate-wide #![allow(unexpected_cfgs)] (lib.rs:5), so a misspelled feature name — e.g. #[cfg(feature = "devnett")] — produces no diagnostic at all, not even under -D warnings. It fails closed, but silently. The devnet job catches exactly that, verified by deliberately introducing the typo and watching the devnet test fail.

Scope notes

The gate touches three sites, but they are not three independent controls, and the PR should not be read as claiming so: cpi_v16.rs is the chokepoint; processor.rs:627 feeds only a debug_assert! and the crate declares no [profile.release], so debug-assertions are off in the deployed artifact and that line enforces nothing on-chain (:624 is the real guard there); transfer_hook.rs:402 is defence-in-depth, since the hook performs no CPI post-#105 and percolator_prog is pinned into the ExtraAccountMetaList at mint. Gating all three is still right — they must not disagree.

Deliberately not included, and offered as follow-ups:

  • --all-features defeats the gate silently, and worse, also removes the two #[cfg(not(feature = "devnet"))] rejection tests, so the suite goes 4 → 3 tests and stays green. Nothing in the repo passes --all-features today. A robust guard would be an explicit mainnet feature plus #[cfg(all(feature = "devnet", feature = "mainnet"))] compile_error!(...), but that adds public feature surface and is a maintainer's call.
  • An artifact-scan CI job asserting the devnet id's bytes are absent from the default .so — the check tabulated above, ~10 lines. This is the only guard that survives a future refactor of the #[cfg] sites, but it needs a Solana toolchain in CI, which this repo's workflow does not currently install. percolator-stake's ci.yml:92,103 is a good template.
  • Scoping #![allow(unexpected_cfgs)] to the entrypoint module rather than the crate. Pre-existing, but newly load-bearing.
  • Cross-repo deploy docs: percolator-prog's build-flags tables (docs/superpowers/specs/2026-07-20-…-as-built.md §8 and the sibling plan doc) list wrapper and stake flags but mention percolator-nft zero times. Its flags are now cluster-dependent and should be recorded there.

Operational note

Once devnet NFTs exist, a devnet deploy built without --features devnet permanently strands them: every burn path calls verify_portfolio_program, and only this program's mint-authority PDA can sign UnwrapEscrowedPortfolio, so there is no recovery route. That is the reason the build invocation belongs in CI rather than a runbook. There is no such risk today if no devnet NFTs have been minted yet.

🤖 Generated with Claude Code

`PERCOLATOR_DEVNET` compiled unconditionally into every build, including the
artifact deployed to mainnet, and was trusted at all three sites that consult the
wrapper allowlist. The crate exposed no feature that could exclude it.

A program's address is its deploy keypair's public key and is not cluster-scoped,
and `DhSkE7u…` is currently unclaimed on mainnet-beta (getAccountInfo returns
null). So whoever holds the devnet deploy keypair could deploy an arbitrary
program at that address on mainnet and be trusted automatically — able to mint
counterfeit Position NFTs from the genuine program, backed by fabricated
portfolio accounts they own. Portfolios owned by the real wrapper stay
unreachable, so this is counterfeiting rather than a drain.

percolator-stake already carries this same allowlist, with the same two program
ids, correctly gated (percolator-stake/src/processor.rs:508-523), and its CI
builds devnet artifacts with `cargo build-sbf -- --features devnet`. This applies
that pattern verbatim: the constant and each allowlist arm move behind
`#[cfg(feature = "devnet")]`, using stake's `let is_valid = ...;
#[cfg(feature = "devnet")] let is_valid = is_valid || ...;` shadowing.

Verified at the artifact level: in a default build the devnet key is absent from
the .so entirely — scanning for the eight 4-byte halves SBF uses to materialise a
pubkey finds 0 occurrences of each, against 10 for the mainnet id. A
`--features devnet` build contains both.

CI now runs both feature configurations. The devnet job is load-bearing: without
it a misspelled cfg would silently produce a devnet build that trusts nothing,
and the crate-wide `#![allow(unexpected_cfgs)]` (lib.rs:5) suppresses the
diagnostic that would otherwise catch it.

Closes dcccrypto#176

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

coderabbitai Bot commented Aug 30, 2026

Copy link
Copy Markdown

Warning

Review limit reached

Next included review available in 7 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: c20e839c-97c4-4775-b0b6-462de424d3aa

📥 Commits

Reviewing files that changed from the base of the PR and between 215842e and 2e4d324.

📒 Files selected for processing (7)
  • .github/workflows/test.yml
  • Cargo.toml
  • README.md
  • src/cpi_v16.rs
  • src/processor.rs
  • src/transfer_hook.rs
  • tests/poc_devnet_id_in_mainnet_build.rs

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

2 participants