Summary
PERCOLATOR_DEVNET is compiled unconditionally into every build of this program, including the artifact deployed to mainnet, and is trusted everywhere the wrapper allowlist is consulted. The crate exposes no feature that would exclude it — its only features are no-entrypoint, test and kani.
This is the rule the sibling repos state explicitly. From percolator-prog's own Cargo.toml, documenting 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.
Both percolator-prog and percolator-stake apply that control. percolator-nft does not — and the divergence from percolator-stake is exact rather than analogous.
percolator-stake carries the same allowlist, with the same two program ids, correctly gated (percolator-stake/src/processor.rs:507-521):
// ... "devnet" feature flag so it only compiles in when explicitly requested.
const PERCOLATOR_MAINNET: Pubkey =
solana_program::pubkey!("ESa89R5Es3rJ5mnwGybVRG1GrNt9etP11Z5V2QWD4edv");
#[cfg(feature = "devnet")]
const PERCOLATOR_DEVNET: Pubkey =
solana_program::pubkey!("DhSkE7uTb8HBUYYWF1xkxMYBGtLYJEoDq1tfBD7SnHcj");
let is_valid = *percolator_program.key == PERCOLATOR_MAINNET;
#[cfg(feature = "devnet")]
let is_valid = is_valid || *percolator_program.key == PERCOLATOR_DEVNET;
The two crates' feature lists differ by exactly this one entry:
| crate |
features |
| percolator-stake |
no-entrypoint, test, kani, devnet |
| percolator-nft |
no-entrypoint, test, kani |
The commit that last set this value (215842e) notes that "nft hardcodes a wrapper allowlist in cpi_v16.rs (like stake did)" — the allowlist was copied, but the gating that makes it safe was not.
Affected code
src/cpi_v16.rs:89-93 declares both ids unconditionally, and three sites consult them:
| Location |
Check |
src/cpi_v16.rs:98 |
verify_portfolio_program — portfolio.owner ∈ {DEVNET, MAINNET} |
src/processor.rs:628 |
mint path — percolator_prog_id ∈ {DEVNET, MAINNET} |
src/transfer_hook.rs:400 |
hook — percolator_prog.key ∈ {DEVNET, MAINNET} |
On-chain state
Verified against the public RPC endpoints:
$ getAccountInfo DhSkE7uTb8HBUYYWF1xkxMYBGtLYJEoDq1tfBD7SnHcj (mainnet-beta)
"value": null <-- UNCLAIMED
$ getAccountInfo DhSkE7uTb8HBUYYWF1xkxMYBGtLYJEoDq1tfBD7SnHcj (devnet)
"program": "bpf-upgradeable-loader",
"programData": "FNckkJfV5LwEwzjeCDnBpYCvgLUHRcnLvqWAVeAPd64e",
"executable": true
A program's address is its deploy keypair's public key, and that keypair is not cluster-scoped. Because the address is still unclaimed on mainnet, whoever holds the devnet deploy keypair can deploy a program of their choosing at that same address on mainnet, at which point the mainnet NFT program trusts it automatically.
Consequence
An attacker who deploys at that address on mainnet does not gain access to portfolios owned by the real wrapper (ESa89R5…) — existing escrowed positions are not reachable. What they gain is the ability to mint counterfeit Position NFTs from the genuine program:
- Create account
P, owned by their program at DhSkE7u…, with the byte layout of a PortfolioAccountV16Account (magic, version, kind, provenance, legs — all attacker-chosen).
- Call
MintPositionNft on the real mainnet NFT program with portfolio = P.
verify_portfolio_program(P) passes: P.owner is on the allowlist.
- The
#109 registry checks (processor.rs:387-405) all derive from percolator_prog_id, which is P.owner: the canonical PDA is derived under the attacker's program, must be owned by the attacker's program, and must contain a record naming this NFT program. The attacker controls that program, so all three are satisfiable.
- The
#110C provenance pin compares provenance_header.portfolio_account_id to P.key — attacker-set.
- The escrow CPI (
TransferPortfolioOwnership, tag 72) targets the attacker's program, which returns success.
- The result is a Position NFT minted by the authentic program, under the authentic mint-authority PDA, with the authentic
Percolator Position metadata and a valid ExtraAccountMetaList. It transfers normally and GetPositionValue reports whatever the fabricated portfolio says.
Such an NFT is indistinguishable from a genuine one to any consumer that checks "was this minted by the real percolator-nft program?" — the check most marketplaces, indexers and wallets would perform. Distinguishing it requires knowing that DhSkE7u… is the devnet wrapper and that it should never appear on mainnet, which is precisely the knowledge the allowlist is supposed to encode.
Proof of concept
tests/poc_devnet_id_in_mainnet_build.rs, against main at 215842e. These run on a default build — no features enabled — which is the artifact a mainnet deploy produces. Five tests: the finding at the predicate level, the finding end-to-end through the real transfer hook, and two controls that establish the result is membership in the allowlist rather than an absent check.
Reproduction note: cargo test does not build as shipped — the solana-sdk dev-dependency pulls openssl-sys, which needs a system OpenSSL. Neither solana-sdk nor proptest is referenced anywhere in src/; removing both from [dev-dependencies] makes the suite build. Results below are the 51 pre-existing tests plus these 5.
Scope: the PoC proves the trust decision — that a default build accepts the devnet wrapper as the owner of an escrowed portfolio, through the complete hook rather than just the predicate. The mainnet deployment step in the consequence section above is reasoned from the on-chain facts, not executed.
//! PoC: the devnet wrapper program id is compiled into every build, including
//! the one deployed to mainnet, and is trusted there end to end.
//!
//! The sibling repos state the rule this violates. percolator-prog's own
//! Cargo.toml, 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."
//!
//! percolator-nft has no such feature -- its only features are `no-entrypoint`,
//! `test` and `kani` -- so `PERCOLATOR_DEVNET` is unconditional. These tests run
//! on a DEFAULT build, i.e. exactly the artifact a mainnet deploy would produce.
use bytemuck::Zeroable;
use percolator_nft::{
cpi_v16::{derive_nft_registry, verify_portfolio_program, PERCOLATOR_DEVNET, PERCOLATOR_MAINNET},
slab_types_v16 as sl,
state_v16::{
mint_authority_pda, position_nft_pda, PositionNftV16, POSITION_NFT_V16_MAGIC,
POSITION_NFT_V16_VERSION,
},
token2022::TOKEN_2022_PROGRAM_ID,
transfer_hook::{extra_account_metas_pda, process_execute},
};
use solana_program::{
account_info::AccountInfo, program_error::ProgramError, pubkey::Pubkey,
sysvar::instructions as sysvar_instructions,
};
const PROG: Pubkey = Pubkey::new_from_array([9u8; 32]);
const ALICE: Pubkey = Pubkey::new_from_array([0xA1; 32]);
const BOB: Pubkey = Pubkey::new_from_array([0xB0; 32]);
const PORTFOLIO: Pubkey = Pubkey::new_from_array([0x50; 32]);
const MARKET_GROUP: Pubkey = Pubkey::new_from_array([0x60; 32]);
const NFT_MINT: Pubkey = Pubkey::new_from_array([0x11; 32]);
const SRC_ATA: Pubkey = Pubkey::new_from_array([0x71; 32]);
const DST_ATA: Pubkey = Pubkey::new_from_array([0x72; 32]);
const ASSET_INDEX: u32 = 7;
const MARKET_ID: u64 = 42;
fn leak<T>(v: T) -> &'static mut T {
Box::leak(Box::new(v))
}
fn acct(key: Pubkey, owner: Pubkey, data: Vec<u8>, writable: bool) -> AccountInfo<'static> {
AccountInfo::new(
leak(key),
false,
writable,
leak(0u64),
Box::leak(data.into_boxed_slice()),
leak(owner),
false,
0,
)
}
fn token_account(mint: &Pubkey, owner: &Pubkey, amount: u64) -> Vec<u8> {
let mut d = vec![0u8; 165];
d[0..32].copy_from_slice(mint.as_ref());
d[32..64].copy_from_slice(owner.as_ref());
d[64..72].copy_from_slice(&amount.to_le_bytes());
d[108] = 1;
d
}
fn mint_account() -> Vec<u8> {
let mut d = vec![0u8; 200];
d[36..44].copy_from_slice(&1u64.to_le_bytes());
d[45] = 1;
d
}
/// A portfolio shaped exactly like a real one, but owned by whichever program
/// the caller names -- that is the whole variable under test.
fn portfolio_buf(escrow_owner: [u8; 32]) -> Vec<u8> {
let mut a: sl::PortfolioAccountV16Account = Zeroable::zeroed();
a.provenance_header.market_group_id = MARKET_GROUP.to_bytes();
a.provenance_header.portfolio_account_id = PORTFOLIO.to_bytes();
a.provenance_header.owner = escrow_owner;
a.provenance_header.version = sl::V16PodU16::new(sl::V16_ACCOUNT_VERSION);
a.provenance_header.layout_discriminator = sl::V16PodU16::new(sl::V16_LAYOUT_DISCRIMINATOR);
a.owner = escrow_owner;
a.legs[0].active = 1;
a.legs[0].asset_index = sl::V16PodU32::new(ASSET_INDEX);
a.legs[0].market_id = sl::V16PodU64::new(MARKET_ID);
let mut buf = vec![0u8; sl::HEADER_LEN + sl::EXPECTED_PORTFOLIO_ACCOUNT_SIZE];
buf[0..8].copy_from_slice(&sl::MAGIC.to_le_bytes());
buf[8..10].copy_from_slice(&sl::VERSION.to_le_bytes());
buf[10] = sl::KIND_PORTFOLIO;
buf[sl::HEADER_LEN..].copy_from_slice(bytemuck::bytes_of(&a));
buf
}
fn nft_pda_buf(bump: u8) -> Vec<u8> {
let mut s: PositionNftV16 = Zeroable::zeroed();
s.magic = sl::V16PodU64::new(POSITION_NFT_V16_MAGIC);
s.version = POSITION_NFT_V16_VERSION;
s.bump = bump;
s.portfolio_account = PORTFOLIO.to_bytes();
s.nft_mint = NFT_MINT.to_bytes();
s.asset_index = sl::V16PodU32::new(ASSET_INDEX);
s.market_id_at_mint = sl::V16PodU64::new(MARKET_ID);
s.last_holder = ALICE.to_bytes();
bytemuck::bytes_of(&s).to_vec()
}
fn build_sysvar(top_prog: &Pubkey, data: &[u8], accounts: &[Pubkey]) -> Vec<u8> {
let mut ib: Vec<u8> = Vec::new();
ib.extend_from_slice(&(accounts.len() as u16).to_le_bytes());
for a in accounts {
ib.push(0u8);
ib.extend_from_slice(a.as_ref());
}
ib.extend_from_slice(top_prog.as_ref());
ib.extend_from_slice(&(data.len() as u16).to_le_bytes());
ib.extend_from_slice(data);
let total = 2 + 2 + ib.len() + 2;
let mut sv = vec![0u8; total];
sv[0..2].copy_from_slice(&1u16.to_le_bytes());
sv[2..4].copy_from_slice(&4u16.to_le_bytes());
sv[4..4 + ib.len()].copy_from_slice(&ib);
sv[total - 2..].copy_from_slice(&0u16.to_le_bytes());
sv
}
/// Run the full transfer hook with every wrapper-bound account pointing at
/// `wrapper` -- the portfolio's owner, the `percolator_prog` account, and the
/// registry PDA's derivation base.
fn run_hook_under_wrapper(wrapper: Pubkey) -> Result<(), ProgramError> {
let (mint_auth, _) = mint_authority_pda(&PROG);
let (nft_pda_key, bump) = position_nft_pda(&PORTFOLIO, MARKET_ID, &PROG);
let (metas, _) = extra_account_metas_pda(&NFT_MINT, &PROG);
let (registry, _) = derive_nft_registry(&wrapper, &MARKET_GROUP);
let mut t22_data = vec![12u8];
t22_data.extend_from_slice(&1u64.to_le_bytes());
t22_data.push(0);
let accounts = vec![
acct(SRC_ATA, TOKEN_2022_PROGRAM_ID, token_account(&NFT_MINT, &ALICE, 1), false),
acct(NFT_MINT, TOKEN_2022_PROGRAM_ID, mint_account(), false),
acct(DST_ATA, TOKEN_2022_PROGRAM_ID, token_account(&NFT_MINT, &BOB, 0), false),
acct(ALICE, Pubkey::default(), vec![], false),
acct(metas, PROG, vec![0u8; 261], false),
acct(nft_pda_key, PROG, nft_pda_buf(bump), true),
acct(PORTFOLIO, wrapper, portfolio_buf(mint_auth.to_bytes()), false),
acct(wrapper, Pubkey::default(), vec![], false),
acct(mint_auth, Pubkey::default(), vec![], false),
acct(
sysvar_instructions::ID,
Pubkey::default(),
build_sysvar(&TOKEN_2022_PROGRAM_ID, &t22_data, &[SRC_ATA, NFT_MINT, DST_ATA, ALICE]),
false,
),
acct(PROG, Pubkey::default(), vec![], false),
acct(registry, wrapper, vec![], false),
];
process_execute(&PROG, &accounts, 1)
}
// -- 1. the finding ----------------------------------------------------------
#[test]
fn a_default_build_trusts_the_devnet_wrapper_id() {
// This is the artifact a mainnet deploy produces: no features enabled.
let devnet_owned = acct(PORTFOLIO, PERCOLATOR_DEVNET, vec![], false);
assert!(
verify_portfolio_program(&devnet_owned).is_ok(),
"a default (mainnet) build accepts a portfolio owned by the DEVNET wrapper",
);
}
#[test]
fn the_whole_hook_runs_against_the_devnet_wrapper_on_a_default_build() {
// End to end, not just the predicate: portfolio owner, percolator_prog and
// the registry derivation base are all the devnet id, and the hook accepts.
assert!(
run_hook_under_wrapper(PERCOLATOR_DEVNET).is_ok(),
"the full transfer hook accepts the devnet wrapper in a mainnet build",
);
}
// -- 2. controls -------------------------------------------------------------
#[test]
fn control_the_mainnet_wrapper_is_accepted_too() {
// Proves the harness is not vacuously passing: the legitimate id works
// through exactly the same path.
assert!(run_hook_under_wrapper(PERCOLATOR_MAINNET).is_ok());
}
#[test]
fn control_an_unrelated_program_is_rejected() {
// Proves the allowlist is load-bearing: anything off it fails, so the
// devnet id's acceptance above is membership, not an absent check.
let stranger = Pubkey::new_from_array([0xEE; 32]);
let owned = acct(PORTFOLIO, stranger, vec![], false);
assert!(verify_portfolio_program(&owned).is_err());
assert!(run_hook_under_wrapper(stranger).is_err());
}
// -- 3. no feature gates it --------------------------------------------------
#[test]
fn nothing_excludes_the_devnet_id_from_this_build() {
// The crate exposes no `devnet` feature at all, so there is no build in
// which PERCOLATOR_DEVNET is absent. Recorded as a test so that adding the
// gate has something to flip.
assert_ne!(PERCOLATOR_DEVNET, PERCOLATOR_MAINNET);
assert_eq!(
PERCOLATOR_DEVNET.to_string(),
"DhSkE7uTb8HBUYYWF1xkxMYBGtLYJEoDq1tfBD7SnHcj",
"the devnet wrapper id is compiled into this binary",
);
assert_eq!(
PERCOLATOR_MAINNET.to_string(),
"ESa89R5Es3rJ5mnwGybVRG1GrNt9etP11Z5V2QWD4edv",
);
}
running 51 tests
test result: ok. 51 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
running 5 tests
test nothing_excludes_the_devnet_id_from_this_build ... ok
test a_default_build_trusts_the_devnet_wrapper_id ... ok
test control_an_unrelated_program_is_rejected ... ok
test control_the_mainnet_wrapper_is_accepted_too ... ok
test the_whole_hook_runs_against_the_devnet_wrapper_on_a_default_build ... ok
test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
The controls carry the argument: control_the_mainnet_wrapper_is_accepted_too runs the identical path with the legitimate id, showing the harness is not vacuously passing, and control_an_unrelated_program_is_rejected shows an off-allowlist program fails both the predicate and the full hook — so the devnet id's acceptance is allowlist membership, not a missing check.
Impact
Counterfeit Position NFTs mintable from the genuine mainnet program, backed by fabricated portfolio data. Existing escrowed positions are not directly reachable, which bounds this below a drain. The precondition is control of the devnet deploy keypair — either its compromise or its misuse — which is exactly the threat model percolator-prog and percolator-stake already defend against, and devnet deploy keys are routinely held to a lower standard than mainnet ones.
Suggested fix
Apply percolator-stake's existing pattern verbatim — this is an in-house convention already proven in a sibling crate, not a new design. Put the devnet id behind a devnet cargo feature so a default build contains only the mainnet id.
[features]
no-entrypoint = []
test = []
kani = []
# Compiles the DEVNET wrapper id into the allowlist. A devnet id must never
# compile into a mainnet binary, so that a compromised devnet deploy keypair
# cannot inherit mainnet authority. Mirrors percolator-prog and percolator-stake.
devnet = []
with the constant and each of the three allowlist arms gated on #[cfg(feature = "devnet")]. Stake's let is_valid = ...; #[cfg(feature = "devnet")] let is_valid = is_valid || ...; shadowing keeps the non-devnet build free of unused-variable warnings and is worth copying directly. Devnet deploys then build with --features devnet, and the mainnet artifact cannot trust a devnet id even if one is later added by mistake.
Two points worth deciding explicitly:
- Whether the default should be fail-closed on devnet. percolator-prog's tag 87 fails closed with
StakeProgramNotPinned when no id is pinned. Here the mainnet id is always present, so a non-devnet build simply rejects devnet portfolios, which is the desired behaviour.
- The deploy pipeline. This only helps if devnet builds actually pass
--features devnet and mainnet builds do not. Worth pinning in CI so the two artifacts cannot be produced by the same command.
Separately, and regardless of the feature gate: claiming DhSkE7u… on mainnet with a keypair the team controls would remove the squatting window entirely, and is worth doing even after the gate lands.
Summary
PERCOLATOR_DEVNETis compiled unconditionally into every build of this program, including the artifact deployed to mainnet, and is trusted everywhere the wrapper allowlist is consulted. The crate exposes no feature that would exclude it — its only features areno-entrypoint,testandkani.This is the rule the sibling repos state explicitly. From percolator-prog's own
Cargo.toml, documenting itsdevnetfeature:Both percolator-prog and percolator-stake apply that control. percolator-nft does not — and the divergence from percolator-stake is exact rather than analogous.
percolator-stake carries the same allowlist, with the same two program ids, correctly gated (
percolator-stake/src/processor.rs:507-521):The two crates' feature lists differ by exactly this one entry:
no-entrypoint,test,kani,devnetno-entrypoint,test,kaniThe commit that last set this value (
215842e) notes that "nft hardcodes a wrapper allowlist in cpi_v16.rs (like stake did)" — the allowlist was copied, but the gating that makes it safe was not.Affected code
src/cpi_v16.rs:89-93declares both ids unconditionally, and three sites consult them:src/cpi_v16.rs:98verify_portfolio_program—portfolio.owner ∈ {DEVNET, MAINNET}src/processor.rs:628percolator_prog_id ∈ {DEVNET, MAINNET}src/transfer_hook.rs:400percolator_prog.key ∈ {DEVNET, MAINNET}On-chain state
Verified against the public RPC endpoints:
A program's address is its deploy keypair's public key, and that keypair is not cluster-scoped. Because the address is still unclaimed on mainnet, whoever holds the devnet deploy keypair can deploy a program of their choosing at that same address on mainnet, at which point the mainnet NFT program trusts it automatically.
Consequence
An attacker who deploys at that address on mainnet does not gain access to portfolios owned by the real wrapper (
ESa89R5…) — existing escrowed positions are not reachable. What they gain is the ability to mint counterfeit Position NFTs from the genuine program:P, owned by their program atDhSkE7u…, with the byte layout of aPortfolioAccountV16Account(magic, version, kind, provenance, legs — all attacker-chosen).MintPositionNfton the real mainnet NFT program withportfolio = P.verify_portfolio_program(P)passes:P.owneris on the allowlist.#109registry checks (processor.rs:387-405) all derive frompercolator_prog_id, which isP.owner: the canonical PDA is derived under the attacker's program, must be owned by the attacker's program, and must contain a record naming this NFT program. The attacker controls that program, so all three are satisfiable.#110Cprovenance pin comparesprovenance_header.portfolio_account_idtoP.key— attacker-set.TransferPortfolioOwnership, tag 72) targets the attacker's program, which returns success.Percolator Positionmetadata and a validExtraAccountMetaList. It transfers normally andGetPositionValuereports whatever the fabricated portfolio says.Such an NFT is indistinguishable from a genuine one to any consumer that checks "was this minted by the real percolator-nft program?" — the check most marketplaces, indexers and wallets would perform. Distinguishing it requires knowing that
DhSkE7u…is the devnet wrapper and that it should never appear on mainnet, which is precisely the knowledge the allowlist is supposed to encode.Proof of concept
tests/poc_devnet_id_in_mainnet_build.rs, againstmainat215842e. These run on a default build — no features enabled — which is the artifact a mainnet deploy produces. Five tests: the finding at the predicate level, the finding end-to-end through the real transfer hook, and two controls that establish the result is membership in the allowlist rather than an absent check.Reproduction note:
cargo testdoes not build as shipped — thesolana-sdkdev-dependency pullsopenssl-sys, which needs a system OpenSSL. Neithersolana-sdknorproptestis referenced anywhere insrc/; removing both from[dev-dependencies]makes the suite build. Results below are the 51 pre-existing tests plus these 5.Scope: the PoC proves the trust decision — that a default build accepts the devnet wrapper as the owner of an escrowed portfolio, through the complete hook rather than just the predicate. The mainnet deployment step in the consequence section above is reasoned from the on-chain facts, not executed.
The controls carry the argument:
control_the_mainnet_wrapper_is_accepted_tooruns the identical path with the legitimate id, showing the harness is not vacuously passing, andcontrol_an_unrelated_program_is_rejectedshows an off-allowlist program fails both the predicate and the full hook — so the devnet id's acceptance is allowlist membership, not a missing check.Impact
Counterfeit Position NFTs mintable from the genuine mainnet program, backed by fabricated portfolio data. Existing escrowed positions are not directly reachable, which bounds this below a drain. The precondition is control of the devnet deploy keypair — either its compromise or its misuse — which is exactly the threat model percolator-prog and percolator-stake already defend against, and devnet deploy keys are routinely held to a lower standard than mainnet ones.
Suggested fix
Apply percolator-stake's existing pattern verbatim — this is an in-house convention already proven in a sibling crate, not a new design. Put the devnet id behind a
devnetcargo feature so a default build contains only the mainnet id.with the constant and each of the three allowlist arms gated on
#[cfg(feature = "devnet")]. Stake'slet is_valid = ...; #[cfg(feature = "devnet")] let is_valid = is_valid || ...;shadowing keeps the non-devnet build free of unused-variable warnings and is worth copying directly. Devnet deploys then build with--features devnet, and the mainnet artifact cannot trust a devnet id even if one is later added by mistake.Two points worth deciding explicitly:
StakeProgramNotPinnedwhen no id is pinned. Here the mainnet id is always present, so a non-devnet build simply rejects devnet portfolios, which is the desired behaviour.--features devnetand mainnet builds do not. Worth pinning in CI so the two artifacts cannot be produced by the same command.Separately, and regardless of the feature gate: claiming
DhSkE7u…on mainnet with a keypair the team controls would remove the squatting window entirely, and is worth doing even after the gate lands.