fix: display and sign multisig proposal call from chain storage (M3) - #575
Merged
Conversation
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.
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 What it does
Strengths
Findings
Verification
|
- 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).
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Fixes audit finding M3: the multisig approve/execute/cancel confirm sheets (and the Keystone hardware flow) rendered
proposal.amount/proposal.recipientbuilt entirely from GraphQL indexer JSON, while the signed extrinsic committed only toproposalId. A compromised indexer could label a malicious co-signer's drain proposal as a benign transfer to every member.What changed:
MultisigService.getOnChainProposalCall()queriesMultisig.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.transfer_allow_death/transfer_keep_aliveand reversibleTransfersschedule_transfer/schedule_transfer_with_delayshow the decodedtoaccount + amount; any other call shows the raw call bytes as hex. Indexer JSON is no longer used for review content.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/submitApproveExtrinsictake the on-chain bytes, wired through the approve sheet andTransactionSubmissionService.approveProposal.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 withdart run polkadart_cli:generateagainst a local dev node running the current chain source (quantus-node --devfrom chain commitb4316f8e, 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-paramapprovetypes/pallet_multisig/pallet/call.dart—Approve.callfieldtypes/pallet_multisig/proposal_data.dart—callWeightwas removed fromProposalDatain the new runtime; the old codec could not decodeProposalsstorage at all, so this file is requiredtypes/pallet_multisig/pallet/error.dart— newCallMismatch(25) variantMultisig pallet index (19) and call indices are unchanged.
Caveats / follow-ups
approveonly 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/cancelextrinsics still only commit toproposalIdchain-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.Verification
dart analyzeinquantus_sdk/— clean (1 pre-existing info lint untouched)dart analyzeinmobile-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 passflutter test test/unit(mobile-app) — 222/222 pass