88251 bc medium the per extrinsic weight ledger undercharges by 91 9 the runtime inherits upstream substrate s sr25519 constant while verifying post quantum dilithium signatures in wasm
Submitted on Aug 12th 2026 at 12:00:20 UTC by @shadowHunter for Audit Comp | Quantus
Report ID: #88251
Report Type: Blockchain/DLT
Report severity: Medium
Target: https://github.com/immunefi-team/audit-comp-quantus-chain
Impacts:
Inaccuracies to the weight (similar to gas) accounting system that under or overcharge by more than 50% of the correct amount (and are not intentional as indicated by the program's terms)
Description
1. Summary
frame_system::Config::BlockWeights sets base_extrinsic, a weight charged once per extrinsic to account for the work performed before the call body runs: SCALE decoding and, principally, signature verification.
Quantus never overrides it, so it inherits upstream Substrate's constant of 108,157 ns — a figure upstream measured on the stock Substrate development chain, which uses sr25519 verified by 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:
ExtrinsicBaseWeight
108,157 ns
1,340,574 ns
91.9%
BlockExecutionWeight
431,614 ns
1,223,182 ns
64.7%
Both exceed the 50% threshold. The per-extrinsic figure is the primary claim; the per-block constant is the same defect from the same inherited file and is included for completeness.
The result does not depend on averaging. Across 100 repetitions, the fastest single observation was 1,261,221 ns — still a 91.4% undercharge. There is no sampling regime in which the declared value is within 50% of correct.
2. Description
2.1 What base_extrinsic is for
It is added to every extrinsic's consumed weight 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.
base_extrinsic is therefore the only budget line 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 generated header records 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 (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, neither reflected in the 108,157 ns:
into_account()—primitives/dilithium-crypto/src/traits.rs:197-198—qp_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— pure Rust lattice verification fromqp-rusty-crystals-dilithium, with no host function, executing inside the WASM sandbox.
This is the delta the inherited constant cannot describe: upstream measured a native host-function call; this chain performs lattice verification in WASM.
3. Steps to reproduce
The complete runnable PoC, with scripts and captured output, is in §7. In outline:
The benchmark's RemarkBuilder signs with a real Dilithium key, so the measurement exercises the actual verification path:
4. Impact
4.1 The claim, measured against the threshold
The impact selected is graded on how far the accounting deviates from the correct amount:
ExtrinsicBaseWeight
BlockExecutionWeight
Correct (measured on this chain)
1,340,574 ns
1,223,182 ns
Charged (inherited)
108,157 ns
431,614 ns
Shortfall
1,232,417 ns
791,568 ns
Undercharge as % of correct
91.9%
64.7%
Threshold for Medium
50%
50%
The declared per-extrinsic value is 8.1% of the correct one — the ledger records about one twelfth of the work it is supposed to account for.
Robustness. The measurement is 100 repetitions after 10 warmups: range 1,261,221–1,897,952 ns, median 1,308,359 ns, standard deviation 106,242 ns (≈8% of the mean). Taking the most conservative single sample — the fastest of the 100 — the undercharge is still 91.4%. The conclusion does not depend on the mean, the median, or outlier handling.
6. Recommendation
6.1 Generate the correct constants
The tooling already exists and is fully written — node/src/benchmarking.rs implements ExtrinsicBuilder and already signs with Dilithium.
6.2 Stop inheriting the upstream defaults
Replace with_sensible_defaults with an explicit builder so both constants come from this chain:
The correct value (1.341 ms) exceeds Substrate's own sanity ceiling of 1 ms, so the generated sane() test will fail (§7.7). That is a deliberate decision for the team to make — the ceiling encodes an assumption about signature cost that a post-quantum chain breaks — rather than something to silently delete.
6.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:
This is arguably the more consequential of the two fixes: without it the project cannot measure its own overhead constants at all.
Proof of Concept
7. Proof of concept
Everything below is complete and self-contained. No external references, no downloads beyond the repository itself.
7.1 Environment
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
7.2 Setup
7.3 Build
The runtime-benchmarks feature is mandatory: the entire Benchmark subcommand is gated at node/src/command.rs:498-499. 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.
7.4 Required instrumentation patch (and why it cannot affect the result)
As described in §6.3, benchmark overhead cannot run as shipped. Save as poc1_patch.diff 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, 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.
7.5 The PoC script
Save as poc1_run.sh, chmod +x, run. It captures the whole evidence chain: the declared constant, its provenance, proof of no override, the signature type, and the measurement.
7.6 PoC output
Static evidence collected before the measurement:
The benchmark run:
Generated extrinsic_weights.rs, complete and unmodified:
Generated block_weights.rs produces BlockExecutionWeight = 1_223_182 ns against the shipped 431,614 ns (frame/support/src/weights/block_weights.rs:56).
7.7 Explanation of the output
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 ships the wrong one.
The measurement is stable, not noise. 100 repetitions after 10 warmups: standard deviation 106,242 ns (≈8% of the mean), median 1,308,359 ns close to the mean 1,340,574 ns. Even the fastest single sample, 1,261,221 ns, represents a 91.4% undercharge.
The correct value violates Substrate's own sanity bound. The generated file asserts w.ref_time() <= WEIGHT_REF_TIME_PER_MILLIS ("At most 1 ms"). 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 — a direct indication that a post-quantum signature scheme breaks an assumption baked into the inherited tooling. BlockExecutionWeight at 1,223,182 ns stays within its 50 ms ceiling.
Hardware dependence, stated honestly. Absolute nanosecond values depend on the machine. What matters is the ratio: both constants describe the same workload — one no-op extrinsic's decode-and-verify overhead — so the 12.39× gap reflects sr25519-with-host-function versus Dilithium-in-WASM, not two CPUs. Re-running on the team's reference hardware will produce different absolute figures and a similar ratio, and the 50% threshold is cleared by a factor of nearly two even at the most conservative sample.
Was this helpful?