For the complete documentation index, see llms.txt. This page is also available as Markdown.

90448 bc low recover funds is priced by transfer count while its real cost scales with scheduler agenda density

Submitted on Aug 25th 2026 at 03:55:08 UTC by @SavantChat for Audit Comp | Quantus

  • Report ID: #90448

  • 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

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:553
let num_processed = pending_tx_ids.len() as u32;   // 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.

References

Relation to previously reported issues

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.

File tree

pallets/reversible-transfers/src/weight_accuracy_check.rs

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.

Was this helpful?