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

90369 bc low a failed enactment placement destroys an approved referendum s proposal so the voted root call never executes

Submitted on Aug 24th 2026 at 17:37:30 UTC by @gzeon for Audit Comp | Quantus

  • Report ID: #90369

  • Report Type: Blockchain/DLT

  • Report severity: Low

  • Target: https://github.com/immunefi-team/audit-comp-quantus-chain

  • Impacts:

    • Contract fails to deliver promised returns, but doesn't lose value

Description

Brief/Intro

When a referendum passes, schedule_enactment asks the scheduler to place the enactment task, retrying on later blocks if the target agenda is full. On each failed attempt the Quantus scheduler calls T::Preimages::drop on the proposal, but it takes its own preimage reference only after a successful placement. On the failure path it therefore drops a reference it never held: the one Referenda::submit took to pin the proposal for its whole lifecycle. If the preimage noter has already called unnote_preimage, that was the last reference, and the proposal bytes are deleted. A later retry then places the enactment successfully, so the referendum is recorded Approved while its call no longer exists. At the enactment block the scheduler finds the call unavailable and terminally removes the task. The vote is recorded as passed and the Root action it authorised never happens. The drop is a Quantus regression: 96de0cf9 (2026-07-06) added it to the two schedule paths two months after 69e12df9 (2026-04-30) had removed the identical drop from schedule_retry on an auditor's recommendation. The trigger is a full agenda at the enactment block. Permissionless traffic alone cannot produce one, because the scheduler reserves the top fifth of every agenda against LOWEST_PRIORITY tasks and the enactment itself takes one of the reserved slots, so this fires when ten higher-priority tasks already sit on that block rather than on demand.

Vulnerability Details

The scheduler drops a reference it never took

// pallets/scheduler/src/lib.rs:955
let res = match Self::place_task(when, task) {
    Ok(res) => res,
    Err((e, task)) => {
        T::Preimages::drop(&task.call);
        return Err(e)
    },
};
// See `do_schedule_inner` (V12 audit #162453).
if let Some(hash) = lookup_hash.filter(|_| request_preimage) {
    T::Preimages::request(&hash);
}
Ok(res)

The request happens after place_task succeeds. On the failure path the scheduler holds no reference of its own, so the drop decrements somebody else's. Upstream pallet-scheduler 40.1.0 has no drop on that path:

The intent of the Quantus addition is stated at pallets/scheduler/src/lib.rs:939: "drop any preimage noted by StorePreimage::bound on every fallible path so a failed schedule cannot leave an unowned, system-requested preimage behind." That is right for a caller that handed over a call the scheduler itself bound and noted. It is wrong for a caller that passes an existing Bounded::Lookup it already owns a request on, and that is exactly what referenda does.

The same file states the correct rule 500 lines further down, in schedule_retry:

That is the rule do_schedule_named_inner and do_schedule_inner break. request_preimage is true on the referenda path because the v3 Named trait routes through do_schedule_named (pallets/scheduler/src/lib.rs:1633), which calls do_schedule_named_inner(..., true) (:928). The dispatchables instead pass lookup_already_requested(&call) (:439, :476, :509), which is what makes the trait path the one that hands the scheduler a reference it does not yet own.

This is a regression introduced while fixing the audited sibling

Equilibrium's May 2026 review of this pallet reported EQ-QNT-SCHEDULER-F-06, "Retry cloning does not preserve preimage ownership for lookup-backed tasks" (Low, chain#557), against schedule_retry. Its recommendation names the rule this finding breaks:

The Err branch in schedule_retry is also unbalanced in the opposite direction: it drops the cloned task even though that clone never acquired its own request in the first place.

Recommendation: [...] In either model, remove the spurious drop on the failed retry-placement branch.

The same report quotes the schedule paths as they stood at review time, and they have no drop on the failure path:

So schedule_retry was fixed, and it now carries the rule in a comment (pallets/scheduler/src/lib.rs:1462). The drop it was told to remove was then installed in do_schedule_inner and do_schedule_named_inner, under a different V12 item (#162453, cited in the code at :963). F-06 covered schedule_retry. This is the same defect in the two functions the audit had quoted as correct.

Git dates both steps:

69e12df9 carries the audit fix. Two months later 96de0cf9 adds four failure-path drops across do_schedule_inner and do_schedule_named_inner, including the two at :942 and :948 that share the same ownership problem.

Referenda owns a request and depends on it surviving to enactment

submit verifies the lookup preimage exists, bounds its size, and requests it (pallets/referenda/src/lib.rs:597). The stated reason is in the source at :583: the request holds the bytes through enactment so unnote cannot delete them. That also lets the proposer reclaim the preimage deposit while the bytes stay pinned, which is the documented workflow.

After unnote_preimage, the referendum's request is the only thing keeping the bytes alive: RequestStatusFor is Requested { maybe_ticket: None, count: 1, maybe_len: Some(len) }. One unrequest takes count to zero, and pallets/preimage/src/lib.rs:459 then removes the bytes outright.

An Exhausted placement failure is handled inside the same call

Sliding forward one block on a full agenda was added so a full block cannot lose an enactment (#160560, #162455). The retry loop calls the buggy path once per failed attempt, so a single congested block is enough: attempt one at T fails and destroys the proposal, attempt two at T+1 succeeds, and the referendum is committed as Approved with a proposal that no longer exists.

What it takes to reach it, and what does not

MaxScheduledPerBlock is 50 (runtime/src/configs/mod.rs:345) and push_to_agenda reserves the top fifth against LOWEST_PRIORITY tasks, so permissionless traffic tops out at 40 slots. The reservation exists for exactly this reason:

With MaxScheduledPerBlock at 50 that is reserved = 10, cap = 40. That guard holds, and it is why this finding is rated where it is. The enactment runs at priority 63, which is exempt from that reservation, so with only the 40 permissionless tasks present it lands in slot 41 and placement succeeds. Reaching the bug needs all 50 slots occupied at the moment schedule_enactment runs, which means ten tasks above LOWEST_PRIORITY already sitting on the enactment block.

Ordering matters, because the guard tests total occupancy rather than the count of LOWEST_PRIORITY tasks. The 40 permissionless tasks have to arrive while occupancy is still under 40; if ten higher-priority tasks land first, permissionless insertion stops at 30 and the block holds 40, leaving headroom.

The higher-priority sources are referendum alarms (priority 128), other referenda's enactments (priority 63), and, since ScheduleOrigin is EnsureRoot (runtime/src/configs/mod.rs:353), any task a passed Root proposal schedules directly. None of the first two can be aimed at a chosen block on short notice: a vote re-arms an alarm to the next block only (pallets/referenda/src/lib.rs:956), and the enactment block is at least one min_enactment_period after approval, so a vote-driven alarm always arrives after the placement it would have to precede. Ten enactments can share a distant target, but max_deciding is 1, so producing them takes about ten days. The undeciding-timeout alarm can be aimed exactly and fires 45 days after submission.

Every deliberate construction of those ten therefore runs through the Technical Collective. This report does not claim one. The trigger it relies on is ordinary scheduling load arriving in the order above, which the retry loop exists to handle gracefully, and the defect is the unbalanced accounting that fires when it does.

Why the in-tree test misses it

full_enactment_block_agenda_retries_on_next_block (pallets/referenda/src/tests.rs:284) congests the enactment block and asserts the retry executes on the next one. It passes, because set_balance_proposal_bounded produces a Bounded::Inline call for a proposal that small. Inline proposals carry their bytes in the task itself and have no preimage reference to lose. The PoC below is that same test with the proposal changed to Bounded::Lookup, which is the shape submit's request path exists to serve. StorePreimage::bound inlines anything up to the 128-byte BoundedInline cap, so small Root calls are Inline and unaffected; runtime upgrades and anything larger are Lookup.

Impact Details

(Blockchain / DLT) Contract fails to deliver promised returns, but doesn't lose value. The voted Root call is silently discarded. No value moves and nobody gains: the referendum's outcome is destroyed rather than altered.

The chain records ReferendumInfo::Approved and emits the approval, and the Root call the collective voted for never runs. The scheduler reaches the enactment block, Preimages::peek fails, and the task is terminally removed as CallUnavailable (peek fails at pallets/scheduler/src/lib.rs:1294, event at :1306). Nothing re-queues it and the referendum cannot be re-run, because it is no longer Ongoing.

The single tech-collective track is the chain's only governance lane and dispatches Root, so the suppressed action can be a runtime upgrade, a treasury spend, a collective membership change, or any other Root call.

The code states the invariant this breaks. At the approval branch, pallets/referenda/src/lib.rs:1570 reads: "The scheduler has just taken its own request for the enactment call in schedule_named, so after we drop the submit-time request the bytes stay pinned until dispatch." That holds when placement succeeds first time. After a failed attempt the scheduler's request is byte-less, because the failed attempt deleted the bytes, and conclude_ongoing then drops the submit-time request as designed, leaving neither bytes nor request.

Recovery is possible and is not free. Anyone may note_preimage the public bytes before the enactment block, which restores what Preimages::peek needs, but the new note is Unrequested and carries its noter's deposit, and its noter can unnote it again before dispatch. It also depends on somebody noticing a Cleared event from pallet-preimage and connecting it to a pending enactment.

References

  • pallets/scheduler/src/lib.rs:958 the drop on the placement-failure path, inside the match opening at :955

  • pallets/scheduler/src/lib.rs:964 the scheduler's own request, taken only after success, guarded at :963

  • pallets/scheduler/src/lib.rs:939 the stated reason for the drop

  • pallets/scheduler/src/lib.rs:748 the LOWEST_PRIORITY agenda reservation

  • pallets/scheduler/src/lib.rs:1294, :1306 terminal removal on CallUnavailable

  • pallet-scheduler-40.1.0/src/lib.rs:1074 upstream, which does not drop on failure

  • pallets/referenda/src/lib.rs:574-:583 why the request is taken, :587-:596 the existence and size checks, :597 the request itself

  • pallets/scheduler/src/lib.rs:1463-:1466 the same file stating the rule this breaks

  • Equilibrium, Quantus Network: Substrate and Node Review, 2026-05-13, EQ-QNT-SCHEDULER-F-06 (pages 56-57): the audited sibling in schedule_retry, its recommendation to remove the drop, and the schedule paths quoted without one

  • pallets/scheduler/src/lib.rs:1633, :928 where request_preimage becomes true, and :439, :476, :509 where the dispatchables pass lookup_already_requested instead

  • pallets/referenda/src/lib.rs:1105 the enactment retry loop

  • pallets/referenda/src/lib.rs:956 a vote re-arms the referendum alarm for the next block

  • pallets/referenda/src/lib.rs:1165 alarms run at priority 128

  • pallets/referenda/src/tests.rs:284 the in-tree test that covers this path with an inline call

  • pallets/preimage/src/lib.rs:459 the count-one branch, :465-:468 where the bytes and status are removed

  • pallets/preimage/src/lib.rs:359-:361, :378-:380 repopulating a byte-less request

  • pallets/reversible-transfers/src/lib.rs:467 the signed entrypoint, :776-:785 the caller-chosen target, :818 the LOWEST_PRIORITY it schedules at

  • runtime/src/configs/mod.rs:242, :345 MaxActiveReferendaPerAccount, MaxScheduledPerBlock

  • runtime/src/governance/definitions.rs:117 confirm_period, :118 min_enactment_period, :119 min_approval

Proof of Concept

End to end in the referenda pallet's own test harness, which wires the real pallet-preimage and the real pallet-scheduler. Local only.

What is the mock rather than the runtime, none of which affects the over-drop: MaxScheduledPerBlock is 100 here against the runtime's 50; SubmitOrigin is EnsureSigned rather than members-only and there is no ranked collective; prepare_period, decision_period, confirm_period and min_enactment_period are 2 to 4 blocks rather than hours or days; UndecidingTimeout is 20; deposits, MaxActive, MaxActivePerAccount, MaxQueued and MaxProposalSize differ; the runtime's 16-pending-transfer-per-account cap has no counterpart; and the congestion is placed by Root. Those change how a full agenda is reached and what it costs, not what happens once it is full.

The proposal is also an 11-byte call wrapped as Bounded::Lookup by hand. That is legal and it is what makes the path visible in one short test, but a call that small would be Inline in production. The shape this reaches in production is any proposal over the 128-byte BoundedInline cap, runtime upgrades included.

1

Setup

2

Add the test

Append to pallets/referenda/src/tests.rs:

3

Run and output

Run the PoC alongside the in-tree test it is derived from, so the difference is visible in one command:

Same congestion, same approval at block 9, same retry onto block 14. The in-tree test's inline proposal executes and sets free_balance(42) to 1. The Bounded::Lookup proposal is destroyed by the failed first attempt, and force_set_balance(42, 1) never runs at block 14 or at any block after it.

Suggested fix

On the placement-failure path, drop only a reference the scheduler owns.

The same pattern is in do_schedule_inner (pallets/scheduler/src/lib.rs:828, :837), and the two unconditional drops earlier in do_schedule_named_inner itself (:942 on a Lookup name collision, :948 on a resolve_time error) have the same ownership problem. Upstream 40.1.0 does not drop on any of these paths, and schedule_retry at :1462 already documents why.

A regression test belongs next to full_enactment_block_agenda_retries_on_next_block, using a Bounded::Lookup proposal and an unnoted noter, since the existing test's inline call cannot reach this path.

Was this helpful?