Skip to content

fix: display and sign multisig proposal call from chain storage (M3) - #575

Merged
n13 merged 6 commits into
mainfrom
fix/m3-multisig-onchain-call-display
Jul 28, 2026
Merged

fix: display and sign multisig proposal call from chain storage (M3)#575
n13 merged 6 commits into
mainfrom
fix/m3-multisig-onchain-call-display

Conversation

@n13

@n13 n13 commented Jul 23, 2026

Copy link
Copy Markdown
Collaborator

Summary

Fixes audit finding M3: the multisig approve/execute/cancel confirm sheets (and the Keystone hardware flow) rendered proposal.amount/proposal.recipient built entirely from GraphQL indexer JSON, while the signed extrinsic committed only to proposalId. A compromised indexer could label a malicious co-signer's drain proposal as a benign transfer to every member.

What changed:

  • On-chain source of truthMultisigService.getOnChainProposalCall() queries Multisig.Proposals(multisig, proposalId) storage for the authoritative inner call bytes. The confirm sheet fails closed (confirm disabled + error shown) when the proposal can't be loaded from chain.
  • Display from on-chain bytes — the sheet decodes the on-chain call: balances transfer_allow_death/transfer_keep_alive and reversibleTransfers schedule_transfer/schedule_transfer_with_delay show the decoded to account + amount; any other call shows the raw call bytes as hex. Indexer JSON is no longer used for review content.
  • Signing commits to the call — the chain's multisig.approve (chain PR #622, call_index 2) now takes the inner call bytes and rejects approvals not byte-equal to the stored payload (CallMismatch). buildApproveCall/estimateApproveFee/submitApproveExtrinsic take the on-chain bytes, wired through the approve sheet and TransactionSubmissionService.approveProposal.
  • Keystone flow — the QR sign screen's displayed amount/recipient (or raw call hex) and the unsigned payload both come from the on-chain call bytes.

Addresses finding M3 of the 2026-07-22 mobile wallet security audit.

Bindings regeneration (please read)

The committed planck bindings were stale (2-param approve). Regenerating against the live chain (wss://a1-planck.quantus.cat) still yields the old runtime, so the bindings were regenerated with dart run polkadart_cli:generate against a local dev node running the current chain source (quantus-node --dev from chain commit b4316f8e, which includes #622). A full regeneration produces ~20k lines of churn (formatter drift + unrelated runtime changes), so only the 4 files with semantic changes required for this fix were kept, taken verbatim from the regeneration:

  • pallets/multisig.dart — 3-param approve
  • types/pallet_multisig/pallet/call.dartApprove.call field
  • types/pallet_multisig/proposal_data.dartcallWeight was removed from ProposalData in the new runtime; the old codec could not decode Proposals storage at all, so this file is required
  • types/pallet_multisig/pallet/error.dart — new CallMismatch (25) variant

Multisig pallet index (19) and call indices are unchanged.

Caveats / follow-ups

  • Deployment coordination: the 3-param approve only works once the runtime upgrade lands on the live chain; until then approvals submitted by this build will be rejected by the old runtime. (Same is true in reverse for old builds after the upgrade — pre-existing issue with the chain change itself.)
  • Execute/cancel signing: the pallet's execute/cancel extrinsics still only commit to proposalId chain-side. Their review content now comes from chain storage, which closes the display-side hole; binding those signatures to the call bytes would need a further pallet change.
  • End-to-end (real device + upgraded node) approval flow not exercised here; verified via analyzer + unit tests only.

Verification

  • dart analyze in quantus_sdk/ — clean (1 pre-existing info lint untouched)
  • dart analyze in mobile-app/ — no issues in any touched file (830 pre-existing warnings/infos elsewhere, unchanged)
  • flutter test test/multisig_service_test.dart (quantus_sdk) — 42/42 pass
  • flutter test test/unit (mobile-app) — 222/222 pass
  • quantus_sdk full test run: 3 pre-existing setUpAll failures (missing compiled Rust dylib in this worktree), unrelated to this change

n13 added 3 commits July 23, 2026 17:04
The multisig approve/execute/cancel confirm sheets rendered recipient and
amount built from GraphQL indexer JSON, while the signed extrinsic only
committed to a proposal id. A compromised indexer could label a malicious
drain proposal as a benign transfer to every co-signer.

The confirm sheet now loads the authoritative inner call bytes from
on-chain Multisig.Proposals storage before showing the sheet content:

- transfer calls (balances transfer_*, reversible schedule_transfer*)
  display the decoded recipient and amount from those bytes
- any other call is shown as raw hex bytes
- if the on-chain proposal cannot be loaded, the sheet fails closed and
  the confirm button stays disabled

The approve extrinsic uses the new pallet API (chain PR #622) which takes
the inner call bytes and rejects approvals that are not byte-equal to the
stored payload (CallMismatch), binding the signature to the reviewed
call. The same bytes feed the Keystone QR signing flow, both for the
displayed details and the unsigned payload.

Generated planck bindings for the multisig pallet were regenerated from a
local node running the current chain runtime (dart run
polkadart_cli:generate against ws://127.0.0.1:9944); only the four files
with semantic changes were kept. ProposalData dropped callWeight in the
new runtime, so the regenerated storage codec is required to decode
Proposals at all.

Addresses finding M3 of the 2026-07-22 mobile wallet security audit.
@n13

n13 commented Jul 24, 2026

Copy link
Copy Markdown
Collaborator Author

🔍 Review — M3 (multisig on-chain call display & signing)

Verdict: 🟡 Approve with non-blocking comments

The core of the fix is correct and the security design is sound: the confirm sheets now derive everything the user reviews and signs from authoritative Multisig.Proposals storage, fail closed when the chain load fails, and the 3‑param approve binds the signature to the exact stored bytes (chain rejects mismatches with CallMismatch), so a compromised indexer can no longer mislabel a drain proposal at the signing gate. The residual gaps — no unit tests for the new decode logic, a verification gap around keeping only 4 of the regenerated bindings, and browsing surfaces that still read indexer amount/recipient — are non‑blocking.

What it does

  • Adds MultisigService.getOnChainProposalCall() (multisig_service.dart:371-379) reading Multisig.Proposals(multisig, proposalId) via RpcEndpointService.rpcTask; returns null when the proposal is absent (executed/cancelled/never proposed).
  • Adds static MultisigService.decodeTransferCall() (multisig_service.dart:386-421) decoding on‑chain bytes for balances transfer_allow_death/transfer_keep_alive and reversible schedule_transfer/schedule_transfer_with_delay, returning recipient+amount, else null (→ raw hex).
  • Confirm sheet (multisig_action_confirm_sheet.dart) loads on‑chain bytes in initState before fee estimation, renders decoded transfer or raw hex, shows an error and disables Confirm when load fails, and feeds the same bytes to the software‑sign, fee‑estimate, and Keystone paths.
  • approve becomes 3‑param (multisig.dart:154-157, call.dart Approve.call), threaded through buildApproveCall/estimateApproveFee/submitApproveExtrinsic/TransactionSubmissionService.approveProposal.
  • Regenerated 4 planck binding files (multisig 3‑param approve, Approve.call, ProposalData drops callWeight, CallMismatch=25); adds two l10n strings (en/id).

Strengths

  • Fail‑closed gating is correct on every path. getOnChainProposalCall returning null and throwing both set _onChainCallFailed=true; Confirm is disabled by _submitting || loadingCall || _onChainCallFailed (multisig_action_confirm_sheet.dart:338,391-392) and _confirm() re‑guards on _onChainCall == null (:218-219).
  • Signing safety does not depend on RPC trust for approve. The chain re‑checks byte‑equality against its own storage, so even a malicious/MITM'd RPC that returns fabricated benign bytes can only cause the approval to be rejected (CallMismatch), never sign a drain. This is the property that makes trusting the RPC for display acceptable.
  • Keystone path fully bound. The unsigned QR payload is built from session.buildCall() → 3‑param buildApproveCall with on‑chain bytes (keystone_sign_screen.dart:61-64), and the displayed detail comes from the on‑chain decode (multisig_action_confirm_sheet.dart:262-277).
  • Decode field names verified against current bindings (dest/value/amount, MultiAddress.Id.value0), MultiAddress non‑Id and unknown calls correctly fall through to raw hex, and decode is wrapped in try/catch (safe on malformed bytes).
  • The 4 regenerated files are internally consistent — proposal_data.dart drops the now‑unused Weight import and renumbers prefixes; Approve updates encode/decode/sizeHint/==/hashCode together.

Findings

  1. [non-blocking] The core new security‑display logic (decodeTransferCall) has zero unit tests. The 42 multisig_service_test.dart tests are almost entirely pre‑existing; the only change is one line adding call: const [1,2,3] to the buildApproveCall test (multisig_service_test.dart:488). decodeTransferCall is a pure static function and trivially testable (each of the 4 transfer variants → recipient/amount; a non‑transfer call → null; malformed bytes → null; non‑Id MultiAddress → null). Given this function decides what the user is told they are signing, it should be covered before merge. Signing safety does not hinge on it (byte‑exact + chain CallMismatch), which is why this is non‑blocking rather than blocking.
  2. [non-blocking] Verification gap in keeping only 4 of the ~20k‑line regen. The display decode relies on the un‑regenerated runtime_call.dart, pallet_balances/call.dart, pallet_reversible_transfers/call.dart, and multi_address.dart. If the current runtime (b4316f8e) changed the encoding/variant indices of those pallets — the PR itself notes the full regen contains "unrelated runtime changes" — the on‑chain bytes could misdecode. Worst case is a display mismatch (approve signing stays byte‑exact and safe), and a variant‑index shift would most likely throw → raw hex (safe), but a clean misdecode to a different transfer cannot be fully excluded from the diff alone. The keeping‑4‑files rationale is plausible but not independently verifiable here; a full regen (accepting the churn) or an explicit check that balances/reversible/multiaddress metadata is unchanged would close it. (multisig_service.dart:386-421)
  3. [nit] decodeTransferCall does not assert all bytes were consumed. After RuntimeCall.decode(input) (multisig_service.dart:389) trailing bytes are ignored, so bytes that decode to a transfer prefix still render as a benign transfer. Low real‑world risk (a RuntimeCall with trailing bytes is non‑dispatchable on‑chain), but ByteInput.remainingLength is available (polkadart_scale_codec input.dart:15,144) — adding if (input.remainingLength != 0) return null; would harden the display to fall back to raw hex.
  4. [non-blocking] Browsing surfaces still render indexer amount/recipient. proposal_row.dart:70-71 and multisig_proposal_detail_sheet.dart:675 still show proposal.amount/proposal.recipient from indexer JSON (not touched by this PR). These are not the signing gate — the detail sheet routes approve/execute/cancel through the now‑fixed confirm sheets (multisig_proposal_detail_sheet.dart:479,531,555), which is the backstop — but a compromised indexer can still mislabel the list/detail views. Worth a follow‑up so the browsing experience matches the confirm sheet.
  5. [nit] Raw call hex is rendered untruncated (multisig_action_confirm_sheet.dart:313-315, secondary text at :351). A large non‑transfer call (e.g. a batch) would render as a very long unwrapped/unscrolled hex string. Minor UX.
  6. [nit] getOnChainProposalCall never disconnects the Provider (multisig_service.dart:372-378). This matches the existing convention in substrate_service.dart (queryBalance et al. also leak the provider), so it is not a regression introduced here, but the pattern is worth cleaning up SDK‑wide.

Verification

  • Fail‑closed: adequate and correct on all approve/execute/cancel + Keystone paths; both the "not in storage" (null) and RPC‑error (throw) cases disable Confirm and surface multisigOnChainProposalUnavailable.
  • Indexer no longer used for review CONTENT: confirmed for all four confirm sheets and the Keystone display — content derives from _onChainCall/_onChainTransfer. (Indexer still drives non‑signing list/detail views — Finding 4.)
  • Deployment‑coordination caveat (3‑param approve rejected by old runtime until the upgrade lands): real and clearly documented; until the runtime upgrade ships, approvals from this build will be rejected by the live chain. This is a hard sequencing dependency — the mobile release must not precede the chain upgrade.
  • Execute/cancel signing limitation: accurately scoped. Their signatures still commit only to proposalId, so a malicious/MITM'd RPC could mislabel the execute confirmation (the money‑moving step) even though approve is bound. Impact is bounded because threshold approvals — each now bound to the real bytes — must have already passed; execute merely triggers a pre‑approved proposal. Display for execute/cancel is on‑chain‑sourced now, closing the indexer display hole. Binding these signatures needs a further pallet change (acknowledged).
  • Bindings mismatch risk: no dangling callWeight/2‑param approve references in the 4 changed files; author reports dart analyze clean on both packages. Could not independently run analyze on the PR branch (read‑only), but the changed files are internally consistent — see Finding 2 for the broader concern about the un‑regenerated files.

n13 added 3 commits July 25, 2026 16:23
- Unify transfer-call decoding in MultisigProposal.decodeTransferCall
  (was duplicated between MultisigService and MultisigProposal._decodeCallRaw)
  and cover it with unit tests: all 4 transfer variants, non-transfer call,
  non-Id destination, malformed bytes, trailing bytes.
- Reject calls with trailing bytes after decode (fall back to raw hex).
- Enrich open proposals with on-chain call data at fetch time so the
  proposal list and detail sheet show chain truth instead of indexer JSON;
  a stored call that is not a recognized transfer clears the indexer details.
- Render raw call hex in a constrained scrollable block in the confirm
  sheet and middle-truncate it on the Keystone signing screen.
- Disconnect the RPC provider after multisig storage queries.

Verified the un-regenerated decode-path files against a fresh polkadart
codegen from wss://a1-planck.quantus.cat: pallet_balances/call.dart,
multi_address.dart and all pallet_multisig files are byte-identical;
pallet_reversible_transfers/call.dart differs only in doc comments;
runtime_call.dart pallet indices match (Balances=2, ReversibleTransfers=11,
Multisig=19) - the committed file only carries extra variants
(Scheduler=8, Referenda=10, ConvictionVoting=12) absent from the live
runtime, which cannot affect transfer decoding.
Main landed its own approve-side fix (#585) and the DecodedCall display
tree (#588), superseding most of this branch's plumbing. Resolution takes
main's architecture and keeps this branch's remaining semantics on top:

- Execute and cancel confirm sheets now also load the stored call from
  chain storage (loadCallBytes), so every action sheet reviews and gates
  on chain truth, not indexer JSON.
- Open proposals are enriched at fetch time with the on-chain call bytes:
  browsing surfaces (rows, detail sheet) show the chain-decoded transfer
  summary, and callRaw carries the authoritative bytes for the decoded
  call view. Indexer data remains the fallback when the chain query fails;
  approve stays gated on fetchProposalCallBytes regardless.
- MultisigProposal.copyWith gains recipient/amount/callRaw and no longer
  drops callRaw on unrelated copies.
- decodeTransferCall and its tests are dropped in favor of
  CallDecoder.decodeBytes (which already rejects trailing bytes); missing
  coverage ported to call_decoder_test: transfer_keep_alive summary,
  schedule_transfer summary, non-account destination yields no recipient.
- Removed the now-unused l10n keys from this branch
  (multisigProposalCallData, multisigOnChainProposalUnavailable).
@n13
n13 merged commit 997ab8b into main Jul 28, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant