You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
[Hardening] Consolidated audit tail: latent truncation, defence-in-depth asymmetries, gratuitous mint authorities, 128B mint over-allocation, CI gaps, dead artifacts #184
The tail of an audit pass over main at 215842e. None of these is exploitable today — each is either latent, unreachable, self-inflicted, or hygiene. They are bundled rather than filed separately so the signal stays on the five issues that do carry a live defect (#174, #176, #178, #180, #182).
Every item below was re-derived against the source rather than taken from a scan. Where I checked something and found no defect, I have said so explicitly rather than padding the list.
For clarity on overlap: nothing here duplicates the open PRs. #183 already adds the missing tag-7 README row, and #181 already splits the compound slot_reuse_detected status line.
1. Latent correctness — will bite if a plausible future change lands
1.1 asset_index is truncated to u16 for the transfer gate but read at full u32 for the bound-leg check
src/transfer_hook.rs:448 narrows the stored index, then :481 widens it back:
asset_index_u16 = nft_state.asset_index.get()asu16;// :448
...
let _slot = verify_bound_leg(p,&nft_state_copy)...;// :478 reads the full u32transfer_gate_check(p, asset_index_u16 asu32)...;// :481 reads asset_index & 0xFFFF
For any stored asset_index > 0xFFFF the two evaluate different legs: the slot-reuse anchor checks the real bound leg while the safety gate checks a truncated one. BurnPositionNft and EmergencyBurn carry the same truncation.
Unreachable today because MintPositionNft decodes asset_index as a u16 (instruction.rs) and stores it widened. But the field is V16PodU32, cpi_v16.rs documents the valid domain as config.max_market_slots (a u32), and the #94 fix was specifically about removing a too-narrow bound — so widening the instruction to u32 is a plausible change that would silently turn this into a live gate bypass.
Fix: carry u32 end to end; the truncation buys nothing.
1.2 Tag 26 admits the whole TransferFeeExtension family — the sub-tag byte is never read
src/transfer_hook.rs:210-228. TOKEN_IX_TRANSFER_CHECKED_WITH_FEE = 26 is Token-2022's extension family tag, not an instruction. TransferCheckedWithFee is 26/1; 26/0, 26/2..5 are InitializeTransferFeeConfig, WithdrawWithheldTokensFromMint/FromAccounts, HarvestWithheldTokensToMint, SetTransferFee. The arm checks data[0] == 26 and accounts[1] == expected_mint, and never inspects data[1].
Not exploitable, and the reason is worth stating: to reach this code with current_ix.program_id == TOKEN_2022_PROGRAM_ID, the program must already be executing under a Token-2022 top-level instruction, and Token-2022's only CPI out of a transfer path is the mint's registered hook — so Ok(true) still implies a real transfer. This is dead width, not a hole. It also describes a state that cannot exist: this program never creates a fee-enabled mint (mint_space allocates only MetadataPointer + TransferHook + MintCloseAuthority, and InitializeTransferFeeConfig is never issued), so the arm's stated justification is moot.
Fix:data[0] == 26 && data.get(1) == Some(&1).
2. Defence-in-depth asymmetries — weaker than the equivalent check elsewhere, for no reason
2.1 The hook allowlists percolator_prog but never ties it to portfolio.owner
src/transfer_hook.rs:400 accepts percolator_prog ∈ {DEVNET, MAINNET}, while verify_portfolio_program separately accepts portfolio.owner ∈ the same set — so a devnet program id alongside a mainnet-owned portfolio passes both. Every other handler uses verify_percolator_prog_account, which requires percolator_prog.key == portfolio.owner (processor.rs:121-130).
Harmless today: the hook performs no CPI post-#105, and the account is pinned by the ExtraAccountMetaList. But the check is strictly weaker than the one used everywhere else. (Note this interacts with #176 — once the devnet id is feature-gated, a default build has only one allowlist entry and the asymmetry mostly dissolves.)
Fix: call verify_percolator_prog_account in the hook too.
2.2 The three unwrap call sites pass nft_registry without deriving it
BurnPositionNft, EmergencyBurn and ReconcileBurnedNft forward nft_registry straight into the unwrap CPI. derive_nft_registry is called only at processor.rs:387 and :634 (mint) and :1549 (RepairExtraMetas). Mint validates the registry three ways — canonical PDA, wrapper-owned, registers this program — and the unwrap sites validate none of it, delegating entirely to the wrapper's tag-82 handler.
I could find no gain from substituting a registry: the CPI signer mint_auth is still pinned, and any wrapper-side check keyed on registry.nft_program_id rejects a foreign one. So this is drift rather than a bug — but the asymmetry with mint looks unintentional.
2.3 last_holder accepts any 32 bytes, including the zero pubkey
src/transfer_hook.rs:382 takes the wallet verbatim from dst_data[32..64] with no shape check. A holder who transfers into a token account whose owner field is Pubkey::default(), a PDA, or another token account's address permanently records an unspendable last_holder; ReconcileBurnedNft would then hand UnwrapEscrowedPortfolio an owner nobody can act as.
Self-inflicted — the holder authorises the transfer — but a new_owner != Pubkey::default() guard is free.
3. Mint construction
3.1 Two of the five surviving mint authorities are gratuitous
After MintPositionNft, the mint carries freeze authority, MintCloseAuthority, TransferHook.authority, MetadataPointer.authority and TokenMetadata.update_authority — all set to the single program-wide ["mint_authority"] PDA. Only MintTokens is revoked.
All five are inert for the deployed binary: there is no instruction anywhere in the crate that issues FreezeAccount, ThawAccount, MetadataPointer::Update, TransferHook::Update or UpdateField, and every invoke_signed under the mint-authority seeds builds a fixed instruction. So they are reachable only via program upgrade. The README justifies the freeze authority; the others are undocumented.
The actionable part is narrow: processor.rs:540 and :545 pass mint_auth.key as the authority for initialize_metadata_pointer and initialize_transfer_hook. OptionalNonZeroPubkey treats the zero pubkey as None, so passing Pubkey::default() costs nothing and makes the hook program id and metadata pointer permanently immutable — which is what the design already assumes. It also makes #178's safety argument structural: that fix relies on "the only code running inside Token-2022's transferring window is this program, which does no CPI", and an immutable hook program id is what guarantees the first half.
3.2 The mint is over-allocated by 128 bytes and never refunded
processor.rs:522: final_size = mint_space + metadata_tlv_size + 128 = 338 + 120 + 128 = 586, funded at minimum_balance(586) = 4,969,440 lamports. Nothing ever reallocs the mint — Token-2022's metadata Initialize writes into the existing TLV region — so the trailing 128 bytes are funded, unused, and never refunded: 890,880 lamports per NFT.
Recovered at burn (the close sweeps the whole balance to the holder), so it is capital lockup rather than loss — except on the out-of-band-burn path, where it is exactly the amount #182 recovers. Both size terms are exact against try_calculate_account_len and get_packed_len, so the + 128 can simply be dropped.
3.3 The mint address is re-creatable after burn (off-chain only)
Burn closes the mint via CloseAccount, which reassigns to System and zeroes it. Whoever holds the mint keypair — the original minter, not necessarily the last holder — can then re-create an account at that address with themselves as mint and freeze authority, including the same Percolator Position metadata. No on-chain consequence: the PositionNft PDA is keyed on market_id and MintPositionNft re-verifies the whole chain. The exposure is limited to indexers, marketplaces or UIs treating a mint address as durable identity for a burned position.
4. Build and CI hygiene — small, but two of these make security controls silently fragile
4.1 cargo test does not build as shipped
solana-sdk = "=2.2.1" and proptest = "1.4" are declared in [dev-dependencies] and referenced nowhere in src/ or tests/. solana-sdk drags in openssl-sys, which fails to build without a system OpenSSL — so cargo test aborts before compiling a single test on any machine without it. Removing both makes the suite build. (I have had to strip them locally for every PoC in this audit.)
4.2 Crate-wide #![allow(unexpected_cfgs)] makes a feature-name typo silent
src/lib.rs:5. A misspelled #[cfg(feature = "devnett")] produces no diagnostic at all — not from cargo build, not under -D warnings, not from clippy — even though cargo correctly passes --check-cfg. This was pre-existing and harmless, but #176 makes it load-bearing: a misspelled feature there silently produces a devnet build that trusts nothing.
Fix: scope the allow to the entrypoint module rather than the crate.
4.3 CI runs neither clippy nor a BPF build
.github/workflows/test.yml runs only cargo test --lib --tests on a host target. No clippy, no -D warnings, no cargo build-sbf — despite the README asserting "no warnings expected" for the SBF build. Consequences seen in this audit: a clippy regression would not be caught, and under #[cfg(target_os = "solana")] a line would never be compiled by anything CI runs (see #178, which switched to a runtime cfg! for exactly this reason).
4.4 --all-features silently defeats the #176 devnet gate
Once #176 lands, cargo test --all-features both enables devnetand removes the #[cfg(not(feature = "devnet"))] rejection tests — the suite goes 4 tests to 3 and stays green. Nothing in the repo passes --all-features today, but nothing prevents it. A mainnet feature plus #[cfg(all(feature = "devnet", feature = "mainnet"))] compile_error!(...) would make the only realistic accidental-enablement path fail loudly.
5. Dead and stale artifacts
5.1 LAYOUT_REVISION is declared and never used
src/slab_types_v16.rs:63 declares pub const LAYOUT_REVISION: u32 = 5, and the only reference is a self-referential assertion at :658 (assert_eq!(LAYOUT_REVISION, 5)). It is never stamped into PositionNftV16 and never compared against anything on-chain, so it provides no drift protection despite reading like it does. Either wire it into the PDA state as a real version stamp, or delete it.
5.2 SCHEMA_DRIFT_v12.19.md references five files that do not exist
src/cpi.rs, src/percolator.rs, src/slab_types.rs, tests/drift_detection.rs, tests/integration_v12_19.rs — none is present; the crate is cpi_v16.rs / slab_types_v16.rs and has no tests/ directory. Its "Activation gating" section also contradicts its own Status: DEFERRED header. This matters more than usual because README:96-97 defers runtime layout validation to #110H and this is the document a reader would go to next.
What I checked and found sound
Recording these so the absence of a finding is informative rather than ambiguous:
Direct-invocation bypass of the hook — closed. The Instructions sysvar key is pinned, the mint's hook program is set once at mint and never updated, and a spoofed direct call is a no-op success that writes nothing.
Account substitution in the hook — closed. Every extra account is re-derived rather than merely cross-compared, and all seven extra-meta entries are stored as literal pubkeys, so Token-2022 resolves fixed addresses.
Token-2022 wire format — I byte-compared every hand-encoded instruction against spl-token-2022 and the ExtraAccountMetaList serialisation; all identical, including account order and signer/writable flags. Both discriminators recompute correctly from SHA-256.
RepairExtraMetas — permissionless but sound: every written key is pinned to state the caller cannot influence, and it is grow-only.
decode_portfolio's lower-bound length check — I initially suspected this and it is correct as written. Live portfolios are 9347 bytes against the NFT's expected 9243, because the wrapper appends a 104-byte matcher-config tail after the engine body. An exact-length check would reject every real portfolio.
EmergencyBurn's portfolio_gone using || on a lamports test — the two disjuncts cannot diverge. portfolio is pinned to nft_state.portfolio_account before the bypass, and the only site in the entire wrapper that drains a portfolio's lamports also realloc(0)s its data first.
Summary
The tail of an audit pass over
mainat215842e. None of these is exploitable today — each is either latent, unreachable, self-inflicted, or hygiene. They are bundled rather than filed separately so the signal stays on the five issues that do carry a live defect (#174, #176, #178, #180, #182).Every item below was re-derived against the source rather than taken from a scan. Where I checked something and found no defect, I have said so explicitly rather than padding the list.
For clarity on overlap: nothing here duplicates the open PRs. #183 already adds the missing tag-7 README row, and #181 already splits the compound
slot_reuse_detectedstatus line.1. Latent correctness — will bite if a plausible future change lands
1.1
asset_indexis truncated tou16for the transfer gate but read at fullu32for the bound-leg checksrc/transfer_hook.rs:448narrows the stored index, then:481widens it back:For any stored
asset_index > 0xFFFFthe two evaluate different legs: the slot-reuse anchor checks the real bound leg while the safety gate checks a truncated one.BurnPositionNftandEmergencyBurncarry the same truncation.Unreachable today because
MintPositionNftdecodesasset_indexas au16(instruction.rs) and stores it widened. But the field isV16PodU32,cpi_v16.rsdocuments the valid domain asconfig.max_market_slots(au32), and the#94fix was specifically about removing a too-narrow bound — so widening the instruction tou32is a plausible change that would silently turn this into a live gate bypass.Fix: carry
u32end to end; the truncation buys nothing.1.2 Tag 26 admits the whole
TransferFeeExtensionfamily — the sub-tag byte is never readsrc/transfer_hook.rs:210-228.TOKEN_IX_TRANSFER_CHECKED_WITH_FEE = 26is Token-2022's extension family tag, not an instruction.TransferCheckedWithFeeis26/1;26/0,26/2..5areInitializeTransferFeeConfig,WithdrawWithheldTokensFromMint/FromAccounts,HarvestWithheldTokensToMint,SetTransferFee. The arm checksdata[0] == 26andaccounts[1] == expected_mint, and never inspectsdata[1].Not exploitable, and the reason is worth stating: to reach this code with
current_ix.program_id == TOKEN_2022_PROGRAM_ID, the program must already be executing under a Token-2022 top-level instruction, and Token-2022's only CPI out of a transfer path is the mint's registered hook — soOk(true)still implies a real transfer. This is dead width, not a hole. It also describes a state that cannot exist: this program never creates a fee-enabled mint (mint_spaceallocates only MetadataPointer + TransferHook + MintCloseAuthority, andInitializeTransferFeeConfigis never issued), so the arm's stated justification is moot.Fix:
data[0] == 26 && data.get(1) == Some(&1).2. Defence-in-depth asymmetries — weaker than the equivalent check elsewhere, for no reason
2.1 The hook allowlists
percolator_progbut never ties it toportfolio.ownersrc/transfer_hook.rs:400acceptspercolator_prog ∈ {DEVNET, MAINNET}, whileverify_portfolio_programseparately acceptsportfolio.owner ∈the same set — so a devnet program id alongside a mainnet-owned portfolio passes both. Every other handler usesverify_percolator_prog_account, which requirespercolator_prog.key == portfolio.owner(processor.rs:121-130).Harmless today: the hook performs no CPI post-#105, and the account is pinned by the ExtraAccountMetaList. But the check is strictly weaker than the one used everywhere else. (Note this interacts with #176 — once the devnet id is feature-gated, a default build has only one allowlist entry and the asymmetry mostly dissolves.)
Fix: call
verify_percolator_prog_accountin the hook too.2.2 The three unwrap call sites pass
nft_registrywithout deriving itBurnPositionNft,EmergencyBurnandReconcileBurnedNftforwardnft_registrystraight into the unwrap CPI.derive_nft_registryis called only atprocessor.rs:387and:634(mint) and:1549(RepairExtraMetas). Mint validates the registry three ways — canonical PDA, wrapper-owned, registers this program — and the unwrap sites validate none of it, delegating entirely to the wrapper's tag-82 handler.I could find no gain from substituting a registry: the CPI signer
mint_authis still pinned, and any wrapper-side check keyed onregistry.nft_program_idrejects a foreign one. So this is drift rather than a bug — but the asymmetry with mint looks unintentional.2.3
last_holderaccepts any 32 bytes, including the zero pubkeysrc/transfer_hook.rs:382takes the wallet verbatim fromdst_data[32..64]with no shape check. A holder who transfers into a token account whose owner field isPubkey::default(), a PDA, or another token account's address permanently records an unspendablelast_holder;ReconcileBurnedNftwould then handUnwrapEscrowedPortfolioan owner nobody can act as.Self-inflicted — the holder authorises the transfer — but a
new_owner != Pubkey::default()guard is free.3. Mint construction
3.1 Two of the five surviving mint authorities are gratuitous
After
MintPositionNft, the mint carries freeze authority,MintCloseAuthority,TransferHook.authority,MetadataPointer.authorityandTokenMetadata.update_authority— all set to the single program-wide["mint_authority"]PDA. OnlyMintTokensis revoked.All five are inert for the deployed binary: there is no instruction anywhere in the crate that issues
FreezeAccount,ThawAccount,MetadataPointer::Update,TransferHook::UpdateorUpdateField, and everyinvoke_signedunder the mint-authority seeds builds a fixed instruction. So they are reachable only via program upgrade. The README justifies the freeze authority; the others are undocumented.The actionable part is narrow:
processor.rs:540and:545passmint_auth.keyas the authority forinitialize_metadata_pointerandinitialize_transfer_hook.OptionalNonZeroPubkeytreats the zero pubkey asNone, so passingPubkey::default()costs nothing and makes the hook program id and metadata pointer permanently immutable — which is what the design already assumes. It also makes #178's safety argument structural: that fix relies on "the only code running inside Token-2022'stransferringwindow is this program, which does no CPI", and an immutable hook program id is what guarantees the first half.3.2 The mint is over-allocated by 128 bytes and never refunded
processor.rs:522:final_size = mint_space + metadata_tlv_size + 128= 338 + 120 + 128 = 586, funded atminimum_balance(586)= 4,969,440 lamports. Nothing ever reallocs the mint — Token-2022's metadataInitializewrites into the existing TLV region — so the trailing 128 bytes are funded, unused, and never refunded: 890,880 lamports per NFT.Recovered at burn (the close sweeps the whole balance to the holder), so it is capital lockup rather than loss — except on the out-of-band-burn path, where it is exactly the amount #182 recovers. Both size terms are exact against
try_calculate_account_lenandget_packed_len, so the+ 128can simply be dropped.3.3 The mint address is re-creatable after burn (off-chain only)
Burn closes the mint via
CloseAccount, which reassigns to System and zeroes it. Whoever holds the mint keypair — the original minter, not necessarily the last holder — can then re-create an account at that address with themselves as mint and freeze authority, including the samePercolator Positionmetadata. No on-chain consequence: the PositionNft PDA is keyed onmarket_idandMintPositionNftre-verifies the whole chain. The exposure is limited to indexers, marketplaces or UIs treating a mint address as durable identity for a burned position.4. Build and CI hygiene — small, but two of these make security controls silently fragile
4.1
cargo testdoes not build as shippedsolana-sdk = "=2.2.1"andproptest = "1.4"are declared in[dev-dependencies]and referenced nowhere insrc/ortests/.solana-sdkdrags inopenssl-sys, which fails to build without a system OpenSSL — socargo testaborts before compiling a single test on any machine without it. Removing both makes the suite build. (I have had to strip them locally for every PoC in this audit.)4.2 Crate-wide
#![allow(unexpected_cfgs)]makes a feature-name typo silentsrc/lib.rs:5. A misspelled#[cfg(feature = "devnett")]produces no diagnostic at all — not fromcargo build, not under-D warnings, not from clippy — even though cargo correctly passes--check-cfg. This was pre-existing and harmless, but #176 makes it load-bearing: a misspelled feature there silently produces a devnet build that trusts nothing.Fix: scope the allow to the
entrypointmodule rather than the crate.4.3 CI runs neither clippy nor a BPF build
.github/workflows/test.ymlruns onlycargo test --lib --testson a host target. No clippy, no-D warnings, nocargo build-sbf— despite the README asserting "no warnings expected" for the SBF build. Consequences seen in this audit: a clippy regression would not be caught, and under#[cfg(target_os = "solana")]a line would never be compiled by anything CI runs (see #178, which switched to a runtimecfg!for exactly this reason).4.4
--all-featuressilently defeats the #176 devnet gateOnce #176 lands,
cargo test --all-featuresboth enablesdevnetand removes the#[cfg(not(feature = "devnet"))]rejection tests — the suite goes 4 tests to 3 and stays green. Nothing in the repo passes--all-featurestoday, but nothing prevents it. Amainnetfeature plus#[cfg(all(feature = "devnet", feature = "mainnet"))] compile_error!(...)would make the only realistic accidental-enablement path fail loudly.5. Dead and stale artifacts
5.1
LAYOUT_REVISIONis declared and never usedsrc/slab_types_v16.rs:63declarespub const LAYOUT_REVISION: u32 = 5, and the only reference is a self-referential assertion at:658(assert_eq!(LAYOUT_REVISION, 5)). It is never stamped intoPositionNftV16and never compared against anything on-chain, so it provides no drift protection despite reading like it does. Either wire it into the PDA state as a real version stamp, or delete it.5.2
SCHEMA_DRIFT_v12.19.mdreferences five files that do not existsrc/cpi.rs,src/percolator.rs,src/slab_types.rs,tests/drift_detection.rs,tests/integration_v12_19.rs— none is present; the crate iscpi_v16.rs/slab_types_v16.rsand has notests/directory. Its "Activation gating" section also contradicts its ownStatus: DEFERREDheader. This matters more than usual because README:96-97 defers runtime layout validation to#110Hand this is the document a reader would go to next.What I checked and found sound
Recording these so the absence of a finding is informative rather than ambiguous:
nft_pda/extra_metas— closed by MintPositionNft: PositionNft PDA susceptible to lamport griefing via pre-fund #117's transfer→allocate→assign.spl-token-2022and theExtraAccountMetaListserialisation; all identical, including account order and signer/writable flags. Both discriminators recompute correctly from SHA-256.RepairExtraMetas— permissionless but sound: every written key is pinned to state the caller cannot influence, and it is grow-only.decode_portfolio's lower-bound length check — I initially suspected this and it is correct as written. Live portfolios are 9347 bytes against the NFT's expected 9243, because the wrapper appends a 104-byte matcher-config tail after the engine body. An exact-length check would reject every real portfolio.EmergencyBurn'sportfolio_goneusing||on a lamports test — the two disjuncts cannot diverge.portfoliois pinned tonft_state.portfolio_accountbefore the bypass, and the only site in the entire wrapper that drains a portfolio's lamports alsorealloc(0)s its data first.