diff --git a/cold-wallet-app/lib/screens/sign_transaction_screen.dart b/cold-wallet-app/lib/screens/sign_transaction_screen.dart index 60d60e6b..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}', - '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); + }); }); } 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