Skip to content

Three false-positive patterns from running sentio on a mainnet ZK/privacy codebase (+ one true positive it caught) #1

Description

@koshak01

Thanks for sentio — ran 0.2.9 across the programs of an open-source Rust
privacy framework on Solana (Groth16 shielded pool + Token-2022 confidential
transfers, live on mainnet). It's a genuinely useful pre-audit pass. Sharing
three false-positive patterns from a ZK/privacy codebase that might help tuning,
plus one real finding it correctly caught.

First, the true positive, since credit is due: SW005 flagged a raw += / -=
on try_borrow_mut_lamports() in a withdraw path. That one was legitimate — we
switched it to checked_sub/checked_add. So the tool paid for itself. The rest
below are just tuning signal.

FP 1 — SW024 (division by zero) doesn't resolve compile-time consts

pub const ROOT_RING_SIZE: usize = 30;
// ...
pool.root_ring_head = (pool.root_ring_head + 1) % ROOT_RING_SIZE as u32;

SW024 flags % ROOT_RING_SIZE because the divisor is a non-literal, but it's a
module-level const that can never be zero. Resolving const values in scope
before flagging a non-literal divisor would remove this class of FP.

FP 2 — SW002 (missing owner check) on accounts secured by a ZK proof or used only as a seed

In privacy/ZK programs, some accounts are intentionally UncheckedAccount with
a documented /// CHECK: rationale, because their integrity comes from
somewhere the AST can't see:

/// CHECK: the recipient's pubkey is reduced mod the BN254 scalar field and
/// bound to the Groth16 proof as a public input, so a front-runner who swaps
/// this field invalidates the proof.
#[account(mut)]
pub recipient: UncheckedAccount<'info>,

Here an owner check would be meaningless — recipient is a payout target, and
front-run protection is the proof binding (Tornado-style). Same for a relayer
payout account, and for an account used only as a PDA seed (to_owner whose
value is validated by the derived PDA's bump).

Two ideas: (a) lower confidence / severity when an UncheckedAccount carries a
documented /// CHECK: comment, and (b) don't flag accounts that are only ever
used as a payout target or as a PDA seed input (never read as data). These are
the dominant SW002 FPs in ZK code.

FP 3 — SW027 (missing event) doesn't recognize msg! program logs

pub fn init_vault(ctx: Context<InitVault>) -> Result<()> {
    ctx.accounts.vault.bump = ctx.bumps.vault;
    msg!("conf-vault-init:{}", ctx.accounts.vault.key());  // <-- observability
    Ok(())
}

SW027 looks for Anchor emit! events, but a large share of Solana programs
(including much of SPL) expose state changes through structured msg! program
logs that off-chain indexers parse directly — no Anchor event involved. Treating
a msg! in the same handler as an observability sink would clear this FP.

Context

The codebase is public if a concrete repro helps:
https://github.com/koshak01/tidex6 — the findings above are in
programs/tidex6-verifier, programs/tidex6-wusdc-pool, and
programs/tidex6-confidential-amounts. Happy to provide anything useful.

Take or leave any of this — just wanted to give back after the tool caught a
real one for us.

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions