90377 bc low stale transaction payment benchmark omits quantus fee collector storage undercharging the extension by 48 12 in the measured run
Submitted on Aug 24th 2026 at 18:21:54 UTC by @Pig46940 for Audit Comp | Quantus
- Report ID: #90377
- Report Type: Blockchain/DLT
- Report severity: Low
- 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 less than 50% of the correct amount (and are not intentional as indicated by the program's terms
Description
Brief/Intro
Quantus configures TransactionFeesCollector as the fee handler used by ChargeTransactionPayment, but continues to use a transaction-payment weight generated against the upstream Kitchensink runtime. That checked-in weight does not include the MiningRewards::CollectedFees storage accessed by the Quantus fee collector. A benchmark-enabled Quantus runtime built from the pinned production configuration recorded this additional storage and measured a transaction-payment extension weight of 310,318,000 ref-time, while the runtime declares only 160,979,000. This is a 48.1245% undercharge in the preserved benchmark run.
Vulnerability Details
A normal fee-paying signed extrinsic reaches the Quantus collector through the following path:
ChargeTransactionPayment::post_dispatch_details
→ OnChargeTransaction::correct_and_deposit_fee
→ TransactionFeesCollector::on_nonzero_unbalanced
→ collect_transaction_fees
→ CollectedFees::mutate
→ FeesCollected event
The production configuration selects the Quantus collector while continuing to use the checked-in transaction-payment WeightInfo:
type OnChargeTransaction =
FungibleAdapter<Balances, pallet_mining_rewards::TransactionFeesCollector<Runtime>>;
type WeightInfo =
pallet_transaction_payment::weights::SubstrateWeight<Runtime>;
The checked-in transaction-payment weight says it was generated using kitchensink_runtime.wasm. Its storage ledger contains only:
System::Account (r:1 w:1)
Under Quantus’s configured RocksDbWeight, the checked-in ref-time is:
35,979,000 + reads(1) + writes(1)
= 35,979,000 + 25,000,000 + 100,000,000
= 160,979,000
I rebuilt the pinned Quantus runtime with the runtime-benchmarks feature and ran the dedicated transaction-payment benchmark using compiled-Wasm execution, 50 steps, and 20 repeats.
The preserved benchmark run generated this storage ledger:
System::Account (r:1 w:1)
MiningRewards::CollectedFees (r:1 w:1)
Its generated ref-time formula was:
60,318,000 + reads(2) + writes(2)
= 60,318,000 + 50,000,000 + 200,000,000
= 310,318,000
The additional MiningRewards::CollectedFees read and write account for 125,000,000 ref-time under the configured database schedule. The generated benchmark also measured additional computation beyond those storage terms.
The difference observed in the preserved benchmark run is:
absolute undercharge = 310,318,000 - 160,979,000
= 149,339,000
percentage undercharge = 149,339,000 / 310,318,000 × 100
= 48.124504540504%
The precise benchmark computation term can vary with the benchmark host. The reproducible structural result is that the Quantus benchmark adds MiningRewards::CollectedFees (r:1 w:1), reads(2), and writes(2), while the checked-in formula contains only System::Account (r:1 w:1), reads(1), and writes(1).
ChargeTransactionPayment::weight() returns this checked-in transaction-payment weight as the extension’s declared weight. During post-dispatch, the same extension invokes the configured OnChargeTransaction, which reaches the omitted Quantus collector storage.
A separate lifecycle test confirms that:
- A normal signed
System::remarkcan reach the production fee collector. - The extension declares
160,979,000ref-time. MiningRewards::CollectedFeesincreases.- The
FeesCollectedandTransactionFeePaidevents are emitted. - Post-dispatch returns zero unspent weight.
That lifecycle test directly invokes the extension lifecycle and does not execute the extrinsic through FRAME Executive. It is therefore used only to establish production callback reachability and state mutation. The numerical discrepancy is established by the dedicated compiled-Wasm benchmark and its generated storage ledger.
The appropriate fix is to regenerate pallet_transaction_payment weights using Quantus’s configured fee collector, or to compose the collector’s weight into ChargeTransactionPayment exactly once.
Impact Details
The selected Blockchain/DLT impact is:
Inaccuracies to the weight (similar to gas) accounting system that under or overcharge by less than 50% of the correct amount (and are not intentional as indicated by the program’s terms).
Severity: Low
In the preserved benchmark run, the transaction-payment extension’s declared ref-time is 160,979,000, while the benchmark-generated ref-time is 310,318,000. The resulting discrepancy is 48.124504540504%, which is below the program’s 50% Medium threshold.
Any funded account can reach the affected callback by submitting an ordinary fee-paying signed extrinsic. The omitted work is therefore executed repeatedly in normal production transaction processing.
This report does not claim network shutdown, liveness failure, theft, asset loss, or Medium severity.
Report #88529 concerns ML-DSA verification omitted from ExtrinsicBaseWeight. This report concerns a different accounting component: the weight returned by ChargeTransactionPayment::weight() and the Quantus-specific fee-collector storage executed during its post-dispatch lifecycle.
A reviewer may nevertheless decide to group both reports as stale fixed-cost benchmarking issues or calculate the discrepancy against a larger complete-extrinsic denominator. This report therefore limits its claim to the dedicated transaction-payment extension formula measured by the provided benchmark.
References
Production transaction-payment configuration, lines 451–464:
Transaction-payment benchmark, lines 45–90:
Transaction-payment extension lifecycle and weight consumer, lines 990–1080:
Checked-in Kitchensink-generated weight, lines 35–91:
CollectedFees storage, lines 35–38:
collect_transaction_fees, lines 195–203:
TransactionFeesCollector::on_nonzero_unbalanced, lines 292–301:
Configured RocksDB weight schedule, lines 23–29:
Link to Proof of Concept
https://gist.github.com/HiroyukiNaito/39deea6b07a58a607148d58b80155835
Proof of Concept
Minimal evidence Gist:
https://gist.github.com/HiroyukiNaito/39deea6b07a58a607148d58b80155835
Clone the audited repository
git clone https://github.com/immunefi-team/audit-comp-quantus-chain.git
cd audit-comp-quantus-chain
git checkout 3b243b870a5442a6e5f443056f67bbba87d472f5
git rev-parse HEAD
Expected revision:
3b243b870a5442a6e5f443056f67bbba87d472f5
Build the benchmark-enabled node
CARGO_BUILD_JOBS=1 cargo build \
--release \
-p quantus-node \
--features runtime-benchmarks
Run the transaction-payment benchmark
mkdir -p outputs/transaction-fees-collector-benchmark
target/release/quantus-node benchmark pallet \
--runtime target/release/wbuild/quantus-runtime/quantus_runtime.wasm \
--genesis-builder none \
--pallets pallet_transaction_payment \
--extrinsic charge_transaction_payment \
--steps 50 \
--repeat 20 \
--wasm-execution compiled \
--heap-pages 4096 \
--no-storage-info \
--no-median-slopes \
--no-min-squares \
--output outputs/transaction-fees-collector-benchmark/run1-weights.rs \
--json-file outputs/transaction-fees-collector-benchmark/run1-raw.json
Inspect the generated weight
sed -n '45,70p' \
outputs/transaction-fees-collector-benchmark/run1-weights.rs
sed -n '35,92p' \
pallets/transaction-payment/src/weights.rs
The preserved benchmark run produced:
/// Storage: `System::Account` (r:1 w:1)
/// Storage: `MiningRewards::CollectedFees` (r:1 w:1)
Weight::from_parts(60_318_000, 0)
.saturating_add(Weight::from_parts(0, 3593))
.saturating_add(T::DbWeight::get().reads(2))
.saturating_add(T::DbWeight::get().writes(2))
A reproduction should independently confirm:
MiningRewards::CollectedFees (r:1 w:1)
reads(2)
writes(2)
The exact measured computation term can vary with the benchmark host.
The checked-in weight contains only:
/// Storage: `System::Account` (r:1 w:1)
Weight::from_parts(35_979_000, 3593)
.saturating_add(T::DbWeight::get().reads(1))
.saturating_add(T::DbWeight::get().writes(1))
Download and verify the lifecycle-test patch
curl -fL \
'https://gist.githubusercontent.com/HiroyukiNaito/39deea6b07a58a607148d58b80155835/raw/697ab2864ed1796b4235a569e4daf389b4148443/poc.patch' \
-o poc.patch
sha256sum poc.patch
Expected SHA-256:
416ffc8a2c8803e2742c35f67c7787f49409d8199dca5ec2c6dd6aab59430ad6 poc.patch
Apply the patch:
git apply poc.patch
Run the lifecycle test
First confirm the test name:
cargo test -p quantus-runtime -- --list | \
grep -F 'researcher_poc_transaction_fee_collector'
Run only the researcher test:
cargo test -p quantus-runtime \
transaction_extensions::tests::researcher_poc_transaction_fee_collector_executes_unbenchmarked_storage_work \
-- --exact --nocapture
The preserved successful run included:
running 1 test
DECLARED_TRANSACTION_PAYMENT_WEIGHT: Weight { ref_time: 160979000, proof_size: 3593 }
COLLECTED_FEES_BEFORE: 0
COLLECTED_FEES_AFTER: 269136001
FEES_COLLECTED_EVENT_EMITTED: true
TRANSACTION_FEE_PAID_EVENT_EMITTED: true
RETURNED_UNSPENT_WEIGHT: Weight { ref_time: 0, proof_size: 0 }
test transaction_extensions::tests::researcher_poc_transaction_fee_collector_executes_unbenchmarked_storage_work ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 50 filtered out
The lifecycle test establishes that the production transaction-payment configuration reaches TransactionFeesCollector and mutates CollectedFees during post-dispatch.
The benchmark establishes that this storage appears in the generated transaction-payment weight but is absent from the checked-in weight consumed by the runtime.