Skip to content

[Hardening] Consolidated audit tail: latent truncation, defence-in-depth asymmetries, gratuitous mint authorities, 128B mint over-allocation, CI gaps, dead artifacts #184

Description

@0x-SquidSol

Summary

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() as u16;   // :448
...
let _slot = verify_bound_leg(p, &nft_state_copy)...;    // :478  reads the full u32
transfer_gate_check(p, asset_index_u16 as u32)...;      // :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 devnet and 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.
  • Reentrancy — no surface. The hook performs zero CPIs under Design: minting does not escrow the position — minter retains direct control until first NFT transfer (contradicts documented custody) #105 and every borrow is scoped.
  • PDA squatting and dust-griefing on nft_pda / extra_metas — closed by MintPositionNft: PositionNft PDA susceptible to lamport griefing via pre-fund #117's transfer→allocate→assign.
  • 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.

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions