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

88274 w a medium with hide balance enabled a utility batch multisig proposal shows no transfer amount anywhere on the approval sheet while the same transfer unwrapped still shows it

Submitted on Aug 12th 2026 at 14:53:28 UTC by @skydev for Audit Comp | Quantus

  • Report ID: #88274

  • Report Type: Websites & Apps

  • Report severity: Medium

  • Target: https://github.com/immunefi-team/audit-comp-quantus-apps/tree/audit-comp-ready/mobile-app

  • Impacts:

    • Direct theft of user funds

    • Malicious interactions with an already-connected wallet, such as:

      • Modifying transaction arguments or parameters

      • Substituting contract addresses

      • Submitting malicious transactions

Description

Brief/Intro

In the frozen Quantus mobile application, the multisig approval sheet shows the exact amount of a direct Balances.transfer_allow_death proposal even when Hide balance is enabled. Wrapping the same transfer in a one-item Utility.batch removes the amount from that sheet entirely: no headline figure, and the nested amount renders as -----. Destination, call and the enabled Approve control remain.

The cause is one function. Utility.batch is the only call wrapper in the decoder that does not carry its inner call's TransferSummary upward; six siblings — including two in the same _utility switch — do. A co-signer therefore supplies the second signature of a threshold multisig without the amount ever appearing on the screen that requests it, and the authentic stored call then moves that amount out of the multisig account.

Vulnerability Details

One wrapper in its class drops the summary

DecodedCall carries an optional TransferSummary. When present, the approval sheet renders it as a headline with the amount. Every wrapper that dispatches its inner call under the signer's own origin lifts that summary through — except one:

Wrapper

call_decoder.dart

carries summary

Multisig (two arms)

:337, :352

yes

Utility.as_derivative

:555

yes

Utility.with_weight

:570

yes

Utility.if_else

:580

yes

Utility.dispatch_as

:607

yes

Recovery

:619

yes

Utility.batch / batch_all / force_batch

:587-595

no

as_derivative and with_weight sit in the same switch as batch, eleven and eighteen lines away, and both pass summary: inner.summary. batch is the only member of that class that does not.

Two further sites build a NestedCallField without a summary — _boundedProposalField (:884-887, reached from Referenda.submit) and _preimageBytesField (:910-912, from Preimage.note_preimage, whose own note reads "Noted for later dispatch by a referendum"). Neither dispatches under the signer's origin: both queue bytes for governance to dispatch later, so neither is in the class above. They inherit the same rendering gap.

The project's own test suite records the expectation. In one group in quantus_sdk/test/chain/call_decoder_test.dart, test('recovery.as_recovered lifts the wrapped transfer summary') at :277-287 asserts expect(decoded.summary?.amount, oneToken), while test('utility.batch_all expands every inner call') at :261-275 asserts the call name, the child count and both child call names, and never mentions the summary. No test anywhere asserts that a batch has no summary.

The file that renders the result documents the intent (decoded_call_view.dart:11-14): "Multisig proposals are not always transfers — governance votes, runtime upgrade authorisations and batches all arrive through the same pallet — so a transfer-shaped layout silently misreports them. This shows the call the runtime actually declared, with each of its parameters." Batches are named there as a proposal shape the sheet must render correctly.

Two merged pull requests state the same rule. Commit 2293f20 ("fix: multisig approve must carry the proposal's inner call", #585) gives the goal as lifting "a nested transfer's amount into the hero position". #586, merged 2026-07-27 and titled "cold wallet decodes every chain call, no blind signing", states it directly:

Amount hero comes from the call's transfer summary, which propagates up from a nested call — a multisig approve of a transfer still reads as an amount and a recipient, while a governance vote names the call instead of claiming zero.

The summary propagating up from a nested call is the behaviour _batch does not implement.

What each branch produces

The two branches, verbatim from quantus_sdk/lib/src/chain/call_decoder.dart

The batch children are decoded correctly — the amount survives strict decoding as an AmountField. What does not survive is the summary, and the summary is what the sheet's headline is built from.

The nested amount is routed through the passive balance-hiding provider

DecodedCallView renders a native AmountField with txAmountDisplayProvider. That provider watches isBalanceHiddenProvider and replaces both token and fiat representations with the hidden placeholder. By contrast, the direct-transfer headline receives NumberFormattingService directly and remains exact.

Privacy-aware nested amount rendering

DecodedCallHeadline.of takes an injected formatter, and the three call sites differ:

Surface
formatter
direct transfer, Hide balance on
one-item batch

Approval sheet headline (multisig_action_confirm_sheet.dart:295, :354)

fmt.formatBalance

exact amount

no amount — the headline falls back to the call name

AmountField detail row (decoded_call_view.dart:66-68)

txAmountDisplayProvider

-----

-----

Proposal list tile (proposal_list_tile.dart:43)

formatAmount

-----

-----

So under Hide balance a direct transfer still states its amount once, in the headline of the screen that asks for the signature. A batch states it nowhere. The sheet has no per-field reveal; the only way to see the figure is to leave the flow and turn the global setting off from the home screen.

The final approval control does not treat an omitted amount as incomplete review data

The approval sheet disables confirmation while submitting, while authoritative bytes are loading, or after those bytes fail to load. It does not disable confirmation when a value-moving field is hidden. After local authentication it forwards the already-loaded authoritative bytes to the submit callback.

Confirmation remains enabled and forwards the authoritative bytes

This report does not rely on modified RPC or indexer data. The proposal bytes are authentic, strict decoding recovers the correct amount, and the approval path forwards the same bytes. The defect occurs after those protections succeed, when the review renderer suppresses the amount.

Where the proposal comes from

Neither scoped asset builds a batch proposal: the app's propose path calls proposeTransfer (multisig_propose_strategy.dart:213), which reaches the SDK's MultisigService.propose (multisig_service.dart:384-392); that in turn builds its call with buildProposeTransferCall (:341, :391), which wraps a plain transfer. The proposal in this report is encoded outside the application.

The runtime accepts that. Multisig::propose (pallets/multisig/src/lib.rs:535-540) takes any encoded RuntimeCall from any registered signer, and the approval renderer was written for exactly this case — decoded_call_view.dart:11-14 names batches among the proposal shapes that "arrive through the same pallet" and must therefore be rendered correctly rather than assumed to be transfers.

A normal multisig threshold makes the omitted field economically consequential

The frozen runtime requires the approver to be a registered signer and to resubmit bytes equal to the stored proposal. The proposing signer already contributes approval one. When a second signer approves the same bytes, the proposal becomes Approved; any registered signer may then execute it. Execution decodes the stored call and dispatches it with the multisig account as the signed origin.

The PoC uses one fixed 46-byte SCALE call in both repositories:

The frozen app decodes this as:

The widget regression shows that the exact amount — 987654321000000 base units, 987.654321 tokens — is decoded but not shown, that the rendered Approve control can be activated, and that the sheet forwards those exact bytes once. The frozen-runtime regression shows that the same bytes remain stored through an ordinary 2-of-2 approval and that execution increases the encoded destination balance by exactly 987654321000000.

This is not a signature or threshold bypass. It is a final-review defect: the second signature is real, but one of the principal transaction parameters is not shown at the moment that signature is requested.

Expected and observed behavior

Expected: Every amount and destination that can move value from a multisig remains visible at the final approval step, or confirmation remains disabled until the user explicitly reveals those fields.

Observed: A one-item batch hides its correctly decoded amount behind the portfolio privacy placeholder while keeping confirmation enabled. Removing only the batch wrapper makes the same amount visible under the same privacy setting.

Affected frozen sources

  • Mobile application and SDK: d5fc4d6572ebc2244e95009a33f5da5048935867

  • Supporting chain: 3b243b870a5442a6e5f443056f67bbba87d472f5

  • Scoped asset: https://github.com/immunefi-team/audit-comp-quantus-apps/tree/audit-comp-ready/mobile-app

  • The affected decoder is supplied by the separately listed quantus_sdk asset at the same frozen commit.

Remediation

_batch should carry the summary of its children, as as_derivative, with_weight, if_else, dispatch_as and the two Multisig arms already do. A batch can hold several transfers, so a single-child summary is not sufficient — every nested value-moving amount and destination needs to reach the sheet.

The same omission is present elsewhere in the decoder and is worth checking in the same pass: in _balances, force_unreserve, force_set_balance, force_adjust_total_issuance and burn each emit a native AmountField with no TransferSummary (call_decoder.dart:106-131). The first three require a Root origin; burn is ensure_signed (pallets/balances/src/lib.rs:881-886).

Impact Details

Impact: Websites and Applications — Critical — "Direct theft of user funds".

The published Critical list for this asset type contains a separate entry for actions taken "on behalf of other users without any interaction by that user". That interaction condition is written into that entry and is absent from "Direct theft of user funds", so the presence of a victim action does not by itself move this finding out of the theft entry.

The funds outcome here needs no second defect and no further step. One Balances.transfer_allow_death is dispatched with the multisig account as the signed origin (pallets/multisig/src/lib.rs:1156-1157), the destination is chosen by the proposer, and the balance delta is asserted exactly in the runtime test. Nothing is displayed incorrectly at any point — the amount is simply not displayed, on the one screen whose purpose is to obtain authorization for it.

If the classification is read differently, the same demonstrated behaviour also falls under the Critical entry "Malicious interactions with an already-connected wallet, such as: … Submitting malicious transactions". Both are listed on the submission.

The flow

An ordinary 2-of-2 or 2-of-3 multisig, threshold at least 2:

1

Submit a one-transfer batch proposal

A registered signer encodes a one-transfer batch outside the application and submits it through Multisig::propose. Proposing supplies approval one.

2

Open the approval sheet

A second registered signer, with Hide balance enabled, opens the normal approval sheet.

3

Review the proposal

The sheet loads the correct stored bytes and shows the batch, the transfer call and the destination. The amount appears nowhere on the sheet.

4

Approve the proposal

The second signer completes the prompted approval, including local biometric authentication.

5

Execute the transfer

The threshold is met and any registered signer executes; the runtime transfers the encoded amount from the multisig account.

The amount is bounded only by the multisig's balance. The PoC uses 987654321000000 base units — 987.654321 tokens at the chain's 12 decimals — and asserts that exact delta on the destination.

Conditions and bounds

  • Hide balance is opt-in and off by default (settings_service.dart:387). The attack requires the second signer to have enabled it.

  • The attacker must already be a registered signer of that multisig, and below the threshold alone. This is a position the victim granted deliberately; it is not access to the project's own systems or infrastructure.

  • The second signer performs a real, locally authenticated approval. No signature or threshold check is bypassed, and no silent signing is claimed.

  • Destination and wrapper stay visible. A signer who declines every unfamiliar destination is unaffected.

  • At threshold 1 the finding does not apply, because no second approval is solicited.

  • A multisig configured with the runtime's high-security policy is not reachable by this exact shape: the gate at pallets/multisig/src/lib.rs:620, re-checked at :1142, admits only the calls listed in runtime/src/configs/mod.rs:683-693 — three ReversibleTransfers calls and Vesting::claim. Utility.batch is not among them. Ordinary multisigs, which the application creates by default, are reachable.

  • The proposal must remain valid and the transfer affordable at execution.

  • Proposing is not free: Multisig::propose charges a non-refundable fee scaled by signer count and reserves a deposit until the proposal is resolved.

The report claims neither control of a wallet nor a runtime authorization failure. It claims that the scoped application omits the amount at the moment it asks a signer to authorize it, and that the omission is specific to one decoder branch.

The public M3 remediation addressed a different stage: it made approval load and bind authoritative call bytes instead of trusting indexer presentation. This report begins after that protection has succeeded — the bytes are authentic, decoding is correct, and the omission happens in rendering.

References

https://gist.github.com/Skydev0h/f5404b2b44029d378825917708a7200d

Proof of Concept

This PoC has two local layers joined by one fixed SCALE byte vector. The first exercises the frozen production decoder and final approval widget. The second applies the same bytes to the frozen runtime's ordinary 2-of-2 multisig lifecycle and verifies the exact balance effect. Neither test contacts a node, RPC endpoint, indexer, device, live account, or project service.

Artifacts

The three reproduction files are in this unlisted Gist:

https://gist.github.com/Skydev0h/f5404b2b44029d378825917708a7200d

File
Purpose

poc_test.dart

Frozen application widget regression

runtime-harness.patch

Directly applicable frozen-chain test patch

runtime_test.rs

Standalone copy of the Rust test added by the patch

Both tests use this exact 46-byte call:

It represents:

Step 1: reproduce the final approval behavior

A Flutter SDK compatible with the frozen project is required. From a clean directory:

A Flutter SDK is required; the repository does not vendor one. Verified on Flutter 3.44.9 stable, which satisfies the sdk: ">=3.8.0 <4.0.0" constraint in mobile-app/pubspec.yaml. The Rust step below needs only a Rust toolchain and the repository's normal native build dependencies.

Expected output:

The widget test performs the following checks:

1

Verify the encoded batch

The generated Utility.batch encodes to the fixed 46-byte vector.

2

Decode the transfer

Strict production decoding recovers the nested transfer, destination, and exact amount.

3

Verify the hidden amount

With Hide balance enabled, the batch amount renders as -----.

4

Verify approval remains enabled

The rendered Approve control is enabled and is actually activated with tester.tap.

5

Verify authoritative bytes are submitted

The sheet invokes submit exactly once with byte-for-byte identical authoritative bytes.

6

Re-decode submitted bytes

Re-decoding the callback bytes returns the same nested transfer and amount.

7

Disable privacy

With privacy disabled, the same batch displays the exact amount.

8

Compare a direct transfer

With privacy enabled, the same direct transfer retains an exact amount headline, showing the difference is wrapper-dependent.

The submit callback is injected and inert: this layer stops at the production UI-to-submission boundary and does not sign or broadcast anything. The widget test also satisfies the local-authentication gate trivially, so it demonstrates the rendering and the enabled control, not the biometric step.

Step 2: reproduce the frozen runtime semantics

A Rust toolchain and the repository's normal native build dependencies are required. From a clean directory:

Expected output:

The runtime test shows that the same fixed bytes:

1

Decode and round-trip the call

Decode under the frozen RuntimeCall with no trailing data and round-trip unchanged.

2

Verify the batch contents

Describe exactly one Utility.batch child: Balances.transfer_allow_death to AccountId32([0xbb; 32]) for 987654321000000 base units (987.654321 tokens at 12 decimals).

3

Store the proposal

Store the bytes unchanged in an ordinary 2-of-2 multisig proposal.

4

Verify initial proposal status

Leave the proposal Active after the proposing signer's first approval.

5

Approve the proposal

Move the proposal to Approved after the second registered signer confirms the same bytes.

6

Execute the transfer

Increase the encoded destination balance by exactly 987654321000000 base units (987.654321 tokens) when the approved proposal is executed.

7

Remove the completed proposal

Remove the completed proposal from storage.

This layer uses local TestExternalities and direct signed origins. It does not start a node or use private keys.

Observed result

Both layers passed on the exact frozen commits listed above. The fixed byte vector is independently decoded by the SDK and runtime. Together the tests connect the final review state—correct destination, hidden amount, enabled and activated approval—to the deterministic balance movement represented by the same bytes.

Attached source files

poc_test.dart
runtime-harness.patch
runtime_test.rs

Was this helpful?