89136 bc critical missing deposit release calls in the high security whitelist permanently freeze reserved balances including an uninvolved third party s recovery deposit
Submitted on Aug 17th 2026 at 12:40:04 UTC by @OxPrince for Audit Comp | Quantus
Report ID: #89136
Report Type: Blockchain/DLT
Report severity: Critical
Target: https://github.com/immunefi-team/audit-comp-quantus-chain
Impacts:
Permanent freezing of funds
Description
Brief/Intro
Quantus offers two account-protection features that the runtime deliberately wires together: high-security mode, which restricts an account to a closed whitelist of four calls, and social recovery, which requires refundable deposits from both the account owner and the rescuer.
None of the four whitelisted calls can decrease a reserved balance, so the two features cannot be combined without permanently freezing funds — and no enrolment order avoids it:
Configure recovery first and its 10–19 UNIT deposit is stranded the moment you enrol.
Enrol first and
create_recoveryis not whitelisted at all, so the account can never configure recovery.
The damage is not confined to the account owner. A rescuer who completes a fully legitimate M-of-N social recovery of a high-security account cannot reclaim their own 10 UNIT RecoveryDeposit: the release call, close_recovery, is dispatchable only as the recovered account, and the whitelist forbids it there.
The one recovery call the rescuer can dispatch themselves, cancel_recovered, refunds nothing and additionally destroys their proxy.
On mainnet parameters this strands roughly 29 UNIT per affected recovery, split across two parties, with no user-reachable remedy at any time horizon and no on-chain signal warning either party beforehand.
Vulnerability Details
1. The whitelist is a closed set, and nothing in it releases a reserve
runtime/src/configs/mod.rs:683-693 defines every call a high-security account may dispatch:
The policy is is_call_allowed(who, call) = !is_high_security(who) || is_whitelisted(call) (primitives/high-security/src/lib.rs:132-147).
It is enforced in the transaction extension and re-enforced at every origin-rewriting wrapper:
Recovery::as_recoveredUtility::as_derivativeUtility::dispatch_asUtility::batch*Utility::if_elseMultisig::execute
There is no wrapper through which a non-whitelisted call reaches a high-security origin.
Because the whitelist is a four-element matches!, exhaustiveness is verifiable by inspection in nine lines: no call outside this set is reachable from a high-security origin, and none of these four decreases AccountData::reserved.
schedule_transfer
free → hold
pallet_balances::hold (pallets/reversible-transfers/src/lib.rs:827-831), bounded by reducible_balance(who, Preservation::Preserve, ..) — free balance only
recover_funds
free → guardian
The closing sweep (pallets/reversible-transfers/src/lib.rs:608-619) dispatches Balances::transfer_all { keep_alive: false }, which moves reducible_balance(.., Expendable, ..); reserved is not part of free
cancel
hold → recipient
Operates on HoldReason::ScheduledTransfer, not on reserves
Vesting::claim
pot → beneficiary
Inbound only
Worse, the outstanding reserve pins a consumer reference, so the account cannot be reaped and Expendable is additionally forced to leave the existential deposit behind.
No third party can help. pallet-balances exposes no signed unreserve. remove_recovery and unnote_preimage are depositor-only, and close_recovery is narrower still: it is dispatchable only by the rescued account — never by the rescuer whose funds it releases.
Every release path therefore terminates at an account the whitelist has gagged.
2. No enrolment order avoids the freeze
create_recovery → set_high_security
The 10–19 UNIT config deposit is stranded permanently at the moment of enrolment.
set_high_security → create_recovery
Impossible. create_recovery is not whitelisted, so a high-security account can never configure social recovery at all.
This is the crux. There is no sequence that works, no parameter to get right, and no warning the user failed to heed.
A user who wants both documented protections must accept permanent loss of access to the deposit. And set_high_security is irreversible by design — enrolment can happen only once per account (ensure!(!HighSecurityAccounts::contains_key(&who))), so the state cannot be unwound.
3. The proximate cause is Quantus-authored, and it strands an uninvolved third party
The check that strands the rescuer is a Quantus addition to pallet_recovery::as_recovered (pallets/recovery/src/lib.rs:452-458, commit d47fed0, "Fix: origin-rewriting wrappers bypass high-security restrictions"):
Upstream pallet-recovery contains no such check.
The consequence is that a rescuer who has completed the entire documented flow reaches a state where recovery demonstrably works for moving funds but cannot return their own money:
as_recovered(lost, ReversibleTransfers::schedule_transfer { .. })→ succeedsas_recovered(lost, Recovery::close_recovery { rescuer })→ fails,CallNotAllowedForHighSecurityas_recovered(lost, Recovery::remove_recovery {})→ fails,CallNotAllowedForHighSecurity
Nor can the rescuer route around the whitelist by calling the release path directly.
close_recovery takes the deposit from ActiveRecoveries[origin][rescuer] and repatriates it to origin — it is a slashing mechanism against malicious recovery attempts, dispatchable only by the rescued account.
The rescuer is therefore not merely blocked by policy; the call is not addressable by them at all, and its effect would be to pay the deposit to the frozen account rather than back.
Every alternative is closed too, by execution rather than argument.
The rescuer commits their 10 UNIT at initiate_recovery, before any of this is observable. No extrinsic, error, or event signals that the target is high-security.
They are a helpful third party who made no choice that caused this and holds no authority to undo it.
4. Which deposits are affected
Recovery::create_recovery
10 UNIT + 1 UNIT × friends (max 9)
Recovery::remove_recovery
The account itself
Yes
Recovery::initiate_recovery
10 UNIT, held on the rescuer
Recovery::close_recovery
The recovered account
Yes
Preimage::note_preimage
1 UNIT + 1 MICRO_UNIT/byte
Preimage::unnote_preimage
The depositor
Yes
Multisig::propose
ProposalDeposit
remove_expired / claim_deposits
Any signer
No
Referenda submission / decision
Per track
refund_*_deposit
Permissionless
No
The bottom two rows are the tell. pallet-multisig deliberately made its refund callable by any signer, with the reasoning stated in its own doc comment (pallets/multisig/src/lib.rs:899-904):
Approved+expired proposals can become stuck if proposer is unavailable (e.g. lost keys, compromise). Allowing any signer to remove them prevents permanent deposit lockup and enables multisig dissolution.
Referenda refunds are permissionless for the same reason. The codebase already recognises this exact hazard and defends against it twice. The high-security whitelist does not carry that defence across.
5. Why this is a defect, not an accepted design choice
The pallet documents an exit path that does not exist.
The "Warning: Permanent and Irreversible" block on set_high_security (pallets/reversible-transfers/src/lib.rs:345-367) tells users:
Users who no longer wish to use high-security features can simply transfer their funds to a different account using
schedule_transfer.
That is false for reserved balance, and schedule_transfer is the very call the sentence names.
The request in this report is that behaviour match the documentation — not that new capability be added.
The correct rule is already applied elsewhere in the same whitelist.
Vesting::claim is whitelisted precisely because it is inbound-only: it cannot weaken the account or confer authority over it.
remove_recovery, close_recovery and unnote_preimage are inbound-only by the identical argument — each strictly increases the account's spendable balance.
The rule was recognised and applied once, then missed in three sibling cases.
The integration was deliberate.
pallet_recovery::Config for this runtime sets type HighSecurity = HighSecurityConfig (runtime/src/configs/mod.rs:521).
A composition built on purpose that cannot be exited is a defect in that composition, not a user error.
Impact Details
Funds frozen per occurrence
10 UNIT
The rescuer's RecoveryDeposit — on an account that never enrolled in high security, whose owner made no choice that caused this
10–19 UNIT
The high-security account's create_recovery deposit (10 UNIT base + 1 UNIT × up to 9 friends)
1 UNIT + 1 MICRO_UNIT/byte
Any note_preimage deposit outstanding at enrolment
1 MILLI_UNIT
The existential deposit, which the reserve's consumer reference forces transfer_all to leave behind
Realistic worst case for one user who used both protections and then actually needed recovery: ~29 UNIT plus ED, split across two parties.
Constants at runtime/src/configs/mod.rs:500-509.
Who is affected
Exactly the target population.
set_high_security is documented for "institutional users and high-value accounts" (primitives/high-security/src/lib.rs:4-5), and social recovery is its natural companion control — the runtime wires the two together on purpose.
The users most likely to adopt both are the ones this hits.
The rescuer variant is the more serious half: the loss falls on a third party with no relationship to the enrolment decision, who cannot inspect the hazard before committing funds and cannot release them afterwards.
References
Root cause
runtime/src/configs/mod.rs:683-693— the closed four-call whitelistprimitives/high-security/src/lib.rs:132-147—is_call_allowed/is_call_allowed_givenpallets/recovery/src/lib.rs:452-458— the Quantus-addedas_recoveredcheck (commitd47fed0)
Blocked release paths
pallets/recovery/src/lib.rs:712-748—close_recovery, dispatchable only as the recovered accountpallets/reversible-transfers/src/lib.rs:608-619— thetransfer_allsweep inrecover_fundspallets/reversible-transfers/src/lib.rs:827-831—holdinschedule_transfer
Proof of Concept
Was this helpful?