88218 bc medium per extrinsic base weight is understated by 12 39 the runtime inherits upstream substrate s sr25519 constant while verifying post quantum dilithium signatures
#88218 [BC-Medium] Per-extrinsic base weight is understated by 12.39×: the runtime inherits upstream Substrate's sr25519 constant while verifying post-quantum Dilithium signatures
Submitted on Aug 12th 2026 at 09:45:08 UTC by @shadowHunter for Audit Comp | Quantus
Report ID: #88218
Report Type: Blockchain/DLT
Report severity: Medium
Target: https://github.com/immunefi-team/audit-comp-quantus-chain
Impacts:
Causing network processing nodes to process transactions from the mempool beyond set parameters
Description
1. Summary
frame_system::Config::BlockWeights sets a base weight charged once per extrinsic, intended to cover extrinsic decoding and signature verification. Quantus never overrides this value, so it inherits upstream Substrate's constant of 108,157 ns.
That constant was measured on the stock Substrate development chain, which uses sr25519 signatures verified through a native host function. Quantus does not use sr25519. It uses Dilithium (ML-DSA-65/87), verified inside the runtime WASM with no host function, plus a Poseidon hash over the entire public key to derive the account.
Running the project's own benchmark overhead command against a release build of this chain produces:
ExtrinsicBaseWeight
108,157 ns
1,340,574 ns
12.39×
BlockExecutionWeight
431,614 ns
1,223,182 ns
2.83×
Because BlockWeight is the mechanism that bounds how long a block takes to execute, the limit is measuring the wrong quantity. A block that fills the normal-dispatch length budget records 58.5 ms of weight while really taking 725.3 ms to execute — 666.7 ms per block of completely unmetered CPU.
A related consequence has no fee attached at all: the same verification cost is paid by every node for every gossiped transaction during pool validation, where no fee is charged and the sender need not be solvent.
2. Description
2.1 What the base weight is for
Every extrinsic in a FRAME runtime is charged base_extrinsic in addition to its call weight. This charge exists to account for the work that happens before the call body runs — SCALE decoding and, critically, signature verification. It is added in CheckWeight:
info.total_weight() is the call weight plus declared extension weights. None of those cover the signature check — the extension weights in pallets/frame-system/src/extensions/weights.rs are small, separately benchmarked values (check_nonce() ≈ 7.26 µs, check_weight() ≈ 3.0 µs) that never touch the signature.
So base_extrinsic is the only budget covering signature verification.
2.2 Quantus never sets it
with_sensible_defaults funnels into the builder, which applies the inherited constant to every dispatch class:
A repository-wide search confirms no override exists anywhere in the runtime:
2.3 The inherited constant describes a different chain
The constant's own auto-generated header records exactly how it was produced:
RUNTIME: Development on substrate-node --chain=dev is the stock Substrate node, whose signature type is MultiSignature and whose sr25519 verification is a native host function call (sp_io::crypto::sr25519_verify), costing on the order of tens of microseconds.
2.4 What Quantus actually does per extrinsic
The upstream type is commented out directly above the replacement. Verification runs entirely in-runtime:
Two expensive operations per extrinsic:
into_account()—primitives/dilithium-crypto/src/traits.rs:197-198— isqp_poseidon_core::hash_bytesover the entire public key (1,952 bytes for ML-DSA-65, 2,592 bytes for ML-DSA-87).verify_ml_dsa_*—primitives/dilithium-crypto/src/scheme_macro.rs:493-501— is pure Rust lattice verification fromqp-rusty-crystals-dilithium, with no host function, so it executes interpreted/JIT-compiled inside the WASM sandbox.
This runs on every extrinsic on every node during block execution, via frame/executive/src/lib.rs:711, and again for every gossiped transaction during pool validation via frame/executive/src/lib.rs:990.
3. Steps to reproduce
These are the plain steps. The complete, runnable PoC with full scripts and captured output is in Section 6.
A user (or attacker) submits an ordinary signed transaction
Any signed extrinsic works; the simplest is a no-op remark. Nothing unusual is required — this is normal chain traffic:
signed with a Dilithium key. In the repo's own benchmarking harness this is built by RemarkBuilder, which signs with the dev account dilithium_bob():
Fill a block with ordinary signed transactions
The normal-dispatch length budget is 0.75 × 5 MiB = 3,932,160 bytes (runtime/src/configs/mod.rs:95). An ML-DSA-87 signed remark encodes to roughly 7,266 bytes, so a full block holds about 541 of them.
This figure does not need to be trusted — the benchmark tool derived it independently during the PoC run:
Observe the accounting gap
Weight recorded into BlockWeight
541 × 108,157 ns = 58.5 ms
Real execution time
541 × 1,340,574 ns = 725.3 ms
Unmetered
666.7 ms
The block reports consuming 1.3 % of the 4.5-second normal-class weight budget while actually costing 725 ms of CPU on every node that imports it. The max_block guard cannot see the difference, so it never engages.
The unpriced variant: no fee at all
Steps 1–5 describe traffic that pays fees. The same verification is also performed during pool validation of gossiped transactions:
uxt.check(..) is the same Verify::verify path shown in §2.4 — the Dilithium lattice verification and the Poseidon hash over the full public key. It runs here before any nonce, solvency, or fee check.
Here no fee is charged and the sender is not required to be solvent. An attacker generates throwaway Dilithium keys, signs transactions with a nonce that will fail, and broadcasts them. Every node performs the full ~1.34 ms verification before discovering the transaction is invalid, and nothing is ever charged because the transaction never enters a block.
4. Impact
Primary: the block weight limit no longer bounds block execution time.
BlockWeight exists so that a block cannot take longer to execute than the network can afford between blocks. With the per-extrinsic base understated 12.39×, that ledger is wrong by 666.7 ms on a full block — against a 12-second target block time (runtime/src/lib.rs:87), roughly 5.6 % of the entire block interval is consumed by work no one is metering.
This is not hypothetical or attacker-dependent: ordinary, fully-paid, legitimate traffic already produces this gap. A miner filling blocks with normal transactions produces blocks whose recorded weight says 58.5 ms and which take ~725 ms to re-execute on every importing node.
Consequences:
Slower or more loaded nodes fall progressively behind on import, because the safety margin that was supposed to prevent this is measuring a number 12× too small.
On a proof-of-work chain whose difficulty controller targets wall-clock block time, systematically longer import times shift the effective time budget in a direction the weight system cannot observe or throttle.
Any future capacity planning based on
BlockWeightis built on a figure that is wrong by more than an order of magnitude.
Secondary: unpriced CPU consumption via the transaction pool. As described in Step 6, gossiped-but-invalid transactions impose the full ~1.34 ms verification on every node with no fee and no solvency requirement.
Also affected: BlockExecutionWeight, the per-block constant, is likewise inherited and understated — 431,614 ns declared (frame/support/src/weights/block_weights.rs:56, from the same 2024-11-08 upstream substrate-node --chain=dev run) versus 1,223,182 ns measured, 2.83× — adding a further ~0.79 ms of unmetered time per block.
What this is not
Stated plainly so the severity is not overread: this is not fee arbitrage. LengthToFeeMultiplier charges 1e6 planck/byte (runtime/src/configs/mod.rs:445) and WeightToFee = IdentityFee charges 1 planck/ps (runtime/src/configs/mod.rs:483). A 7,266-byte extrinsic therefore pays roughly 7.27 ms-equivalent in length fees for ~1.34 ms of real base work — the signature blob's own length fee over-covers its verification by about 5×. An attacker cannot buy in-block compute below cost through this path.
The defect is in the weight ledger that protects block production and import time, and in the pool path, which has no fee at all.
5. Recommendation
5.1 Generate the correct constants
The tooling already exists in the repository and is fully written — node/src/benchmarking.rs:19-53 implements ExtrinsicBuilder, and node/src/benchmarking.rs:101,154-159 already signs with qp_dilithium_crypto::Dilithium87Pair / runtime::Signature::Dilithium87. It has simply never been runnable (see §5.3).
5.2 Stop inheriting the upstream defaults
Replace with_sensible_defaults with an explicit builder so both constants come from this chain:
Note that the correct value (1.341 ms) exceeds Substrate's own sanity ceiling of 1 ms — see §6.6. The generated sane() test will fail and the team will need to make a deliberate decision about that ceiling rather than silently deleting the test.
5.3 Fix the benchmark executor so this can be measured at all
node/src/service.rs hardcodes the executor host functions with no runtime-benchmarks variant:
A runtime built with runtime-benchmarks imports ext_benchmarking_* host functions the node cannot provide, so every benchmark subcommand that constructs a client (overhead, extrinsic, block, storage) aborts at runtime instantiation. Only benchmark pallet works, because it uses frame-benchmarking-cli's own executor — which is exactly why scripts/regenerate_weights.sh only ever invokes benchmark pallet.
The standard upstream wiring (also applied as instrumentation for this PoC, §6.4):
5.4 Address the unpriced pool path separately
Correcting the weight constants does not fix Step 6. Pool validation performs full Dilithium verification for free. Consider per-peer rate limiting on transaction gossip, or a cheap pre-filter (for example, checking the nonce against state before verifying the signature) so an invalid transaction is rejected before the expensive operation.
Proof of Concept
6. Proof of concept
Everything below is complete and self-contained. No external references, no downloads beyond the repository itself.
6.1 Environment used
OS
Ubuntu 24.04.1 LTS (WSL2 on Windows)
CPU
AMD Ryzen 7 5800H
Cores / RAM
12 cores, 9.9 GB
Rust
1.93.0 (pinned by the repo's rust-toolchain)
Targets
wasm32v1-none (required by rust-toolchain)
6.2 Setup — obtain and pin the source
Install the toolchain and system dependencies:
Required environment variable. The build fails at clang-sys v1.8.1 with "couldn't find any valid shared libraries matching: ['libclang.so', ...]" unless libclang is locatable. On Ubuntu 24.04 it lives outside the default search path:
6.3 Build
The runtime-benchmarks feature is mandatory: the entire Benchmark subcommand is gated behind it at node/src/command.rs:498. Build time on the hardware above was 46 minutes. Use -j 6 rather than the full core count — pallet-wormhole's build script peaks around 4.8 GB RSS during ZK circuit generation.
Verify the binary is built from the intended commit:
6.4 Required instrumentation patch (and why it does not affect the result)
As described in §5.3, benchmark overhead cannot run on this repository as shipped. The following patch applies the standard upstream host-function wiring so the project's own command can start.
Save as poc1_patch.diff in the repository root and apply with git apply poc1_patch.diff:
Why this cannot influence the measurement. The patch registers additional host functions with the WASM executor. Those functions (ext_benchmarking_current_time, ext_benchmarking_commit_db, ext_benchmarking_read_write_count, and so on) are called only from inside pallet benchmark code paths. benchmark overhead measures the execution of ordinary System::remark extrinsics and empty blocks, which never invoke any of them. The patch changes only whether the runtime can be instantiated, not what the timed code does.
Without the patch the command aborts before measuring anything:
Rebuild after applying:
6.5 The PoC script
Save the following as poc1_run.sh in the repository root, then chmod +x poc1_run.sh && ./poc1_run.sh. It captures the complete evidence chain — the declared constant, its provenance, proof of no override, the signature type, and finally the measurement.
Adjust CHAIN_DIR to wherever the repository was cloned. OUT may be any writable directory.
6.6 PoC output
Static evidence collected before the measurement:
The benchmark run:
Generated extrinsic_weights.rs, complete and unmodified:
Generated block_weights.rs, complete and unmodified:
6.7 Explanation of the output
The headline numbers.
ExtrinsicBaseWeight
108,157 ns
1,340,574 ns
12.39×
BlockExecutionWeight
431,614 ns
1,223,182 ns
2.83×
Shortfall per extrinsic: 1,340,574 − 108,157 = 1,232,417 ns.
The measurement is stable, not noise. Across 100 repetitions after 10 warmups, the per-extrinsic figure ranged 1,261,221–1,897,952 ns with a standard deviation of 106,242 ns (about 8 % of the mean) and a median of 1,308,359 ns — close to the mean. Even the minimum observed value, 1,261,221 ns, is 11.7× the declared constant. There is no sampling regime in which the declared value is correct.
The tool independently confirms the block-capacity arithmetic. Section 3 Step 4 estimates ~541 ML-DSA-87 extrinsics per full block from the length budget. The benchmark derived the same number by actually building a block: Extrinsics per block: 541.
The two headers, side by side, are the whole finding. The shipped constant reads RUNTIME: Development, dated 2024-11-08, produced by substrate-node — the stock Substrate chain with sr25519 host-function verification. The generated constant reads RUNTIME: Quantus DevNet, produced by quantus-node with real Dilithium signatures. Same command, same parameters (--chain=dev --wasm-execution=compiled --warmup=10 --repeat=100), two different chains, a 12.39× difference — and the runtime is shipping the wrong one.
The correct value violates Substrate's own sanity bound. The generated file carries this assertion:
The measured 1,340,574 ns is 1.34 ms, so the correct constant for this chain fails the framework's own upper bound on a plausible per-extrinsic base. BlockExecutionWeight at 1,223,182 ns stays within its 50 ms ceiling, so only the per-extrinsic constant breaches its bound.
Hardware dependence, stated honestly. Absolute nanosecond values depend on the machine (here an AMD Ryzen 7 5800H). What matters is the ratio: both the declared and measured constants describe the same workload — one no-op extrinsic's decode-and-verify overhead — so the 12.39× gap reflects the difference between sr25519-with-host-function and Dilithium-in-WASM, not between two CPUs. Re-running on the team's own reference hardware will produce different absolute figures and a similar ratio.
Was this helpful?