Skip to content
Open
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
43 changes: 30 additions & 13 deletions cold-wallet-app/lib/screens/sign_transaction_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,8 @@ class SignTransactionScreen extends ConsumerStatefulWidget {
}

class _SignTransactionScreenState extends ConsumerState<SignTransactionScreen> {
TransactionInfo? _txInfo;
bool _parseFailed = false;
ParsedPayload? _parsed;
String? _parseError;
String? _toCheckphrase;
List<String>? _signatureUr;
bool _signing = false;
Expand All @@ -31,13 +31,14 @@ class _SignTransactionScreenState extends ConsumerState<SignTransactionScreen> {
@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<void> _loadCheckphrase(String address) async {
Expand Down Expand Up @@ -86,12 +87,12 @@ class _SignTransactionScreenState extends ConsumerState<SignTransactionScreen> {

@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(
Expand All @@ -108,6 +109,12 @@ class _SignTransactionScreenState extends ConsumerState<SignTransactionScreen> {
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(
Expand All @@ -116,9 +123,11 @@ class _SignTransactionScreenState extends ConsumerState<SignTransactionScreen> {
);
}

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'),
Expand Down Expand Up @@ -153,9 +162,17 @@ class _SignTransactionScreenState extends ConsumerState<SignTransactionScreen> {
_detailRow(context, 'To', info.toAddress, monospace: true),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we can wrap this in scrollable widget, so in small height phone it doesn't overflow.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It already is — the whole review view's mainContent is wrapped in SingleChildScrollView a few lines above this hunk (pre-existing from main, just outside the diff context). So the added Network/Tip/Nonce/Era rows scroll on short screens instead of overflowing.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Awesome!

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(
Expand Down
48 changes: 33 additions & 15 deletions cold-wallet-app/test/transaction_signing_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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));
});
Expand All @@ -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<FormatException>().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<int>.generate(40, (i) => 0xff - i));
expect(QuantusPayloadParser.parsePayload(garbage), isNull);
expect(() => QuantusPayloadParser.parsePayload(garbage), throwsFormatException);
});
});
}
Loading
Loading