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

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_recovery is 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_recovered

  • Utility::as_derivative

  • Utility::dispatch_as

  • Utility::batch*

  • Utility::if_else

  • Multisig::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.

Whitelisted call
What it moves
Why reserved balance is untouched

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

Order
Outcome

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 { .. }) → succeeds

  • as_recovered(lost, Recovery::close_recovery { rescuer }) → fails, CallNotAllowedForHighSecurity

  • as_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

Source
Amount
Release call
Who may call it
Stranded?

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

Amount
What

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 whitelist

  • primitives/high-security/src/lib.rs:132-147 — is_call_allowed / is_call_allowed_given

  • pallets/recovery/src/lib.rs:452-458 — the Quantus-added as_recovered check (commit d47fed0)

Blocked release paths

  • pallets/recovery/src/lib.rs:712-748 — close_recovery, dispatchable only as the recovered account

  • pallets/reversible-transfers/src/lib.rs:608-619 — the transfer_all sweep in recover_funds

  • pallets/reversible-transfers/src/lib.rs:827-831 — hold in schedule_transfer

Proof of Concept

Was this helpful?