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

90466 bc medium a failed enactment scheduling attempt destroys pallet referenda s preimage reference so an approved referendum s root call is silently lost

Submitted on Aug 25th 2026 at 07:02:41 UTC by @Valisthea for Audit Comp | Quantus

  • Report ID: #90466

  • Report Type: Blockchain/DLT

  • Report severity: Medium

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

  • Impacts:

    • A bug in the respective layer 0/1/2 network code that results in unintended smart contract behavior with no concrete funds at direct risk

    • Manipulation of governance voting result deviating from voted outcome and resulting in a direct change from intended effect of original results

Description

Summary

pallet-scheduler releases a preimage reference on every failed scheduling path, including on the path used by callers that hand over an already bounded call. pallet-referenda is such a caller. It takes a preimage reference at submit and its enactment retry loop calls schedule_named once per attempt, so a single failed attempt consumes the referendum's only reference.

The fork already draws the distinction that would prevent this. It added a request_preimage: bool parameter precisely because the two caller shapes differ in who owns the reference, and it applies that flag on the request side. It does not apply it on the drop side.

The result is that an approved referendum records Approved, its submission deposit becomes refundable, and its Root call is not pinned when enactment arrives. Where the proposer already reclaimed their preimage deposit, which the fork's own comment and test declare safe, the bytes are deleted synchronously by the failed attempt.

This is a regression, not a pre-existing issue. The audit finding EQ-QNT-SCHEDULER-F-06 named this exact error class and recommended removing it; commit 69e12df (2026-04-30) did so and wrote the rule down; commit 96de0cf (2026-07-06) reintroduced it on four other paths.

The rule, in the vendor's own words

pallets/scheduler/src/lib.rs:1463-1466, the fix for EQ-QNT-SCHEDULER-F-06:

And the fork states that it cannot know whether a v3 shim caller owns a reference, pallets/scheduler/src/lib.rs:793-796:

That uncertainty is exactly why request_preimage exists. do_schedule_named, the v3 shim that pallet-referenda calls, hard-codes it to true (pallets/scheduler/src/lib.rs:919-929):

The defect

The flag governs the request. It does not govern the drop. pallets/scheduler/src/lib.rs:939-967 (do_schedule_named_inner):

Three unconditional drops at :942, :948 and :958; one conditional request at :963. The same shape exists in do_schedule_inner: drops at :828 and :837, conditional request at :846.

The rationale comment at :821-824 states the assumption that makes the drop correct:

That assumption holds for the scheduler's own dispatchables, which call bound themselves. It does not hold for a v3 shim caller, whose reference predates the call and belongs to someone else.

Upstream pallet-scheduler 40.1.0 has no drop on either path (fork-diff-pallets/.upstream/pallet-scheduler-40.1.0/src/lib.rs:966,983-987):

Upstream does contain seven T::Preimages::drop calls, all on cancel and service paths where the scheduler genuinely owns the reference. None in do_schedule.

Why pallet-referenda loses its reference

pallet-referenda takes exactly one reference, at submit (pallets/referenda/src/lib.rs:597):

and releases exactly one, in conclude_ongoing (:1705-1706):

Its enactment loop calls schedule_named once per attempt, up to 17 times (:1077, :1105):

On approval, schedule_enactment runs first and conclude_ongoing runs only inside the Ok arm (:1560, :1570-1575), so referenda still holds its reference when scheduling is attempted. The comment there states the invariant this defect breaks:

The intended arithmetic is 1 then 2 then 1. With one failed attempt it is:

step

RequestStatusFor[H]

preimage noted

Unrequested { ticket, len }

submit requests

Requested { ticket: Some, count: 1 }

attempt 1: place_task returns Exhausted, scheduler drops

Unrequested { ticket, len }

attempt 2 succeeds, scheduler requests

Requested { ticket: Some, count: 1 }

conclude_ongoing drops

Unrequested

The reference referenda drops at the end is the scheduler's, not its own. At enactment the call is not pinned.

Reachability

The trigger is place_task returning DispatchError::Exhausted on a full agenda. The resolve_time path is not reachable from referenda, which clamps the target forward (:1100-1101), and is not part of this report:

Runtime MaxScheduledPerBlock = 50 (runtime/src/configs/mod.rs:345). Enactment priority is 63 and LOWEST_PRIORITY is 255, so the scheduler's 20 percent reservation (reserved 10, cap 40) does not protect the enactment task. The fork concedes the residual case itself at pallets/referenda/src/lib.rs:1072-1074:

Two points matter for how expensive this is:

  1. Only the first attempt has to fail. The 17 attempt loop was sized against losing the enactment entirely, and the same comment reasons that filling seventeen consecutive agendas is expensive. For this defect one full block is enough, because the reference is consumed on the first failure and never restored.

  2. No attacker is required. The fork built a retry loop because it expects agendas to fill in normal operation. Any natural fill of the target block silently unpins an approved proposal.

A deliberate fill of one 50 slot agenda needs 40 permissionless LOWEST_PRIORITY reversible transfers plus at least 10 non LOWEST_PRIORITY tasks; in this runtime the latter are referendum alarms at priority 128, which requires collective membership. I state this plainly because it bounds the adversarial version of the trigger. It does not bound the accidental version.

Impact

An approved tech collective referendum transitions to ReferendumInfo::Approved, its submission deposit becomes refundable, and its Root call is no longer pinned. If the bytes are then removed the scheduler reaches the enactment slot, peek fails, and the task is treated as terminal: Lookup and Retries are cleared and CallUnavailable is emitted. Recovery requires a fresh submit and a full vote cycle. pallet-scheduler's own extrinsics are #[runtime::disable_call], so set_retry cannot rescue it.

This is the only governance lane in the runtime and every referendum dispatches as Root, so the lost call can be a runtime upgrade, including a security patch, or a collective membership change.

The stronger variant needs no second actor. pallets/referenda/src/lib.rs:578-583 explicitly blesses the proposer reclaiming their preimage deposit before approval, on the grounds that the submit time request keeps the bytes alive. In that state RequestStatusFor[H] = Requested { maybe_ticket: None, count: 1 }, and the failed attempt reaches pallets/preimage/src/lib.rs:464-468:

The bytes are deleted at that moment. No cooperating noter, no further action.

That the approved call's effect is lost, and not merely its bytes, is measured directly in proof of concept 3 below: with a free agenda the enactment sets balance(42) = 1, and with the enactment block full the same referendum still reaches Approved while balance(42) stays 0.

Why this is the primary impact row rather than a lesser one

A bug in the respective layer 0/1/2 network code that results in unintended smart contract behavior with no concrete funds at direct risk, clause by clause:

  • layer 1 network code: pallet-scheduler is runtime code of the L1 itself, not application code deployed on top of it.

  • unintended behavior: an approved Root dispatch on the only governance lane does not execute, while the referendum records Approved and refunds its deposits. Nothing in the runtime reports the loss: pallet-referenda passes 60/60, pallet-scheduler passes 89/89, and Referenda::do_try_state passes, all with the defect live.

  • no concrete funds at direct risk: established, not assumed. The variant sweep above shows the only other production caller of these shims owns its own reference, so there is no fund-moving variant. This clause is an entry condition of the row, and this report satisfies it deliberately.

One reading of that row would restrict "smart contract behavior" to contracts deployed on the chain. That reading makes the row unreachable on this target: the runtime contains no pallet-contracts and no contract execution environment at all (balances, frame-system, mining-rewards, multisig, preimage, qpow, ranked-collective, recovery, referenda, reversible-transfers, scheduler, timestamp, transaction-payment, treasury, utility, vesting, wormhole, zk-tree). A row that no finding on this program could ever satisfy would not be in the program's impact list, so the operative reading is unintended on-chain runtime behavior, which is what this is.

For completeness, the case a triager might weigh against a higher tier: no funds move, and a loss that is noticed can be recovered by resubmitting and re-running the vote. The case they might weigh for one: the lost call is a Root dispatch, it can be the set_code that ships a security patch, the failure is silent, and the enactment block is public a full min_enactment_period in advance so the condition can be reproduced against every subsequent referendum.

Note that a call under 128 bytes is Bounded::Inline (frame/support/src/traits/preimages.rs:29) and carries no preimage, so it is unaffected. A set_code runtime upgrade is Lookup by construction.

Variant sweep: which other callers are affected (answer: none)

Stating this up front because it bounds the finding and it is the first thing worth checking. The defect only bites a caller whose preimage reference predates the scheduling call. Every other production caller of the v3 shims in this runtime owns a fresh reference or carries no preimage at all, and for those the drop is correct cleanup:

caller
call scheduled
affected

referenda:1106 schedule_named (enactment)

the referendum proposal, Lookup, requested at submit

yes

referenda:1162 schedule (alarm)

nudge_referendum, a few bytes, Inline

no, drop on Inline is a no-op

reversible-transfers:815 schedule_named

execute_transfer { tx_id }, Inline, and bound by the caller itself one line earlier at :807

no, it owns the fresh reference

pallets/reversible-transfers/src/lib.rs:807:

That is the caller shape the drop was written for. So there is no fund-moving variant of this finding, and I am not claiming one.

Proof of Concept

Three harnesses, all additive, no pre-existing test modified. Their complete sources are inlined in the appendix at the end of this report, so everything needed to reproduce is in this text: no attachment and no external link.

1. End to end through pallet-referenda

pallets/referenda/src/refute_enactment_pin.rs. Complete source in appendix A.1, with the mod lines that wire it in. Real referenda mock with scheduler and preimage, Root track, Bounded::Lookup proposal, enactment block filled to MaxScheduledPerBlock with priority 63 tasks.

The tests are written as refutation attempts: they assert the claim is false, so a failure means the claim survived. The referendum reaches ReferendumInfo::Approved in all three. The control is the load bearing part: with a free agenda the pin survives, so the failed attempt is isolated as the cause.

2. Scheduler level, with a positive control

pallets/scheduler/src/poc_preimage_drop.rs. Complete source in appendix A.2. Unlike harnesses 1 and 3, this one asserts the OBSERVED behaviour directly rather than the safe outcome, so here a passing run is the proof.

3. The executed effect, with a positive control

The two harnesses above prove the reference is dropped and that the bytes are gone by enactment time. This one measures the consequence instead of inferring it: whether the approved Root call's effect ever happens.

pallets/referenda/src/refute_enactment_impact.rs. Complete source in appendix A.3. Same real referenda mock with scheduler and preimage, Root track, Bounded::Lookup proposal. The proposal is the mock's own set_balance_proposal(1), i.e. Balances::force_set_balance { who: 42, new_free: 1 } (pallets/referenda/src/mock.rs:336-339), a Root only call, so balance(42) is a direct read of whether enactment ran.

Written as refutation attempts like the others, so a failure means the claim survived. The referendum reads ReferendumInfo::Approved(9, ..) in all three runs. The control is the load bearing part: same referendum, same unnote_preimage deposit reclaim, only the agenda differs, and the call executes. CallUnavailable reads false at block 13 because that is one block before the enactment slot, and true once the run is carried through it.

Two things stated plainly so neither is read as more than it is. The harness fills the agenda through the ScheduleAnon trait at priority 63 rather than through an extrinsic, because pallet-scheduler's extrinsics are #[runtime::disable_call]. That models the governance lane congestion described under Reachability, not a permissionless fill. And the run above sets CARGO_PROFILE_TEST_DEBUG_ASSERTIONS=false to match the production profile. With debug assertions left on, the same run instead trips the fork's own guard at pallets/preimage/src/lib.rs:579:

That debug_assert! is the fork's code, not ours, and it fires because the request counter is decremented past what the scheduler ever took.

What no existing test catches

pallet-referenda passes 60/60 and pallet-scheduler passes 89/89 with the defect live, and Referenda::do_try_state passes inside the failing tests above. The vendor test that locks in the drop, pallets/scheduler/src/tests.rs:835-860 (failed_schedule_drops_noted_preimage), binds the call inside the test with Preimage::bound, so it models only a caller that owns a fresh reference. That caller shape is the one for which the drop is correct.

Fix

The distinction already exists as a parameter. Carry it to the drop side: a caller that did not have its preimage requested by this function must not have it dropped by this function either.

and the same at the three failure paths in do_schedule_named_inner (:942, :948, :958).

Note the flag polarity: request_preimage == true means "this caller handed over a call whose reference this function must acquire", so on failure there is nothing this function acquired to release. The minimal correct form is therefore to invert the guard relative to the request, or more readably to drop only after a reference has actually been taken. Either form must preserve the existing cleanup for the dispatchable entrypoints, which do own the reference they bound.

Regression tests worth adding:

  • an external holder's request survives a failed do_schedule_named from a v3 shim caller;

  • an approved referendum's proposal is still Requested after an enactment attempt that hit Exhausted and then succeeded on retry.

Defence in depth: Referenda::do_try_state currently checks counts and metadata only. Asserting that every Ongoing and Approved referendum with a Lookup proposal is still requested would have caught this.

Why this is not EQ-QNT-SCHEDULER-F-06

The audit report quantus-substrate-audit-20260513 contains EQ-QNT-SCHEDULER-F-06: Retry cloning does not preserve preimage ownership for lookup-backed tasks. That finding is about the retry path. Its verbatim quote of the ordinary scheduling paths shows no drop at all:

The code reported here did not exist when the audit was performed. The audit also named this exact error class and recommended removing it:

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.

Repository history shows both halves:

git merge-base --is-ancestor 69e12df 96de0cf confirms #617 descends from #557. The anti pattern was correctly removed, the rule was written down, and it was reintroduced on four other paths sixty seven days later.

Appendix: complete harness sources

Everything needed to reproduce is in this report. Create the three files at the paths below, add the mod lines in A.0, and run the three commands already given. No file is modified except those three mod declarations, and no existing test is touched.

A.0 Wiring

In pallets/referenda/src/lib.rs, immediately after line 115 (use sp_runtime::traits::BlockNumberProvider;):

In pallets/scheduler/src/lib.rs, immediately after line 65 (mod benchmarking;):

A.1 pallets/referenda/src/refute_enactment_pin.rs

A.2 pallets/scheduler/src/poc_preimage_drop.rs

A.3 pallets/referenda/src/refute_enactment_impact.rs

Was this helpful?