Skip to content

Carry the proposal's inner call on multisig execute - #625

Merged
n13 merged 1 commit into
mainfrom
n13/msig-execute-inner-call
Sep 2, 2026
Merged

Carry the proposal's inner call on multisig execute#625
n13 merged 1 commit into
mainfrom
n13/msig-execute-inner-call

Conversation

@n13

@n13 n13 commented Aug 30, 2026

Copy link
Copy Markdown
Collaborator

Replaces #622, which was based on main before the 1.5.12+129 snapshot and could not be rebased cleanly.

Updates the wallets for the runtime in chain#675.

What changed on chain

multisig.execute now takes the call it dispatches:

pub fn execute(origin, multisig_address, proposal_id, call: Box<RuntimeCall>)

The chain dispatches it only if it re-encodes to the payload stored at the proposal id — the same binding approve already enforces. So the executor's wallet displays and signs the actual call, hardware wallets included, instead of an opaque proposal id.

One catch. approve takes BoundedVec<u8>, which is length prefixed. execute takes Box<RuntimeCall>, inline with no prefix. So buildExecuteCall decodes the stored bytes back into a call rather than passing them through — that is CallDecoder.decodeRuntimeCall. A proposal the bundled metadata cannot decode fails before signing rather than producing a call the chain rejects.

estimateExecuteFee and submitExecuteExtrinsic take the bytes and fetch them when missing, exactly like approve. The confirm sheet already loaded them via loadCallBytes; a shared requireCallBytes replaces the inline throw approve had and now guards both actions.

The inner call is decoded, not trusted

Execute carries a RuntimeCall inline, so letting the generated codec read it would skip the policy and nesting checks every other call boundary gets. _decodeCall intercepts it and routes the inner call back through itself, which is how utility.batch_all is already handled.

multisig.execute also replaces recovery.as_recovered as the decoder's second inline-nesting entry point. The decoder tests that used as_recovered as their nesting vehicle move to execute and keep asserting the same properties.

Cold wallet

No cold-wallet code change was needed: the describer emits You are executing with the nested call, and the summary lifts the inner transfer so the amount and recipient headline the review screen. Because the call names the multisig account as an address field, the signer row correctly reads Signed by rather than From — the funds leave the multisig, not the signer.

A Multisig execute [carrying a transfer] debug payload replaces the dropped as_recovered one.

Bindings

Regenerated against a dev node built from chain#675 (spec 147 / tx 6), using the pinned Quantus-Network/polkadart @ 0_7_3_quantus_2 fork. The live Planck endpoints are still on spec 144, so they cannot be the source.

The regenerated runtime drops pallet-recovery: RecoveryService and the recovery describer go with it, neither of which had any caller. toJson() also nests one level deeper, which _generic follows.

Metadata fixture and generated call corpus regenerated from the same node. bundledSpecVersion / bundledTransactionVersion move to 147 / 6.

Why the polkadart fork is pinned

Documented in quantus_sdk/pubspec.yaml, where the comment previously cited only the ML-DSA override. Upstream polkadart_cli emits a compact integer for the MultiAddress::Index variant where the metadata declares the field zero-width. A codec that disagrees with the metadata about a field's width re-frames every byte after it, so a crafted payload can display one call and sign another. test/chain/multi_address_codec_test.dart pins the correct behaviour.

Note on fees

Spec 147 raises the normal-class base extrinsic weight from 108_157_000 to 767_297_000, so transaction_fee_test's reference values move with it. The live cross-check in that test was measured on a1-planck at spec 144 and should be re-measured once Planck runs 147.

Tests

buildExecuteCall round-trips the stored bytes, encodes inline (asserted against approve's length-prefixed encoding), and rejects undecodable bytes, trailing bytes and calls the wallet does not display inside a proposal. requireCallBytes returns the bytes and throws a named StateError without them.

quantus_sdk 463, cold-wallet-app 270, mobile-app 424, all with --exclude-tags=native.

The runtime's `multisig.execute` now takes the call it dispatches:

    pub fn execute(origin, multisig_address, proposal_id, call: Box<RuntimeCall>)

The chain dispatches it only if it re-encodes to the payload stored at the
proposal id — the same binding `approve` already enforces — so the executor's
wallet displays and signs the actual call instead of an opaque proposal id.

Unlike `approve`, which resubmits the stored bytes length-prefixed, `execute`
carries the call inline. `CallDecoder.decodeRuntimeCall` decodes the stored
payload back into a call, so a proposal the bundled metadata cannot read fails
before signing rather than building a call the chain would reject.

The inner call is decoded through `_decodeCall`, not handed to the generated
codec, so it gets the same policy and nesting checks as every other call
boundary. `multisig.execute` replaces `recovery.as_recovered` as the decoder's
second inline-nesting entry point, and the tests that used as_recovered as
their nesting vehicle move with it.

Bindings regenerated against a dev node built from chain #675 (spec 147, tx 6)
using the pinned Quantus polkadart fork, which also drops pallet-recovery:
`RecoveryService` and the recovery describer go with it, and the cold wallet's
as_recovered debug payload becomes a multisig execute. Metadata fixture and
call corpus regenerated from the same node.

Spec 147 raises the normal-class base extrinsic weight from 108_157_000 to
767_297_000, so the transaction fee test's reference values move with it.

Documents in pubspec why the polkadart fork is pinned: upstream's generator
emits a compact integer for the zero-width `MultiAddress::Index` field, and a
codec that disagrees with the metadata about a field's width re-frames every
byte after it — a clearsigning bypass.

Tests: quantus_sdk 463, cold-wallet-app 270, mobile-app 424, all with
--exclude-tags=native.
@n13 n13 added the bot-review Request automated review from review-bot label Sep 2, 2026

@n13 n13 left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewer model: GPT Sol

Verdict (advisory): Approve

No blocking findings.

The spec 147 / transaction 6 bindings line up with merged Chain PR #675: recovery is removed while downstream pallet indices stay fixed, and multisig.execute encodes (multisig account, proposal id, inline RuntimeCall) exactly as the runtime expects. The wallet loads the authoritative stored proposal bytes, refuses missing, trailing, undecodable, and disallowed calls, uses the same bytes for display, fee estimation, software submission, and hardware payload construction, and exposes the nested call to the cold-wallet decoder. The manual execute decoder applies policy and nesting checks before generated recursion, while exact-consumption checks preserve the byte binding enforced by the chain.

Validation at head 11b9b4ebc87c9b8c2a1e6761209c645958d242a3:

  • git diff --check b50f370a...11b9b4eb: passed.
  • dart run melos exec -- dart format lib test --line-length=120 --set-exit-if-changed: 0 files changed.
  • dart run melos exec -- flutter analyze . --fatal-infos: all 4 packages passed with no issues.
  • quantus_sdk: 463 non-native tests passed.
  • quantus_cold_wallet: 270 non-native tests passed.
  • resonance_network_wallet: 424 tests passed.
  • GitHub Analyze check: successful on this head.

@n13 n13 removed the bot-review Request automated review from review-bot label Sep 2, 2026
@n13
n13 merged commit 734b990 into main Sep 2, 2026
4 checks 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