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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion cold-wallet-app/lib/screens/sign_transaction_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -238,7 +238,7 @@ class _SignTransactionScreenState extends ConsumerState<SignTransactionScreen> {
// 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}',
Expand Down
18 changes: 18 additions & 0 deletions cold-wallet-app/test/call_display_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
}
12 changes: 6 additions & 6 deletions cold-wallet-app/test/transaction_signing_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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<FormatException>().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)', () {
Expand Down
25 changes: 14 additions & 11 deletions quantus_sdk/lib/src/quantus_payload_parser.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, String> knownNetworks = {
'4901bf5c57fd3f9e726af399c763de6670dbdb115a91c0237e173f16eef65e72': 'Planck',
'a5aa9e5c84d4a3722c152295e7973c9af522f2fb1ef7db5afaa3d5f4dc8d3b4f': 'Heisenberg',
Expand Down Expand Up @@ -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;
Expand All @@ -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');
Expand All @@ -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<T>(String section, T Function() decode) {
Expand Down
21 changes: 12 additions & 9 deletions quantus_sdk/test/quantus_payload_parser_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -14,15 +14,15 @@ 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';
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 =
Expand Down Expand Up @@ -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
Expand Down
Loading