89848 bc low scheduled reversible transfer can become stuck in a held state after transfer keep alive failure pallet reversible transfers
Submitted on Aug 21st 2026 at 13:48:11 UTC by @Pelican29685 for Audit Comp | Quantus
Report ID: #89848
Report Type: Blockchain/DLT
Report severity: Low
Target: https://github.com/immunefi-team/audit-comp-quantus-chain
Impacts:
Temporary freezing of funds for at least 24 hour
Description
Brief/Intro
do_execute_transfer builds and dispatches an inner transfer_keep_alive call, then returns that inner dispatch's result directly as its own return value. Because every #[pallet::call] dispatchable is wrapped in with_storage_layer (frame/support-procedural/src/pallet/expand/call.rs:244), an Err from the inner call rolls back everything do_execute_transfer did: the released hold, the PendingTransfers::remove, and the TransactionExecuted event.
Meanwhile pallet_scheduler::service_task (pallets/scheduler/src/lib.rs, ~line 1342) treats ANY dispatch outcome (Ok or Err) as terminal when no retry config is set, and permanently removes the scheduled task. reversible-transfers never calls set_retry/set_retry_named for its execute_transfer tasks (confirmed via grep — no matches in do_schedule_transfer_inner). This is the crux of the impact: it is not enough that the inner dispatch can fail — the finding only matters because there is no retry path once it does.
Net effect: if the inner transfer_keep_alive fails, the reversible transfer's hold rolls back to "still pending", but the Scheduler has already permanently dropped the task, producing a three-way state inconsistency:
PendingTransfers storage
still present (as if never executed)
Held balance
still held
Scheduler task
removed (Scheduler believes the job is done)
No normal actor can resolve this mismatch directly: execute_transfer can only be called by the pallet's own sovereign account (ensure!(who == Self::account_id(), ...), line ~443), so the sender cannot re-trigger it by hand, and there is no reschedule/retry extrinsic in this pallet at all.
Why this is inconsistent with the pallet's intended failure handling
The same file's cancel_transfer (~line 865-897) explicitly guards against this exact failure shape: it separates the fund-release step so a later failure in canceling the scheduled task cannot roll back the release, with an inline comment describing almost verbatim the bug reported here ("...propagating rolls back the whole transactional extrinsic...permanently freezing the held funds..."). do_execute_transfer (the auto-execution path) has no equivalent protection.
This is not a speculative interpretation — it is evidence of the project's own design intent: the developers were already aware that "a transactional rollback here can freeze funds" and built a guard for it in cancel_transfer, but the same guard was never applied to do_execute_transfer.
An existing test, cancel_releases_funds_when_scheduled_task_already_gone, only simulates the scheduler losing the task (calls cancel_named manually). No existing test drives a real execute_transfer failure through the actual Scheduler pallet — that is the gap this PoC fills.
Impact
The scheduled transfer fails to complete, while the funds remain held and the Scheduler task is permanently removed without a retry path.
The recipient receives nothing; the sender's funds remain held with no automatic recovery.
No clear on-chain signal identifies which reversible transfer is stuck — the pallet's own
TransactionExecutedevent is rolled back with everything else. Only the Scheduler's genericDispatched{Err}event remains, which does not say which pallet or which transfer.recover_funds(guardian-only) does not help: it seizes everything to the guardian with a fee, it does not retry the original transfer to the intended recipient.
This is not a fund-loss bug — it is an availability/liveness failure. The reversible-transfer guarantee ("your transfer executes automatically after the delay, or you can cancel it") is silently broken for the affected transfer.
Manual cancellation provides a recovery path, but it does not prevent the liveness failure: the sender must first detect the failure (via a generic, unattributed Scheduler event), identify which of their pending transfers is affected, and submit a separate cancel() transaction. The bug is that the pallet's core promise — automatic execution or explicit cancellation, with no silent third state — is broken; recoverability changes the severity, not the validity, of that break.
Additional low-complexity trigger identified by code analysis (2026-08-20)
The original GHSA explicitly left this unverified: "I did not verify the low-amount/new-account trigger against the full runtime." Identified via code inspection (not yet re-run as a live test against the real runtime):
Real runtime
EXISTENTIAL_DEPOSIT = MILLI_UNIT = 1_000_000_000(runtime/src/lib.rs) — not the mock'sExistentialDeposit = 1. This value is confirmed in the current public runtime source.Standard Substrate/FRAME semantics: an account cannot be created with a balance below the existential deposit. Sending
value < EDto a brand-new (never-before-used) recipient address fails deterministically under standard FRAME balance semantics, regardless oftransfer_keep_alivevstransfer_allow_death— separate from (and in addition to)transfer_keep_alive's own sender-side ED guarantee.Consequence: a user scheduling a reversible transfer of any amount below 1 MILLI_UNIT to a fresh recipient address would hit the same
do_execute_transferfailure/rollback/stuck-hold bug described above, without depending on timing or other balance activity during the delay.Who can trigger this: this is not a remote attacker locking another user's funds. The condition is created by the sender's own choices (either their balance changes during the delay, or they schedule a small amount to a new address) — closer to "any ordinary user could hit this by accident" than "an attacker can remotely lock a victim's funds." This materially affects how Privileges Required / Attack Complexity should be read; flagged explicitly here rather than argued away.
Not independently re-verified with a live
cargo testrun against the realEXISTENTIAL_DEPOSITvalue (the existing PoC used the mock's ED=1 and a balance-drain workaround instead). This is a code-reasoning argument backed by well-documented Substrate semantics, not a second passing test.
What I did not verify
Not tested against a running multi-node testnet, only the pallet's own unit-test runtime.
The mock's
ExistentialDeposit = 1meant I could not trigger the failure through the most natural real-world path (small amount to a brand-new never-used recipient account) inside the test; I modeled the equivalent "sender's other balance spent down during the delay" scenario directly via storage mutation instead. Did not independently re-verify the low-amount/new-account trigger against the full runtime.Did not check whether the same pattern exists in
recover_funds's own transfer-out step (and in fact confirmed separately thatrecover_fundsalready handles this failure class correctly — see below).
Suggested Fix
Make do_execute_transfer handle a failed inner dispatch the same way cancel_transfer already handles a failed cancel_named: release the hold and remove the pending-transfer bookkeeping in their own transactional scope so a later failure of the inner transfer does not roll them back; dispatch the inner transfer as best-effort; on failure, emit a clear event ("this reversible transfer's auto-execution failed, please check on it") instead of silently losing the event to the storage-layer rollback.
Disclosure note
I privately reported this same bug to the Quantus-Network/chain maintainers via GitHub Security Advisory GHSA-f38q-8qpf-7m3c on 2026-08-15 (3 days after this competition's Aug 12 launch), through the project's official SECURITY.md channel. As of today it is still in Triage status — not published, not fixed, visible only to me (as reporter) and the Quantus-Network owners. Per this program's "Private Known Issues Reward Policy" ("Private known issues — known issues that were not publicly disclosed — are valid for a reward at their full severity"), I'm submitting it here as well since it remains unpublished/unfixed and is not listed among the program's stated "Known Issues".
AI tooling disclosure
Claude Code (Anthropic) was used to help read the codebase, form and check the hypothesis, and write/run the proof-of-concept test. The analysis, PoC construction choices, and this report are mine.
Proof of Concept
Repo: audit-comp-quantus-chain (matches Quantus-Network/chain @commit c3f7f3b8bd364ba8487f27c06680150f7fdf0d59 at time of writing)
Test logic
Simulate reduced free balance
Simulate the victim's remaining free balance being spent to 0 by ordinary activity during the delay (models fees/other payments; written directly via frame_system::Account::<Test>::mutate only because the mock's ExistentialDeposit = 1 makes the more realistic "small amount to a brand-new recipient account" trigger unreachable in this test environment; the real runtime's much larger EXISTENTIAL_DEPOSIT = MILLI_UNIT makes that natural trigger far easier to hit for real).
State transition observed by the PoC's assertions
PendingTransfers
present
present
Held balance
held
still held
Recipient balance
0
0
Scheduler task
present
removed
Recovery path
scheduled auto-execution
manual cancel() only
(Full Rust diff/patch available — same as attached to GHSA-f38q-8qpf-7m3c, Quantus-Network/chain, currently in private Triage status, not yet published, reporter: gapbbong.)
Was this helpful?