fix(minter): reserve transaction fee when selecting affordable withdrawals - #202
gregorydemay wants to merge 8 commits into
Conversation
There was a problem hiding this comment.
Copilot review overview
🟡 Changes recommended
A newly added test’s setup and assertion do not match its stated intent (“leaving exactly one transaction fee”), reducing confidence in the boundary-condition coverage.
Get a fresh assessment by requesting another Copilot review.
Review effort: Lite
Findings: 1
What changed in this PR
This PR fixes an audit-blocking panic in withdrawal processing by ensuring the “affordable withdrawals” selection logic reserves the Solana transaction fee per batch, matching the later state transition that subtracts amount_to_transfer + tx_fee.
Changes:
- Add regression tests for withdrawing at/near the minter’s full tracked balance to prevent panics and guard leaks.
- Refactor affordability selection into
affordable_withdrawal_requests, reservingBATCH_WITHDRAWAL_TX_FEEat the start of each batch ofMAX_WITHDRAWALS_PER_TX. - Introduce
BATCH_WITHDRAWAL_TX_FEEas the fee reserved for batch-withdrawal transactions (single signer).
| File | Description |
|---|---|
minter/src/withdraw/tests.rs |
Adds regression tests around balance boundary conditions in process_pending_withdrawals. |
minter/src/withdraw/mod.rs |
Updates affordability selection to reserve one tx fee per batch via a helper function. |
minter/src/sol_transfer/mod.rs |
Defines BATCH_WITHDRAWAL_TX_FEE for batch-withdrawal fee reservation. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
There was a problem hiding this comment.
Copilot review overview
🟡 Changes recommended
WithdrawalBatches uses saturating_add for fee reservation, which can mask u64 overflow and break the “only select affordable withdrawals” invariant in extreme cases.
Get a fresh assessment by requesting another Copilot review.
Review effort: Lite
Findings: 1
There was a problem hiding this comment.
Copilot review overview
🟡 Changes recommended
The newly added property tests can overflow on u64 arithmetic, and the batching iterator uses saturating_add in a way that can mask overflow and weaken the intended affordability guarantees.
Get a fresh assessment by requesting another Copilot review.
Review effort: Lite
Findings: 1
Open (2)
Resolved since last review (1)
140ad25 to
8b6619c
Compare
There was a problem hiding this comment.
Copilot review overview
🟢 Approval recommended
The batching/affordability logic now consistently reserves the per-transaction fee and is covered by targeted property tests plus an integration test reproducing the previously trapping scenario.
Review effort: Lite
Findings: 1
8b6619c to
329aa1b
Compare
329aa1b to
bed3bc0
Compare
There was a problem hiding this comment.
Copilot review overview
🟢 Approval recommended
The updated batching/affordability logic correctly accounts for per-transaction fees and is backed by targeted unit/property tests plus an integration regression test for the reported trap scenario.
Review effort: Lite
Findings: 1
|
✅ No security or compliance issues detected. Reviewed everything up to 46a33ba. Security OverviewDetected Code Changes
|
…ance Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…awals Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ests Build the minter balance from one consolidated deposit and one deposit that is minted but not yet consolidated, then queue the withdrawal via the production withdraw() function instead of the accept_withdrawal fixture. This shows the tested state is reachable in production: the user holds ckSOL for both deposits while only the consolidated one has reached the fee payer. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The previous selection reserved a transaction fee every twentieth request and relied on the caller chunking the list with the same constant, so the two could drift apart. State::withdrawal_batches now returns an iterator that builds one affordable batch per step, reserving the fee when a batch opens and stopping at the first request the remaining balance cannot cover. The processing loop takes only the batches it submits this round and peeks once to decide whether to reschedule. Consolidation is scheduled only once the affordable batches are exhausted and pending requests remain uncovered. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Assert on full withdrawal requests instead of burn indices, and cover the fee boundaries with proptest over the transfer amount, the balance, and the shortfall rather than single hand-picked values. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ator Replace the two withdraw unit tests with one integration test on the Solana test validator. One deposit is minted and consolidated, a second is minted but not consolidated, and the withdrawal burns exactly the consolidated balance plus the withdrawal fee. After a single withdrawal round the minter must have consolidated the pending deposit right away, and the withdrawal must finalize. Without the fix the withdrawal round traps and no consolidation is scheduled, so the test fails waiting for the minter balance on chain. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…balance Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Batching used a saturating addition for the amount plus the reserved fee, which admitted a request whose real cost overflows when the balance is exactly u64::MAX. The cost is now computed with checked arithmetic and iteration stops at the first request it cannot represent or afford. The property tests bound the transfer amount so that adding either fee cannot overflow, instead of relying on the withdrawal fee being larger. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
756d4f2 to
46a33ba
Compare
| pub const MAX_WITHDRAWALS_PER_TX: usize = 20; | ||
|
|
||
| /// Fee charged for a batch withdrawal transaction, which is signed by the fee payer only. | ||
| pub const BATCH_WITHDRAWAL_TX_FEE: Lamport = FEE_PER_SIGNATURE; |
There was a problem hiding this comment.
This constant has to agree with what State::transaction_fee computes from the signed message (FEE_PER_SIGNATURE * num_required_signatures) when the batch is recorded as submitted. If the batch withdrawal transaction ever gains a second signer, the iterator would admit a batch that then traps in process_transaction_submitted, which is the same failure shape as the bug fixed here.
Since 2cfb9d0 removed the two timer-level tests, the validator integration test is the only thing that checks that agreement, and it will go away with the manual flow redesign (#207). Could we keep a cheap unit test in withdraw/tests.rs that drives a request costing exactly the balance (and one leaving exactly one fee) through process_pending_withdrawals and asserts the resulting balance? The removed should_process_withdrawal_leaving_exactly_one_transaction_fee does that, once its transfer amount is reduced by one more fee as Copilot pointed out.
| else { | ||
| break; | ||
| }; | ||
| let Some(affordable) = self.pending_requests.next() else { |
There was a problem hiding this comment.
Nit: this let ... else cannot fail after the successful peek() above. Would it make sense to add an expect("BUG: peeked request vanished"), or restructuring so that the request is consumed inside the let Some(...) above?


The balance tracked by the minter is the consolidated balance, that is the funds already swept into the fee payer account. Minted funds may still sit in deposit accounts awaiting consolidation, and users already hold ckSOL for them. A withdrawal can therefore be accepted for more than the consolidated balance. When planning a batch withdrawal transaction, the minter must account exactly for the cost of that transaction, transfer amounts plus fee, so that the consolidated funds are enough to cover it.
The withdrawal timer only checked the transfer amounts and ignored the fee. Recording the submitted transaction then subtracted amount plus fee and trapped when the balance was short. Because the trap drops the in-flight future, the loop's reschedule guard re-armed the timer immediately, so the minter trapped on every round until the periodic consolidation happened to make the request affordable. This was an audit blocker: any user could trigger it by withdrawing exactly the balance reported by
get_minter_info.Batching and affordability now live in one place: the state exposes an iterator that yields one affordable batch at a time, reserving a transaction fee whenever it opens a batch and stopping at the first request the remaining balance cannot cover. The withdrawal timer pulls only the batches it submits in the current round, and schedules a consolidation only once the affordable batches are exhausted while requests remain pending.
The bug is reproduced by an integration test against a Solana test validator: one deposit is minted and consolidated, a second is minted but not consolidated, and a withdrawal for exactly the consolidated balance is queued through the public
withdrawendpoint. After a single withdrawal round the minter must consolidate the pending deposit right away, and the withdrawal must finalize. Without the fix the round traps and no consolidation is scheduled, so the test fails. The batching iterator has its own unit tests, with the fee boundaries covered by property tests over the transfer amount, the balance, and the shortfall.This PR builds on #204, which lets each validator test start its own
solana-test-validator.🤖 Generated with Claude Code