Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
0d74dac
sdk: NEAR transaction decode, hash and ML-DSA-65 signing in Rust
cursoragent Oct 1, 2026
6db5866
sdk: NEAR signing request envelope (v2) and NEAR public key export
cursoragent Oct 1, 2026
5cf864f
cold-wallet: review and sign NEAR transactions, export NEAR public keys
cursoragent Oct 1, 2026
6caece7
sdk: format regenerated bindings at the repo's 120-column width
cursoragent Oct 1, 2026
aa26352
sdk: refuse NEAR account ids the chain would refuse
cursoragent Oct 1, 2026
395ba98
cold-wallet: show NEAR method names and arguments as hex unless displ…
cursoragent Oct 1, 2026
dd21c97
near: accept only the exact mainnet/testnet labels in a signing request
cursoragent Oct 1, 2026
f7756c9
near: identify deployed contract code by its SHA-256 in the review
cursoragent Oct 1, 2026
0257dc8
cold-wallet: rename public key export button
n13 Oct 1, 2026
e5f2074
cold-wallet: re-indent NEAR call arguments without decoding their num…
cursoragent Oct 1, 2026
cf1bbef
cold-wallet: name the receiver in the NEAR account-control warning
cursoragent Oct 1, 2026
305cfc5
cold-wallet: share the QR panel and the refusal view across signing s…
cursoragent Oct 1, 2026
71bc5fa
cold-wallet: make the NEAR network check a suffix hint, not a validit…
cursoragent Oct 1, 2026
935c525
cold-wallet: warn about NEAR account creation even without a key action
cursoragent Oct 1, 2026
8390428
cold-wallet: mark the NEAR network label as the requesting wallet's u…
cursoragent Oct 1, 2026
02f59a6
cold-wallet: present the exported key as the account's public key, no…
cursoragent Oct 1, 2026
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
4 changes: 4 additions & 0 deletions cold-wallet-app/analysis_options.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,7 @@ analyzer:
missing_return: error
must_be_immutable: error
asset_does_not_exist: ignore
exclude:
- build/**
- android/**
- ios/**
43 changes: 43 additions & 0 deletions cold-wallet-app/lib/components/signature_qr_view.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import 'dart:typed_data';

import 'package:flutter/material.dart';
import 'package:quantus_sdk/quantus_sdk.dart';
import 'package:quantus_cold_wallet/components/ur_qr_panel.dart';
import 'package:quantus_cold_wallet/providers/ur_qr_providers.dart';

/// The screen shown once a transaction is signed: [signed] as a UR QR for
/// the hot wallet to scan, and a Done button back to home.
class SignatureQrView extends StatelessWidget {
final Uint8List signed;
final String instruction;

const SignatureQrView({super.key, required this.signed, required this.instruction});

@override
Widget build(BuildContext context) {
final colors = context.colorsV3;
final text = context.themeTextV3;

return ScaffoldBase(
appBar: const V2AppBar(title: 'Signature', showBackButton: false),
mainContent: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const SizedBox(height: 8),
Text(
instruction,
style: text.body.copyWith(color: colors.textMuted),
textAlign: TextAlign.center,
),
const SizedBox(height: 24),
UrQrPanel(payload: UrPayload(signed)),
],
),
),
bottomContent: ScaffoldBaseBottomContent(
child: QuantusButton.simple(label: 'Done', onTap: () => Navigator.popUntil(context, (r) => r.isFirst)),
),
);
}
}
80 changes: 80 additions & 0 deletions cold-wallet-app/lib/components/signing_refusal_view.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import 'package:flutter/material.dart';
import 'package:quantus_sdk/quantus_sdk.dart';

/// Why a signing request was refused, with the one detail that identifies it
/// (the account, the key, the decoder's message) and a way back to home.
/// Shared by every signing screen so a refusal looks the same whatever the
/// chain.
class SigningRefusalView extends StatelessWidget {
final String appBarTitle;
final String title;
final String message;
final Widget detail;

const SigningRefusalView({
super.key,
required this.appBarTitle,
required this.title,
required this.message,
required this.detail,
});

@override
Widget build(BuildContext context) {
final colors = context.colorsV3;
final text = context.themeTextV3;
return ScaffoldBase(
appBar: V2AppBar(title: appBarTitle),
// The detail can be as long as a decoder's message, which no layout can
// bound, so this column scrolls rather than overflowing on a small screen.
mainContent: Center(
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Center(
child: Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(color: colors.semanticEmber.useOpacity(0.12), shape: BoxShape.circle),
child: Icon(Icons.error_outline, size: 72, color: colors.semanticEmber),
),
),
const SizedBox(height: 24),
Text(
title,
style: text.titleHero.copyWith(color: colors.semanticEmber),
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
Text(
message,
style: text.bodyLarge.copyWith(color: colors.textContent),
textAlign: TextAlign.center,
),
const SizedBox(height: 20),
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: colors.semanticEmber.useOpacity(0.08),
borderRadius: context.radiusV3.mdBorder,
border: Border.all(color: colors.semanticEmber),
),
child: detail,
),
const SizedBox(height: 20),
Text(
'Nothing was signed.',
style: text.bodyEmphasis.copyWith(color: colors.textMuted),
textAlign: TextAlign.center,
),
],
),
),
),
bottomContent: ScaffoldBaseBottomContent(
child: QuantusButton.simple(label: 'Back to home', onTap: () => Navigator.popUntil(context, (r) => r.isFirst)),
),
);
}
}
75 changes: 75 additions & 0 deletions cold-wallet-app/lib/components/ur_qr_panel.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:quantus_sdk/quantus_sdk.dart';
import 'package:quantus_cold_wallet/components/qr_tuning_controls.dart';
import 'package:quantus_cold_wallet/providers/settings_providers.dart';
import 'package:quantus_cold_wallet/providers/ur_qr_providers.dart';

/// An animated UR QR of [payload] with its frame/FPS/bytes summary and, for
/// multi-frame payloads, a pause button that opens the tuning sheet. The
/// frames and the paused flag live in providers, so every screen that shows
/// a QR behaves the same and keeps no QR state of its own.
class UrQrPanel extends ConsumerWidget {
final UrPayload payload;

const UrQrPanel({super.key, required this.payload});

Future<void> _pauseAndTune(BuildContext context, WidgetRef ref) async {
final paused = ref.read(qrPausedProvider.notifier);
paused.pause();
await BottomSheetContainer.show<void>(
context,
builder: (ctx) => const BottomSheetContainer(title: 'QR display options', child: QrTuningControls()),
);
paused.resume();
}

@override
Widget build(BuildContext context, WidgetRef ref) {
final colors = context.colorsV3;
final text = context.themeTextV3;
final settings = ref.watch(coldSettingsProvider);
final parts = ref.watch(urQrFramesProvider(payload));
final paused = ref.watch(qrPausedProvider);

return Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Center(
child: AnimatedUrQr(parts: parts, fps: settings.qrFps, paused: paused),
),
const SizedBox(height: 12),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'${parts.length} ${parts.length == 1 ? 'frame' : 'frames'} · ${settings.qrFps} FPS · '
'${settings.qrBytes} bytes',
style: text.caption.copyWith(color: colors.textMuted),
),
if (parts.length > 1) ...[
const SizedBox(width: 12),
GestureDetector(
onTap: () => _pauseAndTune(context, ref),
child: Container(
width: 32,
height: 32,
decoration: BoxDecoration(color: colors.bgSurface2, shape: BoxShape.circle),
child: Icon(Icons.pause_rounded, size: 20, color: colors.textContent),
),
),
],
],
),
if (parts.length > 1) ...[
const SizedBox(height: 16),
Text(
'Animated QR — keep both devices steady until the other device finishes scanning.',
style: text.caption.copyWith(color: colors.textMuted),
textAlign: TextAlign.center,
),
],
],
);
}
}
49 changes: 39 additions & 10 deletions cold-wallet-app/lib/debug/debug_calls_screen.dart
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
import 'dart:typed_data';

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:quantus_sdk/quantus_sdk.dart';
import 'package:quantus_cold_wallet/debug/debug_near_payloads.dart';
import 'package:quantus_cold_wallet/debug/debug_payloads.dart';
import 'package:quantus_cold_wallet/providers/wallet_providers.dart';
import 'package:quantus_cold_wallet/screens/sign_near_transaction_screen.dart';
import 'package:quantus_cold_wallet/screens/sign_transaction_screen.dart';

/// Every call the signer can be asked to review, one tap from its review
Expand All @@ -18,6 +22,7 @@ class DebugCallsScreen extends ConsumerWidget {
final colors = context.colorsV3;
final text = context.themeTextV3;
final signer = ref.watch(addressProvider);
final nearSamples = DebugNearPayloads.samples(_nearSigningKey(ref));

return ScaffoldBase(
appBar: const V2AppBar(title: 'Debug calls'),
Expand All @@ -39,33 +44,57 @@ class DebugCallsScreen extends ConsumerWidget {
for (final call in DebugPayloads.refused) _row(context, call, signer),
_header(context, 'INVALID QR CODE DATA · ${DebugPayloads.invalidQrData.length}', colors.semanticEmber),
for (final call in DebugPayloads.invalidQrData) _row(context, call, signer),
_header(context, 'NEAR · ${nearSamples.length}', colors.accentFlare),
for (final call in nearSamples) _nearRow(context, call),
const SizedBox(height: 24),
],
),
);
}

/// The raw public key of the wallet's first ML-DSA-65 account, the only kind
/// of key a NEAR transaction can name here. Null when there is none.
Uint8List? _nearSigningKey(WidgetRef ref) {
final address = ref
.watch(addressesProvider)
.entries
.where((e) => e.value.scheme == DilithiumScheme.mlDsa65)
.firstOrNull
?.key;
return address == null ? null : keypairFor(ref, address)?.publicKey;
}

Widget _header(BuildContext context, String title, Color color) {
return Padding(
padding: const EdgeInsets.only(top: 20, bottom: 8),
child: Text(title, style: context.themeTextV3.labelMonogram.copyWith(color: color, letterSpacing: 1.2)),
);
}

Widget _row(BuildContext context, DebugCall call, String signer) {
Widget _row(BuildContext context, DebugCall call, String signer) => _tile(
context,
call.label,
() => SignTransactionScreen(
request: SigningRequest(signer: call.signer ?? signer, payload: DebugPayloads.payloadForCall(call.call)),
),
);

Widget _nearRow(BuildContext context, DebugNearCall call) => _tile(
context,
call.label,
() => SignNearTransactionScreen(
request: call.request,
transaction: decodeNearTransaction(transaction: call.request.transaction),
),
);

Widget _tile(BuildContext context, String label, Widget Function() screen) {
final colors = context.colorsV3;
final text = context.themeTextV3;

return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => Navigator.push(
context,
MaterialPageRoute(
builder: (_) => SignTransactionScreen(
request: SigningRequest(signer: call.signer ?? signer, payload: DebugPayloads.payloadForCall(call.call)),
),
),
),
onTap: () => Navigator.push(context, MaterialPageRoute(builder: (_) => screen())),
child: Container(
padding: const EdgeInsets.symmetric(vertical: 14),
decoration: BoxDecoration(
Expand All @@ -74,7 +103,7 @@ class DebugCallsScreen extends ConsumerWidget {
child: Row(
children: [
Expanded(
child: Text(call.label, style: text.body.copyWith(color: colors.textContent)),
child: Text(label, style: text.body.copyWith(color: colors.textContent)),
),
Icon(Icons.chevron_right, size: 18, color: colors.textMuted),
],
Expand Down
Loading
Loading