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
ReversibleTransfers::recover_funds prices its work by counting pending transfers, but the expensive part of each iteration is Scheduler::cancel_named, whose cost scales with how many tasks share the target dispatch block — not with how many transfers the caller has. The benchmark behind the weight advances a block between every schedule, so it only ever measures a one-task agenda, while an ordinary user can put all of their schedules into one agenda key simply by submitting them in a single block. The weight function is agenda-density-blind, and both layouts return byte-identical actual_weight while touching eight agenda keys versus one.
Vulnerability Details
The one runtime-derived parameter is the wrong one
// pallets/reversible-transfers/src/lib.rs:553letnum_processed=pending_tx_ids.len()asu32;// the only runtime-derived weight parameter
Each cancel_named reaches do_cancel_named, which decodes and re-encodes the entire Agenda value for the target dispatch time twice — once in Agenda::try_mutate and once in cleanup_agenda. Clearing a leading slot leaves the vector at full encoded length, so the second pass is not cheaper. None of that depends on num_processed.
Why the code intends otherwise
The benchmark states the assumption in a comment: it advances a block between every scheduled transfer, specifically so the transfers are spread out (pallets/reversible-transfers/src/benchmarking.rs:231). That is a deliberate choice to measure the sparse layout, and the resulting constants — recover_funds(n) = 54_373_808 + n·55_440_982 ps, reads 4+3n, writes 3+4n (pallets/reversible-transfers/src/weights.rs:326) — carry a per-transfer term and no per-agenda term at all. The pricing model therefore encodes "transfers are spread across blocks" as if it were an invariant, while nothing in the extrinsic enforces it.
Attack path
1
Schedule transfers together
Schedule several reversible transfers in a single block, so every one lands in the same Agenda key rather than in separate ones.
2
Trigger recovery
Trigger recover_funds.
3
Receive the sparse-layout price
The dispatch pays WeightInfo::recover_funds(n) — identical to what a sparse layout pays — while performing the denser, more expensive agenda work.
No privileged role, no unusual precondition: clustering is the default outcome of submitting schedules together.
Impact
The PoC builds both layouts and shows the weight is blind to the difference:
An isolated cancel_named micro-measurement across occupancies k = 1..10 fits a slope of 807 ns per extra agenda entry:
The honest reading of those numbers is the reason this is submitted as Low and not higher. The mechanism is real and reachable, but the magnitude is bounded: the scheduler caps a LOWEST_PRIORITY agenda at 40 entries (max - max/5 with MaxScheduledPerBlock = 50), so the worst reachable case undercharges by about 6%. Reaching a 50% undercharge would need occupancy ~330, which the cap makes unreachable. That places it squarely inside the Low bullet — inaccuracies to the weight accounting system that under or overcharge by less than 50% of the correct amount — and explicitly not beyond it.
The damage is recoverable in the sense that no state is corrupted; what is lost is block-space pricing accuracy, systematically and in the caller's favour.
Recommendation
Price the agenda work that is actually performed:
with the benchmark parameterised over agenda occupancy up to the LOWEST_PRIORITY cap, rather than pinned at one task per block.
The more robust alternative is to stop letting the benchmark's convenience choices define the pricing model: where a weight depends on shared storage whose size the caller can influence, the benchmark should sweep that dimension rather than hold it at its minimum. The comment at benchmarking.rs:231 documents the sparse choice honestly — the defect is that the weight formula never learned about it.
Regression test: assert that recover_funds over n transfers clustered in one agenda returns a strictly larger actual_weight than the same n spread across n blocks. Today that assertion fails on byte equality.
The substrate review contains eight weight-accounting or benchmark-accuracy findings, all read. Three are close enough to address directly.
EQ-QNT-REVERSIBLE-F-02 (Low) is the same defect class in this same pallet: both asset scheduling extrinsics reuse WeightInfo::schedule_transfer(), whose benchmark exercises only the native-balance hold path, so "the constant used for pricing asset scheduling is based on the wrong branch." That is a wrong-branch error in a different extrinsic. This finding is a missing-dimension error in recover_funds: the branch is right and the parameter set is incomplete. Fixing F-02 — re-benchmarking the asset path of schedule_transfer — leaves recover_funds priced exactly as it is today.
EQ-QNT-MULTISIG-F-04 (Low) faults a benchmark that claims more precision than it has, in a different pallet. EQ-QNT-SCHEDULER-F-07 concerns the scheduler's own accounting rather than a caller that under-parameterises its use of cancel_named. Neither reaches this.
Proof of Concept
Every file below is given in full; nothing is zipped or linked out.
Repository: https://github.com/immunefi-team/audit-comp-quantus-chain at 3b243b870a5442a6e5f443056f67bbba87d472f5
Prerequisites
The standard Substrate build dependencies: protobuf-compiler and libclang (apt install protobuf-compiler libclang-dev, or the equivalent for your system). With both installed the build scripts find them on PATH; PROTOC, PROTOC_INCLUDE and LIBCLANG_PATH only need setting if yours live somewhere non-standard. The toolchain is the one pinned by the repository's rust-toolchain.toml.
Register it by adding to pallets/reversible-transfers/src/lib.rs:
Run
Observed output
What it asserts
1
Define the relevant limits
Let N be MaxPendingPerAccount and M be the Scheduler’s maximum agenda length.
2
Create controlled accounts
Create an attacker-controlled high-security account A with attacker-controlled guardian G.
3
Cluster transfer schedules
Schedule N transfers from A that all execute at the same future block or timestamp.
4
Fill the scheduler agenda
Using additional ordinary accounts, schedule enough filler transfers for that same dispatch time to grow its Scheduler agenda close to M entries.
5
Ensure recoverable transfers
Ensure sufficient balances and valid recipients so all held-fund releases during recovery succeed.
6
Call recovery
Before the scheduled execution time, call recover_funds(A) as G.
7
Observe the reported weight
Observe that recovery processes N transfers and therefore reports WeightInfo::recover_funds(N).
8
Inspect cancellation behavior
Inspect or benchmark execution: every named cancellation loads and rewrites the same approximately M-entry agenda. Cancelled slots do not reduce the collection’s encoded length while filler tasks remain.
9
Compare against the existing benchmark
Compare this reference time against the existing benchmark, where each cancellation processes a distinct one-entry agenda. The adversarial case performs approximately N × M collection processing while receiving weight derived from N singleton cancellations.
10
Repeat across dispatch times
Repeat the setup across multiple dispatch times and attacker-controlled account pairs. Such recoveries can fit within the declared block weight while consuming substantially more validator execution time.
// pallets/reversible-transfers/src/lib.rs:583 — inside the per-transfer loop
T::Scheduler::cancel_named(id) // cost scales with agenda length, not with n