Skip to content

Don't let FinalizeSettle empty the native SOL buffer - #192

Merged
kaze-cow merged 8 commits into
mainfrom
kaze/sc-552-native-sol-buffer-can-be-deleted-by-a-settlement-paying-out
Oct 9, 2026
Merged

kaze-cow merged 8 commits into
mainfrom
kaze/sc-552-native-sol-buffer-can-be-deleted-by-a-settlement-paying-out

Conversation

@kaze-cow

@kaze-cow kaze-cow commented Oct 8, 2026 •

Copy link
Copy Markdown
Contributor

Description

FinalizeSettle now rejects a settlement whose native SOL payouts would leave the native SOL buffer with a zero balance, with a new NativeSolBufferEmptied error.

Motivation

Native SOL payouts are taken out of NATIVE_SOL_BUFFER_PDA with move_lamports, which has no rent floor. The runtime already rejects a payout that leaves the buffer partially under-funded (InsufficientFundsForRent), but a payout of exactly the whole balance is accepted, and the runtime then deletes the account at the end of the transaction.

Once deleted, the address goes back to being a system-owned account, so every later native SOL payout fails with ExternalAccountLamportSpend (even if the buffer is topped up first). Nothing in the program can recreate it, since Initialize can't run twice and CreateBuffer only makes token accounts. Any SOL sent to the address afterwards would be stuck too. It takes a solver to do this (bug or malice), but recovering would need a program upgrade.

This was confirmed with a litesvm test on main before applying the fix. See SC-552.

Considerations

Zero check vs. a rent floor

The first version of this PR compared the remaining balance against the rent-exempt minimum from the Rent sysvar. But the runtime only lets an account end a transaction either rent-exempt or at exactly zero, so anything in between already reverts with InsufficientFundsForRent (covered by rejects_a_push_spending_the_native_sol_buffers_rent). Zero is the only gap, and checking for it skips the sysvar read, which was nearly all of the cost.

Making the buffer recreatable instead

The alternative was to let CreateBuffer (or a new instruction) recreate the native buffer idempotently. CanonicalPda::create_idempotent already handles a pre-funded address, so this wouldn't be much code. But it only repairs the damage after native settlements have already started failing, whereas this check means it can't happen in the first place.

Cost

A settlement with a native payout costs 5 CU more (happy_path_sell_tokens_for_native_sol: 4658 → 4663), every other settlement 1 CU more. The rent floor version was +131 CU.

Tests

Two tests are added for the edges: paying out everything but the rent succeeds, and paying out the whole balance is rejected with NativeSolBufferEmptied.

How to test

just test. To see the original issue, revert the finalize_settle.rs change and run cargo test --test settle_native_sol whole_native_sol_buffer: the draining settlement goes through.

🤖 Generated with Claude Code

A settlement paying out exactly the native SOL buffer's whole balance was
accepted by the runtime, which then deleted the account, leaving every later
native SOL payout failing with ExternalAccountLamportSpend and no instruction
able to recreate it. FinalizeSettle now rejects payouts leaving the buffer
below its rent-exempt minimum with NativeSolBufferBelowRent.

Fixes SC-552

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@linear-code

linear-code Bot commented Oct 8, 2026

Copy link
Copy Markdown
Contributor

SC-552

The runtime already rejects a transaction leaving the buffer between zero and
its rent-exempt minimum, so zero is the only balance FinalizeSettle needs to
refuse. Dropping the Rent sysvar read brings a native payout's overhead from
~130 CU down to ~5.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@kaze-cow kaze-cow changed the title Keep the native SOL buffer rent-exempt in FinalizeSettle Don't let FinalizeSettle empty the native SOL buffer Oct 8, 2026
@kaze-cow
kaze-cow marked this pull request as ready for review October 8, 2026 16:22
@kaze-cow
kaze-cow requested a review from a team as a code owner October 8, 2026 16:22

@fedgiac fedgiac left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great catch!
Can you also add a test where two orders sum to the total balance?

Comment thread programs/settlement/tests/settle_native_sol.rs Outdated
Comment thread programs/settlement/tests/settle_native_sol.rs Outdated
kaze-cow and others added 2 commits October 9, 2026 01:34
Co-authored-by: Federico Giacon <58218759+fedgiac@users.noreply.github.com>
Co-authored-by: Federico Giacon <58218759+fedgiac@users.noreply.github.com>
@kaze-cow

kaze-cow commented Oct 9, 2026

Copy link
Copy Markdown
Contributor Author

Can you also add a test where two orders sum to the total balance?

The test 941a0d2

Perhaps it may be worth deleting the simpler version of the test which only has one order? Or is it worth keeping both? lmk if I should change it.

@fedgiac fedgiac left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good, only a small suggestion!
(I think it's fine to have a test for one and a test for many, there can be different failure cases.)

Comment thread programs/settlement/tests/settle_native_sol.rs Outdated
Co-authored-by: Federico Giacon <58218759+fedgiac@users.noreply.github.com>
@kaze-cow
kaze-cow merged commit fbc9590 into main Oct 9, 2026
19 checks passed
@kaze-cow
kaze-cow deleted the kaze/sc-552-native-sol-buffer-can-be-deleted-by-a-settlement-paying-out branch October 9, 2026 12:22
kaze-cow added a commit that referenced this pull request Oct 12, 2026
# Description

Adds a `WithdrawNativeSol` instruction (discriminator 12) that moves a
given `amount` of lamports out of the native SOL buffer to a recipient
of the caller's choosing. Only the settlement-owned-order authority can
call it.

## Motivation

Fees paid out in native SOL build up in `NATIVE_SOL_BUFFER_PDA`, and
right now nothing can get them out. The other buffers are emptied by
selling their contents through `CreateSettlementOwnedOrder`, but SOL
isn't a token, so it can't be the sell side of an order. Because the
buffer is owned by the program, we can debit it directly, which makes
this a small instruction. See SC-403.

## Considerations

### Which authority

It's gated by `SettlementOwnedOrderAuthority`, since that role is
already the one trusted with withdrawing fees from the buffers. Adding a
new role would have changed the state layout, `Initialize` args, the IDL
and the CLI just to hold a key that would most likely be the same one
anyway. The role's doc comment, `DESIGN.md` and `SECURITY_DESIGN.md` now
say it can do this too.

### Protecting the buffer's rent

This uses the same approach as #192: Solana itself rejects a withdrawal
that leaves the buffer partly below rent (`InsufficientFundsForRent`),
so the only case the program has to catch is an empty buffer, which it
rejects with the existing `NativeSolBufferEmptied`. I widened that
error's doc to cover both instructions instead of adding a
near-duplicate. The only new errors are
`UnauthorizedNativeSolWithdrawal` (46) and `NativeSolBufferMismatch`
(47).

### Explicit amount vs. sweep

The caller passes an explicit `u64` instead of "everything above rent".
That way partial withdrawals are possible, and the instruction doesn't
have to read the `Rent` sysvar to work out the sweepable amount.

### Cost


`withdraw_native_sol/happy_path_withdraws_to_a_recipient_chosen_by_the_authority`:
374 CU. The extra dispatch arm moves 15 other rows by 1–3 CU (all down).

### Tests

The processor has unit tests for every rejection path and the happy
path. Integration tests in `tests/withdraw_native_sol.rs` cover:
- withdrawing to a third party
- withdrawing to the authority itself
- the other three roles being refused
- dipping into rent
- emptying the buffer
- passing a different program-owned account as the buffer

## How to test

`just test`. For just this instruction: `cargo test --test
withdraw_native_sol` (after `just build-program`).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: Federico Giacon <58218759+fedgiac@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants