Skip to content

fix(close-account): fully close the User PDA and verify the system program account - #3

Open
SwineCoder101 wants to merge 2 commits into
mainfrom
fix/close-account-full-close-native-pinocchio
Open

fix(close-account): fully close the User PDA and verify the system program account#3
SwineCoder101 wants to merge 2 commits into
mainfrom
fix/close-account-full-close-native-pinocchio

Conversation

@SwineCoder101

Copy link
Copy Markdown
Owner

Bug: close_user never closes the PDA and permanently blocks re-creation

close_user in the native and pinocchio variants refunds only lamports - minimum_balance(0), leaving the rent-exempt minimum for a 0-byte account (890,880 lamports) inside the "closed" PDA. The account therefore survives as a System-owned shell, the user is short-changed on the refund, and because the System Program's create_account rejects a destination that already holds lamports, create_user can never succeed again for that user. This is a functional bug (medium): every user who closes their account hits it. A second, low-severity bug in the same instruction is that the system-program account is never verified, so the caller can hand the residual PDA to any program they name.

Affected

  • native/basics/close-account/native/program/src/instructions/close_user.rs
  • pinocchio/basics/close-account/pinocchio/program/src/lib.rs (process_close)

anchor/ is correct: close = user drains every lamport and the PDA can be re-created.

Functionality

create_user creates a User PDA (["USER", payer]) funded by the payer. close_user is meant to undo that: return all of the PDA's lamports to the payer and delete the account so the same PDA can be created again later.

The bug

  • native/program/src/instructions/close_user.rs:29-36 (before the fix) computes diff = lamports - Rent::minimum_balance(0) and moves only diff to the payer, then resizes to 0 and calls assign(system_program.key).
  • pinocchio/program/src/lib.rs:85-93 (before the fix) does the same with rent.try_minimum_balance(0), then assign(system_program.address()) in an unsafe block.

Concrete scenario, both variants:

  1. create_user — PDA holds 953,520 lamports (native, 9-byte account) / 1,002,240 lamports (pinocchio, 16-byte account) and is owned by the program.
  2. close_user — 890,880 lamports stay in the PDA; it is resized to 0 bytes and reassigned to the System Program. The account still exists.
  3. create_user again — the CPI to system_program::create_account fails with Custom(0) ("account ... already in use") because the destination has a non-zero balance. The user is locked out for good.

Additionally, close_user.rs:42 / lib.rs:98 pass the third account's key straight into assign without checking it. A caller who substitutes any address for the System Program ends up with a 890,880-lamport, 0-byte account owned by that address.

The existing TS and Rust tests asserted the residual System-owned account (account.exists, data.length === 0, owner == System Program), codifying the bug; they have been updated.

Reproduce

cd basics/close-account/native    && pnpm install --frozen-lockfile && pnpm build-and-test
cd basics/close-account/pinocchio && pnpm install --frozen-lockfile && pnpm build-and-test
cargo test --manifest-path=./program/Cargo.toml   # in either directory, after the fixture is built

Tests (both TS suites): Close with a bogus system program account is rejected, Close the account, Re-create the account after closing it. Rust: test_close_account.

Against the unmodified program (native shown; pinocchio is identical apart from line numbers):

  Close Account!
    ✔ Create the account
    ✔ An attacker cannot close another user's account
    1) Close with a bogus system program account is rejected
    2) Close the account
    3) Re-create the account after closing it

  2 passing (19ms)
  3 failing

  1) Close Account!
       Close with a bogus system program account is rejected:
     AssertionError: expected the bogus system program to be rejected

  2) Close Account!
       Close the account:
     AssertionError: expected the closed account to no longer exist

  3) Close Account!
       Re-create the account after closing it:
     AssertionError: transaction failed: FailedTransactionMetadata(FailedTransactionMetadata { err: InstructionError(0, Custom(0)), ...
       "Create Account: account Address { address: 2hdu8k1n..., base: None } already in use",
       "Program 11111111111111111111111111111111 failed: custom program error: 0x0" ...
test test_close_account ... FAILED
thread 'test_close_account' panicked at basics/close-account/native/program/tests/test.rs:62:40:
expected the bogus system program to be rejected: TransactionMetadata { ... "Program ... success" ... }

Fix

  • Both variants now verify the third account is the System Program (solana_system_interface::program::ID / pinocchio_system::ID) and return IncorrectProgramId otherwise.
  • native: move the PDA's entire balance to the payer, resize(0), and assign to the System Program's known id instead of the caller-supplied key. A zero-lamport account is removed by the runtime at the end of the instruction, so the PDA address is free for create_user again.
  • pinocchio: add the PDA's entire balance to the payer and call AccountView::close(), which zeroes the account's owner, lamports and data length (the same pattern tokens/escrow/pinocchio uses). The manual rent arithmetic, resize and unsafe { assign } are gone.

The Anchor variant already behaves this way (close = user), so the three variants are now consistent.

Verification

cd basics/close-account/native && pnpm build-and-test
  Close Account!
    ✔ Create the account
    ✔ An attacker cannot close another user's account
    ✔ Close with a bogus system program account is rejected
    ✔ Close the account
    ✔ Re-create the account after closing it
  5 passing (21ms)

cd basics/close-account/pinocchio && pnpm build-and-test
  Close Account!
    ✔ Create the account
    ✔ Close with a bogus system program account is rejected
    ✔ Close the account
    ✔ Re-create the account after closing it
  4 passing (20ms)

cargo test --manifest-path=./program/Cargo.toml   # native and pinocchio
test test_close_account ... ok
test result: ok. 1 passed; 0 failed

cargo fmt and cargo clippy -- -D warnings (the workspace-wide invocation CI uses) are clean.

…e-creation and accepts a bogus system program

native and pinocchio: close_user must delete the PDA (all lamports back to
the user), allow create_user to succeed again afterwards, and reject an
instruction whose system-program account is not the System Program.
…ogram account

native and pinocchio close_user refunded lamports - minimum_balance(0),
leaving 890,880 lamports in a System-owned shell so the PDA was never
deleted and create_user could never succeed again for that user. They
also assigned the account to whatever key the caller passed as the
system program.

Drain every lamport to the payer and close the account (resize(0) +
assign to the System Program id in native, AccountView::close() in
pinocchio), and reject an instruction whose system-program account is
not the System Program. Update the tests that asserted the residual
account.
@SwineCoder101
SwineCoder101 force-pushed the fix/close-account-full-close-native-pinocchio branch from 5e5302b to b98ff4e Compare August 27, 2026 12:23
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