Skip to content

shielded: note selection never checks num_actions against the effective 6-action (20 KiB) transition bound #4250

Description

@DashBot-0001

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions