Skip to content
Closed
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
17 changes: 0 additions & 17 deletions cold-wallet-app/lib/debug/debug_payloads.dart
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,6 @@ class DebugPayloads {
/// combination can be eyeballed without a hot wallet.
static final Map<String, Uint8List Function()> all = {
'Send': transfer,
'Force send': forceTransfer,
'Reversible': reversibleTransfer,
'Reversible 8h': reversibleTransferWithDelay,
'Msig approve': multisigApproveTransfer,
Expand All @@ -38,19 +37,6 @@ class DebugPayloads {
return withExtensions(_send(BigInt.from(1500000000000))); // 1.5 tokens
}

/// A root-level transfer of another account's funds. The screen must show
/// the Source row and say `Signed by`, never `From`: the funds do not leave
/// the signer.
static Uint8List forceTransfer() {
return withExtensions(
const balances_pallet.Txs().forceTransfer(
source: multi_address.MultiAddress.values.id(_debugSourceAccount),
dest: _address(AppConstants.debugTestAddress),
value: BigInt.from(2500000000000), // 2.5 tokens
),
);
}

/// A reversible transfer on the account's default window.
/// Headline: REVERSIBLE SEND.
static Uint8List reversibleTransfer() {
Expand Down Expand Up @@ -110,9 +96,6 @@ class DebugPayloads {
/// checkphrase without needing a real on-chain multisig.
static final Uint8List _debugMultisigAccount = Uint8List.fromList(List.filled(32, 0xA7));

/// Synthetic force_transfer source, distinct from every other address shown.
static final Uint8List _debugSourceAccount = Uint8List.fromList(List.filled(32, 0xF0));

static multi_address.MultiAddress _address(String ss58) =>
multi_address.MultiAddress.values.id(ss58ToAccountId(s: ss58));

Expand Down
4 changes: 3 additions & 1 deletion cold-wallet-app/test/call_display_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -82,10 +82,12 @@ void main() {
});

testWidgets('signer row reads Signed by when the funds leave an explicit source', (tester) async {
// A multisig proposal moves the multisig account's funds, not the signer's.
final inner = const balances_pallet.Txs().transferAllowDeath(dest: account(bobId), value: oneToken);
await pumpSignScreen(
tester,
DebugPayloads.withExtensions(
const balances_pallet.Txs().forceTransfer(source: account(aliceId), dest: account(bobId), value: oneToken),
const multisig_pallet.Txs().propose(multisigAddress: aliceId, call: inner.encode(), expiry: 5000),
),
);
expect(find.text('SIGNED BY'), findsOneWidget);
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 @@ -289,6 +289,7 @@ class TransactionSubmissionService {
required MultisigAccount msig,
required Account signer,
required MultisigProposal proposal,
List<int>? callBytes,
BigInt? fee,
}) async {
final pending = PendingMultisigExecutionEvent.fromProposal(
Expand All @@ -302,18 +303,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 @@ -38,6 +38,10 @@ typedef MultisigConfirmCallBuilder = RuntimeCall Function(Account signer, List<i
/// this null.
typedef MultisigConfirmCallBytesLoader = Future<List<int>> Function(WidgetRef ref);

/// The stored inner call, which a resubmitting action cannot be built without.
List<int> requireCallBytes(List<int>? callBytes, String action) =>
callBytes ?? (throw StateError('$action requires the proposal call bytes'));

/// 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 @@ -33,12 +33,17 @@ void showMultisigExecuteConfirmSheet(
// could not execute anyway.
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, fee: fee, callBytes: callBytes),
submitExternal: (ref, {required signer, required unsignedData, required signature, required publicKey, fee}) =>
ref
.read(transactionSubmissionServiceProvider)
Expand Down
25 changes: 25 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,25 @@
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 stored call bytes unchanged', () {
const bytes = [0x02, 0x00, 0x07];
expect(requireCallBytes(bytes, 'Execute'), same(bytes));
});

test('throws rather than build an action without the call it must resubmit', () {
// Approve and execute are both bound to the proposal's exact bytes, so a
// missing load must fail loudly instead of submitting a call the chain rejects.
expect(() => requireCallBytes(null, 'Execute'), throwsA(isA<StateError>()));
expect(() => requireCallBytes(null, 'Approve'), throwsA(isA<StateError>()));
});

test('names the action in the error', () {
expect(
() => requireCallBytes(null, 'Execute'),
throwsA(isA<StateError>().having((e) => e.message, 'message', contains('Execute'))),
);
});
});
}
46 changes: 0 additions & 46 deletions quantus_sdk/lib/generated/planck/pallets/balances.dart
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ import '../types/frame_support/traits/tokens/misc/id_amount_1.dart' as _i7;
import '../types/frame_support/traits/tokens/misc/id_amount_2.dart' as _i8;
import '../types/pallet_balances/pallet/call.dart' as _i13;
import '../types/pallet_balances/types/account_data.dart' as _i4;
import '../types/pallet_balances/types/adjustment_direction.dart' as _i14;
import '../types/pallet_balances/types/balance_lock.dart' as _i5;
import '../types/pallet_balances/types/reserve_data.dart' as _i6;
import '../types/quantus_runtime/runtime_call.dart' as _i11;
Expand Down Expand Up @@ -351,16 +350,6 @@ class Txs {
return _i11.Balances(_i13.TransferAllowDeath(dest: dest, value: value));
}

/// Exactly as `transfer_allow_death`, except the origin must be root and the source account
/// may be specified.
_i11.Balances forceTransfer({
required _i12.MultiAddress source,
required _i12.MultiAddress dest,
required BigInt value,
}) {
return _i11.Balances(_i13.ForceTransfer(source: source, dest: dest, value: value));
}

/// Same as the [`transfer_allow_death`] call, but with a check that the transfer will not
/// kill the origin account.
///
Expand Down Expand Up @@ -390,41 +379,6 @@ class Txs {
return _i11.Balances(_i13.TransferAll(dest: dest, keepAlive: keepAlive));
}

/// Unreserve some balance from a user by force.
///
/// Can only be called by ROOT.
_i11.Balances forceUnreserve({required _i12.MultiAddress who, required BigInt amount}) {
return _i11.Balances(_i13.ForceUnreserve(who: who, amount: amount));
}

/// Upgrade a specified account.
///
/// - `origin`: Must be `Signed`.
/// - `who`: The account to be upgraded.
///
/// This will waive the transaction fee if at least all but 10% of the accounts needed to
/// be upgraded. (We let some not have to be upgraded just in order to allow for the
/// possibility of churn).
_i11.Balances upgradeAccounts({required List<_i3.AccountId32> who}) {
return _i11.Balances(_i13.UpgradeAccounts(who: who));
}

/// Set the regular balance of a given account.
///
/// The dispatch origin for this call is `root`.
_i11.Balances forceSetBalance({required _i12.MultiAddress who, required BigInt newFree}) {
return _i11.Balances(_i13.ForceSetBalance(who: who, newFree: newFree));
}

/// Adjust the total issuance in a saturating way.
///
/// Can only be called by root and always needs a positive `delta`.
///
/// # Example
_i11.Balances forceAdjustTotalIssuance({required _i14.AdjustmentDirection direction, required BigInt delta}) {
return _i11.Balances(_i13.ForceAdjustTotalIssuance(direction: direction, delta: delta));
}

/// Burn the specified liquid free balance from the origin account.
///
/// If the origin's account ends up below the existential deposit as a result
Expand Down
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