Skip to content

Audit fixes: harden pool init against PDA pre-funding griefing#2

Merged
MagicalTux merged 3 commits into
masterfrom
audit-fixes
May 23, 2026
Merged

Audit fixes: harden pool init against PDA pre-funding griefing#2
MagicalTux merged 3 commits into
masterfrom
audit-fixes

Conversation

@MagicalTux
Copy link
Copy Markdown
Member

@MagicalTux MagicalTux commented May 23, 2026

Summary

Addresses the findings from the post-merge security audit. No critical/high issues were found; this PR fixes the one Medium and both Low items.

Medium — Pool/vault init griefing (fixed)

The pool (["pool", mint]) and vault (["token_vault", pool]) PDAs are deterministic, and system_instruction::create_account fails if the destination already holds lamports. So anyone could send 1 lamport to a not-yet-created pool/vault PDA and permanently block InitializePool for that mint (no theft, but a denial of pool creation).

Fix (initialize.rs):

  • New create_pda_account helper that tolerates a pre-funded PDA: if the target already holds lamports, it tops up to rent-exemption then allocate + assigns under the PDA's own seeds — operations a griefer cannot perform.
  • Explicit re-initialization guards (data_is_empty): a griefer can only add lamports, never allocate/assign, so empty data reliably means "not yet initialized".

Low — member_count accuracy (fixed)

member_count could drift upward because the metadata account was optional on the count-changing instructions (e.g. a full unstake that closed the account without passing metadata never decremented).

Fix: the metadata PDA is now a required account on every member-count-changing instruction — Stake, StakeOnBehalf, Unstake, CompleteUnstake, CloseStakeAccount — and all updates go through a shared state::update_pool_member_count helper that:

  • rejects a non-canonical metadata account (InvalidPDA), guaranteeing the correct PDA is always supplied, and
  • tolerates an uninitialized account (a pool that never created metadata) by skipping the update — so metadata-less pools keep working.

The counter is now exact (+1 on a new stake, −1 on close). Unstake/CompleteUnstake also require the system program positionally (it precedes metadata; used for legacy realloc).

Breaking ABI change: clients must pass the (derived) metadata PDA on these instructions. lib.rs account tables and idl.json are updated accordingly; the reference TS client passes it automatically.

Low — total_residual_unpaid (documented)

After the always-close-on-full-unstake change, current code never increments this field; it's only decremented to drain residual balances from pre-upgrade accounts. It can't be removed without breaking StakingPool's binary layout, so it's documented as legacy-only.

Tests

  • New tests/init_griefing.rs (ProgramTest): pre-funds both PDAs with stray lamports, asserts InitializePool still succeeds (pool program-owned with correct state; vault is a token account whose authority is the pool PDA).
  • tests/legacy_migration.rs updated for the now-required metadata account on CompleteUnstake.
  • Full suite green locally: 33 unit + init_griefing (1) + legacy_migration (2); tsc --noEmit clean; cargo build-sbf succeeds.

Informational audit items (FixStakeAccount PDA re-derivation, SyncPool rounding drift, take_fee_ownership account forwarding, set_metadata UTF-8 truncation) were reviewed and judged safe / not worth changing.

🤖 Generated with Claude Code

MagicalTux and others added 2 commits May 24, 2026 03:49
Addresses the medium + low findings from the post-merge security audit.

Medium — init griefing: the pool (["pool", mint]) and vault (["token_vault",
pool]) PDAs are deterministic, so anyone could send 1 lamport to them before the
real authority initializes; system create_account fails on a non-zero balance,
permanently bricking pool creation for that mint. initialize.rs now uses a
create_pda_account helper that tolerates a pre-funded PDA (fund-to-rent +
allocate + assign under the PDA seeds — operations a griefer cannot perform) and
adds explicit re-init guards. Adds an init_griefing ProgramTest that pre-funds
both PDAs and asserts initialization still succeeds.

Low — total_residual_unpaid: documented as legacy-only (the always-close change
means current code never increments it; kept for layout compat + draining
pre-upgrade residual balances).

Low — member_count: documented as best-effort (accurate only when the optional
metadata account is supplied on stake AND every close path; display-only, never
affects funds).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…paths

Previously the metadata account was optional on the count-changing instructions,
so member_count could drift (e.g. a full unstake that closed the account without
passing metadata never decremented). Make the metadata PDA a REQUIRED account on
every member-count-changing instruction — Stake, StakeOnBehalf, Unstake,
CompleteUnstake, CloseStakeAccount — and route all updates through a shared
state::update_pool_member_count helper that:
  - rejects a non-canonical metadata account (InvalidPDA), so the correct PDA is
    always supplied, and
  - tolerates an uninitialized account (pool that never created metadata) by
    skipping the update — so metadata-less pools keep working.

This makes the counter exact (+1 on a new stake, -1 on close). Unstake and
CompleteUnstake also now require the system program positionally (it precedes
metadata and is used for legacy realloc).

Breaking ABI change: clients must pass the (derived) metadata PDA on these
instructions. TS helpers updated to always pass it; lib.rs account tables and
idl.json updated; legacy_migration complete test passes the metadata account.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…sign

ctx.stake now always passes the metadata PDA, so staking on a metadata pool
increments member_count. Repurpose the old "stake without metadata is backwards
compatible" test (which asserted member_count stayed 0) to assert it increments
to 1 — matching the now-required metadata account.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@MagicalTux MagicalTux merged commit 46b013c into master May 23, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant