Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 7 additions & 8 deletions cold-wallet-app/lib/debug/debug_payloads.dart
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import 'package:convert/convert.dart';
import 'package:polkadart/scale_codec.dart';
import 'package:quantus_sdk/generated/planck/pallets/balances.dart' as balances_pallet;
import 'package:quantus_sdk/generated/planck/pallets/multisig.dart' as multisig_pallet;
import 'package:quantus_sdk/generated/planck/pallets/recovery.dart' as recovery_pallet;
import 'package:quantus_sdk/generated/planck/pallets/tech_collective.dart' as collective_pallet;
import 'package:quantus_sdk/generated/planck/pallets/utility.dart' as utility_pallet;
import 'package:quantus_sdk/generated/planck/types/sp_runtime/multiaddress/multi_address.dart' as multi_address;
Expand Down Expand Up @@ -52,7 +51,7 @@ class DebugPayloads {
final seen = <String>{};
for (final call in [...callCorpus.entries.map(_fromCorpus), ..._composed]) {
// The corpus repeats an identical encoding once per nested-call variant
// (every `as_recovered [call=…]` is the same bytes); one row is enough.
// (every `execute [call=…]` is the same bytes); one row is enough.
if (!seen.add(hex.encode(call.call))) continue;
grouped.putIfAbsent(call.pallet, () => []).add(call);
}
Expand All @@ -74,8 +73,8 @@ class DebugPayloads {

/// The wrapper calls the corpus cannot express: it fills every nested call
/// slot with the same three-byte `System.remark`, so the screens that lift an
/// inner transfer into the hero position — a multisig proposal or approval, a
/// batch, a recovered-account dispatch — are only reachable from here.
/// inner transfer into the hero position — a multisig proposal, approval or
/// execution, a batch — are only reachable from here.
static final List<DebugCall> _composed = [
DebugCall(
pallet: 'Multisig',
Expand Down Expand Up @@ -112,10 +111,10 @@ class DebugPayloads {
.encode(),
),
DebugCall(
pallet: 'Recovery',
label: 'as_recovered [carrying a transfer]',
call: const recovery_pallet.Txs()
.asRecovered(account: _address(AppConstants.debugTestAddress), call: _send(_tokens(3)))
pallet: 'Multisig',
label: 'execute [carrying a transfer]',
call: const multisig_pallet.Txs()
.execute(multisigAddress: _debugMultisigAccount, proposalId: 12, call: _send(_tokens(3)))
.encode(),
),
];
Expand Down
11 changes: 9 additions & 2 deletions mobile-app/lib/services/transaction_submission_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,7 @@ class TransactionSubmissionService {
required MultisigAccount msig,
required Account signer,
required MultisigProposal proposal,
List<int>? callBytes,
BigInt? fee,
}) async {
final pending = PendingMultisigExecutionEvent.fromProposal(
Expand All @@ -293,18 +294,24 @@ class TransactionSubmissionService {

TelemetryService().sendEvent('multisig_execute');

await _submitExecute(msig: msig, signer: signer, proposalId: proposal.id, pending: pending);
await _submitExecute(msig: msig, signer: signer, proposalId: proposal.id, callBytes: callBytes, pending: pending);
}

Future<void> _submitExecute({
required MultisigAccount msig,
required Account signer,
required int proposalId,
required PendingMultisigExecutionEvent pending,
List<int>? callBytes,
}) async {
try {
final service = _ref.read(multisigServiceProvider);
final hashBytes = await service.submitExecuteExtrinsic(msig: msig, signer: signer, proposalId: proposalId);
final hashBytes = await service.submitExecuteExtrinsic(
msig: msig,
signer: signer,
proposalId: proposalId,
callBytes: callBytes,
);
final extrinsicHash = '0x${hex.encode(hashBytes)}';
quantusPrint('[Execute] submitted: $extrinsicHash');

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,12 +32,22 @@ typedef MultisigConfirmCallBuilder = RuntimeCall Function(Account signer, List<i

/// Loads the proposal's inner call bytes from chain storage.
///
/// `multisig.approve` only counts an approval whose bytes are byte-equal to the
/// stored proposal, so actions that resubmit the call must read it from the chain
/// rather than reconstruct it. Actions that carry only a proposal reference leave
/// this null.
/// `multisig.approve` and `multisig.execute` only count a submission whose bytes
/// are byte-equal to the stored proposal, so actions that resubmit the call must
/// read it from the chain rather than reconstruct it. Actions that carry only a
/// proposal reference leave this null.
typedef MultisigConfirmCallBytesLoader = Future<List<int>> Function(WidgetRef ref);

/// The call bytes an action that resubmits the proposal cannot proceed without.
///
/// [MultisigConfirmCallBuilder] takes them as nullable because actions carrying
/// only a proposal reference have none; [action] names the caller so a missing
/// load is not mistaken for a chain rejection.
List<int> requireCallBytes(List<int>? callBytes, String action) {
if (callBytes == null) throw StateError('$action requires the proposal call bytes');
return callBytes;
}

/// Submits a hardware-signed extrinsic for the action.
typedef MultisigConfirmExternalSubmitter =
Future<String> Function(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ void showMultisigApproveConfirmSheet(
buildCall: (resolvedSigner, callBytes) => MultisigService().buildApproveCall(
msig: msig,
proposalId: proposal.id,
call: callBytes ?? (throw StateError('Approve requires the proposal call bytes')),
call: requireCallBytes(callBytes, 'Approve'),
),
submit: (ref, resolvedSigner, fee, callBytes) => ref
.read(transactionSubmissionServiceProvider)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,17 +28,23 @@ void showMultisigExecuteConfirmSheet(
authReason: (l10n) => l10n.multisigExecuteAuthReason,
failedMessage: (l10n) => l10n.multisigExecuteFailed,
),
// Executing dispatches the stored call, so what the signer reviews comes
// from chain storage, not the indexer; a proposal no longer in storage
// could not execute anyway.
// Executing resubmits the proposal's inner call, which the chain compares
// byte-for-byte against what it stored — so the bytes come from chain
// storage, not the indexer, and the signer reviews the call that will
// dispatch rather than an opaque proposal id.
loadCallBytes: (ref) =>
ref.read(multisigServiceProvider).fetchProposalCallBytes(msig: msig, proposalId: proposal.id),
estimateFee: (ref, signer, callBytes) =>
ref.read(multisigServiceProvider).estimateExecuteFee(msig: msig, signer: signer, proposalId: proposal.id),
buildCall: (signer, callBytes) => MultisigService().buildExecuteCall(msig: msig, proposalId: proposal.id),
estimateFee: (ref, signer, callBytes) => ref
.read(multisigServiceProvider)
.estimateExecuteFee(msig: msig, signer: signer, proposalId: proposal.id, callBytes: callBytes),
buildCall: (signer, callBytes) => MultisigService().buildExecuteCall(
msig: msig,
proposalId: proposal.id,
call: requireCallBytes(callBytes, 'Execute'),
),
submit: (ref, signer, fee, callBytes) => ref
.read(transactionSubmissionServiceProvider)
.executeProposal(msig: msig, signer: signer, proposal: proposal, fee: fee),
.executeProposal(msig: msig, signer: signer, proposal: proposal, callBytes: callBytes, fee: fee),
submitExternal: (ref, {required signer, required unsignedData, required signature, required publicKey, fee}) =>
ref
.read(transactionSubmissionServiceProvider)
Expand Down
21 changes: 21 additions & 0 deletions mobile-app/test/unit/multisig_require_call_bytes_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:resonance_network_wallet/v2/screens/multisig/multisig_action_confirm_sheet.dart';

void main() {
group('requireCallBytes', () {
test('returns the bytes the sheet loaded', () {
const bytes = [1, 2, 3];
expect(requireCallBytes(bytes, 'Execute'), bytes);
});

// Approve and execute are both refused by the chain unless they carry the
// stored call, so a sheet that reached the builder without it must fail
// loudly rather than submit something the chain will reject.
test('names the action when the bytes never loaded', () {
expect(
() => requireCallBytes(null, 'Execute'),
throwsA(isA<StateError>().having((e) => e.message, 'message', contains('Execute'))),
);
});
});
}
32 changes: 24 additions & 8 deletions quantus_sdk/lib/generated/planck/pallets/multisig.dart
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,8 @@ class Txs {
/// The multisig address is deterministically derived from:
/// hash(pallet_id || sorted_signers || threshold || nonce)
///
/// Signers are automatically sorted before hashing, so order doesn't matter.
/// Signers are sorted before hashing, so order doesn't matter.
/// Duplicate accounts are rejected.
///
/// Economic costs:
/// - MultisigFee: burned immediately (spam prevention)
Expand Down Expand Up @@ -199,20 +200,35 @@ class Txs {
/// Can be called by any signer of the multisig once the proposal has reached
/// the approval threshold (status = Approved). The proposal must not be expired.
///
/// The executor resubmits the proposal's inner call; execution proceeds only
/// if it is byte-equal to the payload stored at `proposal_id` — the same
/// binding `approve` enforces. This serves two purposes:
/// - **Clearsigning:** the executor's (hardware) wallet displays and signs the actual call
/// being dispatched, not an opaque proposal id.
/// - **Self-describing weight:** the executing extrinsic carries the inner call, so its
/// declared weight carries the inner call's own declared weight (refunded to actuals
/// post-dispatch) instead of reserving a flat `MaxInnerCallWeight`, and runtime
/// transaction extensions can inspect the inner call and price its side effects
/// (account-reap cleanup, transfer-proof recording) exactly as they do for directly
/// submitted calls. Nothing about the dispatch is invisible to pre-dispatch admission or
/// fees. (Only the bookkeeping term is reserved at `MaxCallSize`, since the stored bytes'
/// length is unknown pre-dispatch; the unused remainder is refunded.)
///
/// On execution:
/// - The call is decoded and dispatched as the multisig account
/// - The call is dispatched as the multisig account
/// - Proposal is removed from storage
/// - Deposit is returned to the proposer
///
/// Parameters:
/// - `multisig_address`: The multisig account
/// - `proposal_id`: ID (nonce) of the proposal to execute
///
/// Note: The weight charged includes both multisig bookkeeping and MaxInnerCallWeight.
/// Actual weight is refunded based on the inner call's post-dispatch info.
/// The inner call's weight is validated against MaxInnerCallWeight at propose time.
_i8.Multisig execute({required _i2.AccountId32 multisigAddress, required int proposalId}) {
return _i8.Multisig(_i9.Execute(multisigAddress: multisigAddress, proposalId: proposalId));
/// - `call`: The proposal's inner call, byte-equal to the stored payload
_i8.Multisig execute({
required _i2.AccountId32 multisigAddress,
required int proposalId,
required _i8.RuntimeCall call,
}) {
return _i8.Multisig(_i9.Execute(multisigAddress: multisigAddress, proposalId: proposalId, call: call));
}
}

Expand Down
Loading
Loading