Carry the proposal's inner call on multisig execute - #625
Conversation
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
left a comment
There was a problem hiding this comment.
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
Analyzecheck: successful on this head.
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.executenow takes the call it dispatches:The chain dispatches it only if it re-encodes to the payload stored at the proposal id — the same binding
approvealready enforces. So the executor's wallet displays and signs the actual call, hardware wallets included, instead of an opaque proposal id.One catch.
approvetakesBoundedVec<u8>, which is length prefixed.executetakesBox<RuntimeCall>, inline with no prefix. SobuildExecuteCalldecodes the stored bytes back into a call rather than passing them through — that isCallDecoder.decodeRuntimeCall. A proposal the bundled metadata cannot decode fails before signing rather than producing a call the chain rejects.estimateExecuteFeeandsubmitExecuteExtrinsictake the bytes and fetch them when missing, exactly like approve. The confirm sheet already loaded them vialoadCallBytes; a sharedrequireCallBytesreplaces the inline throw approve had and now guards both actions.The inner call is decoded, not trusted
Executecarries aRuntimeCallinline, so letting the generated codec read it would skip the policy and nesting checks every other call boundary gets._decodeCallintercepts it and routes the inner call back through itself, which is howutility.batch_allis already handled.multisig.executealso replacesrecovery.as_recoveredas 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 executingwith 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 readsSigned byrather thanFrom— the funds leave the multisig, not the signer.A
Multisig execute [carrying a transfer]debug payload replaces the droppedas_recoveredone.Bindings
Regenerated against a dev node built from chain#675 (spec 147 / tx 6), using the pinned
Quantus-Network/polkadart @ 0_7_3_quantus_2fork. The live Planck endpoints are still on spec 144, so they cannot be the source.The regenerated runtime drops
pallet-recovery:RecoveryServiceand the recovery describer go with it, neither of which had any caller.toJson()also nests one level deeper, which_genericfollows.Metadata fixture and generated call corpus regenerated from the same node.
bundledSpecVersion/bundledTransactionVersionmove 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. Upstreampolkadart_cliemits a compact integer for theMultiAddress::Indexvariant 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.dartpins 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
buildExecuteCallround-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.requireCallBytesreturns the bytes and throws a namedStateErrorwithout them.quantus_sdk 463, cold-wallet-app 270, mobile-app 424, all with
--exclude-tags=native.