Summary
note_selection.rs computes num_actions for fee purposes but never compares it against the effective per-transition action bound that this codebase documents elsewhere. A spend that genuinely needs more than 6 input notes is therefore selected, built, Halo 2-proved, and only then rejected at submission with:
Grpc(Status { code: InvalidArgument, message: "... exceeds maximum size of 20480 bytes" })
The wasted work includes proof generation, which is the most expensive step in the flow.
The bound is already known and documented here
packages/rs-platform-version/src/version/system_limits/v3.rs:
// NOTE: the Halo 2 proof grows with the action count (~2,273 B/action on top of
// the 408 B serialized action), so a transition's on-wire size is ~2,681 B per
// action + ~2,930 B fixed (measured: 2 actions → 8,294 B, 6 → 19,018 B).
// The effective per-transition action bound under the 20 KiB
// max_state_transition_size is therefore 6, NOT this cap — 16 only becomes
// reachable if the size limit is raised. Pinned by dpp's
// seed_pool_batch_fits_max_state_transition_size signing test.
max_shielded_transition_actions: 16,
max_state_transition_size: 20480, //20 KiB
packages/rs-platform-wallet/src/wallet/shielded/seed_pool.rs caps itself accordingly:
/// The binding constraint is NOT the 16-action consensus cap but the 20 KiB
/// transaction-size limit ... 2 actions → 8,294 B, 6 → 19,018 B,
/// 7 → 21,699 B (rejected by tenderdash as "Tx too large").
/// 6 actions is the largest batch that fits
const MAX_ACTIONS_PER_BATCH: usize = 6;
What the spend path does and does not do
To be fair to the code: selection is action-count aware.
select_notes is greedy largest-first and is documented as "minimizes the number of inputs, which keeps the Orchard action count low and reduces proof generation time and fees" (note_selection.rs)
select_notes_with_fee iterates to fee convergence and computes num_actions = selected.len().max(min_actions)
- It already has the up-front-rejection pattern:
ShieldedNoUnspentNotes and ShieldedInsufficientBalance { available, required }
What is missing is a comparison of that num_actions against the bound. MAX_ACTIONS_PER_BATCH, max_shielded_transition_actions and max_state_transition_size appear only in seed_pool.rs within rs-platform-wallet — there are no references in note_selection.rs, operations.rs or coordinator.rs (checked on feat/platform-wallet-storage-rehydration).
Reproduction (testnet)
Wallet with notes fragmented across many small inbound shields, then ShieldedTransfer to self at increasing amounts:
| amount (credits) |
pool notes delta |
fee (credits) |
result |
| 2,000,000,000 |
+2 |
162,851,200 |
ok |
| 5,500,000,000 |
+4 |
225,702,400 |
ok |
| 12,000,000,000 |
+6 |
288,553,600 |
ok (ceiling) |
| 25,000,000,000 |
0 |
— |
rejected, 20480 |
No ShieldedMerkleWitnessUnavailable / invalid frontier in the logs for any of these, so this is unrelated to #4144.
The binding variable is input note count, not amount. Same amount, two outcomes:
| wallet state |
amount |
result |
| fragmented |
25,000,000,000 |
rejected (20480) |
| after intervening self-transfers consolidated the notes |
same amount |
accepted (+2 notes, fee 162,851,200) |
Why this matters for self-custody
Note fragmentation can put a wallet into a state where the balance cannot be moved in a single transaction. The remedy — consolidating via z→z self-transfers — is subject to the same 6-action bound, so it converges over several rounds rather than in one step. None of that is discoverable from the error the user receives.
Suggested fix
Add an action-count check alongside the existing ShieldedInsufficientBalance check in select_notes_with_fee, reusing the constant that already exists in seed_pool.rs. On overflow, either split automatically or return an actionable error (e.g. "requires N transactions; consolidate first") rather than building and proving a transition the codebase already knows will be rejected.
Fee observation (possibly worth documenting separately)
Fees are linear in action count: 162,851,200 / 194,276,800 / 225,702,400 / 288,553,600 for 2 / 3 / 4 / 6 output notes — a constant +31,425,600 per action. Single-note spends look like a flat fee, which is what a casual test would show.
Testing done on testnet against a self-built DET v1.0-dev pinning feat/platform-wallet-storage-rehydration. Investigation and this report were produced with AI assistance (Claude); all figures are from executed runs, and the source citations were re-checked against this repo before filing.
Summary
note_selection.rscomputesnum_actionsfor fee purposes but never compares it against the effective per-transition action bound that this codebase documents elsewhere. A spend that genuinely needs more than 6 input notes is therefore selected, built, Halo 2-proved, and only then rejected at submission with:The wasted work includes proof generation, which is the most expensive step in the flow.
The bound is already known and documented here
packages/rs-platform-version/src/version/system_limits/v3.rs:packages/rs-platform-wallet/src/wallet/shielded/seed_pool.rscaps itself accordingly:What the spend path does and does not do
To be fair to the code: selection is action-count aware.
select_notesis greedy largest-first and is documented as "minimizes the number of inputs, which keeps the Orchard action count low and reduces proof generation time and fees" (note_selection.rs)select_notes_with_feeiterates to fee convergence and computesnum_actions = selected.len().max(min_actions)ShieldedNoUnspentNotesandShieldedInsufficientBalance { available, required }What is missing is a comparison of that
num_actionsagainst the bound.MAX_ACTIONS_PER_BATCH,max_shielded_transition_actionsandmax_state_transition_sizeappear only inseed_pool.rswithinrs-platform-wallet— there are no references innote_selection.rs,operations.rsorcoordinator.rs(checked onfeat/platform-wallet-storage-rehydration).Reproduction (testnet)
Wallet with notes fragmented across many small inbound shields, then
ShieldedTransferto self at increasing amounts:No
ShieldedMerkleWitnessUnavailable/invalid frontierin the logs for any of these, so this is unrelated to #4144.The binding variable is input note count, not amount. Same amount, two outcomes:
Why this matters for self-custody
Note fragmentation can put a wallet into a state where the balance cannot be moved in a single transaction. The remedy — consolidating via z→z self-transfers — is subject to the same 6-action bound, so it converges over several rounds rather than in one step. None of that is discoverable from the error the user receives.
Suggested fix
Add an action-count check alongside the existing
ShieldedInsufficientBalancecheck inselect_notes_with_fee, reusing the constant that already exists inseed_pool.rs. On overflow, either split automatically or return an actionable error (e.g. "requires N transactions; consolidate first") rather than building and proving a transition the codebase already knows will be rejected.Fee observation (possibly worth documenting separately)
Fees are linear in action count: 162,851,200 / 194,276,800 / 225,702,400 / 288,553,600 for 2 / 3 / 4 / 6 output notes — a constant +31,425,600 per action. Single-note spends look like a flat fee, which is what a casual test would show.
Testing done on testnet against a self-built DET
v1.0-devpinningfeat/platform-wallet-storage-rehydration. Investigation and this report were produced with AI assistance (Claude); all figures are from executed runs, and the source citations were re-checked against this repo before filing.