From f8dc405cc5e95b366bc74edb463a0479dd909572 Mon Sep 17 00:00:00 2001 From: Nikolaus Heger Date: Wed, 9 Sep 2026 10:49:25 +0800 Subject: [PATCH 1/2] fix: cold wallet signs for any genesis hash The signer no longer rejects payloads whose genesis hash is not in its network table. The name is shown in Advanced when known, alongside the raw genesis hash which is always shown; unlisted chains such as mainnet sign like any other. --- .../lib/screens/sign_transaction_screen.dart | 2 +- .../test/transaction_signing_test.dart | 12 ++++----- .../lib/src/quantus_payload_parser.dart | 25 +++++++++++-------- .../test/quantus_payload_parser_test.dart | 21 +++++++++------- 4 files changed, 33 insertions(+), 27 deletions(-) diff --git a/cold-wallet-app/lib/screens/sign_transaction_screen.dart b/cold-wallet-app/lib/screens/sign_transaction_screen.dart index 60d60e6b..207e16d5 100644 --- a/cold-wallet-app/lib/screens/sign_transaction_screen.dart +++ b/cold-wallet-app/lib/screens/sign_transaction_screen.dart @@ -238,7 +238,7 @@ class _SignTransactionScreenState extends ConsumerState { // The one place the runtime's own naming appears, nested calls included — // the headlines above deliberately summarise it away. 'Call: ${parsed.call.displayTitleChain}', - 'Network: ${parsed.network}', + if (parsed.network != null) 'Network: ${parsed.network}', 'Runtime: spec ${ext.specVersion}, tx version ${ext.transactionVersion}', 'Nonce: ${ext.nonce}', 'Era: ${ext.era}', diff --git a/cold-wallet-app/test/transaction_signing_test.dart b/cold-wallet-app/test/transaction_signing_test.dart index 356dacbb..fb1564ae 100644 --- a/cold-wallet-app/test/transaction_signing_test.dart +++ b/cold-wallet-app/test/transaction_signing_test.dart @@ -26,7 +26,7 @@ void main() { '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. + // The signer signs for any chain, so this decodes like any other payload. const retiredDevnetHex = '0200007416854906f03a9dff66e3270a736c44e15970ac03a638471523a03069f276ca0700e876481755010000007400000002000000826beefbe2be72645ff376f18de745ac196dc77637436090de4174180706118e5a77ae1c95817ee664cf733fafa7baa8e6244b396a54e57a5bc414b24c52800600'; @@ -106,12 +106,12 @@ void main() { expect(QuantusSigningPayload.signablePayload(payload), payload); }); - test('retired devnet payload is rejected with unknown genesis (never signed)', () { + test('retired devnet payload with an unlisted genesis hash is signable', () { final payload = Uint8List.fromList(hex.decode(retiredDevnetHex)); - expect( - () => QuantusPayloadParser.parsePayload(payload, policy: const FullCallPolicy()), - throwsA(isA().having((e) => e.message, 'message', contains('Unknown genesis hash'))), - ); + final parsed = QuantusPayloadParser.parsePayload(payload, policy: const FullCallPolicy()); + expect(parsed.network, isNull); + expect(parsed.call.summary?.amount, BigInt.from(100000000000)); + expect(QuantusSigningPayload.signablePayload(payload), payload); }); test('non-transaction bytes are rejected (never signed)', () { diff --git a/quantus_sdk/lib/src/quantus_payload_parser.dart b/quantus_sdk/lib/src/quantus_payload_parser.dart index 21444ebb..7cd2e026 100644 --- a/quantus_sdk/lib/src/quantus_payload_parser.dart +++ b/quantus_sdk/lib/src/quantus_payload_parser.dart @@ -31,8 +31,9 @@ import 'package:quantus_sdk/src/constants/app_constants.dart'; /// Hard cap on the raw signing payload; every supported call is far below this. const int maxPayloadBytes = maxCallBytes; -/// Networks this wallet will sign for, keyed by genesis hash (lowercase hex). -/// A payload whose genesis hash is not listed here is rejected. +/// Display names for known networks, keyed by genesis hash (lowercase hex). +/// Purely informational: the signer signs for any genesis hash and shows the +/// raw hash alongside the name when one is known. const Map knownNetworks = { '4901bf5c57fd3f9e726af399c763de6670dbdb115a91c0237e173f16eef65e72': 'Planck', 'a5aa9e5c84d4a3722c152295e7973c9af522f2fb1ef7db5afaa3d5f4dc8d3b4f': 'Heisenberg', @@ -94,7 +95,9 @@ class ParsedPayload { final DecodedCall call; final SignedExtensions extensions; - final String network; + + /// Display name from [knownNetworks], or null when the genesis hash is not listed. + final String? network; /// The raw payload bytes, so a signer can offer them for inspection. final Uint8List raw; @@ -113,8 +116,8 @@ class ParsedPayload { class QuantusPayloadParser { /// Decodes a full signing payload. Throws [FormatException] on any rejection: /// unknown pallet/call index, an inner call that does not decode exactly, - /// malformed extensions, trailing bytes, metadata-mode inconsistency, or a - /// genesis hash not in [knownNetworks]. + /// malformed extensions, trailing bytes, or metadata-mode inconsistency. + /// The genesis hash is never validated; any chain is accepted. static ParsedPayload parsePayload(Uint8List payload, {required CallPolicy policy}) { if (payload.length > maxPayloadBytes) { throw FormatException('Payload too large: ${payload.length} bytes'); @@ -136,12 +139,12 @@ class QuantusPayloadParser { throw FormatException('Metadata hash mode ${extensions.metadataMode} inconsistent with metadata hash presence'); } - final network = knownNetworks[hex.encode(extensions.genesisHash)]; - if (network == null) { - throw FormatException('Unknown genesis hash: 0x${hex.encode(extensions.genesisHash)}'); - } - - return ParsedPayload(call: call, extensions: extensions, network: network, raw: payload); + return ParsedPayload( + call: call, + extensions: extensions, + network: knownNetworks[hex.encode(extensions.genesisHash)], + raw: payload, + ); } static T _section(String section, T Function() decode) { diff --git a/quantus_sdk/test/quantus_payload_parser_test.dart b/quantus_sdk/test/quantus_payload_parser_test.dart index 0812f06d..73655ddb 100644 --- a/quantus_sdk/test/quantus_payload_parser_test.dart +++ b/quantus_sdk/test/quantus_payload_parser_test.dart @@ -14,7 +14,7 @@ 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. +// vectors were captured on a retired devnet whose genesis hash is not in [knownNetworks]. // The reversible call is re-indexed from the retired pallet index 13 to the current 11. const transferCall1 = '020000ef5f320156894f0fde742921c6990bf446e82c89fae5a23e701900abcd92dfb40700282e8cd1'; const transferCall2 = '0200007416854906f03a9dff66e3270a736c44e15970ac03a638471523a03069f276ca0700e8764817'; @@ -22,7 +22,7 @@ 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. +// captured on the retired devnet (genesis 826beefb…), which no network table lists. const oldNetworkTransfer = '020000ef5f320156894f0fde742921c6990bf446e82c89fae5a23e701900abcd92dfb40700282e8cd185012800007400000002000000826beefbe2be72645ff376f18de745ac196dc77637436090de4174180706118e3d3e081c6e3599f8ae31d404d9f087f50c25b4e08c35712e23470a60da5799ca00'; const oldNetworkReversible = @@ -222,16 +222,19 @@ void main() { }); }); - group('rejections', () { - test('rejects old devnet transfer with unknown genesis (regression)', () { - // Proves the parser walks all the way to the genesis hash and rejects unknown networks. + group('genesis hash', () { + test('signs for any chain: an unlisted genesis hash decodes with no network name', () { final payload = Uint8List.fromList(hex.decode(oldNetworkTransfer)); - expect( - () => QuantusPayloadParser.parsePayload(payload, policy: const FullCallPolicy()), - throwsRejection('Unknown genesis hash'), - ); + final parsed = QuantusPayloadParser.parsePayload(payload, policy: const FullCallPolicy()); + + expect(parsed.network, isNull); + expect(hex.encode(parsed.extensions.genesisHash), startsWith('826beefb')); + expect(parsed.call.call, 'transfer_allow_death'); + expect(parsed.specMatchesBundled, isFalse); }); + }); + group('rejections', () { test('rejects old devnet reversible transfer (regression)', () { // Rejected, but note *where*: pallet index 13 was ReversibleTransfers on the // retired devnet and is TechCollective on this runtime, so these bytes now From d2313a715468967774ec60e60784b6f87bbfae78 Mon Sep 17 00:00:00 2001 From: Nikolaus Heger Date: Wed, 9 Sep 2026 10:55:26 +0800 Subject: [PATCH 2/2] cold wallet: always show Network in Advanced, Unknown when unlisted --- .../lib/screens/sign_transaction_screen.dart | 2 +- cold-wallet-app/test/call_display_test.dart | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/cold-wallet-app/lib/screens/sign_transaction_screen.dart b/cold-wallet-app/lib/screens/sign_transaction_screen.dart index 207e16d5..ac375501 100644 --- a/cold-wallet-app/lib/screens/sign_transaction_screen.dart +++ b/cold-wallet-app/lib/screens/sign_transaction_screen.dart @@ -238,7 +238,7 @@ class _SignTransactionScreenState extends ConsumerState { // The one place the runtime's own naming appears, nested calls included — // the headlines above deliberately summarise it away. 'Call: ${parsed.call.displayTitleChain}', - if (parsed.network != null) 'Network: ${parsed.network}', + 'Network: ${parsed.network ?? 'Unknown'}', 'Runtime: spec ${ext.specVersion}, tx version ${ext.transactionVersion}', 'Nonce: ${ext.nonce}', 'Era: ${ext.era}', diff --git a/cold-wallet-app/test/call_display_test.dart b/cold-wallet-app/test/call_display_test.dart index 556f2249..dea26701 100644 --- a/cold-wallet-app/test/call_display_test.dart +++ b/cold-wallet-app/test/call_display_test.dart @@ -133,5 +133,23 @@ void main() { await pumpSignScreen(tester, DebugPayloads.governanceVoteAye()); expect(signerRow(tester).label, 'Signed by'); }); + + testWidgets('an unlisted genesis hash reaches review and reads Network: Unknown', (tester) async { + final payload = DebugPayloads.payloadForCall( + const balances_pallet.Txs().transferAllowDeath(dest: account(bobId), value: oneToken).encode(), + ); + // Genesis hash sits before the 32-byte block hash and the metadata None byte. + payload.fillRange(payload.length - 65, payload.length - 33, 0xEE); + await pumpSignScreen(tester, SigningRequest(signer: DebugPayloads.debugSigner, payload: payload).encode()); + + expect(find.text('SEND'), findsOneWidget); + expect(find.text('Could not read transaction'), findsNothing); + + await tester.ensureVisible(find.text('ADVANCED')); + await tester.tap(find.text('ADVANCED')); + await tester.pumpAndSettle(); + expect(find.textContaining('Network: Unknown'), findsOneWidget); + expect(find.textContaining('Genesis hash: 0x${'ee' * 32}'), findsOneWidget); + }); }); }