Skip to content

fix(multisig): drop per-level encoded_size from execute weight annotation - #698

Open
Bortlesboat wants to merge 1 commit into
Quantus-Network:mainfrom
Bortlesboat:fix/multisig-execute-weight-annotation
Open

fix(multisig): drop per-level encoded_size from execute weight annotation#698
Bortlesboat wants to merge 1 commit into
Quantus-Network:mainfrom
Bortlesboat:fix/multisig-execute-weight-annotation

Conversation

@Bortlesboat

Copy link
Copy Markdown

Summary

Stop sizing Multisig::execute's weight annotation by call.encoded_size(), so nesting execute no longer costs O(depth × bytes) of encode-walking during transaction validation.

This change:

  • declares bookkeeping as a flat WeightInfo::execute(T::MaxCallSize::get()) instead of MaxCallSize.max(call.encoded_size())
  • clamps the body's call_size to MaxCallSize so no error path can report more weight than was declared
  • adds a regression test for the over-sized-submission ordering

Root cause

The annotation at pallets/multisig/src/lib.rs was:

<T as Config>::WeightInfo::execute(
    T::MaxCallSize::get().max(call.encoded_size() as u32),
)
.saturating_add(call.get_dispatch_info().call_weight)

Two things compound:

  • Encode::encoded_size is O(bytes of the whole remaining subtree) — it runs a full encode_to into a size tracker.
  • get_dispatch_info() recurses into the inner call, and when that inner call is another Multisig::execute, this same expression is evaluated again over its subtree.

So k nested execute wrappers around a B-byte payload perform roughly k × B bytes of walking.

frame_executive::validate_transaction computes the dispatch info immediately after the signature check:

let xt = uxt.check(&Default::default())?;
let dispatch_info = xt.get_dispatch_info();

CheckWeight and ChargeTransactionPayment are transaction extensions and run after that point, so the walk happens even when the transaction is ultimately rejected for exhausting resources or for insufficient balance — in which case no fee is charged at all.

Each execute wrapper costs 38 encoded bytes and exactly one codec depth level (WrapperTypeDecode for Box calls descend_ref; derived enums do not), so ~255 wrappers fit under MAX_EXTRINSIC_DEPTH = 256 in under 10 KB, leaving essentially the whole normal RuntimeBlockLength budget for the innermost payload.

This is not equivalent to Utility::batch_all, whose annotation calls weight_and_dispatch_class(&calls) — O(number of children) per level, never O(bytes). Nesting batch_all is O(depth) in total.

The .max(...) was not gratuitous: it existed so the post-dispatch bookkeeping_weight, sized by max(proposal.call.len(), call.encoded_size()), could never exceed the declaration. That constraint has to be preserved by any fix.

Proposed solution

Reserve bookkeeping at a flat WeightInfo::execute(MaxCallSize) and clamp call_size in the body to the same constant.

The clamp is what keeps the post-dispatch weight inside the declaration. proposal.call is a BoundedVec<u8, MaxCallSize>, so a submitted call encoding to more than MaxCallSize bytes can never be byte-equal to the stored payload; it can only ever fall through to CallMismatch, and charging that path execute(MaxCallSize) is correct. Every other path either reports a fixed small read count or the clamped bookkeeping_weight.

The get_dispatch_info() recursion is inherent to the call-carrying execute interface and is left alone — it is the per-level byte count that made the annotation quadratic. This also brings execute in line with batch_all's O(children)-per-level shape.

The existing doc comment on execute already described the intended behaviour ("Only the bookkeeping term is reserved at MaxCallSize, since the stored bytes' length is unknown pre-dispatch"); the code had drifted from it.

Impact

  • Validation of a nested execute chain no longer scales with the payload size at every level; the annotation is now two constants plus the inherent inner-call recursion.
  • No change to fees or reported weight for ordinary single-level execute calls: MaxCallSize was already the floor of the old expression, so the declaration is unchanged for any call at or below MaxCallSize.
  • Only calls submitted above MaxCallSize see a lower declaration — and those cannot do anything but fail CallMismatch.
  • No storage, API, or migration impact.

Verification

  • SKIP_WASM_BUILD=1 cargo test --locked -p pallet-multisig --lib: 63 passed
  • SKIP_WASM_BUILD=1 cargo test --locked -p quantus-runtime --lib: 77 passed
  • SKIP_WASM_BUILD=1 cargo clippy --locked -p pallet-multisig --all-targets -- -D warnings: clean
  • scripts/fmt.sh --all -- --check: clean

The new test execute_oversized_submitted_call_stays_within_bookkeeping_reservation was confirmed to fail without the call_size clamp:

mismatch bookkeeping must stay within execute(MaxCallSize):
actual=Weight { ref_time: 306386936, proof_size: 17022 }
reserved=Weight { ref_time: 304796644, proof_size: 17022 }

It asserts against execute(MaxCallSize) rather than only against the full declaration, because the declaration also carries the inner call's own weight — for a large remark that term alone is enough to absorb the bookkeeping overshoot and hide the regression. The pre-existing execute_mismatch_never_reports_more_weight_than_declared covers the opposite ordering (small submission, near-max stored call) and still passes.

Fixes #673

`Multisig::execute`'s weight annotation took
`MaxCallSize.max(call.encoded_size())`. `encoded_size` walks the whole
remaining call subtree, and `get_dispatch_info()` recurses into a nested
`execute`, re-evaluating the annotation over that subtree in turn, so a
chain of `k` nested wrappers around a `B`-byte payload cost O(k * B) of
encode-walking.

`frame_executive::validate_transaction` computes dispatch info right
after the signature check, before `CheckWeight` and
`ChargeTransactionPayment` run, so the walk happened even for
transactions ultimately rejected for exhausted resources or insufficient
balance -- charging no fee. Each wrapper is 38 encoded bytes and one
codec depth level, so ~255 fit under `MAX_EXTRINSIC_DEPTH` in under
10 KB, leaving the rest of the normal block-length budget for the
innermost payload.

Reserve bookkeeping at a flat `WeightInfo::execute(MaxCallSize)` and
clamp the body's `call_size` to the same constant. The clamp keeps the
post-dispatch weight inside the declaration: `proposal.call` is a
`BoundedVec<_, MaxCallSize>`, so a submitted call encoding to more than
`MaxCallSize` can never be byte-equal to it and can only end in
`CallMismatch`, which `execute(MaxCallSize)` covers.

The `get_dispatch_info()` recursion is inherent and is unchanged; only
the per-level byte count is removed. `Utility::batch_all` is already
O(children) per level rather than O(bytes), so this brings `execute` in
line with it.

Adds a regression test for the over-sized-submission ordering, asserting
against `execute(MaxCallSize)` -- the existing declared-weight test
covers small-submission/large-stored, and a large inner call's own
declared weight is enough to absorb the bookkeeping overshoot and hide
the regression if only the full declaration is compared.

Fixes Quantus-Network#673
@n13

n13 commented Sep 12, 2026

Copy link
Copy Markdown
Collaborator

Ok this is a weights issue - sometimes AI oscillates on these we've had a lot of them - they're not necessarily all accurate

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.

Multisig::execute weight annotation is O(depth × bytes), evaluated before fee/weight rejection

2 participants