From a665f1a4f7b8a2f7397035a673137263dbb10d38 Mon Sep 17 00:00:00 2001 From: Nikolaus Heger Date: Thu, 2 Jul 2026 21:03:17 +0800 Subject: [PATCH] fix: port Keystone parser hardening to payload parsers (WYSIWYS) Port the firmware fixes from Quantus-Network/keystone3-firmware#6 to the standalone Rust parser and the cold-wallet Dart display path: decode the full signed payload (call + every TxExtension field) with no trailing bytes, correct the ReversibleTransfers pallet index (13 -> 11), validate the genesis hash against a known-network whitelist, bound multisig nesting, fix display decimals to 12, and surface network/era/nonce/tip in the cold-wallet review screen. --- .../lib/screens/sign_transaction_screen.dart | 43 +- .../test/transaction_signing_test.dart | 48 +- .../lib/src/quantus_payload_parser.dart | 336 +++++--- .../test/quantus_payload_parser_test.dart | 256 +++--- rust-transaction-parser/Cargo.lock | 8 - rust-transaction-parser/Cargo.toml | 2 - rust-transaction-parser/src/lib.rs | 756 +++++++++++++++--- 7 files changed, 1031 insertions(+), 418 deletions(-) diff --git a/cold-wallet-app/lib/screens/sign_transaction_screen.dart b/cold-wallet-app/lib/screens/sign_transaction_screen.dart index 6ed732790..ba250e8cc 100644 --- a/cold-wallet-app/lib/screens/sign_transaction_screen.dart +++ b/cold-wallet-app/lib/screens/sign_transaction_screen.dart @@ -21,8 +21,8 @@ class SignTransactionScreen extends ConsumerStatefulWidget { } class _SignTransactionScreenState extends ConsumerState { - TransactionInfo? _txInfo; - bool _parseFailed = false; + ParsedPayload? _parsed; + String? _parseError; String? _toCheckphrase; List? _signatureUr; bool _signing = false; @@ -31,13 +31,14 @@ class _SignTransactionScreenState extends ConsumerState { @override void initState() { super.initState(); - final info = QuantusPayloadParser.parsePayload(widget.payload); - if (info == null) { - _parseFailed = true; - return; + try { + final parsed = QuantusPayloadParser.parsePayload(widget.payload); + _parsed = parsed; + _loadCheckphrase(parsed.call.toAddress); + } catch (e) { + debugPrint('Rejected signing payload: $e'); + _parseError = e is FormatException ? e.message : e.toString(); } - _txInfo = info; - _loadCheckphrase(info.toAddress); } Future _loadCheckphrase(String address) async { @@ -86,12 +87,12 @@ class _SignTransactionScreenState extends ConsumerState { @override Widget build(BuildContext context) { - if (_parseFailed) return _errorView(context); + if (_parseError != null) return _errorView(context, _parseError!); if (_signatureUr != null) return _signatureView(context, _signatureUr!); - return _reviewView(context, _txInfo!); + return _reviewView(context, _parsed!); } - Widget _errorView(BuildContext context) { + Widget _errorView(BuildContext context, String reason) { final colors = context.colors; final text = context.themeText; return ScaffoldBase( @@ -108,6 +109,12 @@ class _SignTransactionScreenState extends ConsumerState { style: text.smallParagraph?.copyWith(color: colors.textSecondary), textAlign: TextAlign.center, ), + const SizedBox(height: 12), + Text( + reason, + style: text.detail?.copyWith(color: colors.textMuted), + textAlign: TextAlign.center, + ), ], ), bottomContent: ScaffoldBaseBottomContent( @@ -116,9 +123,11 @@ class _SignTransactionScreenState extends ConsumerState { ); } - Widget _reviewView(BuildContext context, TransactionInfo info) { + Widget _reviewView(BuildContext context, ParsedPayload parsed) { final colors = context.colors; final text = context.themeText; + final info = parsed.call; + final ext = parsed.extensions; return ScaffoldBase( appBar: const V2AppBar(title: 'Review & Sign'), @@ -153,9 +162,17 @@ class _SignTransactionScreenState extends ConsumerState { _detailRow(context, 'To', info.toAddress, monospace: true), if (_toCheckphrase != null && _toCheckphrase!.isNotEmpty) _detailRow(context, 'Checkphrase', _toCheckphrase!, valueColor: colors.checksum), + _detailRow(context, 'Network', parsed.network), _detailRow(context, 'Reversible', info.isReversible ? 'Yes' : 'No'), if (info.isReversible && info.reversibleTimeframe != null) - _detailRow(context, 'Reversible window', '${info.reversibleTimeframe} blocks'), + _detailRow( + context, + 'Reversible window', + DatetimeFormattingService.formatDuration(Duration(milliseconds: info.reversibleTimeframe!)).formatted, + ), + _detailRow(context, 'Tip', '${_formatAmount(ext.tip)} ${AppConstants.tokenSymbol}'), + _detailRow(context, 'Nonce', '${ext.nonce}'), + _detailRow(context, 'Era', '${ext.era}'), if (_error != null) ...[ const SizedBox(height: 16), Text( diff --git a/cold-wallet-app/test/transaction_signing_test.dart b/cold-wallet-app/test/transaction_signing_test.dart index 8181792d0..dab9eda90 100644 --- a/cold-wallet-app/test/transaction_signing_test.dart +++ b/cold-wallet-app/test/transaction_signing_test.dart @@ -5,15 +5,22 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:quantus_sdk/quantus_sdk.dart'; void main() { - // Real SCALE-encoded signing payload (transfer of 0.1 QUAN) reused from the - // quantus_sdk keystone test, so the cold wallet is verified against the exact - // bytes the hot wallet produces. - const keystoneHex = + // The 0.1 QUAN keystone transfer call with a Planck extension suffix (era 5501 = + // period 64 phase 21, nonce 0, tip 0, spec 131, tx version 2, metadata None), so the + // cold wallet is verified against the exact byte layout the hot wallet produces. + const planckHex = + '0200007416854906f03a9dff66e3270a736c44e15970ac03a638471523a03069f276ca0700e876481755010000008300000002000000' + '4901bf5c57fd3f9e726af399c763de6670dbdb115a91c0237e173f16eef65e72' + '111111111111111111111111111111111111111111111111111111111111111100'; + + // The same transfer as originally captured on the retired devnet (genesis 826beefb…). + // Regression: the signer must reject payloads for networks it does not know. + const retiredDevnetHex = '0200007416854906f03a9dff66e3270a736c44e15970ac03a638471523a03069f276ca0700e876481755010000007400000002000000826beefbe2be72645ff376f18de745ac196dc77637436090de4174180706118e5a77ae1c95817ee664cf733fafa7baa8e6244b396a54e57a5bc414b24c52800600'; group('QuantusSigningPayload.signablePayload (shared hot/cold signing rule)', () { test('keystone transfer payload is signed raw (<= 256 bytes)', () { - final payload = Uint8List.fromList(hex.decode(keystoneHex)); + final payload = Uint8List.fromList(hex.decode(planckHex)); expect(payload.length, lessThanOrEqualTo(256)); expect(QuantusSigningPayload.signablePayload(payload), equals(payload)); }); @@ -29,19 +36,30 @@ void main() { group('QuantusPayloadParser.parsePayload (scan -> display path)', () { test('keystone transfer payload decodes to a displayable transaction', () { - final payload = Uint8List.fromList(hex.decode(keystoneHex)); - final info = QuantusPayloadParser.parsePayload(payload); - - expect(info, isNotNull); - expect(info!.isReversible, isFalse); - expect(info.reversibleTimeframe, isNull); - expect(info.toAddress, startsWith('qz')); - expect(info.amount, BigInt.parse('100000000000')); // 0.1 QUAN at 12 decimals + final payload = Uint8List.fromList(hex.decode(planckHex)); + final parsed = QuantusPayloadParser.parsePayload(payload); + + expect(parsed.call.isReversible, isFalse); + expect(parsed.call.reversibleTimeframe, isNull); + expect(parsed.call.toAddress, startsWith('qz')); + expect(parsed.call.amount, BigInt.parse('100000000000')); // 0.1 QUAN at 12 decimals + expect(parsed.network, 'Planck'); + expect(parsed.extensions.era.toString(), '64 blocks'); + expect(parsed.extensions.nonce, 0); + expect(parsed.extensions.tip, BigInt.zero); + }); + + test('retired devnet payload is rejected with unknown genesis (never signed)', () { + final payload = Uint8List.fromList(hex.decode(retiredDevnetHex)); + expect( + () => QuantusPayloadParser.parsePayload(payload), + throwsA(isA().having((e) => e.message, 'message', contains('Unknown genesis hash'))), + ); }); - test('non-transaction bytes parse to null (rejected, never signed)', () { + test('non-transaction bytes are rejected (never signed)', () { final garbage = Uint8List.fromList(List.generate(40, (i) => 0xff - i)); - expect(QuantusPayloadParser.parsePayload(garbage), isNull); + expect(() => QuantusPayloadParser.parsePayload(garbage), throwsFormatException); }); }); } diff --git a/quantus_sdk/lib/src/quantus_payload_parser.dart b/quantus_sdk/lib/src/quantus_payload_parser.dart index ef3afa5c0..34af10ad7 100644 --- a/quantus_sdk/lib/src/quantus_payload_parser.dart +++ b/quantus_sdk/lib/src/quantus_payload_parser.dart @@ -1,35 +1,96 @@ -/// A parser for Quantus blockchain transaction payloads. +/// A parser for Quantus blockchain signing payloads. /// -/// This parser extracts human-readable transaction information from SCALE-encoded -/// payloads, specifically designed for hardware wallets that need to display -/// transaction details to users before signing. +/// Mirrors the Keystone firmware parser (rust/apps/quantus/src/parser.rs): the +/// full signed payload — call plus every signed-extension field — is decoded +/// with nothing left over, so what the signer displays is exactly what it +/// signs. Any pallet, call, address type, or network not declared here +/// hard-fails with a [FormatException]; nothing is silently ignored. /// -/// Supported transaction types: -/// - Balance transfers (pallet index 3) -/// - Reversible transfers (pallet index 12) +/// Supported calls (runtime pallet/call indices, chain `main`, spec >= 133): +/// - Balances (pallet 2): transfer_allow_death (0), transfer_keep_alive (3) +/// - ReversibleTransfers (pallet 11): schedule_transfer (3), +/// schedule_transfer_with_delay (4) /// /// Usage: /// ```dart /// final payload = signingPayload.encodeRaw(registry); -/// final txInfo = QuantusPayloadParser.parsePayload(payload); -/// if (txInfo != null) { -/// print(txInfo); // Shows formatted transaction details -/// } +/// final parsed = QuantusPayloadParser.parsePayload(payload); // throws on rejection +/// print('${parsed.call} on ${parsed.network}'); /// ``` library; +import 'dart:math' as math; import 'dart:typed_data'; import 'package:convert/convert.dart'; import 'package:polkadart/scale_codec.dart'; -import 'package:ss58/ss58.dart'; import 'package:quantus_sdk/src/constants/app_constants.dart'; +import 'package:ss58/ss58.dart'; + +/// Hard cap on the raw signing payload; every supported call is far below this. +const int maxPayloadBytes = 8 * 1024; + +/// Networks this wallet will sign for, keyed by genesis hash (lowercase hex). +/// A payload whose genesis hash is not listed here is rejected. +const Map knownNetworks = { + '4901bf5c57fd3f9e726af399c763de6670dbdb115a91c0237e173f16eef65e72': 'Planck', + 'a5aa9e5c84d4a3722c152295e7973c9af522f2fb1ef7db5afaa3d5f4dc8d3b4f': 'Heisenberg', +}; + +/// sp_runtime `Era` (immortal = one zero byte, mortal = two bytes encoding period/phase). +class Era { + final int? period; + final int? phase; + + const Era.immortal() : period = null, phase = null; + const Era.mortal(int this.period, int this.phase); + + bool get isImmortal => period == null; + + @override + String toString() => isImmortal ? 'Immortal' : '$period blocks'; + + @override + bool operator ==(Object other) => other is Era && other.period == period && other.phase == phase; + + @override + int get hashCode => Object.hash(period, phase); +} + +/// The runtime `TxExtension` data that follows the call in a signing payload, in declaration +/// order: explicit parts (era, nonce, tip, metadata-hash mode) then the implicit +/// "additional signed" parts (spec/tx version, genesis + block hash, optional metadata hash). +/// Extensions with unit encoding (CheckNonZeroSender, CheckWeight, Reversible, Wormhole) +/// contribute no bytes. +class SignedExtensions { + final Era era; + final int nonce; + final BigInt tip; + final int metadataMode; + final int specVersion; + final int transactionVersion; + final Uint8List genesisHash; + final Uint8List blockHash; + final Uint8List? metadataHash; + + SignedExtensions({ + required this.era, + required this.nonce, + required this.tip, + required this.metadataMode, + required this.specVersion, + required this.transactionVersion, + required this.genesisHash, + required this.blockHash, + required this.metadataHash, + }); +} class TransactionInfo { final String toAddress; final BigInt amount; final bool isReversible; - final int? reversibleTimeframe; // in blocks or milliseconds + final int? reversibleTimeframe; // in milliseconds TransactionInfo({ required this.toAddress, @@ -40,154 +101,195 @@ class TransactionInfo { @override String toString() { - final amountStr = (amount / BigInt.from(10).pow(10)).toStringAsFixed(4); + final amountStr = (amount / BigInt.from(10).pow(AppConstants.decimals)).toStringAsFixed(4); return ''' Transaction Details: To Address: $toAddress - Amount: $amountStr QUS + Amount: $amountStr ${AppConstants.tokenSymbol} Reversible: $isReversible - ${isReversible && reversibleTimeframe != null ? 'Reversible Timeframe: $reversibleTimeframe blocks' : ''} + ${isReversible && reversibleTimeframe != null ? 'Reversible Timeframe: $reversibleTimeframe ms' : ''} '''; } } +/// A fully decoded signing payload: the call plus every signed-extension field, with no +/// bytes left over. Everything that gets signed is either displayed or validated. +class ParsedPayload { + final TransactionInfo call; + final SignedExtensions extensions; + final String network; + + ParsedPayload({required this.call, required this.extensions, required this.network}); +} + class QuantusPayloadParser { static String bytesToSs58(Uint8List bytes) { - final address = Address(prefix: AppConstants.ss58prefix, pubkey: bytes); - return address.encode(); + if (bytes.length != 32) { + throw FormatException('AccountId32 must be 32 bytes, got ${bytes.length}'); + } + return Address(prefix: AppConstants.ss58prefix, pubkey: bytes).encode(); } - static TransactionInfo? parsePayload(Uint8List payload) { - try { - final input = Input.fromBytes(payload); + /// Decodes a full signing payload. Throws [FormatException] on any rejection: + /// unknown pallet/call/address type, malformed extensions, trailing bytes, + /// metadata-mode inconsistency, or a genesis hash not in [knownNetworks]. + static ParsedPayload parsePayload(Uint8List payload) { + if (payload.length > maxPayloadBytes) { + throw FormatException('Payload too large: ${payload.length} bytes'); + } + + final input = Input.fromBytes(payload); + final call = _section('call', () => _decodeCall(input)); + final extensions = _section('extensions', () => _decodeExtensions(input)); + + final remaining = input.remainingLength ?? 0; + if (remaining != 0) { + throw FormatException('$remaining trailing bytes after signed payload'); + } - // Read pallet index (first byte) - final palletIndex = U8Codec.codec.decode(input); + final modeConsistent = + (extensions.metadataMode == 0 && extensions.metadataHash == null) || + (extensions.metadataMode == 1 && extensions.metadataHash != null); + if (!modeConsistent) { + throw FormatException('Metadata hash mode ${extensions.metadataMode} inconsistent with metadata hash presence'); + } - // Read the call data (remaining bytes) - final callData = input.readBytes(input.remainingLength ?? 0); + final network = knownNetworks[hex.encode(extensions.genesisHash)]; + if (network == null) { + throw FormatException('Unknown genesis hash: 0x${hex.encode(extensions.genesisHash)}'); + } - if (palletIndex == 2) { - // Balances pallet - return _parseBalancesCall(callData); - } else if (palletIndex == 13) { - // ReversibleTransfers pallet - return _parseReversibleTransfersCall(callData); - } + return ParsedPayload(call: call, extensions: extensions, network: network); + } - // Unknown pallet - return null; + static T _section(String section, T Function() decode) { + try { + return decode(); + } on FormatException catch (e) { + throw FormatException('$section: ${e.message}'); } catch (e) { - print('Error parsing payload: $e'); - return null; + throw FormatException('$section: $e'); } } - static TransactionInfo? _parseBalancesCall(Uint8List callData) { - try { - final input = Input.fromBytes(callData); - final callIndex = U8Codec.codec.decode(input); + // Mirror of the runtime call enums; indices must match the runtime pallet/call + // declarations and compact encoding must match `#[pallet::compact]` usage. + static TransactionInfo _decodeCall(Input input) { + final palletIndex = U8Codec.codec.decode(input); + switch (palletIndex) { + case 2: + return _decodeBalancesCall(input); + case 11: + return _decodeReversibleTransfersCall(input); + default: + throw FormatException('Unknown pallet index: $palletIndex'); + } + } - if (callIndex == 0) { - // transfer_allow_death - final dest = _parseMultiAddress(input); - final amount = CompactBigIntCodec.codec.decode(input); - return TransactionInfo(toAddress: dest, amount: amount, isReversible: false); - } else if (callIndex == 3) { - // transfer_keep_alive - final dest = _parseMultiAddress(input); - final amount = CompactBigIntCodec.codec.decode(input); + static TransactionInfo _decodeBalancesCall(Input input) { + final callIndex = U8Codec.codec.decode(input); + switch (callIndex) { + case 0: // transfer_allow_death + case 3: // transfer_keep_alive + final dest = _decodeMultiAddress(input); + final amount = CompactBigIntCodec.codec.decode(input); // #[pallet::compact] value return TransactionInfo(toAddress: dest, amount: amount, isReversible: false); - } - } catch (e) { - print('Error parsing balances call: $e'); + default: + throw FormatException('Balances: unsupported call index $callIndex'); } - return null; } - static TransactionInfo? _parseReversibleTransfersCall(Uint8List callData) { - try { - final input = Input.fromBytes(callData); - final callIndex = U8Codec.codec.decode(input); - - if (callIndex == 3) { - // schedule_transfer - final dest = _parseMultiAddress(input); - final amount = U128Codec.codec.decode(input); + static TransactionInfo _decodeReversibleTransfersCall(Input input) { + final callIndex = U8Codec.codec.decode(input); + switch (callIndex) { + case 3: // schedule_transfer + final dest = _decodeMultiAddress(input); + final amount = U128Codec.codec.decode(input); // fixed u128, not compact return TransactionInfo( toAddress: dest, amount: amount, isReversible: true, reversibleTimeframe: null, // Uses configured delay ); - } else if (callIndex == 4) { - // schedule_transfer_with_delay - final dest = _parseMultiAddress(input); + case 4: // schedule_transfer_with_delay + final dest = _decodeMultiAddress(input); final amount = U128Codec.codec.decode(input); - final delay = _parseBlockNumberOrTimestamp(input); + final delay = _decodeTimestampDelay(input); return TransactionInfo(toAddress: dest, amount: amount, isReversible: true, reversibleTimeframe: delay); - // } else if (callIndex == 5) { - // // schedule_asset_transfer - // final assetId = U32Codec.codec.decode(input); - // final dest = _parseMultiAddress(input); - // final amount = U128Codec.codec.decode(input); - // return TransactionInfo( - // toAddress: dest, - // amount: amount, - // isReversible: true, - // reversibleTimeframe: null, // Uses configured delay - // ); - // } else if (callIndex == 6) { - // // schedule_asset_transfer_with_delay - // final assetId = U32Codec.codec.decode(input); - // final dest = _parseMultiAddress(input); - // final amount = U128Codec.codec.decode(input); - // final delay = _parseBlockNumberOrTimestamp(input); - // return TransactionInfo(toAddress: dest, amount: amount, isReversible: true, reversibleTimeframe: delay); - } - } catch (e) { - print('Error parsing reversible transfers call: $e'); + default: + throw FormatException('ReversibleTransfers: unsupported call index $callIndex'); } - return null; } - static String _parseMultiAddress(Input input) { + static String _decodeMultiAddress(Input input) { final addressType = U8Codec.codec.decode(input); + if (addressType != 0) { + throw FormatException('Unsupported MultiAddress type: $addressType (only Id is accepted)'); + } + return bytesToSs58(input.readBytes(32)); + } - switch (addressType) { - case 0: // Id(AccountId) - final accountId = input.readBytes(32); - return bytesToSs58(accountId); - case 1: // Index(Compact) - final index = CompactBigIntCodec.codec.decode(input); - return 'Index($index)'; - case 2: // Raw(Vec) - final length = CompactBigIntCodec.codec.decode(input); - final raw = input.readBytes(length.toInt()); - return 'Raw(0x${hex.encode(raw)})'; - case 3: // Address32([u8; 32]) - final address32 = input.readBytes(32); - return bytesToSs58(address32); - case 4: // Address20([u8; 20]) - final address20 = input.readBytes(20); - return '0x${hex.encode(address20)}'; + // qp_scheduler::BlockNumberOrTimestamp + static int _decodeTimestampDelay(Input input) { + final variant = U8Codec.codec.decode(input); + switch (variant) { + case 0: + final block = U32Codec.codec.decode(input); + throw FormatException('Block-number delays are not supported (got block $block)'); + case 1: + return U64Codec.codec.decode(input).toInt(); default: - throw Exception('Unknown MultiAddress type: $addressType'); + throw FormatException('Unknown BlockNumberOrTimestamp variant: $variant'); } } - static int? _parseBlockNumberOrTimestamp(Input input) { - final variant = U8Codec.codec.decode(input); + static Era _decodeEra(Input input) { + final first = U8Codec.codec.decode(input); + if (first == 0) return const Era.immortal(); + final encoded = first + (U8Codec.codec.decode(input) << 8); + final period = 2 << (encoded % (1 << 4)); + final quantizeFactor = math.max(period >> 12, 1); + final phase = (encoded >> 4) * quantizeFactor; + if (period >= 4 && phase < period) { + return Era.mortal(period, phase); + } + throw const FormatException('Invalid era period/phase'); + } - if (variant == 0) { - // BlockNumber(u32) - return U32Codec.codec.decode(input); - } else if (variant == 1) { - // Timestamp(u64) - final timestamp = U64Codec.codec.decode(input); - return timestamp.toInt(); + static SignedExtensions _decodeExtensions(Input input) { + final era = _decodeEra(input); + final nonce = CompactCodec.codec.decode(input); // Compact + if (nonce > 0xFFFFFFFF) { + throw FormatException('Nonce exceeds u32 range: $nonce'); + } + final tip = CompactBigIntCodec.codec.decode(input); // Compact + if (tip.bitLength > 128) { + throw FormatException('Tip exceeds u128 range: $tip'); + } + final metadataMode = U8Codec.codec.decode(input); + if (metadataMode > 1) { + throw FormatException('Invalid metadata hash mode: $metadataMode'); + } + final specVersion = U32Codec.codec.decode(input); + final transactionVersion = U32Codec.codec.decode(input); + final genesisHash = input.readBytes(32); + final blockHash = input.readBytes(32); + final metadataHashPresent = U8Codec.codec.decode(input); + if (metadataHashPresent > 1) { + throw FormatException('Invalid Option byte for metadata hash: $metadataHashPresent'); } + final metadataHash = metadataHashPresent == 1 ? input.readBytes(32) : null; - return null; + return SignedExtensions( + era: era, + nonce: nonce, + tip: tip, + metadataMode: metadataMode, + specVersion: specVersion, + transactionVersion: transactionVersion, + genesisHash: genesisHash, + blockHash: blockHash, + metadataHash: metadataHash, + ); } } diff --git a/quantus_sdk/test/quantus_payload_parser_test.dart b/quantus_sdk/test/quantus_payload_parser_test.dart index 4524932d8..64a69392a 100644 --- a/quantus_sdk/test/quantus_payload_parser_test.dart +++ b/quantus_sdk/test/quantus_payload_parser_test.dart @@ -2,163 +2,149 @@ import 'dart:typed_data'; import 'package:convert/convert.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:polkadart/scale_codec.dart'; import 'package:quantus_sdk/quantus_sdk.dart'; +const planckGenesisHex = '4901bf5c57fd3f9e726af399c763de6670dbdb115a91c0237e173f16eef65e72'; + +// Call portions of the original "real world" vectors (extensions stripped); the full +// vectors were captured on a retired devnet whose genesis hash is no longer accepted. +// The reversible call is re-indexed from the retired pallet index 13 to the current 11. +const transferCall1 = '020000ef5f320156894f0fde742921c6990bf446e82c89fae5a23e701900abcd92dfb40700282e8cd1'; +const transferCall2 = '0200007416854906f03a9dff66e3270a736c44e15970ac03a638471523a03069f276ca0700e8764817'; +const reversibleCall = + '0b04007416854906f03a9dff66e3270a736c44e15970ac03a638471523a03069f276ca0040b0464f010000000000000000000001e093040000000000'; + +// The two original real-world vectors, kept verbatim as regression tests: both were +// captured on the retired devnet (genesis 826beefb…) and must now be rejected. +const oldNetworkTransfer = + '020000ef5f320156894f0fde742921c6990bf446e82c89fae5a23e701900abcd92dfb40700282e8cd185012800007400000002000000826beefbe2be72645ff376f18de745ac196dc77637436090de4174180706118e3d3e081c6e3599f8ae31d404d9f087f50c25b4e08c35712e23470a60da5799ca00'; +const oldNetworkReversible = + '0d04007416854906f03a9dff66e3270a736c44e15970ac03a638471523a03069f276ca0040b0464f010000000000000000000001e093040000000000d5010c00007400000002000000826beefbe2be72645ff376f18de745ac196dc77637436090de4174180706118efeebb9b31159a679a1e49ccc34d363b5d4a00b836ad4f85cbba8c6274ac2566800'; + +Uint8List extSuffix({List era = const [0x00], int nonce = 0, BigInt? tip, String genesisHex = planckGenesisHex}) { + final out = ByteOutput(); + out.write(era); + CompactCodec.codec.encodeTo(nonce, out); + CompactBigIntCodec.codec.encodeTo(tip ?? BigInt.zero, out); + out.pushByte(0); // metadata hash mode: disabled + U32Codec.codec.encodeTo(131, out); // spec_version + U32Codec.codec.encodeTo(2, out); // transaction_version + out.write(hex.decode(genesisHex)); + out.write(List.filled(32, 0x11)); // block hash (not validated) + out.pushByte(0); // metadata hash: None + return out.toBytes(); +} + +Uint8List payloadWithSuffix(String callHex, {List era = const [0x00], int nonce = 0, BigInt? tip}) { + return Uint8List.fromList([...hex.decode(callHex), ...extSuffix(era: era, nonce: nonce, tip: tip)]); +} + +Matcher throwsRejection(String needle) => + throwsA(isA().having((e) => e.message, 'message', contains(needle))); + void main() { group('QuantusPayloadParser', () { - test('parses balance transfer', () { - // Create a mock balance transfer payload - // Pallet index 2 (Balances), call index 0 (transfer_allow_death) - final payload = Uint8List.fromList([ - 2, // pallet index - 0, // call index - 0, // MultiAddress::Id - ...List.filled(32, 1), // mock account ID (32 bytes) - 0x0b, 0x00, 0xa0, 0x72, 0x4e, 0x18, 0x09, // Compact encoded amount (10000000000000) - ]); - - final result = QuantusPayloadParser.parsePayload(payload); - - expect(result, isNotNull); - expect(result!.toAddress, startsWith('qz')); - expect(result.amount, BigInt.from(10000000000000)); - expect(result.isReversible, false); + test('parses transfer with extensions', () { + // Mortal era bytes 8501 = period 64 phase 24; compact nonce 10. + final payload = payloadWithSuffix(transferCall1, era: const [0x85, 0x01], nonce: 10); + final parsed = QuantusPayloadParser.parsePayload(payload); + + expect(parsed.call.toAddress, 'qzps6MnSixszZAWiwcpjtw6uXBjWg2aEyrXBdp9thijzY1g86'); + expect(parsed.call.amount, BigInt.from(900000000000)); + expect(parsed.call.isReversible, false); + expect(parsed.call.reversibleTimeframe, null); + expect(parsed.extensions.era, const Era.mortal(64, 24)); + expect(parsed.extensions.era.toString(), '64 blocks'); + expect(parsed.extensions.nonce, 10); + expect(parsed.extensions.tip, BigInt.zero); + expect(parsed.extensions.specVersion, 131); + expect(parsed.extensions.transactionVersion, 2); + expect(parsed.network, 'Planck'); + }); + + test('parses transfer with tip and immortal era', () { + final tip = BigInt.from(1500000000000); + final payload = payloadWithSuffix(transferCall2, tip: tip); + final parsed = QuantusPayloadParser.parsePayload(payload); + + expect(parsed.call.toAddress, 'qzn5St24cMsjE4JKYdXLBctusWj5zom67dnrW22SweAahLGeG'); + expect(parsed.call.amount, BigInt.from(100000000000)); + expect(parsed.extensions.era, const Era.immortal()); + expect(parsed.extensions.era.toString(), 'Immortal'); + expect(parsed.extensions.tip, tip); + }); + + test('parses reversible transfer with delay', () { + final payload = payloadWithSuffix(reversibleCall, nonce: 3); + final parsed = QuantusPayloadParser.parsePayload(payload); + + expect(parsed.call.toAddress, 'qzn5St24cMsjE4JKYdXLBctusWj5zom67dnrW22SweAahLGeG'); + expect(parsed.call.amount, BigInt.from(1440000000000)); + expect(parsed.call.isReversible, true); + expect(parsed.call.reversibleTimeframe, 300000); // 5 minutes in milliseconds + expect(parsed.extensions.nonce, 3); + }); + + test('rejects old devnet transfer with unknown genesis (regression)', () { + // Proves the parser walks all the way to the genesis hash and rejects unknown networks. + final payload = Uint8List.fromList(hex.decode(oldNetworkTransfer)); + expect(() => QuantusPayloadParser.parsePayload(payload), throwsRejection('Unknown genesis hash')); + }); + + test('rejects old devnet reversible transfer (regression)', () { + // Rejected at call decode: ReversibleTransfers moved from pallet index 13 to 11 when + // the devnet was retired, so this never reaches the (equally retired) genesis hash. + final payload = Uint8List.fromList(hex.decode(oldNetworkReversible)); + expect(() => QuantusPayloadParser.parsePayload(payload), throwsRejection('Unknown pallet index: 13')); + }); + + test('rejects trailing bytes after signed payload', () { + final payload = payloadWithSuffix(transferCall1, era: const [0x85, 0x01], nonce: 10); + final tampered = Uint8List.fromList([...payload, 0xde, 0xad, 0xbe, 0xef]); + expect(() => QuantusPayloadParser.parsePayload(tampered), throwsRejection('trailing bytes')); }); - test('parses real world balance transfer (0.9 QUAN)', () { - // Test with real world value as follows - // final hexPayload = '020000ef5f320156894f0fde742921c6990bf446e82c89fae5a23e701900abcd92dfb40700282e8cd185012800007400000002000000826beefbe2be72645ff376f18de745ac196dc77637436090de4174180706118e3d3e081c6e3599f8ae31d404d9f087f50c25b4e08c35712e23470a60da5799ca00'; - // final expectedAmount = (BigInt): 900000000000 - // final expectedTargetAddress = 'qzps6MnSixszZAWiwcpjtw6uXBjWg2aEyrXBdp9thijzY1g86'; - - // Real world hex payload from production - final hexPayload = - '020000ef5f320156894f0fde742921c6990bf446e82c89fae5a23e701900abcd92dfb40700282e8cd185012800007400000002000000826beefbe2be72645ff376f18de745ac196dc77637436090de4174180706118e3d3e081c6e3599f8ae31d404d9f087f50c25b4e08c35712e23470a60da5799ca00'; - final expectedTargetAddress = 'qzps6MnSixszZAWiwcpjtw6uXBjWg2aEyrXBdp9thijzY1g86'; - final expectedAmount = BigInt.from(900000000000); - final payload = Uint8List.fromList(hex.decode(hexPayload)); - - final result = QuantusPayloadParser.parsePayload(payload); - - expect(result, isNotNull); - expect(result!.amount, expectedAmount); - expect(result.isReversible, false); - expect(result.reversibleTimeframe, null); - expect(result.toAddress, expectedTargetAddress); + test('rejects bare call without extensions', () { + final payload = Uint8List.fromList(hex.decode(transferCall1)); + expect(() => QuantusPayloadParser.parsePayload(payload), throwsRejection('extensions')); }); - // flutter: Showing confirmation for amount (BigInt): 1440000000000 - // Reverisble transfer to qzn5St24cMsjE4JKYdXLBctusWj5zom67dnrW22SweAahLGeG - // delay 5 minutes = 300 seconds. - // flutter: KAT raw encoded payload: 0d04007416854906f03a9dff66e3270a736c44e15970ac03a638471523a03069f276ca0040b0464f010000000000000000000001e093040000000000d5010c00007400000002000000826beefbe2be72645ff376f18de745ac196dc77637436090de4174180706118efeebb9b31159a679a1e49ccc34d363b5d4a00b836ad4f85cbba8c6274ac2566800 - test('Real world reversible transfer (1.44 QUAN, delay 5 minutes)', () { - final hexPayload = - '0d04007416854906f03a9dff66e3270a736c44e15970ac03a638471523a03069f276ca0040b0464f010000000000000000000001e093040000000000d5010c00007400000002000000826beefbe2be72645ff376f18de745ac196dc77637436090de4174180706118efeebb9b31159a679a1e49ccc34d363b5d4a00b836ad4f85cbba8c6274ac2566800'; - final expectedTargetAddress = 'qzn5St24cMsjE4JKYdXLBctusWj5zom67dnrW22SweAahLGeG'; - final expectedAmount = BigInt.from(1440000000000); - final expectedReversibleTimeframe = 5 * 60 * 1000; // 5 minutes in millisecond - final payload = Uint8List.fromList(hex.decode(hexPayload)); - - final result = QuantusPayloadParser.parsePayload(payload); - - expect(result, isNotNull); - expect(result!.amount, expectedAmount); - expect(result.isReversible, true); - expect(result.reversibleTimeframe, expectedReversibleTimeframe); - expect(result.toAddress, expectedTargetAddress); + test('rejects metadata mode mismatch', () { + final suffix = extSuffix(); + suffix[3] = 1; // mode: enabled, but metadata hash stays None + final payload = Uint8List.fromList([...hex.decode(transferCall1), ...suffix]); + expect(() => QuantusPayloadParser.parsePayload(payload), throwsRejection('inconsistent')); }); - test('parses reversible transfer', () { - // Create a mock reversible transfer payload - // Pallet index 13 (ReversibleTransfers), call index 3 (schedule_transfer) - final payload = Uint8List.fromList([ - 13, // pallet index - 3, // call index - 0, // MultiAddress::Id - ...List.filled(32, 2), // mock account ID (32 bytes) - 0x00, - 0xa0, - 0x72, - 0x4e, - 0x18, - 0x09, - 0x00, - 0x00, - 0x00, - 0x00, - 0x00, - 0x00, - 0x00, - 0x00, - 0x00, - 0x00, // amount (16 bytes, little endian) - 10000000000000 as u128 - ]); - - final result = QuantusPayloadParser.parsePayload(payload); - - expect(result, isNotNull); - expect(result!.toAddress, startsWith('qz')); - expect(result.amount, BigInt.from(10000000000000)); - expect(result.isReversible, true); - expect(result.reversibleTimeframe, null); // Uses configured delay + test('rejects oversized payload', () { + final payload = Uint8List(maxPayloadBytes + 1); + expect(() => QuantusPayloadParser.parsePayload(payload), throwsRejection('too large')); }); - test('parses reversible transfer with custom delay', () { - // Create a mock reversible transfer with delay payload - // Pallet index 13 (ReversibleTransfers), call index 4 (schedule_transfer_with_delay) - final payload = Uint8List.fromList([ - 13, // pallet index - 4, // call index - 0, // MultiAddress::Id - ...List.filled(32, 3), // mock account ID (32 bytes) - 0x00, - 0xa0, - 0x72, - 0x4e, - 0x18, - 0x09, - 0x00, - 0x00, - 0x00, - 0x00, - 0x00, - 0x00, - 0x00, - 0x00, - 0x00, - 0x00, // amount (16 bytes, little endian) - 10000000000000 as u128 - 0, // BlockNumber variant - 100, 0, 0, 0, // delay: 100 blocks - ]); - - final result = QuantusPayloadParser.parsePayload(payload); - - expect(result, isNotNull); - expect(result!.toAddress, startsWith('qz')); - expect(result.amount, BigInt.from(10000000000000)); - expect(result.isReversible, true); - expect(result.reversibleTimeframe, 100); + test('rejects unknown pallet and unknown call', () { + expect(() => QuantusPayloadParser.parsePayload(payloadWithSuffix('0500')), throwsRejection('Unknown pallet')); + expect(() => QuantusPayloadParser.parsePayload(payloadWithSuffix('0202')), throwsRejection('call')); }); - test('returns null for unknown pallet', () { - final payload = Uint8List.fromList([99, 0]); // Unknown pallet index 99 - final result = QuantusPayloadParser.parsePayload(payload); - expect(result, null); + test('rejects multisig payloads (pallet 19 not displayable here)', () { + final payload = payloadWithSuffix('1300'); + expect(() => QuantusPayloadParser.parsePayload(payload), throwsRejection('Unknown pallet index: 19')); }); - test('TransactionInfo toString formats correctly', () { + test('TransactionInfo toString formats with 12 decimals', () { final tx = TransactionInfo( - toAddress: '0x01010101010101010101010101010101010101010101010101010101010101', - amount: BigInt.from(10000000000000), // 1000 QUS with 10 decimals + toAddress: 'qzps6MnSixszZAWiwcpjtw6uXBjWg2aEyrXBdp9thijzY1g86', + amount: BigInt.from(10000000000000), // 10 QUAN at 12 decimals isReversible: true, - reversibleTimeframe: 7200, + reversibleTimeframe: 300000, ); final output = tx.toString(); - expect(output, contains('To Address: 0x01010101010101010101010101010101010101010101010101010101010101')); - expect(output, contains('Amount: 1000.0000 QUS')); + expect(output, contains('To Address: qzps6MnSixszZAWiwcpjtw6uXBjWg2aEyrXBdp9thijzY1g86')); + expect(output, contains('Amount: 10.0000 ${AppConstants.tokenSymbol}')); expect(output, contains('Reversible: true')); - expect(output, contains('Reversible Timeframe: 7200 blocks')); + expect(output, contains('Reversible Timeframe: 300000 ms')); }); }); } diff --git a/rust-transaction-parser/Cargo.lock b/rust-transaction-parser/Cargo.lock index b00419daa..aa255de0d 100644 --- a/rust-transaction-parser/Cargo.lock +++ b/rust-transaction-parser/Cargo.lock @@ -32,12 +32,6 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" -[[package]] -name = "base58" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6107fe1be6682a68940da878d9e9f5e90ca5745b3dec9fd1bb393c8777d4f581" - [[package]] name = "bitflags" version = "2.11.0" @@ -839,8 +833,6 @@ dependencies = [ name = "quantus-transaction-parser" version = "0.1.0" dependencies = [ - "base58", - "blake2", "hex", "parity-scale-codec", "ss58", diff --git a/rust-transaction-parser/Cargo.toml b/rust-transaction-parser/Cargo.toml index 39acd38f1..3f22f2a50 100644 --- a/rust-transaction-parser/Cargo.toml +++ b/rust-transaction-parser/Cargo.toml @@ -6,6 +6,4 @@ edition = "2021" [dependencies] parity-scale-codec = { version = "3.6", default-features = false, features = ["derive", "full"] } ss58 = "0.0.3" -base58 = "0.2" -blake2 = "0.10" hex = "0.4" \ No newline at end of file diff --git a/rust-transaction-parser/src/lib.rs b/rust-transaction-parser/src/lib.rs index 6ece0a3f0..dab14aaf9 100644 --- a/rust-transaction-parser/src/lib.rs +++ b/rust-transaction-parser/src/lib.rs @@ -1,180 +1,680 @@ -use parity_scale_codec::{Decode, Compact}; +use parity_scale_codec::{Decode, Error as CodecError, Input}; use std::fmt; -#[derive(Debug, PartialEq)] -pub struct TransactionInfo { - pub to_address: String, - pub amount: u128, - pub is_reversible: bool, - pub reversible_timeframe: Option, -} +/// Hard cap on the raw signing payload; every supported call is far below this. +const MAX_PAYLOAD_BYTES: usize = 8 * 1024; +/// Maximum nesting of multisig `propose` inner calls (top-level call is depth 0). +const MAX_CALL_DEPTH: u32 = 2; -impl fmt::Display for TransactionInfo { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - let amount_str = format!("{:.4}", self.amount as f64 / 10_f64.powi(10)); - write!(f, "Transaction Details:\n To Address: {}\n Amount: {} QUS\n Reversible: {}", - self.to_address, amount_str, self.is_reversible)?; +/// Networks this parser will accept: (genesis hash, display name). +/// A payload whose `CheckGenesis` hash is not listed here is rejected. +const KNOWN_NETWORKS: &[([u8; 32], &str)] = &[ + ( + // Planck: 0x4901bf5c57fd3f9e726af399c763de6670dbdb115a91c0237e173f16eef65e72 + [ + 0x49, 0x01, 0xbf, 0x5c, 0x57, 0xfd, 0x3f, 0x9e, 0x72, 0x6a, 0xf3, 0x99, 0xc7, 0x63, + 0xde, 0x66, 0x70, 0xdb, 0xdb, 0x11, 0x5a, 0x91, 0xc0, 0x23, 0x7e, 0x17, 0x3f, 0x16, + 0xee, 0xf6, 0x5e, 0x72, + ], + "Planck", + ), + ( + // Heisenberg: 0xa5aa9e5c84d4a3722c152295e7973c9af522f2fb1ef7db5afaa3d5f4dc8d3b4f + [ + 0xa5, 0xaa, 0x9e, 0x5c, 0x84, 0xd4, 0xa3, 0x72, 0x2c, 0x15, 0x22, 0x95, 0xe7, 0x97, + 0x3c, 0x9a, 0xf5, 0x22, 0xf2, 0xfb, 0x1e, 0xf7, 0xdb, 0x5a, 0xfa, 0xa3, 0xd5, 0xf4, + 0xdc, 0x8d, 0x3b, 0x4f, + ], + "Heisenberg", + ), +]; - if self.is_reversible && self.reversible_timeframe.is_some() { - write!(f, "\n Reversible Timeframe: {} milliseconds ", self.reversible_timeframe.unwrap())?; - } +// Mirrors of the on-chain call types, decoded with the same SCALE derive the runtime uses. +// `#[codec(index)]` must match the runtime pallet/call indices and `#[codec(compact)]` must +// match `#[pallet::compact]` in the pallet declarations (chain `main`, spec >= 133). +// Any pallet, call, or variant not declared here hard-fails decoding. - Ok(()) - } +#[derive(Decode)] +enum MultiAddress { + #[codec(index = 0)] + Id([u8; 32]), } -pub struct QuantusPayloadParser; +#[derive(Decode)] +enum BalancesCall { + #[codec(index = 0)] + TransferAllowDeath { + dest: MultiAddress, + #[codec(compact)] + value: u128, + }, + #[codec(index = 3)] + TransferKeepAlive { + dest: MultiAddress, + #[codec(compact)] + value: u128, + }, +} -impl QuantusPayloadParser { - pub fn bytes_to_ss58(bytes: &[u8]) -> String { - const SS58_PREFIX: u16 = 189; // Quantus SS58 prefix - - if bytes.len() != 32 { - panic!("AccountId32 must be 32 bytes"); - } - - let mut account_id_bytes = [0u8; 32]; - account_id_bytes.copy_from_slice(bytes); - - ss58::encode(&account_id_bytes, ss58::Ss58AddressFormat::Custom(SS58_PREFIX)) - } +// qp_scheduler::BlockNumberOrTimestamp +#[derive(Decode)] +enum BlockNumberOrTimestamp { + #[codec(index = 0)] + BlockNumber(u32), + #[codec(index = 1)] + Timestamp(u64), +} - pub fn parse_payload(payload: &[u8]) -> Result { - let mut input = &payload[..]; +#[derive(Decode)] +enum ReversibleTransfersCall { + #[codec(index = 3)] + ScheduleTransfer { dest: MultiAddress, amount: u128 }, + #[codec(index = 4)] + ScheduleTransferWithDelay { + dest: MultiAddress, + amount: u128, + delay: BlockNumberOrTimestamp, + }, +} + +#[derive(Decode)] +enum MultisigCall { + #[codec(index = 0)] + CreateMultisig { + signers: Vec<[u8; 32]>, + threshold: u32, + nonce: u64, + }, + #[codec(index = 1)] + Propose { + multisig_address: [u8; 32], + call: Vec, + expiry: u32, + }, + #[codec(index = 2)] + Approve { + multisig_address: [u8; 32], + proposal_id: u32, + }, + #[codec(index = 6)] + Execute { + multisig_address: [u8; 32], + proposal_id: u32, + }, +} - // Read pallet index (first byte) - let pallet_index: u8 = Decode::decode(&mut input).map_err(|e| e.to_string())?; +#[derive(Decode)] +enum RuntimeCall { + #[codec(index = 2)] + Balances(BalancesCall), + #[codec(index = 11)] + ReversibleTransfers(ReversibleTransfersCall), + #[codec(index = 19)] + Multisig(MultisigCall), +} - // Read the call data (remaining bytes) - let call_data = input; +/// sp_runtime `Era` (immortal = one zero byte, mortal = two bytes encoding period/phase). +#[derive(Debug, PartialEq, Clone, Copy)] +pub enum Era { + Immortal, + Mortal { period: u64, phase: u64 }, +} - match pallet_index { - 2 => Self::parse_balances_call(call_data), // Balances pallet - 13 => Self::parse_reversible_transfers_call(call_data), // ReversibleTransfers pallet - _ => Err("Unknown pallet".to_string()), // Unknown pallet +impl Decode for Era { + fn decode(input: &mut I) -> Result { + let first = input.read_byte()?; + if first == 0 { + return Ok(Era::Immortal); + } + let encoded = first as u64 + ((input.read_byte()? as u64) << 8); + let period = 2u64 << (encoded % (1 << 4)); + let quantize_factor = (period >> 12).max(1); + let phase = (encoded >> 4) * quantize_factor; + if period >= 4 && phase < period { + Ok(Era::Mortal { period, phase }) + } else { + Err("invalid era period/phase".into()) } } +} - fn parse_balances_call(call_data: &[u8]) -> Result { - let mut input = call_data; - - // Read call index - let call_index: u8 = Decode::decode(&mut input).map_err(|e| e.to_string())?; - - match call_index { - 0 | 3 => { // transfer_allow_death or transfer_keep_alive - let dest = Self::parse_multi_address(&mut input)?; - let amount: Compact = Decode::decode(&mut input).map_err(|e| e.to_string())?; - Ok(TransactionInfo { - to_address: dest, - amount: amount.0, - is_reversible: false, - reversible_timeframe: None, - }) - } - _ => Err(format!("Balances: Unsupported call index {}", call_index)), +impl fmt::Display for Era { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Era::Immortal => write!(f, "Immortal"), + Era::Mortal { period, .. } => write!(f, "{} blocks", period), } } +} + +#[derive(Decode, Debug, PartialEq)] +enum MetadataHashMode { + #[codec(index = 0)] + Disabled, + #[codec(index = 1)] + Enabled, +} + +/// The runtime `TxExtension` data that follows the call in a signing payload, in declaration +/// order: explicit parts (era, nonce, tip, metadata-hash mode) then the implicit +/// "additional signed" parts (spec/tx version, genesis + block hash, optional metadata hash). +/// Extensions with unit encoding (CheckNonZeroSender, CheckWeight, Reversible, Wormhole) +/// contribute no bytes. +#[derive(Decode, Debug)] +pub struct SignedExtensions { + pub era: Era, + #[codec(compact)] + pub nonce: u32, + #[codec(compact)] + pub tip: u128, + metadata_mode: MetadataHashMode, + pub spec_version: u32, + pub transaction_version: u32, + pub genesis_hash: [u8; 32], + pub block_hash: [u8; 32], + metadata_hash: Option<[u8; 32]>, +} - fn parse_reversible_transfers_call(call_data: &[u8]) -> Result { - let mut input = call_data; +/// A decoded Quantus call. Display-only: the signer never blind-signs, it shows what it parses. +#[derive(Debug, PartialEq)] +pub enum QuantusTx { + Transfer { + to: String, + amount: u128, + is_reversible: bool, + reversible_timeframe: Option, + }, + MultisigCreate { + signers: Vec, + threshold: u32, + nonce: u64, + }, + MultisigPropose { + multisig: String, + expiry: u32, + inner: Box, + }, + MultisigApprove { + multisig: String, + proposal_id: u32, + }, + MultisigExecute { + multisig: String, + proposal_id: u32, + }, +} - // Read call index - let call_index: u8 = Decode::decode(&mut input).map_err(|e| e.to_string())?; +impl QuantusTx { + pub fn is_transfer(&self) -> bool { + matches!(self, QuantusTx::Transfer { .. }) + } - match call_index { - 3 => { // schedule_transfer - let dest = Self::parse_multi_address(&mut input)?; - let amount: u128 = Decode::decode(&mut input).map_err(|e| e.to_string())?; - Ok(TransactionInfo { - to_address: dest, + fn from_call(call: RuntimeCall, depth: u32) -> Result { + match call { + RuntimeCall::Balances( + BalancesCall::TransferAllowDeath { dest, value } + | BalancesCall::TransferKeepAlive { dest, value }, + ) => Ok(QuantusTx::Transfer { + to: multi_address_to_ss58(dest), + amount: value, + is_reversible: false, + reversible_timeframe: None, + }), + RuntimeCall::ReversibleTransfers(ReversibleTransfersCall::ScheduleTransfer { + dest, + amount, + }) => Ok(QuantusTx::Transfer { + to: multi_address_to_ss58(dest), + amount, + is_reversible: true, + reversible_timeframe: None, // Uses configured delay + }), + RuntimeCall::ReversibleTransfers( + ReversibleTransfersCall::ScheduleTransferWithDelay { dest, amount, delay }, + ) => { + let delay_ms = match delay { + BlockNumberOrTimestamp::Timestamp(ms) => ms, + BlockNumberOrTimestamp::BlockNumber(n) => { + return Err(format!( + "Block-number delays are not supported (got block {})", + n + )) + } + }; + Ok(QuantusTx::Transfer { + to: multi_address_to_ss58(dest), amount, is_reversible: true, - reversible_timeframe: None, // Uses configured delay + reversible_timeframe: Some(delay_ms), }) } - 4 => { // schedule_transfer_with_delay - let dest = Self::parse_multi_address(&mut input)?; - let amount: u128 = Decode::decode(&mut input).map_err(|e| e.to_string())?; - let delay = Self::parse_block_number_or_timestamp(&mut input)?; - Ok(TransactionInfo { - to_address: dest, - amount, - is_reversible: true, - reversible_timeframe: Some(delay), + RuntimeCall::Multisig(MultisigCall::CreateMultisig { + signers, + threshold, + nonce, + }) => Ok(QuantusTx::MultisigCreate { + signers: signers.iter().map(bytes_to_ss58).collect(), + threshold, + nonce, + }), + RuntimeCall::Multisig(MultisigCall::Propose { + multisig_address, + call, + expiry, + }) => { + if depth >= MAX_CALL_DEPTH { + return Err(format!( + "Multisig call nesting exceeds depth limit {}", + MAX_CALL_DEPTH + )); + } + let inner = decode_call(&call, depth + 1)?; + Ok(QuantusTx::MultisigPropose { + multisig: bytes_to_ss58(&multisig_address), + expiry, + inner: Box::new(inner), }) } - _ => Err(format!("ReversibleTransfers: Unsupported call index {}", call_index)), + RuntimeCall::Multisig(MultisigCall::Approve { + multisig_address, + proposal_id, + }) => Ok(QuantusTx::MultisigApprove { + multisig: bytes_to_ss58(&multisig_address), + proposal_id, + }), + RuntimeCall::Multisig(MultisigCall::Execute { + multisig_address, + proposal_id, + }) => Ok(QuantusTx::MultisigExecute { + multisig: bytes_to_ss58(&multisig_address), + proposal_id, + }), } } +} - fn parse_multi_address(input: &mut &[u8]) -> Result { - let address_type: u8 = Decode::decode(input).map_err(|e| e.to_string())?; - - match address_type { - 0 => { // Id(AccountId) - let account_id: [u8; 32] = Decode::decode(input).map_err(|e| e.to_string())?; - Ok(Self::bytes_to_ss58(&account_id)) +impl fmt::Display for QuantusTx { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + QuantusTx::Transfer { to, amount, is_reversible, reversible_timeframe } => { + let amount_f64 = *amount as f64 / 1_000_000_000_000.0; + write!(f, "Transfer to {} amount {:.4} reversible {}", to, amount_f64, is_reversible)?; + if let Some(t) = reversible_timeframe { + write!(f, " timeframe {}ms", t)?; + } + Ok(()) + } + QuantusTx::MultisigCreate { signers, threshold, nonce } => { + write!(f, "Create multisig {} of {} nonce {}", threshold, signers.len(), nonce) + } + QuantusTx::MultisigPropose { multisig, expiry, inner } => { + write!(f, "Multisig propose on {} expiry {} call [{}]", multisig, expiry, inner) + } + QuantusTx::MultisigApprove { multisig, proposal_id } => { + write!(f, "Multisig approve on {} proposal {}", multisig, proposal_id) + } + QuantusTx::MultisigExecute { multisig, proposal_id } => { + write!(f, "Multisig execute on {} proposal {}", multisig, proposal_id) } - 1 => Err("Index(Compact) MultiAddress type 1 is not supported".to_string()), - 2 => Err("Raw(Vec) MultiAddress type 2 is not supported".to_string()), - 3 => Err("Address32([u8; 32]) MultiAddress type 3 is not supported".to_string()), - 4 => Err("Address20([u8; 20]) MultiAddress type 4 is not supported".to_string()), - _ => Err(format!("Unknown multi address type: {}", address_type)), } } +} - fn parse_block_number_or_timestamp(input: &mut &[u8]) -> Result { - let variant: u8 = Decode::decode(input).map_err(|e| e.to_string())?; +/// A fully decoded signing payload: the call plus every signed-extension field, with no +/// bytes left over. Everything that gets signed is either displayed or validated. +#[derive(Debug)] +pub struct ParsedPayload { + pub call: QuantusTx, + pub extensions: SignedExtensions, + pub network: &'static str, +} - match variant { - 0 => Err("Block numbers are not supported for delayed transactions".to_string()), - 1 => { // Timestamp(u64) - let timestamp: u64 = Decode::decode(input).map_err(|e| e.to_string())?; - Ok(timestamp) - } - _ => Err(format!("Unknown time variant: {}", variant)), +impl fmt::Display for ParsedPayload { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "{}\nNetwork: {}\nEra: {}\nNonce: {}\nTip: {:.4}", + self.call, + self.network, + self.extensions.era, + self.extensions.nonce, + self.extensions.tip as f64 / 1_000_000_000_000.0, + ) + } +} + +pub fn bytes_to_ss58(bytes: &[u8; 32]) -> String { + const SS58_PREFIX: u16 = 189; // Quantus SS58 prefix + ss58::encode(bytes, ss58::Ss58AddressFormat::Custom(SS58_PREFIX)) +} + +fn multi_address_to_ss58(address: MultiAddress) -> String { + let MultiAddress::Id(account_id) = address; + bytes_to_ss58(&account_id) +} + +fn decode_call(bytes: &[u8], depth: u32) -> Result { + let mut input = bytes; + let call = RuntimeCall::decode(&mut input).map_err(|e| format!("call: {}", e))?; + if !input.is_empty() { + return Err(format!("{} trailing bytes after call", input.len())); + } + QuantusTx::from_call(call, depth) +} + +pub fn parse_payload(payload: &[u8]) -> Result { + if payload.len() > MAX_PAYLOAD_BYTES { + return Err(format!("Payload too large: {} bytes", payload.len())); + } + + let mut input = payload; + let call = RuntimeCall::decode(&mut input).map_err(|e| format!("call: {}", e))?; + let extensions = + SignedExtensions::decode(&mut input).map_err(|e| format!("extensions: {}", e))?; + if !input.is_empty() { + return Err(format!("{} trailing bytes after signed payload", input.len())); + } + + match (&extensions.metadata_mode, extensions.metadata_hash.is_some()) { + (MetadataHashMode::Disabled, false) | (MetadataHashMode::Enabled, true) => {} + (mode, _) => { + return Err(format!( + "Metadata hash mode {:?} inconsistent with metadata hash presence", + mode + )) } } + + let network = KNOWN_NETWORKS + .iter() + .find(|(genesis, _)| *genesis == extensions.genesis_hash) + .map(|(_, name)| *name) + .ok_or_else(|| { + format!("Unknown genesis hash: {}", hex::encode(extensions.genesis_hash)) + })?; + + let call = QuantusTx::from_call(call, 0)?; + Ok(ParsedPayload { call, extensions, network }) } #[cfg(test)] mod tests { use super::*; use hex; + use parity_scale_codec::{Compact, Encode}; + + const PLANCK_GENESIS: [u8; 32] = KNOWN_NETWORKS[0].0; + + // Call portions of the original "real world" vectors (extensions stripped); the full + // vectors were captured on a retired devnet whose genesis hash is no longer accepted. + // The reversible call is re-indexed from the retired pallet index 13 to the current 11. + const TRANSFER_CALL_1: &str = + "020000ef5f320156894f0fde742921c6990bf446e82c89fae5a23e701900abcd92dfb40700282e8cd1"; + const TRANSFER_CALL_2: &str = + "0200007416854906f03a9dff66e3270a736c44e15970ac03a638471523a03069f276ca0700e8764817"; + const REVERSIBLE_CALL: &str = + "0b04007416854906f03a9dff66e3270a736c44e15970ac03a638471523a03069f276ca0040b0464f010000000000000000000001e093040000000000"; + + // The two original real-world vectors, kept verbatim as regression tests: both were + // captured on the retired devnet (genesis 826beefb…) and must now be rejected. + const OLD_NETWORK_TRANSFER: &str = + "020000ef5f320156894f0fde742921c6990bf446e82c89fae5a23e701900abcd92dfb40700282e8cd185012800007400000002000000826beefbe2be72645ff376f18de745ac196dc77637436090de4174180706118e3d3e081c6e3599f8ae31d404d9f087f50c25b4e08c35712e23470a60da5799ca00"; + const OLD_NETWORK_REVERSIBLE: &str = + "0d04007416854906f03a9dff66e3270a736c44e15970ac03a638471523a03069f276ca0040b0464f010000000000000000000001e093040000000000d5010c00007400000002000000826beefbe2be72645ff376f18de745ac196dc77637436090de4174180706118efeebb9b31159a679a1e49ccc34d363b5d4a00b836ad4f85cbba8c6274ac2566800"; + + fn ext_suffix(era: &[u8], nonce: u32, tip: u128, genesis: &[u8; 32]) -> Vec { + let mut v = Vec::new(); + v.extend_from_slice(era); + v.extend(Compact(nonce).encode()); + v.extend(Compact(tip).encode()); + v.push(0); // metadata hash mode: disabled + v.extend_from_slice(&131u32.to_le_bytes()); // spec_version + v.extend_from_slice(&2u32.to_le_bytes()); // transaction_version + v.extend_from_slice(genesis); + v.extend_from_slice(&[0x11; 32]); // block hash (not validated) + v.push(0); // metadata hash: None + v + } + + fn payload_with_suffix(call_hex: &str, era: &[u8], nonce: u32, tip: u128) -> Vec { + let mut payload = hex::decode(call_hex).unwrap(); + payload.extend(ext_suffix(era, nonce, tip, &PLANCK_GENESIS)); + payload + } + + fn parse(payload: &[u8]) -> ParsedPayload { + parse_payload(payload).unwrap() + } + + fn assert_transfer(tx: &QuantusTx, address: &str, amount: u128, reversible: bool, timeframe: Option) { + match tx { + QuantusTx::Transfer { to, amount: a, is_reversible, reversible_timeframe } => { + assert_eq!(to, address); + assert_eq!(*a, amount); + assert_eq!(*is_reversible, reversible); + assert_eq!(*reversible_timeframe, timeframe); + } + other => panic!("expected Transfer, got {:?}", other), + } + } + + #[test] + fn test_parse_transfer_with_extensions() { + // Mortal era bytes 8501 = period 64 phase 24; compact nonce 10. + let payload = payload_with_suffix(TRANSFER_CALL_1, &[0x85, 0x01], 10, 0); + let parsed = parse(&payload); + assert_transfer( + &parsed.call, + "qzps6MnSixszZAWiwcpjtw6uXBjWg2aEyrXBdp9thijzY1g86", + 900000000000u128, + false, + None, + ); + assert_eq!(parsed.extensions.era, Era::Mortal { period: 64, phase: 24 }); + assert_eq!(parsed.extensions.nonce, 10); + assert_eq!(parsed.extensions.tip, 0); + assert_eq!(parsed.extensions.spec_version, 131); + assert_eq!(parsed.extensions.transaction_version, 2); + assert_eq!(parsed.network, "Planck"); + } + + #[test] + fn test_parse_transfer_with_tip_and_immortal_era() { + let tip = 1_500_000_000_000u128; + let payload = payload_with_suffix(TRANSFER_CALL_2, &[0x00], 0, tip); + let parsed = parse(&payload); + assert_transfer( + &parsed.call, + "qzn5St24cMsjE4JKYdXLBctusWj5zom67dnrW22SweAahLGeG", + 100000000000u128, + false, + None, + ); + assert_eq!(parsed.extensions.era, Era::Immortal); + assert_eq!(parsed.extensions.tip, tip); + } + + #[test] + fn test_parse_reversible_transfer_with_delay() { + let payload = payload_with_suffix(REVERSIBLE_CALL, &[0x00], 3, 0); + let parsed = parse(&payload); + assert_transfer( + &parsed.call, + "qzn5St24cMsjE4JKYdXLBctusWj5zom67dnrW22SweAahLGeG", + 1440000000000u128, + true, + Some(300000u64), + ); + assert_eq!(parsed.extensions.nonce, 3); + } + + #[test] + fn test_reject_old_devnet_transfer_unknown_genesis() { + // Proves the parser walks all the way to the genesis hash and rejects unknown networks. + let payload = hex::decode(OLD_NETWORK_TRANSFER).unwrap(); + let err = parse_payload(&payload).unwrap_err(); + assert!(err.contains("Unknown genesis hash"), "{}", err); + } + + #[test] + fn test_reject_old_devnet_reversible_transfer() { + // Rejected at call decode: ReversibleTransfers moved from pallet index 13 to 11 when + // the devnet was retired, so this never reaches the (equally retired) genesis hash. + let payload = hex::decode(OLD_NETWORK_REVERSIBLE).unwrap(); + let err = parse_payload(&payload).unwrap_err(); + assert!(err.contains("call"), "{}", err); + } + + #[test] + fn test_reject_trailing_bytes() { + let mut payload = payload_with_suffix(TRANSFER_CALL_1, &[0x85, 0x01], 10, 0); + payload.extend_from_slice(&[0xde, 0xad, 0xbe, 0xef]); + let err = parse_payload(&payload).unwrap_err(); + assert!(err.contains("trailing bytes"), "{}", err); + } + + #[test] + fn test_reject_bare_call_without_extensions() { + let payload = hex::decode(TRANSFER_CALL_1).unwrap(); + assert!(parse_payload(&payload).is_err()); + } + + #[test] + fn test_reject_metadata_mode_mismatch() { + let mut payload = hex::decode(TRANSFER_CALL_1).unwrap(); + let mut suffix = ext_suffix(&[0x00], 0, 0, &PLANCK_GENESIS); + suffix[3] = 1; // mode: enabled, but metadata hash stays None + payload.extend(suffix); + let err = parse_payload(&payload).unwrap_err(); + assert!(err.contains("inconsistent"), "{}", err); + } + + #[test] + fn test_reject_oversized_payload() { + let payload = vec![0u8; MAX_PAYLOAD_BYTES + 1]; + let err = parse_payload(&payload).unwrap_err(); + assert!(err.contains("too large"), "{}", err); + } + + #[test] + fn test_reject_unknown_pallet_and_call() { + assert!(parse_payload(&payload_with_suffix("0500", &[0x00], 0, 0)).is_err()); + assert!(parse_payload(&payload_with_suffix("0202", &[0x00], 0, 0)).is_err()); + } #[test] - fn test_parse_real_world_balance_transfer() { - let hex_payload = "020000ef5f320156894f0fde742921c6990bf446e82c89fae5a23e701900abcd92dfb40700282e8cd185012800007400000002000000826beefbe2be72645ff376f18de745ac196dc77637436090de4174180706118e3d3e081c6e3599f8ae31d404d9f087f50c25b4e08c35712e23470a60da5799ca00"; - let payload = hex::decode(hex_payload).unwrap(); - let expected_address = "qzps6MnSixszZAWiwcpjtw6uXBjWg2aEyrXBdp9thijzY1g86"; - let expected_amount = 900000000000u128; + fn test_reject_huge_vec_length_prefix() { + // create_multisig with a signers length prefix far beyond the input size + let err = parse_payload(&payload_with_suffix("1300feffffff", &[0x00], 0, 0)).unwrap_err(); + assert!(err.contains("call"), "{}", err); + } - let result = QuantusPayloadParser::parse_payload(&payload); + // Authoritative multisig vectors: SCALE-encoded offline from the live chain + // metadata via subxt (quantus-cli example `encode_multisig_vectors`). + // SS58 strings below are sp_core-encoded (network 189) and cross-check the ss58 crate. + const SS58_A: &str = "qzoK1UVQSssYHuTWxAN1U8egoJWRjTzF1LBcRubYp5a19ium3"; + const SS58_B: &str = "qzohPMkqjuMjQajDBZCU52NqZUjuMQLHSYWiSR3PhWZSegGEF"; + const SS58_C: &str = "qzp5mF2H2vqvXFzuQx2vfv6zKeyNyLgKskqpSvVEawYt9dJPY"; + const SS58_MULTISIG: &str = "qznvdbDy9rPMBEBpimXYsEvY38Gx7XeCa7rWRQ9hveaZemr8U"; + const SS58_DEST: &str = "qzn9sph6ZoQxwseSFyrdfTUEWmozsex7hhCJQPG29nbgesGei"; - assert!(result.is_ok()); - let tx = result.unwrap(); - assert_eq!(tx.amount, expected_amount); - assert_eq!(tx.is_reversible, false); - assert_eq!(tx.reversible_timeframe, None); - assert_eq!(tx.to_address, expected_address); + fn parse_call_hex(call_hex: &str) -> QuantusTx { + parse(&payload_with_suffix(call_hex, &[0x00], 0, 0)).call } #[test] - fn test_parse_real_world_reversible_transfer() { - let hex_payload = "0d04007416854906f03a9dff66e3270a736c44e15970ac03a638471523a03069f276ca0040b0464f010000000000000000000001e093040000000000d5010c00007400000002000000826beefbe2be72645ff376f18de745ac196dc77637436090de4174180706118efeebb9b31159a679a1e49ccc34d363b5d4a00b836ad4f85cbba8c6274ac2566800"; - let payload = hex::decode(hex_payload).unwrap(); - let expected_address = "qzn5St24cMsjE4JKYdXLBctusWj5zom67dnrW22SweAahLGeG"; - let expected_amount = 1440000000000u128; - let expected_delay = 300000u64; // 5 minutes in milliseconds + fn test_parse_real_multisig_create() { + let tx = parse_call_hex("13000caaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaabbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbcccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc020000000000000000000000"); + match tx { + QuantusTx::MultisigCreate { signers, threshold, nonce } => { + assert_eq!(signers, [SS58_A, SS58_B, SS58_C]); + assert_eq!(threshold, 2); + assert_eq!(nonce, 0); + } + other => panic!("expected MultisigCreate, got {:?}", other), + } + } - let result = QuantusPayloadParser::parse_payload(&payload); + #[test] + fn test_parse_real_multisig_approve() { + match parse_call_hex("1302999999999999999999999999999999999999999999999999999999999999999907000000") { + QuantusTx::MultisigApprove { multisig, proposal_id } => { + assert_eq!(multisig, SS58_MULTISIG); + assert_eq!(proposal_id, 7); + } + other => panic!("expected MultisigApprove, got {:?}", other), + } + } - assert!(result.is_ok()); - let tx = result.unwrap(); - assert_eq!(tx.amount, expected_amount); - assert_eq!(tx.is_reversible, true); - assert_eq!(tx.reversible_timeframe, Some(expected_delay)); - assert_eq!(tx.to_address, expected_address); + #[test] + fn test_parse_real_multisig_execute() { + match parse_call_hex("1306999999999999999999999999999999999999999999999999999999999999999907000000") { + QuantusTx::MultisigExecute { multisig, proposal_id } => { + assert_eq!(multisig, SS58_MULTISIG); + assert_eq!(proposal_id, 7); + } + other => panic!("expected MultisigExecute, got {:?}", other), + } } -} \ No newline at end of file + + #[test] + fn test_parse_real_multisig_propose_transfer() { + // propose wrapping Balances::transfer_allow_death(dest, 42_000_000_000), expiry 5000 + match parse_call_hex("13019999999999999999999999999999999999999999999999999999999999999999a4020000777777777777777777777777777777777777777777777777777777777777777707002465c70988130000") { + QuantusTx::MultisigPropose { multisig, expiry, inner } => { + assert_eq!(multisig, SS58_MULTISIG); + assert_eq!(expiry, 5000); + assert_transfer(&inner, SS58_DEST, 42_000_000_000u128, false, None); + } + other => panic!("expected MultisigPropose, got {:?}", other), + } + } + + fn propose_wrapping(inner: &[u8]) -> Vec { + let mut call = vec![0x13, 0x01]; + call.extend_from_slice(&[0x99; 32]); + call.extend(Compact(inner.len() as u32).encode()); + call.extend_from_slice(inner); + call.extend_from_slice(&5000u32.to_le_bytes()); + call + } + + #[test] + fn test_multisig_nesting_depth_limit() { + let transfer = hex::decode(TRANSFER_CALL_1).unwrap(); + + // propose(propose(transfer)) — inner calls at depth 1 and 2 — is accepted. + let mut payload = propose_wrapping(&propose_wrapping(&transfer)); + payload.extend(ext_suffix(&[0x00], 0, 0, &PLANCK_GENESIS)); + assert!(parse_payload(&payload).is_ok()); + + // One more level of nesting exceeds MAX_CALL_DEPTH and is rejected. + let mut payload = propose_wrapping(&propose_wrapping(&propose_wrapping(&transfer))); + payload.extend(ext_suffix(&[0x00], 0, 0, &PLANCK_GENESIS)); + let err = parse_payload(&payload).unwrap_err(); + assert!(err.contains("depth limit"), "{}", err); + } + + #[test] + fn test_deep_nesting_bomb_rejected_quickly() { + // The audit's C-1 payload shape: hundreds of nested propose levels. Must fail via the + // depth limit, not by exhausting the stack. + let mut call = hex::decode(TRANSFER_CALL_1).unwrap(); + for _ in 0..300 { + call = propose_wrapping(&call); + } + call.extend(ext_suffix(&[0x00], 0, 0, &PLANCK_GENESIS)); + let err = parse_payload(&call).unwrap_err(); + assert!(err.contains("depth limit") || err.contains("too large"), "{}", err); + } + + #[test] + fn test_reject_trailing_bytes_inside_inner_call() { + let mut inner = hex::decode(TRANSFER_CALL_1).unwrap(); + inner.push(0xff); + let mut payload = propose_wrapping(&inner); + payload.extend(ext_suffix(&[0x00], 0, 0, &PLANCK_GENESIS)); + let err = parse_payload(&payload).unwrap_err(); + assert!(err.contains("trailing bytes after call"), "{}", err); + } +}