diff --git a/cold-wallet-app/analysis_options.yaml b/cold-wallet-app/analysis_options.yaml index e82fe23fc..6ea1efb48 100644 --- a/cold-wallet-app/analysis_options.yaml +++ b/cold-wallet-app/analysis_options.yaml @@ -29,3 +29,7 @@ analyzer: missing_return: error must_be_immutable: error asset_does_not_exist: ignore + exclude: + - build/** + - android/** + - ios/** diff --git a/cold-wallet-app/lib/components/signature_qr_view.dart b/cold-wallet-app/lib/components/signature_qr_view.dart new file mode 100644 index 000000000..81abe5e8e --- /dev/null +++ b/cold-wallet-app/lib/components/signature_qr_view.dart @@ -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)), + ), + ); + } +} diff --git a/cold-wallet-app/lib/components/signing_refusal_view.dart b/cold-wallet-app/lib/components/signing_refusal_view.dart new file mode 100644 index 000000000..ee5302950 --- /dev/null +++ b/cold-wallet-app/lib/components/signing_refusal_view.dart @@ -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)), + ), + ); + } +} diff --git a/cold-wallet-app/lib/components/ur_qr_panel.dart b/cold-wallet-app/lib/components/ur_qr_panel.dart new file mode 100644 index 000000000..ef26dff00 --- /dev/null +++ b/cold-wallet-app/lib/components/ur_qr_panel.dart @@ -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 _pauseAndTune(BuildContext context, WidgetRef ref) async { + final paused = ref.read(qrPausedProvider.notifier); + paused.pause(); + await BottomSheetContainer.show( + 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, + ), + ], + ], + ); + } +} diff --git a/cold-wallet-app/lib/debug/debug_calls_screen.dart b/cold-wallet-app/lib/debug/debug_calls_screen.dart index 2e0655cc0..a189ca23d 100644 --- a/cold-wallet-app/lib/debug/debug_calls_screen.dart +++ b/cold-wallet-app/lib/debug/debug_calls_screen.dart @@ -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 @@ -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'), @@ -39,12 +44,26 @@ 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), @@ -52,20 +71,30 @@ class DebugCallsScreen extends ConsumerWidget { ); } - 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( @@ -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), ], diff --git a/cold-wallet-app/lib/debug/debug_near_payloads.dart b/cold-wallet-app/lib/debug/debug_near_payloads.dart new file mode 100644 index 000000000..62c2ad400 --- /dev/null +++ b/cold-wallet-app/lib/debug/debug_near_payloads.dart @@ -0,0 +1,245 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:quantus_sdk/quantus_sdk.dart'; + +/// One NEAR transaction the signer can be asked to review. +class DebugNearCall { + final String label; + final NearSigningRequest request; + + const DebugNearCall({required this.label, required this.request}); +} + +/// Sample NEAR signing requests for the simulator, built with a minimal borsh +/// writer so they are byte-identical in shape to a hot wallet's `TransactionV0`. +/// +/// [signingKey] is the raw 1952-byte ML-DSA-65 public key the wallet's own +/// account holds, so the review screen finds a signer; null when the wallet +/// has no ML-DSA-65 account, in which case every sample is refused as a key +/// not held here, which is also worth seeing. +class DebugNearPayloads { + const DebugNearPayloads._(); + + static final BigInt _near = BigInt.from(10).pow(24); + static final BigInt _tgas = BigInt.from(10).pow(12); + static final Uint8List _blockHash = Uint8List.fromList(List.filled(32, 0x22)); + static final Uint8List _ed25519Key = Uint8List.fromList(List.filled(32, 0x11)); + static final Uint8List _foreignMlDsaKey = Uint8List.fromList(List.filled(1952, 0x42)); + + static List samples(Uint8List? signingKey) { + final key = signingKey ?? _foreignMlDsaKey; + return [ + DebugNearCall( + label: 'send 1.5 NEAR', + request: _testnet( + _tx(key, receiver: 'bob.testnet', actions: [_transfer(_near * BigInt.from(15) ~/ BigInt.from(10))]), + ), + ), + DebugNearCall( + label: 'ft_transfer on a token contract', + request: _testnet( + _tx( + key, + receiver: 'token.rhea.testnet', + actions: [ + _functionCall( + 'ft_transfer', + args: {'receiver_id': 'bob.testnet', 'amount': '1000000', 'memo': null}, + gas: _tgas * BigInt.from(30), + deposit: BigInt.one, + ), + ], + ), + ), + ), + DebugNearCall( + label: 'swap: ft_transfer_call then storage_deposit [two actions]', + request: _testnet( + _tx( + key, + receiver: 'ref-finance.testnet', + actions: [ + _functionCall( + 'storage_deposit', + args: {}, + gas: _tgas * BigInt.from(30), + deposit: _near ~/ BigInt.from(80), + ), + _functionCall( + 'ft_transfer_call', + args: {'receiver_id': 'ref-finance.testnet', 'amount': '500000', 'msg': '{"actions":[]}'}, + gas: _tgas * BigInt.from(180), + deposit: BigInt.one, + ), + ], + ), + ), + ), + DebugNearCall( + label: 'add a full access key [dangerous]', + request: _testnet(_tx(key, receiver: 'alice.testnet', actions: [_addFullAccessKey(_ed25519Key)])), + ), + DebugNearCall( + label: 'add a function call key limited to one contract', + request: _testnet( + _tx( + key, + receiver: 'alice.testnet', + actions: [ + _addFunctionCallKey( + _ed25519Key, + receiver: 'app.rhea.testnet', + methods: ['claim', 'stake'], + allowance: _near ~/ BigInt.from(4), + ), + ], + ), + ), + ), + DebugNearCall( + label: 'delete the account, balance to bob [dangerous]', + request: _testnet(_tx(key, receiver: 'alice.testnet', actions: [_deleteAccount('bob.testnet')])), + ), + DebugNearCall( + label: 'mainnet label on testnet account names [mismatch]', + request: NearSigningRequest( + network: 'mainnet', + transaction: _tx(key, receiver: 'bob.testnet', actions: [_transfer(_near)]), + ), + ), + DebugNearCall( + label: 'signed by an ed25519 key [refused]', + request: _testnet(_tx(_ed25519Key, receiver: 'bob.testnet', actions: [_transfer(_near)])), + ), + DebugNearCall( + label: 'signed by an ML-DSA-65 key this wallet does not hold [refused]', + request: _testnet(_tx(_foreignMlDsaKey, receiver: 'bob.testnet', actions: [_transfer(_near)])), + ), + ]; + } + + static NearSigningRequest _testnet(Uint8List tx) => NearSigningRequest(network: 'testnet', transaction: tx); + + /// borsh `TransactionV0`: signer, public key, nonce, receiver, block hash, actions. + static Uint8List _tx(Uint8List publicKey, {required String receiver, required List> actions}) { + final w = _Borsh() + ..string('alice.testnet') + ..publicKey(publicKey) + ..u64(BigInt.from(42)) + ..string(receiver) + ..bytes(_blockHash) + ..u32(actions.length); + for (final action in actions) { + w.bytes(action); + } + return w.take(); + } + + static List _transfer(BigInt deposit) => + (_Borsh() + ..u8(3) + ..u128(deposit)) + .take(); + + static List _functionCall( + String method, { + required Map args, + required BigInt gas, + required BigInt deposit, + }) { + final encodedArgs = utf8.encode(jsonEncode(args)); + return (_Borsh() + ..u8(2) + ..string(method) + ..u32(encodedArgs.length) + ..bytes(encodedArgs) + ..u64(gas) + ..u128(deposit)) + .take(); + } + + static List _addFullAccessKey(Uint8List key) => + (_Borsh() + ..u8(5) + ..publicKey(key) + ..u64(BigInt.zero) // access key nonce + ..u8(1)) // FullAccess + .take(); + + static List _addFunctionCallKey( + Uint8List key, { + required String receiver, + required List methods, + BigInt? allowance, + }) { + final w = _Borsh() + ..u8(5) + ..publicKey(key) + ..u64(BigInt.zero) + ..u8(0); // FunctionCall permission + if (allowance == null) { + w.u8(0); + } else { + w + ..u8(1) + ..u128(allowance); + } + w + ..string(receiver) + ..u32(methods.length); + for (final method in methods) { + w.string(method); + } + return w.take(); + } + + static List _deleteAccount(String beneficiary) => + (_Borsh() + ..u8(7) + ..string(beneficiary)) + .take(); +} + +/// Just enough borsh to write a `TransactionV0`. +class _Borsh { + final BytesBuilder _out = BytesBuilder(); + + void u8(int v) => _out.addByte(v); + + void u32(int v) => _out.add((ByteData(4)..setUint32(0, v, Endian.little)).buffer.asUint8List()); + + void u64(BigInt v) => _le(v, 8); + + void u128(BigInt v) => _le(v, 16); + + void bytes(List v) => _out.add(v); + + void string(String s) { + final encoded = utf8.encode(s); + u32(encoded.length); + bytes(encoded); + } + + /// A NEAR `PublicKey`: curve tag then the raw key. 32 bytes is ed25519 (0), + /// 1952 is ML-DSA-65 (2). + void publicKey(Uint8List key) { + u8(switch (key.length) { + 32 => 0, + 1952 => 2, + _ => throw ArgumentError('Not an ed25519 or ML-DSA-65 key: ${key.length} bytes'), + }); + bytes(key); + } + + void _le(BigInt v, int width) { + var rest = v; + final mask = BigInt.from(0xff); + for (var i = 0; i < width; i++) { + u8((rest & mask).toInt()); + rest >>= 8; + } + } + + Uint8List take() => _out.takeBytes(); +} diff --git a/cold-wallet-app/lib/providers/ur_qr_providers.dart b/cold-wallet-app/lib/providers/ur_qr_providers.dart new file mode 100644 index 000000000..77ef692ef --- /dev/null +++ b/cold-wallet-app/lib/providers/ur_qr_providers.dart @@ -0,0 +1,39 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:quantus_sdk/quantus_sdk.dart'; +import 'package:quantus_cold_wallet/providers/settings_providers.dart'; + +/// Bytes to show as a UR QR, equal by content so they can key a provider +/// family: two screens showing the same bytes share one set of frames. +@immutable +class UrPayload { + final Uint8List bytes; + + const UrPayload(this.bytes); + + @override + bool operator ==(Object other) => other is UrPayload && listEquals(bytes, other.bytes); + + @override + int get hashCode => Object.hashAll(bytes); +} + +/// The UR frames for a payload at the user's bytes-per-frame setting. +/// Recomputed only when that setting changes; dropped when nothing shows it. +final urQrFramesProvider = Provider.autoDispose.family, UrPayload>((ref, payload) { + final qrBytes = ref.watch(coldSettingsProvider.select((s) => s.qrBytes)); + return encodeUrForQr(data: payload.bytes, maxFragmentLength: qrBytes); +}); + +/// Whether the QR animation on screen is paused, as while its tuning sheet is +/// open. One QR is shown at a time, so one flag serves every QR screen. +class QrPausedController extends Notifier { + @override + bool build() => false; + + void pause() => state = true; + + void resume() => state = false; +} + +final qrPausedProvider = NotifierProvider.autoDispose(QrPausedController.new); diff --git a/cold-wallet-app/lib/providers/wallet_providers.dart b/cold-wallet-app/lib/providers/wallet_providers.dart index c07c0a21c..a84cf5942 100644 --- a/cold-wallet-app/lib/providers/wallet_providers.dart +++ b/cold-wallet-app/lib/providers/wallet_providers.dart @@ -1,3 +1,4 @@ +import 'package:convert/convert.dart'; import 'package:cryptography/cryptography.dart' show SecretBoxAuthenticationError; import 'package:flutter/foundation.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -241,6 +242,23 @@ Keypair? keypairFor(WidgetRef ref, String address) { return HdWalletService().keyPairAtPath(mnemonic, account.derivationPath, account.scheme); } +/// The address of the account whose ML-DSA-65 public key is [publicKeyHex], +/// or null when no account in this wallet holds it. A NEAR transaction names +/// its key rather than a Quantus address, and the address is a hash of the +/// key, so each ML-DSA-65 account's key pair is derived and compared. Keyed +/// by hex so equal keys resolve to the same provider instance. +final nearKeyOwnerProvider = Provider.family((ref, publicKeyHex) { + final mnemonic = ref.watch(walletControllerProvider).mnemonic; + if (mnemonic == null) return null; + final service = HdWalletService(); + for (final entry in ref.watch(addressesProvider).entries) { + if (entry.value.scheme != DilithiumScheme.mlDsa65) continue; + final keypair = service.keyPairAtPath(mnemonic, entry.value.derivationPath, entry.value.scheme); + if (hex.encode(keypair.publicKey) == publicKeyHex) return entry.key; + } + return null; +}); + /// The first account, which the home screen leads with. final addressProvider = Provider((ref) => ref.watch(addressesProvider).keys.firstOrNull); diff --git a/cold-wallet-app/lib/screens/public_key_export_screen.dart b/cold-wallet-app/lib/screens/public_key_export_screen.dart new file mode 100644 index 000000000..d94728586 --- /dev/null +++ b/cold-wallet-app/lib/screens/public_key_export_screen.dart @@ -0,0 +1,96 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.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'; + +/// Shows one account's full ML-DSA-65 public key, as an animated UR QR and +/// as selectable text with a copy control. +/// +/// A Quantus address is a hash of the key, so anything that registers the key +/// itself needs this screen. The key is a Quantus key first; NEAR is the one +/// other system that accepts ML-DSA-65 keys today, so the text form is the +/// `ml-dsa-65:` encoding NEAR reads, and the `ml-dsa-65-hash:` handle +/// is how NEAR would list the key if it were added to an account there. +/// Nothing here touches any chain: registering the key is done elsewhere. +class PublicKeyExportScreen extends StatelessWidget { + final NearPublicKeyExport export; + final String handle; + + const PublicKeyExportScreen({super.key, required this.export, required this.handle}); + + Future _copyKey(BuildContext context) async { + await Clipboard.setData(ClipboardData(text: export.nearPublicKey)); + if (!context.mounted) return; + final colors = context.colorsV3; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Public key copied', style: context.themeTextV3.body.copyWith(color: colors.textContent)), + backgroundColor: colors.bgSurface2, + duration: const Duration(seconds: 2), + ), + ); + } + + @override + Widget build(BuildContext context) { + final colors = context.colorsV3; + final text = context.themeTextV3; + + return ScaffoldBase( + appBar: const V2AppBar(title: 'Public Key'), + mainContent: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const SizedBox(height: 8), + Text( + 'The account\'s full ML-DSA-65 public key. Your Quantus address is a hash of it; show the key itself ' + 'only where a key is registered directly. It is written in the ml-dsa-65: form NEAR reads: ' + 'to put it on a NEAR account, paste it into the NEAR CLI for now, as the Quantus hot wallet does not ' + 'yet read this QR. Transactions for that account can then be reviewed and signed here.', + style: text.body.copyWith(color: colors.textMuted), + textAlign: TextAlign.center, + ), + const SizedBox(height: 24), + UrQrPanel(payload: UrPayload(export.encode())), + const SizedBox(height: 24), + DetailSummaryRow.stacked( + label: 'If added to a NEAR account', + value: handle, + monospace: true, + note: 'NEAR lists keys by this handle rather than the key itself. Check it matches on the explorer.', + ), + DetailSummaryRow.stacked(label: 'Quantus account', value: export.address, monospace: true), + Padding( + padding: const EdgeInsets.symmetric(vertical: 10), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text('FULL KEY', style: text.labelData.copyWith(color: colors.textMuted)), + ), + QuantusIconButton.ghost( + icon: Icons.copy_rounded, + size: IconButtonSize.small, + onTap: () => _copyKey(context), + ), + ], + ), + const SizedBox(height: 4), + SelectableText(export.nearPublicKey, style: text.dataAddress.copyWith(color: colors.textContent)), + ], + ), + ), + const SizedBox(height: 24), + ], + ), + ), + bottomContent: ScaffoldBaseBottomContent( + child: QuantusButton.simple(label: 'Done', onTap: () => Navigator.pop(context)), + ), + ); + } +} diff --git a/cold-wallet-app/lib/screens/scan_transaction_screen.dart b/cold-wallet-app/lib/screens/scan_transaction_screen.dart index 37ad83cfc..9e764ab79 100644 --- a/cold-wallet-app/lib/screens/scan_transaction_screen.dart +++ b/cold-wallet-app/lib/screens/scan_transaction_screen.dart @@ -4,6 +4,7 @@ import 'package:mobile_scanner/mobile_scanner.dart'; import 'package:quantus_sdk/quantus_sdk.dart'; import 'package:quantus_cold_wallet/app_version.dart'; import 'package:quantus_cold_wallet/debug/debug_calls_screen.dart'; +import 'package:quantus_cold_wallet/screens/sign_near_transaction_screen.dart'; import 'package:quantus_cold_wallet/screens/sign_transaction_screen.dart'; /// Scans a (possibly multi-part / animated) UR QR code, accumulating parts @@ -80,10 +81,16 @@ class _ScanTransactionScreenState extends State { _done = true; try { - final request = SigningRequest.decode(decodeUr(urParts: parts)); + final screen = switch (AnySigningRequest.decode(decodeUr(urParts: parts))) { + final SigningRequest request => SignTransactionScreen(request: request), + final NearSigningRequest request => SignNearTransactionScreen( + request: request, + transaction: decodeNearTransaction(transaction: request.transaction), + ), + }; _controller.stop(); if (!mounted) return; - Navigator.pushReplacement(context, MaterialPageRoute(builder: (_) => SignTransactionScreen(request: request))); + Navigator.pushReplacement(context, MaterialPageRoute(builder: (_) => screen)); } catch (e) { debugPrint('Rejected signing request QR: $e'); // Keeping the parts would make every replayed frame a duplicate, so the @@ -92,6 +99,8 @@ class _ScanTransactionScreenState extends State { _restartAccumulation(switch (e) { UnsupportedSigningRequestVersionException() => '${e.message}. $updateAppHint $currentAppVersion', FormatException() => 'Not a Quantus signing request: ${e.message}', + // decodeNearTransaction reports a malformed transaction as a bare string. + String() => 'Could not read the NEAR transaction: $e', _ => 'Failed to decode QR: $e', }); } diff --git a/cold-wallet-app/lib/screens/show_key_screen.dart b/cold-wallet-app/lib/screens/show_key_screen.dart index 6a263f5db..41d6d316a 100644 --- a/cold-wallet-app/lib/screens/show_key_screen.dart +++ b/cold-wallet-app/lib/screens/show_key_screen.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:quantus_sdk/quantus_sdk.dart'; import 'package:quantus_cold_wallet/providers/wallet_providers.dart'; +import 'package:quantus_cold_wallet/screens/public_key_export_screen.dart'; class ShowKeyScreen extends ConsumerWidget { /// The account to show; defaults to the wallet's first. @@ -15,6 +16,7 @@ class ShowKeyScreen extends ConsumerWidget { final text = context.themeTextV3; final address = this.address ?? ref.watch(addressProvider); final checkphrase = address == null ? const AsyncValue.data('') : ref.watch(checksumNameProvider(address)); + final account = address == null ? null : ref.watch(addressesProvider)[address]; return ScaffoldBase( appBar: const V2AppBar(title: 'Show Key'), @@ -53,9 +55,56 @@ class ShowKeyScreen extends ConsumerWidget { error: (_, _) => const SizedBox.shrink(), ), const SizedBox(height: 24), + if (account?.scheme == DilithiumScheme.mlDsa65) _publicKeySection(context, ref, address), ], ), ), ); } + + /// The full key is only useful where a key is registered directly rather + /// than by Quantus address, and ML-DSA-65 is the one scheme such a system + /// (NEAR) accepts today, so only those accounts offer it. The key pair is + /// derived on tap, not on build. + Widget _publicKeySection(BuildContext context, WidgetRef ref, String address) { + final colors = context.colorsV3; + final text = context.themeTextV3; + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration(color: colors.bgSurface, borderRadius: context.radiusV3.mdBorder), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text('PUBLIC KEY', style: text.labelMonogram.copyWith(color: colors.accentFlare, letterSpacing: 1.2)), + const SizedBox(height: 8), + Text( + 'The address above is a hash of this account\'s key. Systems that register the key itself, such as ' + 'NEAR, need the full public key; transactions they send for it are then reviewed and signed here.', + style: text.caption.copyWith(color: colors.textMuted), + ), + const SizedBox(height: 16), + QuantusButton.simple( + label: 'Show public key', + variant: ButtonVariant.staged, + onTap: () { + final keypair = keypairFor(ref, address); + if (keypair == null) return; + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => PublicKeyExportScreen( + export: NearPublicKeyExport( + address: address, + nearPublicKey: nearPublicKeyText(keypair: keypair), + ), + handle: nearPublicKeyHandle(keypair: keypair), + ), + ), + ); + }, + ), + ], + ), + ); + } } diff --git a/cold-wallet-app/lib/screens/sign_near_transaction_screen.dart b/cold-wallet-app/lib/screens/sign_near_transaction_screen.dart new file mode 100644 index 000000000..37b02adfc --- /dev/null +++ b/cold-wallet-app/lib/screens/sign_near_transaction_screen.dart @@ -0,0 +1,390 @@ +import 'dart:typed_data'; + +import 'package:convert/convert.dart'; +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/address_with_checkphrase.dart'; +import 'package:quantus_cold_wallet/components/signature_qr_view.dart'; +import 'package:quantus_cold_wallet/components/signing_refusal_view.dart'; +import 'package:quantus_cold_wallet/providers/wallet_providers.dart'; +import 'package:quantus_cold_wallet/services/near_display.dart'; + +/// Reviews a NEAR transaction a hot wallet asked this device to sign, and on +/// approval produces the `signature ‖ public key` QR. +/// +/// A NEAR transaction names the key that must sign it rather than a Quantus +/// address, so the screen first finds which account here holds that key. The +/// receiver and every action are listed in full: a NEAR transaction can carry +/// several, and the dangerous ones — key and account changes, code +/// deployment — are marked so they are not mistaken for a plain send. +class SignNearTransactionScreen extends ConsumerStatefulWidget { + final NearSigningRequest request; + + /// [request]'s transaction, decoded by whoever scanned it; undecodable + /// bytes never reach this screen. + final NearTransaction transaction; + + const SignNearTransactionScreen({super.key, required this.request, required this.transaction}); + + @override + ConsumerState createState() => _SignNearTransactionScreenState(); +} + +class _SignNearTransactionScreenState extends ConsumerState { + Uint8List? _signed; + bool _signing = false; + String? _error; + + NearTransaction get tx => widget.transaction; + + void _sign(String owner) { + final keypair = keypairFor(ref, owner); + if (keypair == null) { + setState(() => _error = 'Wallet is locked — unlock and try again. Nothing was signed.'); + return; + } + setState(() { + _signing = true; + _error = null; + }); + try { + // Pure ML-DSA-65 over SHA-256 of the transaction, no Quantus context: + // this is what the NEAR runtime verifies. Returns signature ++ public key. + final signed = keypair.signNear(widget.request.transaction); + setState(() { + _signing = false; + _signed = signed; + }); + } catch (e) { + setState(() { + _signing = false; + _error = 'Signing failed: $e'; + }); + } + } + + @override + Widget build(BuildContext context) { + final muted = context.themeTextV3.body.copyWith(color: context.colorsV3.textMuted); + if (!tx.signsWithMlDsa65) { + return _errorView( + context, + title: 'Not a Quantus key', + message: + 'This transaction names a key of type ${_keyKind(tx.publicKey)}. This wallet holds only ML-DSA-65 ' + 'keys, so it cannot sign it.', + detail: Text(tx.publicKey, style: muted, textAlign: TextAlign.center), + ); + } + if (ref.watch(walletControllerProvider).status != WalletStatus.unlocked) { + return _errorView( + context, + title: 'Wallet is locked', + message: 'Unlock the wallet and scan the request again.', + detail: Text(tx.publicKey, style: muted, textAlign: TextAlign.center), + ); + } + final owner = ref.watch(nearKeyOwnerProvider(hex.encode(tx.publicKeyBytes))); + if (owner == null) { + return _errorView( + context, + title: 'Key not in this wallet', + message: 'No ML-DSA-65 account in this cold wallet holds the key this transaction names.', + detail: DetailSummaryRow.stacked(label: 'Requested key', value: tx.publicKey, monospace: true), + ); + } + if (_signed != null) { + return SignatureQrView( + signed: _signed!, + instruction: 'Scan this with your hot wallet to broadcast the NEAR transaction.', + ); + } + return _reviewView(context, owner); + } + + static String _keyKind(String publicKey) { + final colon = publicKey.indexOf(':'); + return colon < 0 ? 'different' : publicKey.substring(0, colon); + } + + Widget _errorView(BuildContext context, {required String title, required String message, required Widget detail}) => + SigningRefusalView(appBarTitle: 'Sign NEAR Transaction', title: title, message: message, detail: detail); + + Widget _reviewView(BuildContext context, String owner) { + final colors = context.colorsV3; + final text = context.themeTextV3; + final accountChange = NearDisplay.accountChangeTitle(tx); + final dangerous = accountChange != null; + final mismatch = NearDisplay.networkMismatch(tx, widget.request.network); + final soleTransfer = tx.actions.length == 1 && tx.actions.single.kind == NearActionKind.transfer; + + return ScaffoldBase( + appBar: const V2AppBar(title: 'Review & Sign'), + mainContent: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const SizedBox(height: 8), + _networkChip(context), + if (mismatch != null) _warningBanner(context, title: 'Check the network', body: mismatch), + if (accountChange != null) + _warningBanner( + context, + title: accountChange, + body: + 'An account, its keys or its code are being changed, not just funds moved. Only sign this if ' + 'you asked for exactly these actions.', + ), + const SizedBox(height: 8), + Text( + NearDisplay.headline(tx), + style: text.titleScreen.copyWith(color: dangerous ? colors.semanticEmber : colors.accentFlare), + ), + if (soleTransfer) ...[ + _amountHero(context, tx.actions.single.amount ?? BigInt.zero), + DetailSummaryRow.stacked(label: 'To', value: tx.receiverId, monospace: true), + DetailSummaryRow.stacked(label: 'From', value: tx.signerId, monospace: true), + ] else ...[ + DetailSummaryRow.stacked(label: 'Receiver', value: tx.receiverId, monospace: true), + DetailSummaryRow.stacked(label: 'Signed by', value: tx.signerId, monospace: true), + for (final (i, action) in tx.actions.indexed) ..._actionBody(context, action, i), + ], + AddressWithCheckphrase(label: 'Signing key held by', address: owner), + const SizedBox(height: 20), + _advancedSection(context), + if (_error != null) ...[ + const SizedBox(height: 16), + Text( + _error!, + style: text.caption.copyWith(color: colors.semanticEmber), + textAlign: TextAlign.center, + ), + ], + const SizedBox(height: 8), + ], + ), + ), + bottomContent: ScaffoldBaseBottomContent( + child: Row( + children: [ + Expanded( + child: QuantusButton.simple( + label: 'Cancel', + variant: ButtonVariant.staged, + onTap: () => Navigator.popUntil(context, (r) => r.isFirst), + ), + ), + const SizedBox(width: 16), + Expanded( + child: QuantusButton.simple( + label: 'Sign', + isLoading: _signing, + onTap: _signing ? null : () => _sign(owner), + ), + ), + ], + ), + ), + ); + } + + /// The network label with its provenance. It is never shown bare: a + /// signer must not read it as something this device established. + Widget _networkChip(BuildContext context) { + final colors = context.colorsV3; + final text = context.themeTextV3; + final network = widget.request.network; + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: colors.bgSurface2, + borderRadius: context.radiusV3.mdBorder, + border: Border.all(color: colors.textMuted.useOpacity(0.4)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text( + 'NEAR · ${network.toUpperCase()}', + style: text.labelMonogram.copyWith(color: colors.textContent, letterSpacing: 1.2), + ), + const SizedBox(width: 8), + Text('UNVERIFIED', style: text.labelMonogram.copyWith(color: colors.semanticEmber, letterSpacing: 1.2)), + ], + ), + const SizedBox(height: 6), + Text(NearDisplay.networkDisclaimer(network), style: text.caption.copyWith(color: colors.textMuted)), + ], + ), + ); + } + + Widget _warningBanner(BuildContext context, {required String title, required String body}) { + final colors = context.colorsV3; + final text = context.themeTextV3; + return Container( + margin: const EdgeInsets.only(top: 16), + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: colors.semanticEmber.useOpacity(0.12), + borderRadius: context.radiusV3.mdBorder, + border: Border.all(color: colors.semanticEmber), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(Icons.warning_amber_rounded, color: colors.semanticEmber, size: 22), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, style: text.bodyEmphasis.copyWith(color: colors.semanticEmber)), + const SizedBox(height: 4), + Text(body, style: text.caption.copyWith(color: colors.textMuted)), + ], + ), + ), + ], + ), + ); + } + + Widget _amountHero(BuildContext context, BigInt yocto) { + final colors = context.colorsV3; + final text = context.themeTextV3; + return Padding( + padding: const EdgeInsets.symmetric(vertical: 10), + child: FittedBox( + fit: BoxFit.scaleDown, + alignment: Alignment.centerLeft, + child: RichText( + text: TextSpan( + children: [ + TextSpan( + text: NearDisplay.formatNear(yocto), + style: text.amountHero.copyWith(color: colors.textContent), + ), + TextSpan( + text: ' NEAR', + style: text.amountInline.copyWith(color: colors.textMuted), + ), + ], + ), + ), + ), + ); + } + + /// Every parameter of one action, under a heading that names it. Nothing is + /// summarised away: a function-call key's method list or a stake's validator + /// key is exactly what a signer has to check. + List _actionBody(BuildContext context, NearAction action, int index) { + final colors = context.colorsV3; + final text = context.themeTextV3; + final danger = NearDisplay.isDangerous(action); + final heading = Padding( + padding: const EdgeInsets.only(top: 16), + child: Text( + '${index + 1}. ${NearDisplay.actionTitle(action)}', + style: text.headingRow.copyWith(color: danger ? colors.semanticEmber : colors.semanticLilac), + ), + ); + String near(BigInt? yocto) => '${NearDisplay.formatNear(yocto ?? BigInt.zero)} NEAR'; + + final rows = switch (action.kind) { + NearActionKind.transfer => [DetailSummaryRow.stacked(label: 'Amount', value: near(action.amount))], + NearActionKind.functionCall => [ + DetailSummaryRow.stacked(label: 'Method', value: NearDisplay.safeText(action.target ?? ''), monospace: true), + DetailSummaryRow.stacked(label: 'Attached deposit', value: near(action.amount)), + DetailSummaryRow.stacked(label: 'Gas', value: NearDisplay.formatGas(action.gas ?? BigInt.zero)), + DetailSummaryRow.stacked( + label: 'Arguments', + value: NearDisplay.argsText(action.args ?? Uint8List(0)), + monospace: true, + ), + ], + NearActionKind.createAccount => [ + DetailSummaryRow.stacked(label: 'New account', value: tx.receiverId, monospace: true), + ], + NearActionKind.deployContract => [ + DetailSummaryRow.stacked(label: 'Deploys to', value: tx.receiverId, monospace: true), + DetailSummaryRow.stacked(label: 'Code size', value: '${action.codeLen ?? 0} bytes'), + DetailSummaryRow.stacked( + label: 'Code SHA-256', + value: hex.encode(action.codeHash ?? Uint8List(0)), + monospace: true, + note: 'Compare with the hash of the contract you meant to deploy; the code itself cannot be shown here.', + ), + ], + NearActionKind.stake => [ + DetailSummaryRow.stacked(label: 'Amount', value: near(action.amount)), + DetailSummaryRow.stacked(label: 'Validator key', value: action.publicKey ?? '', monospace: true), + ], + NearActionKind.addKey => [ + DetailSummaryRow.stacked(label: 'Key', value: action.publicKey ?? '', monospace: true), + if (action.fullAccess) + DetailSummaryRow.stacked( + label: 'Access', + value: 'Full access', + valueColor: colors.semanticEmber, + note: 'This key can do anything the account can, including removing your key.', + ) + else ...[ + DetailSummaryRow.stacked(label: 'May call', value: action.target ?? '', monospace: true), + DetailSummaryRow.stacked( + label: 'Methods', + value: action.methodNames.isEmpty ? 'Any method' : action.methodNames.map(NearDisplay.safeText).join(', '), + monospace: action.methodNames.isNotEmpty, + ), + DetailSummaryRow.stacked( + label: 'Gas allowance', + value: action.amount == null ? 'Unlimited' : near(action.amount), + ), + ], + ], + NearActionKind.deleteKey => [ + DetailSummaryRow.stacked(label: 'Key', value: action.publicKey ?? '', monospace: true), + ], + NearActionKind.deleteAccount => [ + DetailSummaryRow.stacked(label: 'Deletes', value: tx.receiverId, monospace: true), + DetailSummaryRow.stacked(label: 'Balance goes to', value: action.target ?? '', monospace: true), + ], + }; + return [heading, ...rows]; + } + + Widget _advancedSection(BuildContext context) { + final colors = context.colorsV3; + final text = context.themeTextV3; + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => BottomSheetContainer.show( + context, + builder: (ctx) => BottomSheetContainer( + title: 'Advanced', + child: Text(_advancedLines().join('\n'), style: text.dataAddress.copyWith(color: colors.textMuted)), + ), + ), + child: Row( + children: [ + Text('ADVANCED', style: text.labelData.copyWith(color: colors.textMuted)), + const SizedBox(width: 6), + Icon(Icons.chevron_right, size: 18, color: colors.textMuted), + ], + ), + ); + } + + List _advancedLines() => [ + 'Network: ${widget.request.network}', + 'Nonce: ${tx.nonce}', + 'Block hash: ${tx.blockHash}', + 'Public key: ${tx.publicKey}', + 'Transaction hash: ${hex.encode(tx.hash)}', + 'Raw transaction (${widget.request.transaction.length} bytes): 0x${hex.encode(widget.request.transaction)}', + ]; +} diff --git a/cold-wallet-app/lib/screens/sign_transaction_screen.dart b/cold-wallet-app/lib/screens/sign_transaction_screen.dart index 4f0409aeb..5c3677873 100644 --- a/cold-wallet-app/lib/screens/sign_transaction_screen.dart +++ b/cold-wallet-app/lib/screens/sign_transaction_screen.dart @@ -7,8 +7,8 @@ import 'package:quantus_sdk/quantus_sdk.dart' hide CallFieldView; import 'package:quantus_cold_wallet/app_version.dart'; import 'package:quantus_cold_wallet/components/address_with_checkphrase.dart'; import 'package:quantus_cold_wallet/components/call_detail_view.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/components/signature_qr_view.dart'; +import 'package:quantus_cold_wallet/components/signing_refusal_view.dart'; import 'package:quantus_cold_wallet/providers/wallet_providers.dart'; /// Reviews a scanned signing payload and, on approval, produces the signature QR. @@ -30,9 +30,6 @@ class _SignTransactionScreenState extends ConsumerState { ParsedPayload? _parsed; FormatException? _parseError; Uint8List? _signed; - List? _urParts; - int? _urPartsBytes; - bool _qrPaused = false; bool _signing = false; String? _error; @@ -98,7 +95,12 @@ class _SignTransactionScreenState extends ConsumerState { detail: AddressWithCheckphrase(label: 'Requested signer', address: widget.request.signer), ); } - if (_signed != null) return _signatureView(context, _signed!); + if (_signed != null) { + return SignatureQrView( + signed: _signed!, + instruction: 'Scan this with your hot wallet to broadcast the transaction.', + ); + } return _reviewView(context, _parsed!); } @@ -127,63 +129,8 @@ class _SignTransactionScreenState extends ConsumerState { }; } - Widget _errorView(BuildContext context, {required String title, required String message, required Widget detail}) { - final colors = context.colorsV3; - final text = context.themeTextV3; - return ScaffoldBase( - appBar: const V2AppBar(title: 'Sign Transaction'), - // The detail can be as long as the 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)), - ), - ); - } + Widget _errorView(BuildContext context, {required String title, required String message, required Widget detail}) => + SigningRefusalView(appBarTitle: 'Sign Transaction', title: title, message: message, detail: detail); Widget _reviewView(BuildContext context, ParsedPayload parsed) { final colors = context.colorsV3; @@ -347,78 +294,4 @@ class _SignTransactionScreenState extends ConsumerState { builder: (ctx) => BottomSheetContainer(title: title, child: child), ); } - - /// Pauses the animation and opens the tuning sheet; resumes when it closes. - Future _pauseAndTune() async { - setState(() => _qrPaused = true); - await _showSheet(title: 'QR display options', child: const QrTuningControls()); - if (mounted) setState(() => _qrPaused = false); - } - - Widget _signatureView(BuildContext context, Uint8List signed) { - final colors = context.colorsV3; - final text = context.themeTextV3; - final settings = ref.watch(coldSettingsProvider); - - if (_urParts == null || _urPartsBytes != settings.qrBytes) { - _urParts = encodeUrForQr(data: signed, maxFragmentLength: settings.qrBytes); - _urPartsBytes = settings.qrBytes; - } - final parts = _urParts!; - - return ScaffoldBase( - appBar: const V2AppBar(title: 'Signature', showBackButton: false), - mainContent: SingleChildScrollView( - child: Column( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - const SizedBox(height: 8), - Text( - 'Scan this with your hot wallet to broadcast the transaction.', - style: text.body.copyWith(color: colors.textMuted), - textAlign: TextAlign.center, - ), - const SizedBox(height: 24), - Center( - child: AnimatedUrQr(parts: parts, fps: settings.qrFps, paused: _qrPaused), - ), - 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, - 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 hot wallet finishes scanning.', - style: text.caption.copyWith(color: colors.textMuted), - textAlign: TextAlign.center, - ), - ], - ], - ), - ), - bottomContent: ScaffoldBaseBottomContent( - child: QuantusButton.simple(label: 'Done', onTap: () => Navigator.popUntil(context, (r) => r.isFirst)), - ), - ); - } } diff --git a/cold-wallet-app/lib/services/near_display.dart b/cold-wallet-app/lib/services/near_display.dart new file mode 100644 index 000000000..5f74fb87f --- /dev/null +++ b/cold-wallet-app/lib/services/near_display.dart @@ -0,0 +1,207 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:convert/convert.dart'; +import 'package:quantus_sdk/quantus_sdk.dart'; + +/// Display rules for reviewing a NEAR transaction. Pure functions over the +/// decoded [NearTransaction], so they can be checked without the Rust bridge. +class NearDisplay { + const NearDisplay._(); + + static const nearDecimals = 24; + static final BigInt _yoctoPerNear = BigInt.from(10).pow(nearDecimals); + static final BigInt _gasPerTgas = BigInt.from(10).pow(12); + + /// Exact: every significant decimal is kept, so a signer never sees a + /// rounded figure for what they are authorising. + static String formatNear(BigInt yocto) { + final whole = yocto ~/ _yoctoPerNear; + var fraction = (yocto % _yoctoPerNear).toString().padLeft(nearDecimals, '0'); + fraction = fraction.replaceFirst(RegExp(r'0+$'), ''); + return fraction.isEmpty ? '$whole' : '$whole.$fraction'; + } + + static String formatGas(BigInt gas) { + if (gas % _gasPerTgas == BigInt.zero) return '${gas ~/ _gasPerTgas} Tgas'; + return '$gas gas'; + } + + /// Function-call arguments are JSON by convention; anything else is shown as hex. + /// + /// The JSON is re-indented from its own text, never decoded and re-encoded: + /// a Dart `num` cannot hold every JSON number, and an amount the signer + /// reads must be the amount the contract receives, digit for digit. + static String argsText(Uint8List args) { + if (args.isEmpty) return '(none)'; + final String text; + try { + text = utf8.decode(args, allowMalformed: false); + json.decode(text); + } on FormatException { + return '0x${hex.encode(args)}'; + } + final pretty = reindentJson(text); + return isDisplaySafe(pretty, allowNewlines: true) ? pretty : '0x${hex.encode(args)}'; + } + + /// Re-indents [text], which must already be valid JSON, copying every token + /// through verbatim. Only whitespace outside strings is changed. + static String reindentJson(String text) { + final out = StringBuffer(); + var depth = 0; + var inString = false; + var escaped = false; + void newline() => out.write('\n${' ' * depth}'); + + for (var i = 0; i < text.length; i++) { + final c = text[i]; + if (inString) { + out.write(c); + if (escaped) { + escaped = false; + } else if (c == r'\') { + escaped = true; + } else if (c == '"') { + inString = false; + } + continue; + } + switch (c) { + case '"': + inString = true; + out.write(c); + case '{' || '[': + out.write(c); + if (_closesImmediately(text, i)) { + out.write(text[_nextNonSpace(text, i + 1)]); + i = _nextNonSpace(text, i + 1); + } else { + depth++; + newline(); + } + case '}' || ']': + depth--; + newline(); + out.write(c); + case ',': + out.write(c); + newline(); + case ':': + out.write(': '); + case ' ' || '\t' || '\n' || '\r': + break; + default: + out.write(c); + } + } + return out.toString(); + } + + static int _nextNonSpace(String text, int from) { + var i = from; + while (i < text.length && ' \t\n\r'.contains(text[i])) { + i++; + } + return i; + } + + /// Whether the bracket at [open] is followed by its closer, so `{}` and + /// `[]` stay on one line. + static bool _closesImmediately(String text, int open) { + final next = _nextNonSpace(text, open + 1); + if (next >= text.length) return false; + return (text[open] == '{' && text[next] == '}') || (text[open] == '[' && text[next] == ']'); + } + + /// A string from the transaction — a method name, a key's method list — + /// shown only when nothing in it can reorder or hide what is rendered; + /// otherwise its bytes, as hex. + static String safeText(String text) => isDisplaySafe(text) ? text : '0x${hex.encode(utf8.encode(text))}'; + + /// Whether every character renders in place: no control characters, and + /// none of the bidi or zero-width format characters that could reverse, + /// join or hide the text around them. + static bool isDisplaySafe(String text, {bool allowNewlines = false}) => text.runes.every((r) { + if (allowNewlines && r == 0x0A) return true; + if (r < 0x20 || (r >= 0x7F && r <= 0x9F)) return false; + return !((r >= 0x200B && r <= 0x200F) || + (r >= 0x202A && r <= 0x202E) || + (r >= 0x2060 && r <= 0x2064) || + (r >= 0x2066 && r <= 0x2069) || + r == 0xFEFF); + }); + + static String actionTitle(NearAction action) => switch (action.kind) { + NearActionKind.transfer => 'SEND', + NearActionKind.functionCall => 'CALL CONTRACT', + NearActionKind.createAccount => 'CREATE ACCOUNT', + NearActionKind.deployContract => 'DEPLOY CONTRACT', + NearActionKind.stake => 'STAKE', + NearActionKind.addKey => action.fullAccess ? 'ADD FULL ACCESS KEY' : 'ADD FUNCTION CALL KEY', + NearActionKind.deleteKey => 'DELETE KEY', + NearActionKind.deleteAccount => 'DELETE ACCOUNT', + }; + + /// Actions that change which accounts exist, who controls them or what code + /// runs under them, rather than moving a stated amount. Shown in warning + /// colour. + static bool isDangerous(NearAction action) => switch (action.kind) { + NearActionKind.createAccount || NearActionKind.deleteAccount => true, + NearActionKind.addKey || NearActionKind.deleteKey => true, + NearActionKind.deployContract || NearActionKind.stake => true, + NearActionKind.transfer || NearActionKind.functionCall => false, + }; + + /// Title of the account-change warning, or null when nothing in [tx] is + /// [isDangerous]. Creating the receiver is called out by name, since the + /// signer is bringing a new account into existence; anything else that + /// is dangerous changes control of an account that already exists. + static String? accountChangeTitle(NearTransaction tx) { + final dangerous = tx.actions.where(isDangerous).toList(); + if (dangerous.isEmpty) return null; + if (dangerous.every((a) => a.kind == NearActionKind.createAccount)) return 'This creates ${tx.receiverId}'; + return 'This changes control of ${tx.receiverId}'; + } + + /// What the whole transaction does, in one line: the single action's title, + /// or the count when there are several. + static String headline(NearTransaction tx) { + if (tx.actions.length == 1) return actionTitle(tx.actions.single); + return '${tx.actions.length} ACTIONS'; + } + + /// Shown on every review beside the network label: the label is the + /// requesting wallet's word, and nothing in the signed bytes contradicts + /// or confirms it, so a signature made under a wrong label is still a + /// valid signature on whichever network the account and nonce exist. + static String networkDisclaimer(String network) => + 'Network as stated by the requesting wallet. The transaction bytes do not name a network, so this ' + 'cannot be checked here: the signature will be valid wherever the signer account and nonce exist. ' + 'Confirm your hot wallet is on $network before signing.'; + + /// A naming-convention hint, not a verdict on the request. NEAR account IDs + /// do not encode a network: the `network` label comes from the requesting + /// wallet, and this only notices when a sub-account's top-level name is the + /// *other* network's customary one (`.near` on mainnet, `.testnet` on + /// testnet). Top-level accounts (`near`, `registrar`), implicit accounts + /// (64 hex, `0x` + 40 hex) and deterministic accounts (`0s` + 40 hex) carry + /// no such convention and are never flagged. Only the labels + /// [NearSigningRequest.supportedNetworks] admits reach here; anything else + /// is a bug upstream and is reported, never passed over. + static String? networkMismatch(NearTransaction tx, String network) { + final otherTld = switch (network) { + 'mainnet' => '.testnet', + 'testnet' => '.near', + _ => throw ArgumentError.value(network, 'network', 'not a supported NEAR network'), + }; + final suspicious = [tx.signerId, tx.receiverId].where((id) => id.endsWith(otherTld)).toList(); + if (suspicious.isEmpty) return null; + final plural = suspicious.length > 1; + return 'The requesting wallet labelled this $network, but ${suspicious.join(' and ')} ' + '${plural ? 'end' : 'ends'} in $otherTld, the name usually used on ${_networkOf(otherTld)}. ' + 'Account names do not prove a network; check which one your hot wallet is on before signing.'; + } + + static String _networkOf(String tld) => tld == '.near' ? 'mainnet' : 'testnet'; +} diff --git a/cold-wallet-app/test/near_display_test.dart b/cold-wallet-app/test/near_display_test.dart new file mode 100644 index 000000000..a847c03df --- /dev/null +++ b/cold-wallet-app/test/near_display_test.dart @@ -0,0 +1,194 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:quantus_cold_wallet/debug/debug_near_payloads.dart'; +import 'package:quantus_cold_wallet/services/near_display.dart'; +import 'package:quantus_sdk/quantus_sdk.dart'; + +NearTransaction _tx({ + String signer = 'alice.testnet', + String receiver = 'bob.testnet', + List actions = const [], +}) => NearTransaction( + signerId: signer, + publicKey: 'ml-dsa-65:abc', + publicKeyBytes: Uint8List(1952), + signsWithMlDsa65: true, + nonce: BigInt.from(42), + receiverId: receiver, + blockHash: '3KLgJ', + hash: Uint8List(32), + actions: actions, +); + +NearAction _action(NearActionKind kind, {bool fullAccess = false}) => + NearAction(kind: kind, fullAccess: fullAccess, methodNames: const []); + +void main() { + final near = BigInt.from(10).pow(24); + + group('NearDisplay.formatNear', () { + test('keeps every significant decimal and drops trailing zeros', () { + expect(NearDisplay.formatNear(near), '1'); + expect(NearDisplay.formatNear(near * BigInt.from(15) ~/ BigInt.from(10)), '1.5'); + expect(NearDisplay.formatNear(BigInt.one), '0.000000000000000000000001'); + expect(NearDisplay.formatNear(BigInt.zero), '0'); + expect(NearDisplay.formatNear(near * BigInt.from(1000000) + BigInt.from(10).pow(23)), '1000000.1'); + }); + }); + + test('formatGas shows whole Tgas, otherwise raw gas', () { + expect(NearDisplay.formatGas(BigInt.from(30) * BigInt.from(10).pow(12)), '30 Tgas'); + expect(NearDisplay.formatGas(BigInt.from(1234)), '1234 gas'); + }); + + test('argsText pretty-prints JSON and falls back to hex', () { + expect(NearDisplay.argsText(Uint8List.fromList(utf8.encode('{"a":1}'))), '{\n "a": 1\n}'); + expect(NearDisplay.argsText(Uint8List.fromList([0xff, 0x01])), '0xff01'); + expect(NearDisplay.argsText(Uint8List(0)), '(none)'); + }); + + test('text that could reorder or hide what is shown falls back to hex', () { + expect(NearDisplay.safeText('ft_transfer'), 'ft_transfer'); + expect(NearDisplay.safeText('a\u202Eb'), '0x61e280ae62'); + expect(NearDisplay.safeText('a\u200Bb'), '0x61e2808b62'); + expect(NearDisplay.safeText('a\x1bb'), '0x611b62'); + // JSON strings carrying a bidi override are shown as the raw bytes. + final args = Uint8List.fromList(utf8.encode('{"to":"bob\u202Eten.tset"}')); + expect(NearDisplay.argsText(args), '0x${args.map((b) => b.toRadixString(16).padLeft(2, '0')).join()}'); + // Pretty-printed JSON keeps its newlines; escaped controls inside strings stay escaped. + expect(NearDisplay.argsText(Uint8List.fromList(utf8.encode('{"a":"x\\ny"}'))), '{\n "a": "x\\ny"\n}'); + }); + + test('argsText keeps every numeric token exactly as the contract will read it', () { + String shown(String json) => NearDisplay.argsText(Uint8List.fromList(utf8.encode(json))); + // Beyond 2^53 and beyond double precision: a decode/encode round trip + // would show 18446744073709552000.0 and 0.12345678901234568. + expect(shown('{"amount":18446744073709551617}'), '{\n "amount": 18446744073709551617\n}'); + expect(shown('{"limit":0.123456789012345678901234}'), '{\n "limit": 0.123456789012345678901234\n}'); + expect( + shown('{"e":1E+400,"n":-0,"s":"18446744073709551617"}'), + contains('"e": 1E+400,\n "n": -0,\n "s": "18446744073709551617"'), + ); + // Nesting, empty containers, and escapes inside strings survive untouched. + expect( + shown('{"a":[1,{"b":[]},{}],"c":"x{y}\\"z,:[]"}'), + '{\n "a": [\n 1,\n {\n "b": []\n },\n {}\n ],\n "c": "x{y}\\"z,:[]"\n}', + ); + expect(shown(' [ 1 , 2 ] '), '[\n 1,\n 2\n]'); + expect(shown('"just a string"'), '"just a string"'); + expect(shown('{"a":1'), startsWith('0x')); + }); + + test('headline names a single action and counts several', () { + expect(NearDisplay.headline(_tx(actions: [_action(NearActionKind.transfer)])), 'SEND'); + expect( + NearDisplay.headline(_tx(actions: [_action(NearActionKind.addKey, fullAccess: true)])), + 'ADD FULL ACCESS KEY', + ); + expect(NearDisplay.headline(_tx(actions: [_action(NearActionKind.addKey)])), 'ADD FUNCTION CALL KEY'); + expect( + NearDisplay.headline(_tx(actions: [_action(NearActionKind.functionCall), _action(NearActionKind.transfer)])), + '2 ACTIONS', + ); + }); + + test('account, key and code changes are dangerous; sends and calls are not', () { + for (final kind in [ + NearActionKind.createAccount, + NearActionKind.addKey, + NearActionKind.deleteKey, + NearActionKind.deleteAccount, + NearActionKind.deployContract, + NearActionKind.stake, + ]) { + expect(NearDisplay.isDangerous(_action(kind)), isTrue, reason: '$kind'); + } + for (final kind in [NearActionKind.transfer, NearActionKind.functionCall]) { + expect(NearDisplay.isDangerous(_action(kind)), isFalse, reason: '$kind'); + } + }); + + test('the account-change title names creation apart from control changes', () { + expect(NearDisplay.accountChangeTitle(_tx()), isNull); + expect( + NearDisplay.accountChangeTitle( + _tx( + receiver: 'vault.alice.testnet', + actions: [_action(NearActionKind.createAccount), _action(NearActionKind.transfer)], + ), + ), + 'This creates vault.alice.testnet', + ); + expect( + NearDisplay.accountChangeTitle( + _tx( + receiver: 'vault.alice.testnet', + actions: [_action(NearActionKind.createAccount), _action(NearActionKind.addKey)], + ), + ), + 'This changes control of vault.alice.testnet', + ); + expect( + NearDisplay.accountChangeTitle(_tx(actions: [_action(NearActionKind.deleteKey)])), + 'This changes control of bob.testnet', + ); + }); + + test('the network disclaimer attributes the label and names the network to confirm', () { + final text = NearDisplay.networkDisclaimer('mainnet'); + expect(text, startsWith('Network as stated by the requesting wallet.')); + expect(text, contains('do not name a network')); + expect(text, endsWith('Confirm your hot wallet is on mainnet before signing.')); + }); + + group('NearDisplay.networkMismatch', () { + test('is silent when names use the labelled network\'s suffix', () { + expect(NearDisplay.networkMismatch(_tx(), 'testnet'), isNull); + expect(NearDisplay.networkMismatch(_tx(signer: 'alice.near', receiver: 'bob.near'), 'mainnet'), isNull); + }); + + test('never flags accounts whose names carry no network convention', () { + // Top-level accounts exist on every network and are created by the registrar. + expect(NearDisplay.networkMismatch(_tx(signer: 'near', receiver: 'alice.near'), 'mainnet'), isNull); + expect(NearDisplay.networkMismatch(_tx(signer: 'registrar', receiver: 'com'), 'testnet'), isNull); + // NEAR-implicit, ETH-implicit and NEAR-deterministic account IDs. + expect(NearDisplay.networkMismatch(_tx(signer: 'a' * 64, receiver: '0x${'b' * 40}'), 'mainnet'), isNull); + expect(NearDisplay.networkMismatch(_tx(signer: '0s${'c' * 40}', receiver: 'a' * 64), 'testnet'), isNull); + // Other sub-account hierarchies (e.g. .com, .aurora, .tg) say nothing about the network. + expect(NearDisplay.networkMismatch(_tx(signer: 'alice.aurora', receiver: 'bob.tg'), 'mainnet'), isNull); + expect(NearDisplay.networkMismatch(_tx(signer: 'alice.aurora', receiver: 'bob.tg'), 'testnet'), isNull); + }); + + test('rejects a label the request envelope should never have let through', () { + expect(() => NearDisplay.networkMismatch(_tx(), 'localnet'), throwsArgumentError); + expect(() => NearDisplay.networkMismatch(_tx(), 'testnet '), throwsArgumentError); + }); + + test('names each account that uses the other network\'s suffix, as a hint', () { + final both = NearDisplay.networkMismatch(_tx(), 'mainnet')!; + expect(both, contains('The requesting wallet labelled this mainnet')); + expect(both, contains('alice.testnet and bob.testnet end in .testnet, the name usually used on testnet')); + expect(both, contains('Account names do not prove a network')); + expect( + NearDisplay.networkMismatch(_tx(signer: 'alice.near'), 'mainnet'), + contains('bob.testnet ends in .testnet'), + ); + expect( + NearDisplay.networkMismatch(_tx(signer: 'alice.near', receiver: 'bob.testnet'), 'testnet'), + contains('alice.near ends in .near, the name usually used on mainnet'), + ); + }); + }); + + test('debug borsh writer reproduces the near-cli-rs transfer vector byte for byte', () { + // near-cli-rs 0.30.1 `sign-later` for alice.testnet -> bob.testnet, 1 NEAR, + // ed25519 key 0x11*32, nonce 42, block hash 0x22*32. + const golden = + 'DQAAAGFsaWNlLnRlc3RuZXQAEREREREREREREREREREREREREREREREREREREREREREqAAAAAAAAAAsAAABib2IudGVzdG5ldCIiIiIiIiIi' + 'IiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiAQAAAAMAAACh7czOG8LTAAAAAAAA'; + final sample = DebugNearPayloads.samples(null).singleWhere((c) => c.label.startsWith('signed by an ed25519')); + expect(base64Encode(sample.request.transaction), golden); + }); +} diff --git a/cold-wallet-app/test/public_key_export_screen_test.dart b/cold-wallet-app/test/public_key_export_screen_test.dart new file mode 100644 index 000000000..10b1f5ae1 --- /dev/null +++ b/cold-wallet-app/test/public_key_export_screen_test.dart @@ -0,0 +1,48 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:quantus_cold_wallet/providers/ur_qr_providers.dart'; +import 'package:quantus_cold_wallet/screens/public_key_export_screen.dart'; +import 'package:quantus_sdk/quantus_sdk.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + const address = 'qznQKhufTDfU3szAzfgCny7wMhxUN3qjEqneiRUNgC7MjSDyG'; + const key = 'ml-dsa-65:3TJcasmT6vhSWasNoHMfCe6jJ5GFAT9e8kLXkz6VrqLU'; + const handle = 'ml-dsa-65-hash:3jLEZwqD3ScQ2B6uRSuwVNGwML1XM8qqc1UBPLSdwLzJ'; + + testWidgets('presents the key as the account\'s public key, with NEAR as one place it can be registered', ( + tester, + ) async { + await tester.pumpWidget( + ProviderScope( + overrides: [ + urQrFramesProvider.overrideWith((ref, payload) => ['ur:bytes/1-2/abcd', 'ur:bytes/2-2/efgh']), + ], + child: Builder( + builder: (context) => MaterialApp( + theme: AppTheme.darkTheme(context), + home: const PublicKeyExportScreen( + export: NearPublicKeyExport(address: address, nearPublicKey: key), + handle: handle, + ), + ), + ), + ), + ); + await tester.pump(); + + expect(find.text('Public Key'), findsOneWidget); + expect(find.text('NEAR Public Key'), findsNothing); + expect(find.textContaining('full ML-DSA-65 public key'), findsOneWidget); + expect(find.textContaining('Your Quantus address is a hash of it'), findsOneWidget); + expect(find.text('IF ADDED TO A NEAR ACCOUNT'), findsOneWidget); + expect(find.text(handle), findsOneWidget); + expect(find.text('QUANTUS ACCOUNT'), findsOneWidget); + expect(find.text(address), findsOneWidget); + expect(find.text('FULL KEY'), findsOneWidget); + expect(find.text(key), findsOneWidget); + expect(find.byIcon(Icons.copy_rounded), findsOneWidget); + }); +} diff --git a/cold-wallet-app/test/sign_near_transaction_screen_test.dart b/cold-wallet-app/test/sign_near_transaction_screen_test.dart new file mode 100644 index 000000000..b175e9aa9 --- /dev/null +++ b/cold-wallet-app/test/sign_near_transaction_screen_test.dart @@ -0,0 +1,278 @@ +import 'dart:typed_data'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:quantus_cold_wallet/providers/wallet_providers.dart'; +import 'package:quantus_cold_wallet/screens/sign_near_transaction_screen.dart'; +import 'package:quantus_sdk/quantus_sdk.dart'; + +const owner = 'qzmNaLjPU7hcvkjHpGmrVDPD9y12vdAFimCSrP1GkhVFJMaUq'; + +class _UnlockedController extends WalletController { + @override + WalletState build() => const WalletState(status: WalletStatus.unlocked, mnemonic: 'unused in these tests'); +} + +class _LockedController extends WalletController { + @override + WalletState build() => const WalletState(status: WalletStatus.locked); +} + +NearTransaction _tx({ + required List actions, + String signer = 'alice.testnet', + String receiver = 'bob.testnet', + bool mlDsa = true, +}) => NearTransaction( + signerId: signer, + publicKey: mlDsa ? 'ml-dsa-65:7Kq' : 'ed25519:4rN', + publicKeyBytes: Uint8List(mlDsa ? 1952 : 32), + signsWithMlDsa65: mlDsa, + nonce: BigInt.from(42), + receiverId: receiver, + blockHash: '3KLgJ', + hash: Uint8List(32), + actions: actions, +); + +final _near = BigInt.from(10).pow(24); + +NearAction _transfer(BigInt amount) => + NearAction(kind: NearActionKind.transfer, amount: amount, fullAccess: false, methodNames: const []); + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + const colors = AppColorsV3.dark(); + + Future pump( + WidgetTester tester, + NearTransaction tx, { + String network = 'testnet', + String? keyOwner = owner, + WalletController Function() controller = _UnlockedController.new, + }) async { + await tester.pumpWidget( + ProviderScope( + overrides: [ + walletControllerProvider.overrideWith(controller), + nearKeyOwnerProvider.overrideWith((ref, _) => keyOwner), + checksumNameProvider.overrideWith((ref, _) async => 'amber glacier quartz'), + ], + child: MaterialApp( + home: Builder( + builder: (context) => Theme( + data: AppTheme.darkTheme(context), + child: SignNearTransactionScreen( + request: NearSigningRequest(network: network, transaction: Uint8List.fromList([1, 2, 3])), + transaction: tx, + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + } + + testWidgets('a plain send leads with the amount, the receiver and the key holder', (tester) async { + await pump(tester, _tx(actions: [_transfer(_near * BigInt.from(15) ~/ BigInt.from(10))])); + + expect(find.text('NEAR · TESTNET'), findsOneWidget); + expect(find.text('UNVERIFIED'), findsOneWidget); + expect(find.textContaining('Network as stated by the requesting wallet'), findsOneWidget); + expect(find.textContaining('Confirm your hot wallet is on testnet'), findsOneWidget); + final headline = tester.widget(find.text('SEND')); + expect(headline.style?.color, colors.accentFlare); + expect(find.textContaining('1.5', findRichText: true), findsOneWidget); + expect(find.text('TO'), findsOneWidget); + expect(find.text('bob.testnet'), findsOneWidget); + expect(find.text('FROM'), findsOneWidget); + expect(find.text('alice.testnet'), findsOneWidget); + expect(find.text('SIGNING KEY HELD BY'), findsOneWidget); + expect(find.text(owner), findsOneWidget); + expect(find.text('amber glacier quartz'), findsOneWidget); + expect(find.text('Sign'), findsOneWidget); + expect(find.textContaining('Check the network'), findsNothing); + expect(find.textContaining('changes control'), findsNothing); + }); + + testWidgets('a full access key is headlined as dangerous with every parameter listed', (tester) async { + await pump( + tester, + _tx( + receiver: 'alice.testnet', + actions: [ + const NearAction(kind: NearActionKind.addKey, publicKey: 'ed25519:4rN', fullAccess: true, methodNames: []), + ], + ), + ); + + final headline = tester.widget(find.text('ADD FULL ACCESS KEY')); + expect(headline.style?.color, colors.semanticEmber); + expect(find.text('This changes control of alice.testnet'), findsOneWidget); + expect(find.text('Nothing was signed.'), findsNothing); + expect(find.text('1. ADD FULL ACCESS KEY'), findsOneWidget); + expect(find.text('ed25519:4rN'), findsOneWidget); + expect(find.text('Full access'), findsOneWidget); + expect(find.text('Sign'), findsOneWidget); + }); + + testWidgets('a key added to a sub-account names that account, not the signer, in the warning', (tester) async { + await pump( + tester, + _tx( + receiver: 'vault.alice.testnet', + actions: [ + const NearAction(kind: NearActionKind.createAccount, fullAccess: false, methodNames: []), + const NearAction(kind: NearActionKind.addKey, publicKey: 'ed25519:4rN', fullAccess: true, methodNames: []), + ], + ), + ); + + expect(find.text('This changes control of vault.alice.testnet'), findsOneWidget); + expect(find.textContaining('control of alice.testnet'), findsNothing); + expect(find.text('1. CREATE ACCOUNT'), findsOneWidget); + expect(find.text('2. ADD FULL ACCESS KEY'), findsOneWidget); + }); + + testWidgets('creating an account with no key action is still warned about, by name', (tester) async { + await pump( + tester, + _tx( + receiver: 'vault.alice.testnet', + actions: [ + const NearAction(kind: NearActionKind.createAccount, fullAccess: false, methodNames: []), + _transfer(_near), + ], + ), + ); + + expect(find.text('This creates vault.alice.testnet'), findsOneWidget); + expect(find.textContaining('changes control'), findsNothing); + final headline = tester.widget(find.text('2 ACTIONS')); + expect(headline.style?.color, colors.semanticEmber); + final row = tester.widget(find.text('1. CREATE ACCOUNT')); + expect(row.style?.color, colors.semanticEmber); + final sendRow = tester.widget(find.text('2. SEND')); + expect(sendRow.style?.color, colors.semanticLilac); + expect(find.text('1 NEAR'), findsOneWidget); + expect(find.text('Sign'), findsOneWidget); + }); + + testWidgets('a contract deployment identifies the code by its hash, not just its size', (tester) async { + final hash = Uint8List.fromList(List.generate(32, (i) => i)); + await pump( + tester, + _tx( + receiver: 'alice.testnet', + actions: [ + NearAction( + kind: NearActionKind.deployContract, + codeLen: 4096, + codeHash: hash, + fullAccess: false, + methodNames: const [], + ), + ], + ), + ); + + expect(tester.widget(find.text('DEPLOY CONTRACT')).style?.color, colors.semanticEmber); + expect(find.text('4096 bytes'), findsOneWidget); + expect(find.text('000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f'), findsOneWidget); + }); + + testWidgets('every action of a multi-action call is listed with its own parameters', (tester) async { + await pump( + tester, + _tx( + receiver: 'ref-finance.testnet', + actions: [ + NearAction( + kind: NearActionKind.functionCall, + target: 'storage_deposit', + args: Uint8List.fromList('{}'.codeUnits), + gas: BigInt.from(30) * BigInt.from(10).pow(12), + amount: _near ~/ BigInt.from(80), + fullAccess: false, + methodNames: const [], + ), + _transfer(_near), + ], + ), + ); + + expect(find.text('2 ACTIONS'), findsOneWidget); + expect(find.text('1. CALL CONTRACT'), findsOneWidget); + expect(find.text('storage_deposit'), findsOneWidget); + expect(find.text('30 Tgas'), findsOneWidget); + expect(find.text('0.0125 NEAR'), findsOneWidget); + expect(find.text('2. SEND'), findsOneWidget); + expect(find.text('1 NEAR'), findsOneWidget); + expect(find.text('ref-finance.testnet'), findsOneWidget); + }); + + testWidgets('hints when the account names use the other network\'s suffix', (tester) async { + await pump(tester, _tx(actions: [_transfer(_near)]), network: 'mainnet'); + + expect(find.text('NEAR · MAINNET'), findsOneWidget); + expect(find.text('Check the network'), findsOneWidget); + expect(find.textContaining('alice.testnet and bob.testnet end in .testnet'), findsOneWidget); + expect(find.text('Sign'), findsOneWidget); + }); + + testWidgets('network-neutral account IDs still see the network marked as the wallet\'s claim', (tester) async { + // An implicit signer and an ETH-implicit receiver exist on every network: + // the suffix hint has nothing to say, so the standing notice is all there is. + await pump( + tester, + _tx(signer: 'a' * 64, receiver: '0x${'b' * 40}', actions: [_transfer(_near)]), + network: 'testnet', + ); + + expect(find.text('NEAR · TESTNET'), findsOneWidget); + expect(find.text('UNVERIFIED'), findsOneWidget); + expect(find.textContaining('The transaction bytes do not name a network'), findsOneWidget); + expect(find.textContaining('Confirm your hot wallet is on testnet'), findsOneWidget); + expect(find.text('Check the network'), findsNothing); + expect(find.text('Sign'), findsOneWidget); + }); + + testWidgets('a mainnet transfer labelled mainnet is reviewed without a mismatch warning', (tester) async { + await pump( + tester, + _tx(signer: 'alice.near', receiver: 'bob.near', actions: [_transfer(_near)]), + network: 'mainnet', + ); + + expect(find.text('NEAR · MAINNET'), findsOneWidget); + expect(find.text('UNVERIFIED'), findsOneWidget); + expect(find.text('Check the network'), findsNothing); + }); + + testWidgets('refuses a key no account here holds, and offers no Sign', (tester) async { + await pump(tester, _tx(actions: [_transfer(_near)]), keyOwner: null); + + expect(find.text('Key not in this wallet'), findsOneWidget); + expect(find.text('ml-dsa-65:7Kq'), findsOneWidget); + expect(find.text('Nothing was signed.'), findsOneWidget); + expect(find.text('Sign'), findsNothing); + }); + + testWidgets('refuses a transaction for a non-ML-DSA-65 key before looking up any account', (tester) async { + await pump(tester, _tx(actions: [_transfer(_near)], mlDsa: false)); + + expect(find.text('Not a Quantus key'), findsOneWidget); + expect(find.textContaining('names a key of type ed25519'), findsOneWidget); + expect(find.text('Sign'), findsNothing); + }); + + testWidgets('a locked wallet is told to unlock, not shown a review', (tester) async { + await pump(tester, _tx(actions: [_transfer(_near)]), controller: _LockedController.new); + + expect(find.text('Wallet is locked'), findsOneWidget); + expect(find.text('Sign'), findsNothing); + }); +} diff --git a/cold-wallet-app/test/ur_qr_panel_test.dart b/cold-wallet-app/test/ur_qr_panel_test.dart new file mode 100644 index 000000000..4b7e0c8ee --- /dev/null +++ b/cold-wallet-app/test/ur_qr_panel_test.dart @@ -0,0 +1,107 @@ +import 'dart:typed_data'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:quantus_cold_wallet/components/signing_refusal_view.dart'; +import 'package:quantus_cold_wallet/components/ur_qr_panel.dart'; +import 'package:quantus_cold_wallet/providers/settings_providers.dart'; +import 'package:quantus_cold_wallet/providers/ur_qr_providers.dart'; +import 'package:quantus_sdk/quantus_sdk.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + const colors = AppColorsV3.dark(); + + group('UrPayload', () { + test('is equal by content, so two screens share one frame set', () { + final a = UrPayload(Uint8List.fromList([1, 2, 3])); + final b = UrPayload(Uint8List.fromList([1, 2, 3])); + final c = UrPayload(Uint8List.fromList([1, 2, 4])); + expect(a, b); + expect(a.hashCode, b.hashCode); + expect(a, isNot(c)); + }); + }); + + group('qrPausedProvider', () { + test('starts running and toggles', () { + final container = ProviderContainer(); + addTearDown(container.dispose); + expect(container.read(qrPausedProvider), isFalse); + container.read(qrPausedProvider.notifier).pause(); + expect(container.read(qrPausedProvider), isTrue); + container.read(qrPausedProvider.notifier).resume(); + expect(container.read(qrPausedProvider), isFalse); + }); + }); + + testWidgets('UrQrPanel shows the frame summary and a pause control for animated payloads', (tester) async { + final frames = List.generate(7, (i) => 'ur:bytes/${i + 1}-7/abcd'); + await tester.pumpWidget( + ProviderScope( + overrides: [urQrFramesProvider.overrideWith((ref, payload) => frames)], + child: Builder( + builder: (context) => MaterialApp( + theme: AppTheme.darkTheme(context), + home: Scaffold(body: UrQrPanel(payload: UrPayload(Uint8List(5000)))), + ), + ), + ), + ); + await tester.pump(); + + expect(find.byType(AnimatedUrQr), findsOneWidget); + expect( + find.text('7 frames · ${ColdSettings.defaultQrFps} FPS · ${ColdSettings.defaultQrBytes} bytes'), + findsOneWidget, + ); + expect(find.byIcon(Icons.pause_rounded), findsOneWidget); + expect(find.textContaining('keep both devices steady'), findsOneWidget); + }); + + testWidgets('UrQrPanel hides the pause control for a single frame', (tester) async { + await tester.pumpWidget( + ProviderScope( + overrides: [ + urQrFramesProvider.overrideWith((ref, payload) => const ['ur:bytes/abcd']), + ], + child: Builder( + builder: (context) => MaterialApp( + theme: AppTheme.darkTheme(context), + home: Scaffold(body: UrQrPanel(payload: UrPayload(Uint8List(5)))), + ), + ), + ), + ); + await tester.pump(); + + expect(find.textContaining('1 frame ·'), findsOneWidget); + expect(find.byIcon(Icons.pause_rounded), findsNothing); + }); + + testWidgets('SigningRefusalView shows the reason, the detail and that nothing was signed', (tester) async { + await tester.pumpWidget( + Builder( + builder: (context) => MaterialApp( + theme: AppTheme.darkTheme(context), + home: const SigningRefusalView( + appBarTitle: 'Sign Something', + title: 'Refused', + message: 'Because.', + detail: Text('the detail'), + ), + ), + ), + ); + await tester.pump(); + + expect(find.text('Sign Something'), findsOneWidget); + expect(tester.widget(find.text('Refused')).style?.color, colors.semanticEmber); + expect(find.text('Because.'), findsOneWidget); + expect(find.text('the detail'), findsOneWidget); + expect(find.text('Nothing was signed.'), findsOneWidget); + expect(find.text('Back to home'), findsOneWidget); + }); +} diff --git a/quantus_sdk/lib/quantus_sdk.dart b/quantus_sdk/lib/quantus_sdk.dart index 2765e314c..75dce79f0 100644 --- a/quantus_sdk/lib/quantus_sdk.dart +++ b/quantus_sdk/lib/quantus_sdk.dart @@ -33,6 +33,7 @@ export 'src/models/event_type.dart'; export 'src/models/extrinsic_data.dart'; export 'src/models/extrinsic_fee_data.dart'; export 'src/models/signing_request.dart'; +export 'src/models/near_public_key_export.dart'; export 'src/models/unsigned_transaction_data.dart'; export 'src/models/remote_config_model.dart'; export 'src/models/miner_reward_event.dart'; @@ -58,6 +59,8 @@ export 'src/rust/api/ur.dart'; export 'src/utils/ur_qr.dart'; export 'src/rust/api/airdrop.dart'; export 'src/rust/api/wormhole.dart'; +export 'src/rust/api/near.dart'; +export 'src/rust/lib.dart' show U8Array32; export 'src/services/account_discovery_service.dart'; export 'src/services/airdrop_claim_service.dart'; export 'src/services/accounts_service.dart'; diff --git a/quantus_sdk/lib/src/extensions/keypair_extensions.dart b/quantus_sdk/lib/src/extensions/keypair_extensions.dart index 235ad75db..6604f7eac 100644 --- a/quantus_sdk/lib/src/extensions/keypair_extensions.dart +++ b/quantus_sdk/lib/src/extensions/keypair_extensions.dart @@ -1,6 +1,8 @@ import 'dart:math'; import 'dart:typed_data'; import 'package:quantus_sdk/src/rust/api/crypto.dart' as crypto; +import 'package:quantus_sdk/src/rust/api/near.dart' as near; +import 'package:quantus_sdk/src/rust/lib.dart' show U8Array32; import 'package:ss58/ss58.dart'; extension KeypairExtensions on crypto.Keypair { @@ -10,9 +12,14 @@ extension KeypairExtensions on crypto.Keypair { /// Hedged ML-DSA signature. [specVersion] 148+ uses QUANTUS_EXTRINSIC; earlier specs use none. Uint8List sign(List message, {required int specVersion}) => crypto.signMessage(keypair: this, message: message, entropy: _hedgeEntropy(), specVersion: specVersion); + + /// Hedged pure ML-DSA-65 signature over a borsh NEAR `TransactionV0`. + /// Returns `signature ‖ public_key`, the cold-signing response NEAR tooling expects. + Uint8List signNear(List transaction) => + near.signNearTransaction(keypair: this, transaction: transaction, entropy: _hedgeEntropy()); } -crypto.U8Array32 _hedgeEntropy() { +U8Array32 _hedgeEntropy() { final random = Random.secure(); - return crypto.U8Array32(Uint8List.fromList(List.generate(32, (_) => random.nextInt(256)))); + return U8Array32(Uint8List.fromList(List.generate(32, (_) => random.nextInt(256)))); } diff --git a/quantus_sdk/lib/src/models/near_public_key_export.dart b/quantus_sdk/lib/src/models/near_public_key_export.dart new file mode 100644 index 000000000..ceb24fc6d --- /dev/null +++ b/quantus_sdk/lib/src/models/near_public_key_export.dart @@ -0,0 +1,51 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +/// A cold wallet's ML-DSA-65 key published for use on NEAR, as the cold +/// wallet shows it in a QR and a hot wallet or CLI reads it. +/// +/// [address] is the Quantus SS58 address of the account that holds the key, +/// so the reader can tie the NEAR key to a cold account it already knows; +/// [nearPublicKey] is the key in NEAR's `ml-dsa-65:` text form, ready +/// for `near account add-key` and for a signing request's `--signer-public-key`. +class NearPublicKeyExport { + static const int version = 1; + static const String kind = 'near-public-key'; + static const String nearKeyPrefix = 'ml-dsa-65:'; + + final String address; + final String nearPublicKey; + + const NearPublicKeyExport({required this.address, required this.nearPublicKey}); + + Uint8List encode() => Uint8List.fromList( + utf8.encode(jsonEncode({'v': version, 'kind': kind, 'address': address, 'near_public_key': nearPublicKey})), + ); + + /// Throws [FormatException] on anything that is not exactly one export. + static NearPublicKeyExport decode(Uint8List bytes) { + final Object? json; + try { + json = jsonDecode(utf8.decode(bytes)); + } catch (e) { + throw FormatException('Not a NEAR public key export: $e'); + } + if (json is! Map) throw const FormatException('NEAR public key export is not a JSON object'); + final keys = json.keys.map((k) => '$k').toSet(); + const expected = {'v', 'kind', 'address', 'near_public_key'}; + if (keys.length != expected.length || !keys.containsAll(expected)) { + throw FormatException( + 'NEAR public key export keys ${keys.toList()..sort()} are not ${expected.toList()..sort()}', + ); + } + if (json['v'] != version) throw FormatException('NEAR public key export version ${json['v']} is not $version'); + if (json['kind'] != kind) throw FormatException('NEAR public key export kind ${json['kind']} is not $kind'); + final address = json['address']; + final nearPublicKey = json['near_public_key']; + if (address is! String || address.isEmpty) throw const FormatException('NEAR public key export address missing'); + if (nearPublicKey is! String || !nearPublicKey.startsWith(nearKeyPrefix)) { + throw const FormatException('NEAR public key export key is not $nearKeyPrefix…'); + } + return NearPublicKeyExport(address: address, nearPublicKey: nearPublicKey); + } +} diff --git a/quantus_sdk/lib/src/models/signing_request.dart b/quantus_sdk/lib/src/models/signing_request.dart index eac8b1db6..2c5c41843 100644 --- a/quantus_sdk/lib/src/models/signing_request.dart +++ b/quantus_sdk/lib/src/models/signing_request.dart @@ -11,7 +11,115 @@ class UnsupportedSigningRequestVersionException extends FormatException { final Object? requested; UnsupportedSigningRequestVersionException(this.requested) - : super('Signing request version $requested is not supported, this app reads version ${SigningRequest.version}'); + : super( + 'Signing request version $requested is not supported, this app reads versions ' + '${SigningRequest.version} and ${NearSigningRequest.version}', + ); +} + +/// Any signing request this app reads, dispatched on the envelope's `v`. +/// +/// Version 1 ([SigningRequest]) carries a Quantus signing payload for a named +/// SS58 account; version 2 ([NearSigningRequest]) carries a NEAR transaction, +/// which names its own signer and key. Both travel in the same +/// `ur:quantus-sign-request` UR, so a wallet built before v2 refuses it by +/// version, exactly as it refuses any version it does not know. +sealed class AnySigningRequest { + const AnySigningRequest(); + + /// Throws [FormatException] on anything that is not exactly one envelope. + static AnySigningRequest decode(Uint8List bytes) { + final json = _decodeJsonObject(bytes); + final version = json['v']; + return switch (version) { + SigningRequest.version => SigningRequest.decode(bytes), + NearSigningRequest.version => NearSigningRequest.decode(bytes), + _ => throw UnsupportedSigningRequestVersionException(version), + }; + } +} + +Map _decodeJsonObject(Uint8List bytes) { + final Object? json; + try { + json = jsonDecode(utf8.decode(bytes)); + } catch (e) { + throw FormatException('Not a signing request: $e'); + } + if (json is! Map) throw const FormatException('Signing request is not a JSON object'); + return json; +} + +void _requireExactKeys(Map json, Set keys) { + final actual = json.keys.map((k) => '$k').toSet(); + if (!actual.containsAll(keys) || actual.length != keys.length) { + throw FormatException('Signing request keys ${actual.toList()..sort()} are not ${keys.toList()..sort()}'); + } +} + +Uint8List _decodePayload(Object? payload) { + if (payload is! String || !payload.startsWith('0x')) { + throw const FormatException('Signing request payload is not 0x hex'); + } + final payloadHex = payload.substring(2); + if (payloadHex.length > maxPayloadBytes * 2) { + throw const FormatException('Signing request payload too large: more than $maxPayloadBytes bytes'); + } + final Uint8List bytesOut; + try { + bytesOut = Uint8List.fromList(hex.decode(payloadHex)); + } catch (e) { + throw FormatException('Signing request payload is not hex: $e'); + } + if (bytesOut.isEmpty) throw const FormatException('Signing request payload is empty'); + if (bytesOut.length > maxPayloadBytes) { + throw FormatException('Signing request payload too large: ${bytesOut.length} bytes'); + } + return bytesOut; +} + +/// A NEAR transaction for this wallet to sign (envelope version 2). +/// +/// The payload is the borsh-encoded NEAR `TransactionV0`, sent raw so the +/// signer can decode and display every action. It already names the signer +/// account and the exact public key that must sign, so the envelope carries no +/// `signer`; the device matches that key against its own ML-DSA-65 accounts. +/// [network] is for display only — NEAR transactions carry no chain id, and +/// nothing in it is signed. +class NearSigningRequest extends AnySigningRequest { + static const int version = 2; + static const String chain = 'near'; + + /// The only network labels a request may carry, spelled exactly. The label + /// drives what the signer is told and which account-name check runs, so a + /// lookalike such as `"testnet "` must be refused rather than shown. + static const Set supportedNetworks = {'mainnet', 'testnet'}; + + final String network; + final Uint8List transaction; + + const NearSigningRequest({required this.network, required this.transaction}); + + Uint8List encode() => Uint8List.fromList( + utf8.encode( + jsonEncode({'v': version, 'chain': chain, 'network': network, 'payload': '0x${hex.encode(transaction)}'}), + ), + ); + + /// Throws [FormatException] on anything that is not exactly this envelope. + static NearSigningRequest decode(Uint8List bytes) { + final json = _decodeJsonObject(bytes); + _requireExactKeys(json, const {'v', 'chain', 'network', 'payload'}); + if (json['v'] != version) throw UnsupportedSigningRequestVersionException(json['v']); + if (json['chain'] != chain) throw FormatException('Signing request is for chain ${json['chain']}, not NEAR'); + + final network = json['network']; + if (network is! String || !supportedNetworks.contains(network)) { + throw FormatException('Signing request NEAR network ${jsonEncode(network)} is not one of $supportedNetworks'); + } + + return NearSigningRequest(network: network, transaction: _decodePayload(json['payload'])); + } } /// A signing payload together with the account that must sign it. @@ -19,7 +127,7 @@ class UnsupportedSigningRequestVersionException extends FormatException { /// The payload alone says nothing about which key it belongs to, so a signer /// holding several accounts cannot tell which to use, and one holding the wrong /// account cannot tell that it does. -class SigningRequest { +class SigningRequest extends AnySigningRequest { static const int version = 1; final String signer; @@ -33,19 +141,8 @@ class SigningRequest { /// Throws [FormatException] on anything that is not exactly this envelope. static SigningRequest decode(Uint8List bytes) { - final Object? json; - try { - json = jsonDecode(utf8.decode(bytes)); - } catch (e) { - throw FormatException('Not a signing request: $e'); - } - if (json is! Map) throw const FormatException('Signing request is not a JSON object'); - - const keys = {'v', 'signer', 'payload'}; - final actual = json.keys.map((k) => '$k').toSet(); - if (!actual.containsAll(keys) || actual.length != keys.length) { - throw FormatException('Signing request keys ${actual.toList()..sort()} are not ${keys.toList()..sort()}'); - } + final json = _decodeJsonObject(bytes); + _requireExactKeys(json, const {'v', 'signer', 'payload'}); if (json['v'] != version) throw UnsupportedSigningRequestVersionException(json['v']); final signer = json['signer']; @@ -56,25 +153,6 @@ class SigningRequest { throw FormatException('Signing request signer is not a valid address: $e'); } - final payload = json['payload']; - if (payload is! String || !payload.startsWith('0x')) { - throw const FormatException('Signing request payload is not 0x hex'); - } - final payloadHex = payload.substring(2); - if (payloadHex.length > maxPayloadBytes * 2) { - throw const FormatException('Signing request payload too large: more than $maxPayloadBytes bytes'); - } - final Uint8List bytesOut; - try { - bytesOut = Uint8List.fromList(hex.decode(payloadHex)); - } catch (e) { - throw FormatException('Signing request payload is not hex: $e'); - } - if (bytesOut.isEmpty) throw const FormatException('Signing request payload is empty'); - if (bytesOut.length > maxPayloadBytes) { - throw FormatException('Signing request payload too large: ${bytesOut.length} bytes'); - } - - return SigningRequest(signer: signer, payload: bytesOut); + return SigningRequest(signer: signer, payload: _decodePayload(json['payload'])); } } diff --git a/quantus_sdk/lib/src/rust/api/crypto.dart b/quantus_sdk/lib/src/rust/api/crypto.dart index 502c92c0f..4f7701bcf 100644 --- a/quantus_sdk/lib/src/rust/api/crypto.dart +++ b/quantus_sdk/lib/src/rust/api/crypto.dart @@ -4,7 +4,7 @@ // ignore_for_file: invalid_use_of_internal_member, unused_import, unnecessary_import import '../frb_generated.dart'; -import 'package:collection/collection.dart'; +import '../lib.dart'; import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated.dart'; // These functions are ignored because they are not marked as `pub`: `ml_dsa_87_from_entropy`, `new` @@ -121,18 +121,6 @@ class Keypair { scheme == other.scheme; } -class U8Array32 extends NonGrowableListView { - static const arraySize = 32; - - @internal - Uint8List get inner => _inner; - final Uint8List _inner; - - U8Array32(this._inner) : assert(_inner.length == arraySize), super(_inner); - - U8Array32.init() : this(Uint8List(arraySize)); -} - class WormholeResult { final String address; final Uint8List firstHash; diff --git a/quantus_sdk/lib/src/rust/api/near.dart b/quantus_sdk/lib/src/rust/api/near.dart new file mode 100644 index 000000000..73e3f17dd --- /dev/null +++ b/quantus_sdk/lib/src/rust/api/near.dart @@ -0,0 +1,212 @@ +// This file is automatically generated, so please do not edit it. +// @generated by `flutter_rust_bridge`@ 2.12.0. + +// ignore_for_file: invalid_use_of_internal_member, unused_import, unnecessary_import + +import '../frb_generated.dart'; +import '../lib.dart'; +import 'crypto.dart'; +import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated.dart'; + +// These functions are ignored because they are not marked as `pub`: `bytes`, `check_account_id`, `decode_wire`, `require_ml_dsa_65`, `text` +// These types are ignored because they are neither used by any `pub` functions nor (for structs and enums) marked `#[frb(unignore)]`: `WireAccessKeyPermission`, `WireAccessKey`, `WireAction`, `WireFunctionCallPermission`, `WirePublicKey`, `WireTransaction` +// These function are ignored because they are on traits that is not defined in current crate (put an empty `#[frb]` on it to unignore): `assert_receiver_is_total_eq`, `assert_receiver_is_total_eq`, `assert_receiver_is_total_eq`, `assert_receiver_is_total_eq`, `assert_receiver_is_total_eq`, `assert_receiver_is_total_eq`, `assert_receiver_is_total_eq`, `assert_receiver_is_total_eq`, `assert_receiver_is_total_eq`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `deserialize_reader`, `deserialize_reader`, `deserialize_reader`, `deserialize_reader`, `deserialize_reader`, `deserialize_reader`, `deserialize_variant`, `deserialize_variant`, `deserialize_variant`, `eq`, `eq`, `eq`, `eq`, `eq`, `eq`, `eq`, `eq`, `eq`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `from`, `serialize`, `serialize`, `serialize`, `serialize`, `serialize`, `serialize` + +/// Decode a borsh `TransactionV0`. Refuses trailing bytes. +NearTransaction decodeNearTransaction({required List transaction}) => + RustLib.instance.api.crateApiNearDecodeNearTransaction(transaction: transaction); + +/// SHA-256 of the transaction bytes: the message NEAR signs and verifies. +Uint8List nearTransactionHash({required List transaction}) => + RustLib.instance.api.crateApiNearNearTransactionHash(transaction: transaction); + +/// Sign a borsh `TransactionV0` for NEAR. Refuses unless `keypair` is +/// ML-DSA-65 *and* is the key the transaction declares. Returns +/// `signature ‖ public_key` (3309 + 1952 bytes). +Uint8List signNearTransaction({required Keypair keypair, required List transaction, U8Array32? entropy}) => + RustLib.instance.api.crateApiNearSignNearTransaction(keypair: keypair, transaction: transaction, entropy: entropy); + +/// Whether `signature` is a valid pure ML-DSA-65 signature by `public_key` +/// over the transaction, as the NEAR runtime checks it. +bool verifyNearSignature({ + required List publicKey, + required List transaction, + required List signature, +}) => RustLib.instance.api.crateApiNearVerifyNearSignature( + publicKey: publicKey, + transaction: transaction, + signature: signature, +); + +/// The account's key in NEAR text form, `ml-dsa-65:`. This is the +/// `--signer-public-key` near-cli-rs takes and what an `AddKey` carries. +String nearPublicKeyText({required Keypair keypair}) => + RustLib.instance.api.crateApiNearNearPublicKeyText(keypair: keypair); + +/// The on-chain handle of the key, `ml-dsa-65-hash:`: what +/// `view_access_key_list` shows, SHA3-256 of a domain tag and the key. +String nearPublicKeyHandle({required Keypair keypair}) => + RustLib.instance.api.crateApiNearNearPublicKeyHandle(keypair: keypair); + +/// One action of a NEAR transaction, flattened for display. Which fields are +/// set depends on [`NearAction::kind`]; amounts are yoctoNEAR (24 decimals) +/// and keys are NEAR text form (`:`). +/// +/// A flat struct rather than an enum with payloads: that keeps the Dart side +/// a plain class instead of a generated sealed hierarchy. +class NearAction { + final NearActionKind kind; + + /// `FunctionCall`: the method; `DeleteAccount`: the beneficiary; + /// `AddKey` (function-call key): the contract the key may call. + final String? target; + + /// `FunctionCall`: raw call arguments (usually JSON). + final Uint8List? args; + + /// `FunctionCall`: prepaid gas. + final BigInt? gas; + + /// `Transfer`/`FunctionCall`: attached deposit; `Stake`: staked amount; + /// `AddKey` (function-call key): gas allowance, `None` = unlimited. + final BigInt? amount; + + /// `Stake`/`AddKey`/`DeleteKey`: the key concerned. + final String? publicKey; + + /// `AddKey`: whether the key gets full access. + final bool fullAccess; + + /// `AddKey` (function-call key): the methods it may call; empty = any. + final List methodNames; + + /// `DeployContract`: size of the code blob. + final int? codeLen; + + /// `DeployContract`: SHA-256 of the code, how NEAR identifies contract + /// code, so a signer can compare it with the artifact they meant to deploy. + final Uint8List? codeHash; + + const NearAction({ + required this.kind, + this.target, + this.args, + this.gas, + this.amount, + this.publicKey, + required this.fullAccess, + required this.methodNames, + this.codeLen, + this.codeHash, + }); + + static Future default_() => RustLib.instance.api.crateApiNearNearActionDefault(); + + @override + int get hashCode => + kind.hashCode ^ + target.hashCode ^ + args.hashCode ^ + gas.hashCode ^ + amount.hashCode ^ + publicKey.hashCode ^ + fullAccess.hashCode ^ + methodNames.hashCode ^ + codeLen.hashCode ^ + codeHash.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is NearAction && + runtimeType == other.runtimeType && + kind == other.kind && + target == other.target && + args == other.args && + gas == other.gas && + amount == other.amount && + publicKey == other.publicKey && + fullAccess == other.fullAccess && + methodNames == other.methodNames && + codeLen == other.codeLen && + codeHash == other.codeHash; +} + +/// The kind of a [`NearAction`]. Everything but `Transfer` and +/// `FunctionCall` changes who controls the account or what code it runs. +enum NearActionKind { + createAccount, + deployContract, + functionCall, + transfer, + stake, + addKey, + deleteKey, + deleteAccount; + + static Future default_() => RustLib.instance.api.crateApiNearNearActionKindDefault(); +} + +/// A decoded NEAR transaction, ready to display. +class NearTransaction { + final String signerId; + + /// The key that must sign, in NEAR text form. + final String publicKey; + + /// Raw bytes of that key; compare against a [`Keypair::public_key`]. + final Uint8List publicKeyBytes; + + /// Whether the signing key is ML-DSA-65 — the only scheme a Quantus + /// account can sign for. + final bool signsWithMlDsa65; + final BigInt nonce; + final String receiverId; + + /// Base58, as NEAR tooling prints it. + final String blockHash; + + /// SHA-256 of the transaction bytes: what gets signed, and the id the + /// NEAR RPC reports (as base58) once submitted. + final Uint8List hash; + final List actions; + + const NearTransaction({ + required this.signerId, + required this.publicKey, + required this.publicKeyBytes, + required this.signsWithMlDsa65, + required this.nonce, + required this.receiverId, + required this.blockHash, + required this.hash, + required this.actions, + }); + + @override + int get hashCode => + signerId.hashCode ^ + publicKey.hashCode ^ + publicKeyBytes.hashCode ^ + signsWithMlDsa65.hashCode ^ + nonce.hashCode ^ + receiverId.hashCode ^ + blockHash.hashCode ^ + hash.hashCode ^ + actions.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is NearTransaction && + runtimeType == other.runtimeType && + signerId == other.signerId && + publicKey == other.publicKey && + publicKeyBytes == other.publicKeyBytes && + signsWithMlDsa65 == other.signsWithMlDsa65 && + nonce == other.nonce && + receiverId == other.receiverId && + blockHash == other.blockHash && + hash == other.hash && + actions == other.actions; +} diff --git a/quantus_sdk/lib/src/rust/frb_generated.dart b/quantus_sdk/lib/src/rust/frb_generated.dart index 60c08e8dc..53eb01eb1 100644 --- a/quantus_sdk/lib/src/rust/frb_generated.dart +++ b/quantus_sdk/lib/src/rust/frb_generated.dart @@ -6,12 +6,14 @@ import 'api/airdrop.dart'; import 'api/crypto.dart'; import 'api/multisig.dart'; +import 'api/near.dart'; import 'api/ur.dart'; import 'api/wormhole.dart'; import 'dart:async'; import 'dart:convert'; import 'frb_generated.dart'; import 'frb_generated.io.dart' if (dart.library.js_interop) 'frb_generated.web.dart'; +import 'lib.dart'; import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated.dart'; /// Main entrypoint of the Rust API @@ -66,7 +68,7 @@ class RustLib extends BaseEntrypoint { String get codegenVersion => '2.12.0'; @override - int get rustContentHash => -1962506674; + int get rustContentHash => -91816623; static const kDefaultExternalLibraryLoaderConfig = ExternalLibraryLoaderConfig( stem: 'rust_lib_quantus_wallet', @@ -116,6 +118,8 @@ abstract class RustLibApi extends BaseApi { BigInt crateApiWormholeDecodeLeafTransferCount({required List leafData}); + NearTransaction crateApiNearDecodeNearTransaction({required List transaction}); + Uint8List crateApiUrDecodeUr({required List urParts}); WormholeResult crateApiCryptoDeriveWormhole({required String mnemonicStr, required String path}); @@ -157,6 +161,16 @@ abstract class RustLibApi extends BaseApi { int crateApiUrMaxUrParts(); + Future crateApiNearNearActionDefault(); + + Future crateApiNearNearActionKindDefault(); + + String crateApiNearNearPublicKeyHandle({required Keypair keypair}); + + String crateApiNearNearPublicKeyText({required Keypair keypair}); + + Uint8List crateApiNearNearTransactionHash({required List transaction}); + String crateApiMultisigPredictMultisigAddress({ required List signers, required int threshold, @@ -188,6 +202,12 @@ abstract class RustLibApi extends BaseApi { required int specVersion, }); + Uint8List crateApiNearSignNearTransaction({ + required Keypair keypair, + required List transaction, + U8Array32? entropy, + }); + int crateApiCryptoSignatureBytes({required DilithiumScheme scheme}); Uint8List crateApiCryptoSs58ToAccountId({required String s}); @@ -201,6 +221,12 @@ abstract class RustLibApi extends BaseApi { required int specVersion, }); + bool crateApiNearVerifyNearSignature({ + required List publicKey, + required List transaction, + required List signature, + }); + int crateApiWormholeWormholeComputeOutputAmount({required int inputAmount, required int feeBps}); String crateApiWormholeZkCircuitsVersion(); @@ -505,6 +531,26 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { TaskConstMeta get kCrateApiWormholeDecodeLeafTransferCountConstMeta => const TaskConstMeta(debugName: 'decode_leaf_transfer_count', argNames: ['leafData']); + @override + NearTransaction crateApiNearDecodeNearTransaction({required List transaction}) { + return handler.executeSync( + SyncTask( + callFfi: () { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_list_prim_u_8_loose(transaction, serializer); + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 14)!; + }, + codec: SseCodec(decodeSuccessData: sse_decode_near_transaction, decodeErrorData: sse_decode_String), + constMeta: kCrateApiNearDecodeNearTransactionConstMeta, + argValues: [transaction], + apiImpl: this, + ), + ); + } + + TaskConstMeta get kCrateApiNearDecodeNearTransactionConstMeta => + const TaskConstMeta(debugName: 'decode_near_transaction', argNames: ['transaction']); + @override Uint8List crateApiUrDecodeUr({required List urParts}) { return handler.executeSync( @@ -512,7 +558,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { callFfi: () { final serializer = SseSerializer(generalizedFrbRustBinding); sse_encode_list_String(urParts, serializer); - return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 14)!; + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 15)!; }, codec: SseCodec(decodeSuccessData: sse_decode_list_prim_u_8_strict, decodeErrorData: sse_decode_String), constMeta: kCrateApiUrDecodeUrConstMeta, @@ -532,7 +578,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { final serializer = SseSerializer(generalizedFrbRustBinding); sse_encode_String(mnemonicStr, serializer); sse_encode_String(path, serializer); - return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 15)!; + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 16)!; }, codec: SseCodec( decodeSuccessData: sse_decode_wormhole_result, @@ -557,7 +603,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { final serializer = SseSerializer(generalizedFrbRustBinding); sse_encode_list_prim_u_8_loose(data, serializer); sse_encode_opt_box_autoadd_u_32(maxFragmentLength, serializer); - return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 16)!; + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 17)!; }, codec: SseCodec(decodeSuccessData: sse_decode_list_String, decodeErrorData: sse_decode_String), constMeta: kCrateApiUrEncodeUrConstMeta, @@ -577,7 +623,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { callFfi: (port_) { final serializer = SseSerializer(generalizedFrbRustBinding); sse_encode_String(binsDir, serializer); - pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 17, port: port_); + pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 18, port: port_); }, codec: SseCodec(decodeSuccessData: sse_decode_String, decodeErrorData: sse_decode_String), constMeta: kCrateApiWormholeEnsureCircuitBinariesConstMeta, @@ -605,7 +651,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { sse_encode_opt_list_prim_u_8_strict(dilithiumPublicKey, serializer); sse_encode_opt_String(mnemonic, serializer); sse_encode_list_list_prim_u_8_strict(extraWormholeSecrets, serializer); - pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 18, port: port_); + pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 19, port: port_); }, codec: SseCodec(decodeSuccessData: sse_decode_list_airdrop_match, decodeErrorData: sse_decode_String), constMeta: kCrateApiAirdropFindAirdropMatchesConstMeta, @@ -627,7 +673,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { callFfi: () { final serializer = SseSerializer(generalizedFrbRustBinding); sse_encode_String(firstHashHex, serializer); - return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 19)!; + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 20)!; }, codec: SseCodec(decodeSuccessData: sse_decode_String, decodeErrorData: sse_decode_String), constMeta: kCrateApiCryptoFirstHashToAddressConstMeta, @@ -653,7 +699,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { sse_encode_String(mnemonicStr, serializer); sse_encode_String(path, serializer); sse_encode_dilithium_scheme(scheme, serializer); - return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 20)!; + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 21)!; }, codec: SseCodec( decodeSuccessData: sse_decode_keypair, @@ -677,7 +723,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { callFfi: () { final serializer = SseSerializer(generalizedFrbRustBinding); sse_encode_String(mnemonicStr, serializer); - return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 21)!; + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 22)!; }, codec: SseCodec( decodeSuccessData: sse_decode_keypair, @@ -701,7 +747,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { callFfi: () { final serializer = SseSerializer(generalizedFrbRustBinding); sse_encode_list_prim_u_8_loose(seed, serializer); - return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 22)!; + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 23)!; }, codec: SseCodec(decodeSuccessData: sse_decode_keypair, decodeErrorData: null), constMeta: kCrateApiCryptoGenerateKeypairFromSeedConstMeta, @@ -727,7 +773,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { sse_encode_box_autoadd_proof_input(input, serializer); sse_encode_String(proverBinPath, serializer); sse_encode_String(commonBinPath, serializer); - pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 23, port: port_); + pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 24, port: port_); }, codec: SseCodec(decodeSuccessData: sse_decode_proof_output, decodeErrorData: sse_decode_String), constMeta: kCrateApiWormholeGenerateProofConstMeta, @@ -746,7 +792,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { NormalTask( callFfi: (port_) { final serializer = SseSerializer(generalizedFrbRustBinding); - pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 24, port: port_); + pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 25, port: port_); }, codec: SseCodec(decodeSuccessData: sse_decode_unit, decodeErrorData: null), constMeta: kCrateApiCryptoInitAppConstMeta, @@ -765,7 +811,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { callFfi: () { final serializer = SseSerializer(generalizedFrbRustBinding); sse_encode_list_String(urParts, serializer); - return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 25)!; + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 26)!; }, codec: SseCodec(decodeSuccessData: sse_decode_bool, decodeErrorData: null), constMeta: kCrateApiUrIsCompleteUrConstMeta, @@ -784,7 +830,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { SyncTask( callFfi: () { final serializer = SseSerializer(generalizedFrbRustBinding); - return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 26)!; + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 27)!; }, codec: SseCodec(decodeSuccessData: sse_decode_u_32, decodeErrorData: null), constMeta: kCrateApiUrMaxUrPartCharsConstMeta, @@ -803,7 +849,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { SyncTask( callFfi: () { final serializer = SseSerializer(generalizedFrbRustBinding); - return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 27)!; + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 28)!; }, codec: SseCodec(decodeSuccessData: sse_decode_u_32, decodeErrorData: null), constMeta: kCrateApiUrMaxUrPartsConstMeta, @@ -815,6 +861,104 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { TaskConstMeta get kCrateApiUrMaxUrPartsConstMeta => const TaskConstMeta(debugName: 'max_ur_parts', argNames: []); + @override + Future crateApiNearNearActionDefault() { + return handler.executeNormal( + NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 29, port: port_); + }, + codec: SseCodec(decodeSuccessData: sse_decode_near_action, decodeErrorData: null), + constMeta: kCrateApiNearNearActionDefaultConstMeta, + argValues: [], + apiImpl: this, + ), + ); + } + + TaskConstMeta get kCrateApiNearNearActionDefaultConstMeta => + const TaskConstMeta(debugName: 'near_action_default', argNames: []); + + @override + Future crateApiNearNearActionKindDefault() { + return handler.executeNormal( + NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 30, port: port_); + }, + codec: SseCodec(decodeSuccessData: sse_decode_near_action_kind, decodeErrorData: null), + constMeta: kCrateApiNearNearActionKindDefaultConstMeta, + argValues: [], + apiImpl: this, + ), + ); + } + + TaskConstMeta get kCrateApiNearNearActionKindDefaultConstMeta => + const TaskConstMeta(debugName: 'near_action_kind_default', argNames: []); + + @override + String crateApiNearNearPublicKeyHandle({required Keypair keypair}) { + return handler.executeSync( + SyncTask( + callFfi: () { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_box_autoadd_keypair(keypair, serializer); + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 31)!; + }, + codec: SseCodec(decodeSuccessData: sse_decode_String, decodeErrorData: sse_decode_String), + constMeta: kCrateApiNearNearPublicKeyHandleConstMeta, + argValues: [keypair], + apiImpl: this, + ), + ); + } + + TaskConstMeta get kCrateApiNearNearPublicKeyHandleConstMeta => + const TaskConstMeta(debugName: 'near_public_key_handle', argNames: ['keypair']); + + @override + String crateApiNearNearPublicKeyText({required Keypair keypair}) { + return handler.executeSync( + SyncTask( + callFfi: () { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_box_autoadd_keypair(keypair, serializer); + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 32)!; + }, + codec: SseCodec(decodeSuccessData: sse_decode_String, decodeErrorData: sse_decode_String), + constMeta: kCrateApiNearNearPublicKeyTextConstMeta, + argValues: [keypair], + apiImpl: this, + ), + ); + } + + TaskConstMeta get kCrateApiNearNearPublicKeyTextConstMeta => + const TaskConstMeta(debugName: 'near_public_key_text', argNames: ['keypair']); + + @override + Uint8List crateApiNearNearTransactionHash({required List transaction}) { + return handler.executeSync( + SyncTask( + callFfi: () { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_list_prim_u_8_loose(transaction, serializer); + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 33)!; + }, + codec: SseCodec(decodeSuccessData: sse_decode_list_prim_u_8_strict, decodeErrorData: null), + constMeta: kCrateApiNearNearTransactionHashConstMeta, + argValues: [transaction], + apiImpl: this, + ), + ); + } + + TaskConstMeta get kCrateApiNearNearTransactionHashConstMeta => + const TaskConstMeta(debugName: 'near_transaction_hash', argNames: ['transaction']); + @override String crateApiMultisigPredictMultisigAddress({ required List signers, @@ -828,7 +972,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { sse_encode_list_String(signers, serializer); sse_encode_u_32(threshold, serializer); sse_encode_u_64(nonce, serializer); - return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 28)!; + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 34)!; }, codec: SseCodec(decodeSuccessData: sse_decode_String, decodeErrorData: sse_decode_String), constMeta: kCrateApiMultisigPredictMultisigAddressConstMeta, @@ -852,7 +996,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { final serializer = SseSerializer(generalizedFrbRustBinding); sse_encode_list_prim_u_8_loose(wormholeSecret, serializer); sse_encode_String(claimAccount, serializer); - pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 29, port: port_); + pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 35, port: port_); }, codec: SseCodec(decodeSuccessData: sse_decode_wormhole_claim_body, decodeErrorData: sse_decode_String), constMeta: kCrateApiAirdropProveAirdropWormholeConstMeta, @@ -872,7 +1016,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { callFfi: () { final serializer = SseSerializer(generalizedFrbRustBinding); sse_encode_dilithium_scheme(scheme, serializer); - return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 30)!; + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 36)!; }, codec: SseCodec(decodeSuccessData: sse_decode_u_32, decodeErrorData: null), constMeta: kCrateApiCryptoPublicKeyBytesConstMeta, @@ -892,7 +1036,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { callFfi: () { final serializer = SseSerializer(generalizedFrbRustBinding); sse_encode_dilithium_scheme(scheme, serializer); - return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 31)!; + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 37)!; }, codec: SseCodec(decodeSuccessData: sse_decode_u_32, decodeErrorData: null), constMeta: kCrateApiCryptoSecretKeyBytesConstMeta, @@ -912,7 +1056,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { callFfi: () { final serializer = SseSerializer(generalizedFrbRustBinding); sse_encode_u_16(prefix, serializer); - return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 32)!; + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 38)!; }, codec: SseCodec(decodeSuccessData: sse_decode_unit, decodeErrorData: null), constMeta: kCrateApiCryptoSetDefaultSs58PrefixConstMeta, @@ -940,7 +1084,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { sse_encode_list_prim_u_8_loose(message, serializer); sse_encode_opt_u_8_array_32(entropy, serializer); sse_encode_u_32(specVersion, serializer); - return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 33)!; + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 39)!; }, codec: SseCodec(decodeSuccessData: sse_decode_list_prim_u_8_strict, decodeErrorData: null), constMeta: kCrateApiCryptoSignMessageConstMeta, @@ -968,7 +1112,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { sse_encode_list_prim_u_8_loose(message, serializer); sse_encode_opt_u_8_array_32(entropy, serializer); sse_encode_u_32(specVersion, serializer); - return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 34)!; + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 40)!; }, codec: SseCodec(decodeSuccessData: sse_decode_list_prim_u_8_strict, decodeErrorData: null), constMeta: kCrateApiCryptoSignMessageWithPubkeyConstMeta, @@ -983,6 +1127,32 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { argNames: ['keypair', 'message', 'entropy', 'specVersion'], ); + @override + Uint8List crateApiNearSignNearTransaction({ + required Keypair keypair, + required List transaction, + U8Array32? entropy, + }) { + return handler.executeSync( + SyncTask( + callFfi: () { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_box_autoadd_keypair(keypair, serializer); + sse_encode_list_prim_u_8_loose(transaction, serializer); + sse_encode_opt_u_8_array_32(entropy, serializer); + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 41)!; + }, + codec: SseCodec(decodeSuccessData: sse_decode_list_prim_u_8_strict, decodeErrorData: sse_decode_String), + constMeta: kCrateApiNearSignNearTransactionConstMeta, + argValues: [keypair, transaction, entropy], + apiImpl: this, + ), + ); + } + + TaskConstMeta get kCrateApiNearSignNearTransactionConstMeta => + const TaskConstMeta(debugName: 'sign_near_transaction', argNames: ['keypair', 'transaction', 'entropy']); + @override int crateApiCryptoSignatureBytes({required DilithiumScheme scheme}) { return handler.executeSync( @@ -990,7 +1160,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { callFfi: () { final serializer = SseSerializer(generalizedFrbRustBinding); sse_encode_dilithium_scheme(scheme, serializer); - return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 35)!; + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 42)!; }, codec: SseCodec(decodeSuccessData: sse_decode_u_32, decodeErrorData: null), constMeta: kCrateApiCryptoSignatureBytesConstMeta, @@ -1010,7 +1180,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { callFfi: () { final serializer = SseSerializer(generalizedFrbRustBinding); sse_encode_String(s, serializer); - return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 36)!; + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 43)!; }, codec: SseCodec(decodeSuccessData: sse_decode_list_prim_u_8_strict, decodeErrorData: sse_decode_String), constMeta: kCrateApiCryptoSs58ToAccountIdConstMeta, @@ -1030,7 +1200,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { callFfi: () { final serializer = SseSerializer(generalizedFrbRustBinding); sse_encode_box_autoadd_keypair(obj, serializer); - return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 37)!; + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 44)!; }, codec: SseCodec(decodeSuccessData: sse_decode_String, decodeErrorData: null), constMeta: kCrateApiCryptoToAccountIdConstMeta, @@ -1058,7 +1228,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { sse_encode_list_prim_u_8_loose(message, serializer); sse_encode_list_prim_u_8_loose(signature, serializer); sse_encode_u_32(specVersion, serializer); - return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 38)!; + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 45)!; }, codec: SseCodec(decodeSuccessData: sse_decode_bool, decodeErrorData: null), constMeta: kCrateApiCryptoVerifyMessageConstMeta, @@ -1071,6 +1241,32 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { TaskConstMeta get kCrateApiCryptoVerifyMessageConstMeta => const TaskConstMeta(debugName: 'verify_message', argNames: ['keypair', 'message', 'signature', 'specVersion']); + @override + bool crateApiNearVerifyNearSignature({ + required List publicKey, + required List transaction, + required List signature, + }) { + return handler.executeSync( + SyncTask( + callFfi: () { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_list_prim_u_8_loose(publicKey, serializer); + sse_encode_list_prim_u_8_loose(transaction, serializer); + sse_encode_list_prim_u_8_loose(signature, serializer); + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 46)!; + }, + codec: SseCodec(decodeSuccessData: sse_decode_bool, decodeErrorData: null), + constMeta: kCrateApiNearVerifyNearSignatureConstMeta, + argValues: [publicKey, transaction, signature], + apiImpl: this, + ), + ); + } + + TaskConstMeta get kCrateApiNearVerifyNearSignatureConstMeta => + const TaskConstMeta(debugName: 'verify_near_signature', argNames: ['publicKey', 'transaction', 'signature']); + @override int crateApiWormholeWormholeComputeOutputAmount({required int inputAmount, required int feeBps}) { return handler.executeSync( @@ -1079,7 +1275,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { final serializer = SseSerializer(generalizedFrbRustBinding); sse_encode_u_32(inputAmount, serializer); sse_encode_u_32(feeBps, serializer); - return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 39)!; + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 47)!; }, codec: SseCodec(decodeSuccessData: sse_decode_u_32, decodeErrorData: sse_decode_String), constMeta: kCrateApiWormholeWormholeComputeOutputAmountConstMeta, @@ -1098,7 +1294,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { SyncTask( callFfi: () { final serializer = SseSerializer(generalizedFrbRustBinding); - return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 40)!; + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 48)!; }, codec: SseCodec(decodeSuccessData: sse_decode_String, decodeErrorData: null), constMeta: kCrateApiWormholeZkCircuitsVersionConstMeta, @@ -1137,6 +1333,12 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { return raw as String; } + @protected + BigInt dco_decode_U128(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return BigInt.parse(raw); + } + @protected AirdropMatch dco_decode_airdrop_match(dynamic raw) { // Codec=Dco (DartCObject based), see doc to use other codecs @@ -1177,6 +1379,12 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { return raw as int; } + @protected + BigInt dco_decode_box_autoadd_u_64(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return dco_decode_u_64(raw); + } + @protected DilithiumClaimBody dco_decode_dilithium_claim_body(dynamic raw) { // Codec=Dco (DartCObject based), see doc to use other codecs @@ -1240,6 +1448,12 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { return (raw as List).map(dco_decode_list_prim_u_8_strict).toList(); } + @protected + List dco_decode_list_near_action(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return (raw as List).map(dco_decode_near_action).toList(); + } + @protected List dco_decode_list_prim_u_8_loose(dynamic raw) { // Codec=Dco (DartCObject based), see doc to use other codecs @@ -1263,18 +1477,73 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { ); } + @protected + NearAction dco_decode_near_action(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + final arr = raw as List; + if (arr.length != 10) throw Exception('unexpected arr length: expect 10 but see ${arr.length}'); + return NearAction( + kind: dco_decode_near_action_kind(arr[0]), + target: dco_decode_opt_String(arr[1]), + args: dco_decode_opt_list_prim_u_8_strict(arr[2]), + gas: dco_decode_opt_box_autoadd_u_64(arr[3]), + amount: dco_decode_opt_U128(arr[4]), + publicKey: dco_decode_opt_String(arr[5]), + fullAccess: dco_decode_bool(arr[6]), + methodNames: dco_decode_list_String(arr[7]), + codeLen: dco_decode_opt_box_autoadd_u_32(arr[8]), + codeHash: dco_decode_opt_list_prim_u_8_strict(arr[9]), + ); + } + + @protected + NearActionKind dco_decode_near_action_kind(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return NearActionKind.values[raw as int]; + } + + @protected + NearTransaction dco_decode_near_transaction(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + final arr = raw as List; + if (arr.length != 9) throw Exception('unexpected arr length: expect 9 but see ${arr.length}'); + return NearTransaction( + signerId: dco_decode_String(arr[0]), + publicKey: dco_decode_String(arr[1]), + publicKeyBytes: dco_decode_list_prim_u_8_strict(arr[2]), + signsWithMlDsa65: dco_decode_bool(arr[3]), + nonce: dco_decode_u_64(arr[4]), + receiverId: dco_decode_String(arr[5]), + blockHash: dco_decode_String(arr[6]), + hash: dco_decode_list_prim_u_8_strict(arr[7]), + actions: dco_decode_list_near_action(arr[8]), + ); + } + @protected String? dco_decode_opt_String(dynamic raw) { // Codec=Dco (DartCObject based), see doc to use other codecs return raw == null ? null : dco_decode_String(raw); } + @protected + BigInt? dco_decode_opt_U128(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return raw == null ? null : dco_decode_U128(raw); + } + @protected int? dco_decode_opt_box_autoadd_u_32(dynamic raw) { // Codec=Dco (DartCObject based), see doc to use other codecs return raw == null ? null : dco_decode_box_autoadd_u_32(raw); } + @protected + BigInt? dco_decode_opt_box_autoadd_u_64(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return raw == null ? null : dco_decode_box_autoadd_u_64(raw); + } + @protected Uint8List? dco_decode_opt_list_prim_u_8_strict(dynamic raw) { // Codec=Dco (DartCObject based), see doc to use other codecs @@ -1411,6 +1680,13 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { return utf8.decoder.convert(inner); } + @protected + BigInt sse_decode_U128(SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + var inner = sse_decode_String(deserializer); + return BigInt.parse(inner); + } + @protected AirdropMatch sse_decode_airdrop_match(SseDeserializer deserializer) { // Codec=Sse (Serialization based), see doc to use other codecs @@ -1456,6 +1732,12 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { return (sse_decode_u_32(deserializer)); } + @protected + BigInt sse_decode_box_autoadd_u_64(SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + return (sse_decode_u_64(deserializer)); + } + @protected DilithiumClaimBody sse_decode_dilithium_claim_body(SseDeserializer deserializer) { // Codec=Sse (Serialization based), see doc to use other codecs @@ -1539,6 +1821,18 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { return ans_; } + @protected + List sse_decode_list_near_action(SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + + var len_ = sse_decode_i_32(deserializer); + var ans_ = []; + for (var idx_ = 0; idx_ < len_; ++idx_) { + ans_.add(sse_decode_near_action(deserializer)); + } + return ans_; + } + @protected List sse_decode_list_prim_u_8_loose(SseDeserializer deserializer) { // Codec=Sse (Serialization based), see doc to use other codecs @@ -1561,6 +1855,65 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { return MerkleProcessed(sortedSiblingsFlat: var_sortedSiblingsFlat, positions: var_positions); } + @protected + NearAction sse_decode_near_action(SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + var var_kind = sse_decode_near_action_kind(deserializer); + var var_target = sse_decode_opt_String(deserializer); + var var_args = sse_decode_opt_list_prim_u_8_strict(deserializer); + var var_gas = sse_decode_opt_box_autoadd_u_64(deserializer); + var var_amount = sse_decode_opt_U128(deserializer); + var var_publicKey = sse_decode_opt_String(deserializer); + var var_fullAccess = sse_decode_bool(deserializer); + var var_methodNames = sse_decode_list_String(deserializer); + var var_codeLen = sse_decode_opt_box_autoadd_u_32(deserializer); + var var_codeHash = sse_decode_opt_list_prim_u_8_strict(deserializer); + return NearAction( + kind: var_kind, + target: var_target, + args: var_args, + gas: var_gas, + amount: var_amount, + publicKey: var_publicKey, + fullAccess: var_fullAccess, + methodNames: var_methodNames, + codeLen: var_codeLen, + codeHash: var_codeHash, + ); + } + + @protected + NearActionKind sse_decode_near_action_kind(SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + var inner = sse_decode_i_32(deserializer); + return NearActionKind.values[inner]; + } + + @protected + NearTransaction sse_decode_near_transaction(SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + var var_signerId = sse_decode_String(deserializer); + var var_publicKey = sse_decode_String(deserializer); + var var_publicKeyBytes = sse_decode_list_prim_u_8_strict(deserializer); + var var_signsWithMlDsa65 = sse_decode_bool(deserializer); + var var_nonce = sse_decode_u_64(deserializer); + var var_receiverId = sse_decode_String(deserializer); + var var_blockHash = sse_decode_String(deserializer); + var var_hash = sse_decode_list_prim_u_8_strict(deserializer); + var var_actions = sse_decode_list_near_action(deserializer); + return NearTransaction( + signerId: var_signerId, + publicKey: var_publicKey, + publicKeyBytes: var_publicKeyBytes, + signsWithMlDsa65: var_signsWithMlDsa65, + nonce: var_nonce, + receiverId: var_receiverId, + blockHash: var_blockHash, + hash: var_hash, + actions: var_actions, + ); + } + @protected String? sse_decode_opt_String(SseDeserializer deserializer) { // Codec=Sse (Serialization based), see doc to use other codecs @@ -1572,6 +1925,17 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { } } + @protected + BigInt? sse_decode_opt_U128(SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + + if (sse_decode_bool(deserializer)) { + return (sse_decode_U128(deserializer)); + } else { + return null; + } + } + @protected int? sse_decode_opt_box_autoadd_u_32(SseDeserializer deserializer) { // Codec=Sse (Serialization based), see doc to use other codecs @@ -1583,6 +1947,17 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { } } + @protected + BigInt? sse_decode_opt_box_autoadd_u_64(SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + + if (sse_decode_bool(deserializer)) { + return (sse_decode_box_autoadd_u_64(deserializer)); + } else { + return null; + } + } + @protected Uint8List? sse_decode_opt_list_prim_u_8_strict(SseDeserializer deserializer) { // Codec=Sse (Serialization based), see doc to use other codecs @@ -1741,6 +2116,12 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { sse_encode_list_prim_u_8_strict(utf8.encoder.convert(self), serializer); } + @protected + void sse_encode_U128(BigInt self, SseSerializer serializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + sse_encode_String(self.toString(), serializer); + } + @protected void sse_encode_airdrop_match(AirdropMatch self, SseSerializer serializer) { // Codec=Sse (Serialization based), see doc to use other codecs @@ -1777,6 +2158,12 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { sse_encode_u_32(self, serializer); } + @protected + void sse_encode_box_autoadd_u_64(BigInt self, SseSerializer serializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + sse_encode_u_64(self, serializer); + } + @protected void sse_encode_dilithium_claim_body(DilithiumClaimBody self, SseSerializer serializer) { // Codec=Sse (Serialization based), see doc to use other codecs @@ -1841,6 +2228,15 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { } } + @protected + void sse_encode_list_near_action(List self, SseSerializer serializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + sse_encode_i_32(self.length, serializer); + for (final item in self) { + sse_encode_near_action(item, serializer); + } + } + @protected void sse_encode_list_prim_u_8_loose(List self, SseSerializer serializer) { // Codec=Sse (Serialization based), see doc to use other codecs @@ -1862,6 +2258,41 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { sse_encode_list_prim_u_8_strict(self.positions, serializer); } + @protected + void sse_encode_near_action(NearAction self, SseSerializer serializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + sse_encode_near_action_kind(self.kind, serializer); + sse_encode_opt_String(self.target, serializer); + sse_encode_opt_list_prim_u_8_strict(self.args, serializer); + sse_encode_opt_box_autoadd_u_64(self.gas, serializer); + sse_encode_opt_U128(self.amount, serializer); + sse_encode_opt_String(self.publicKey, serializer); + sse_encode_bool(self.fullAccess, serializer); + sse_encode_list_String(self.methodNames, serializer); + sse_encode_opt_box_autoadd_u_32(self.codeLen, serializer); + sse_encode_opt_list_prim_u_8_strict(self.codeHash, serializer); + } + + @protected + void sse_encode_near_action_kind(NearActionKind self, SseSerializer serializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + sse_encode_i_32(self.index, serializer); + } + + @protected + void sse_encode_near_transaction(NearTransaction self, SseSerializer serializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + sse_encode_String(self.signerId, serializer); + sse_encode_String(self.publicKey, serializer); + sse_encode_list_prim_u_8_strict(self.publicKeyBytes, serializer); + sse_encode_bool(self.signsWithMlDsa65, serializer); + sse_encode_u_64(self.nonce, serializer); + sse_encode_String(self.receiverId, serializer); + sse_encode_String(self.blockHash, serializer); + sse_encode_list_prim_u_8_strict(self.hash, serializer); + sse_encode_list_near_action(self.actions, serializer); + } + @protected void sse_encode_opt_String(String? self, SseSerializer serializer) { // Codec=Sse (Serialization based), see doc to use other codecs @@ -1872,6 +2303,16 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { } } + @protected + void sse_encode_opt_U128(BigInt? self, SseSerializer serializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + + sse_encode_bool(self != null, serializer); + if (self != null) { + sse_encode_U128(self, serializer); + } + } + @protected void sse_encode_opt_box_autoadd_u_32(int? self, SseSerializer serializer) { // Codec=Sse (Serialization based), see doc to use other codecs @@ -1882,6 +2323,16 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { } } + @protected + void sse_encode_opt_box_autoadd_u_64(BigInt? self, SseSerializer serializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + + sse_encode_bool(self != null, serializer); + if (self != null) { + sse_encode_box_autoadd_u_64(self, serializer); + } + } + @protected void sse_encode_opt_list_prim_u_8_strict(Uint8List? self, SseSerializer serializer) { // Codec=Sse (Serialization based), see doc to use other codecs diff --git a/quantus_sdk/lib/src/rust/frb_generated.io.dart b/quantus_sdk/lib/src/rust/frb_generated.io.dart index b115cd87f..6c519da61 100644 --- a/quantus_sdk/lib/src/rust/frb_generated.io.dart +++ b/quantus_sdk/lib/src/rust/frb_generated.io.dart @@ -6,12 +6,14 @@ import 'api/airdrop.dart'; import 'api/crypto.dart'; import 'api/multisig.dart'; +import 'api/near.dart'; import 'api/ur.dart'; import 'api/wormhole.dart'; import 'dart:async'; import 'dart:convert'; import 'dart:ffi' as ffi; import 'frb_generated.dart'; +import 'lib.dart'; import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated_io.dart'; abstract class RustLibApiImplPlatform extends BaseApiImpl { @@ -36,6 +38,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected String dco_decode_String(dynamic raw); + @protected + BigInt dco_decode_U128(dynamic raw); + @protected AirdropMatch dco_decode_airdrop_match(dynamic raw); @@ -51,6 +56,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected int dco_decode_box_autoadd_u_32(dynamic raw); + @protected + BigInt dco_decode_box_autoadd_u_64(dynamic raw); + @protected DilithiumClaimBody dco_decode_dilithium_claim_body(dynamic raw); @@ -75,6 +83,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected List dco_decode_list_list_prim_u_8_strict(dynamic raw); + @protected + List dco_decode_list_near_action(dynamic raw); + @protected List dco_decode_list_prim_u_8_loose(dynamic raw); @@ -84,12 +95,27 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected MerkleProcessed dco_decode_merkle_processed(dynamic raw); + @protected + NearAction dco_decode_near_action(dynamic raw); + + @protected + NearActionKind dco_decode_near_action_kind(dynamic raw); + + @protected + NearTransaction dco_decode_near_transaction(dynamic raw); + @protected String? dco_decode_opt_String(dynamic raw); + @protected + BigInt? dco_decode_opt_U128(dynamic raw); + @protected int? dco_decode_opt_box_autoadd_u_32(dynamic raw); + @protected + BigInt? dco_decode_opt_box_autoadd_u_64(dynamic raw); + @protected Uint8List? dco_decode_opt_list_prim_u_8_strict(dynamic raw); @@ -142,6 +168,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected String sse_decode_String(SseDeserializer deserializer); + @protected + BigInt sse_decode_U128(SseDeserializer deserializer); + @protected AirdropMatch sse_decode_airdrop_match(SseDeserializer deserializer); @@ -157,6 +186,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected int sse_decode_box_autoadd_u_32(SseDeserializer deserializer); + @protected + BigInt sse_decode_box_autoadd_u_64(SseDeserializer deserializer); + @protected DilithiumClaimBody sse_decode_dilithium_claim_body(SseDeserializer deserializer); @@ -181,6 +213,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected List sse_decode_list_list_prim_u_8_strict(SseDeserializer deserializer); + @protected + List sse_decode_list_near_action(SseDeserializer deserializer); + @protected List sse_decode_list_prim_u_8_loose(SseDeserializer deserializer); @@ -190,12 +225,27 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected MerkleProcessed sse_decode_merkle_processed(SseDeserializer deserializer); + @protected + NearAction sse_decode_near_action(SseDeserializer deserializer); + + @protected + NearActionKind sse_decode_near_action_kind(SseDeserializer deserializer); + + @protected + NearTransaction sse_decode_near_transaction(SseDeserializer deserializer); + @protected String? sse_decode_opt_String(SseDeserializer deserializer); + @protected + BigInt? sse_decode_opt_U128(SseDeserializer deserializer); + @protected int? sse_decode_opt_box_autoadd_u_32(SseDeserializer deserializer); + @protected + BigInt? sse_decode_opt_box_autoadd_u_64(SseDeserializer deserializer); + @protected Uint8List? sse_decode_opt_list_prim_u_8_strict(SseDeserializer deserializer); @@ -250,6 +300,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected void sse_encode_String(String self, SseSerializer serializer); + @protected + void sse_encode_U128(BigInt self, SseSerializer serializer); + @protected void sse_encode_airdrop_match(AirdropMatch self, SseSerializer serializer); @@ -265,6 +318,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected void sse_encode_box_autoadd_u_32(int self, SseSerializer serializer); + @protected + void sse_encode_box_autoadd_u_64(BigInt self, SseSerializer serializer); + @protected void sse_encode_dilithium_claim_body(DilithiumClaimBody self, SseSerializer serializer); @@ -289,6 +345,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected void sse_encode_list_list_prim_u_8_strict(List self, SseSerializer serializer); + @protected + void sse_encode_list_near_action(List self, SseSerializer serializer); + @protected void sse_encode_list_prim_u_8_loose(List self, SseSerializer serializer); @@ -298,12 +357,27 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected void sse_encode_merkle_processed(MerkleProcessed self, SseSerializer serializer); + @protected + void sse_encode_near_action(NearAction self, SseSerializer serializer); + + @protected + void sse_encode_near_action_kind(NearActionKind self, SseSerializer serializer); + + @protected + void sse_encode_near_transaction(NearTransaction self, SseSerializer serializer); + @protected void sse_encode_opt_String(String? self, SseSerializer serializer); + @protected + void sse_encode_opt_U128(BigInt? self, SseSerializer serializer); + @protected void sse_encode_opt_box_autoadd_u_32(int? self, SseSerializer serializer); + @protected + void sse_encode_opt_box_autoadd_u_64(BigInt? self, SseSerializer serializer); + @protected void sse_encode_opt_list_prim_u_8_strict(Uint8List? self, SseSerializer serializer); diff --git a/quantus_sdk/lib/src/rust/frb_generated.web.dart b/quantus_sdk/lib/src/rust/frb_generated.web.dart index f8299322b..64b26bfb5 100644 --- a/quantus_sdk/lib/src/rust/frb_generated.web.dart +++ b/quantus_sdk/lib/src/rust/frb_generated.web.dart @@ -9,11 +9,13 @@ import 'api/airdrop.dart'; import 'api/crypto.dart'; import 'api/multisig.dart'; +import 'api/near.dart'; import 'api/ur.dart'; import 'api/wormhole.dart'; import 'dart:async'; import 'dart:convert'; import 'frb_generated.dart'; +import 'lib.dart'; import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated_web.dart'; abstract class RustLibApiImplPlatform extends BaseApiImpl { @@ -38,6 +40,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected String dco_decode_String(dynamic raw); + @protected + BigInt dco_decode_U128(dynamic raw); + @protected AirdropMatch dco_decode_airdrop_match(dynamic raw); @@ -53,6 +58,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected int dco_decode_box_autoadd_u_32(dynamic raw); + @protected + BigInt dco_decode_box_autoadd_u_64(dynamic raw); + @protected DilithiumClaimBody dco_decode_dilithium_claim_body(dynamic raw); @@ -77,6 +85,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected List dco_decode_list_list_prim_u_8_strict(dynamic raw); + @protected + List dco_decode_list_near_action(dynamic raw); + @protected List dco_decode_list_prim_u_8_loose(dynamic raw); @@ -86,12 +97,27 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected MerkleProcessed dco_decode_merkle_processed(dynamic raw); + @protected + NearAction dco_decode_near_action(dynamic raw); + + @protected + NearActionKind dco_decode_near_action_kind(dynamic raw); + + @protected + NearTransaction dco_decode_near_transaction(dynamic raw); + @protected String? dco_decode_opt_String(dynamic raw); + @protected + BigInt? dco_decode_opt_U128(dynamic raw); + @protected int? dco_decode_opt_box_autoadd_u_32(dynamic raw); + @protected + BigInt? dco_decode_opt_box_autoadd_u_64(dynamic raw); + @protected Uint8List? dco_decode_opt_list_prim_u_8_strict(dynamic raw); @@ -144,6 +170,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected String sse_decode_String(SseDeserializer deserializer); + @protected + BigInt sse_decode_U128(SseDeserializer deserializer); + @protected AirdropMatch sse_decode_airdrop_match(SseDeserializer deserializer); @@ -159,6 +188,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected int sse_decode_box_autoadd_u_32(SseDeserializer deserializer); + @protected + BigInt sse_decode_box_autoadd_u_64(SseDeserializer deserializer); + @protected DilithiumClaimBody sse_decode_dilithium_claim_body(SseDeserializer deserializer); @@ -183,6 +215,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected List sse_decode_list_list_prim_u_8_strict(SseDeserializer deserializer); + @protected + List sse_decode_list_near_action(SseDeserializer deserializer); + @protected List sse_decode_list_prim_u_8_loose(SseDeserializer deserializer); @@ -192,12 +227,27 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected MerkleProcessed sse_decode_merkle_processed(SseDeserializer deserializer); + @protected + NearAction sse_decode_near_action(SseDeserializer deserializer); + + @protected + NearActionKind sse_decode_near_action_kind(SseDeserializer deserializer); + + @protected + NearTransaction sse_decode_near_transaction(SseDeserializer deserializer); + @protected String? sse_decode_opt_String(SseDeserializer deserializer); + @protected + BigInt? sse_decode_opt_U128(SseDeserializer deserializer); + @protected int? sse_decode_opt_box_autoadd_u_32(SseDeserializer deserializer); + @protected + BigInt? sse_decode_opt_box_autoadd_u_64(SseDeserializer deserializer); + @protected Uint8List? sse_decode_opt_list_prim_u_8_strict(SseDeserializer deserializer); @@ -252,6 +302,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected void sse_encode_String(String self, SseSerializer serializer); + @protected + void sse_encode_U128(BigInt self, SseSerializer serializer); + @protected void sse_encode_airdrop_match(AirdropMatch self, SseSerializer serializer); @@ -267,6 +320,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected void sse_encode_box_autoadd_u_32(int self, SseSerializer serializer); + @protected + void sse_encode_box_autoadd_u_64(BigInt self, SseSerializer serializer); + @protected void sse_encode_dilithium_claim_body(DilithiumClaimBody self, SseSerializer serializer); @@ -291,6 +347,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected void sse_encode_list_list_prim_u_8_strict(List self, SseSerializer serializer); + @protected + void sse_encode_list_near_action(List self, SseSerializer serializer); + @protected void sse_encode_list_prim_u_8_loose(List self, SseSerializer serializer); @@ -300,12 +359,27 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected void sse_encode_merkle_processed(MerkleProcessed self, SseSerializer serializer); + @protected + void sse_encode_near_action(NearAction self, SseSerializer serializer); + + @protected + void sse_encode_near_action_kind(NearActionKind self, SseSerializer serializer); + + @protected + void sse_encode_near_transaction(NearTransaction self, SseSerializer serializer); + @protected void sse_encode_opt_String(String? self, SseSerializer serializer); + @protected + void sse_encode_opt_U128(BigInt? self, SseSerializer serializer); + @protected void sse_encode_opt_box_autoadd_u_32(int? self, SseSerializer serializer); + @protected + void sse_encode_opt_box_autoadd_u_64(BigInt? self, SseSerializer serializer); + @protected void sse_encode_opt_list_prim_u_8_strict(Uint8List? self, SseSerializer serializer); diff --git a/quantus_sdk/lib/src/rust/lib.dart b/quantus_sdk/lib/src/rust/lib.dart new file mode 100644 index 000000000..a0d0fa1d6 --- /dev/null +++ b/quantus_sdk/lib/src/rust/lib.dart @@ -0,0 +1,20 @@ +// This file is automatically generated, so please do not edit it. +// @generated by `flutter_rust_bridge`@ 2.12.0. + +// ignore_for_file: invalid_use_of_internal_member, unused_import, unnecessary_import + +import 'frb_generated.dart'; +import 'package:collection/collection.dart'; +import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated.dart'; + +class U8Array32 extends NonGrowableListView { + static const arraySize = 32; + + @internal + Uint8List get inner => _inner; + final Uint8List _inner; + + U8Array32(this._inner) : assert(_inner.length == arraySize), super(_inner); + + U8Array32.init() : this(Uint8List(arraySize)); +} diff --git a/quantus_sdk/rust/Cargo.lock b/quantus_sdk/rust/Cargo.lock index 5a1daca99..6e9e0e853 100644 --- a/quantus_sdk/rust/Cargo.lock +++ b/quantus_sdk/rust/Cargo.lock @@ -654,6 +654,30 @@ dependencies = [ "generic-array", ] +[[package]] +name = "borsh" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "553c5d846a6ba5150c65e3b1b8ec073bcf1abc20f9b7220de384a4443ea4e20a" +dependencies = [ + "borsh-derive", + "bytes", + "cfg_aliases", +] + +[[package]] +name = "borsh-derive" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12cdfe656708a01f89b451a7d36466e6fe6c414de0aa18fc54f864f6f9ca9f56" +dependencies = [ + "once_cell", + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 3.0.3", +] + [[package]] name = "bounded-collections" version = "0.3.2" @@ -729,6 +753,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + [[package]] name = "chacha20" version = "0.10.1" @@ -3358,6 +3388,8 @@ version = "0.1.0" dependencies = [ "anyhow", "blake3", + "borsh", + "bs58", "flutter_rust_bridge", "hex", "nam-tiny-hderive", @@ -3381,6 +3413,7 @@ dependencies = [ "quantus_ur", "rand_chacha 0.9.0", "sha2 0.10.9", + "sha3", "sp-core", "sp-runtime", "tempfile", diff --git a/quantus_sdk/rust/Cargo.toml b/quantus_sdk/rust/Cargo.toml index 882f2e64f..7ca05be8e 100644 --- a/quantus_sdk/rust/Cargo.toml +++ b/quantus_sdk/rust/Cargo.toml @@ -27,6 +27,13 @@ parity-scale-codec = { version = "3.7.5", default-features = false, features = [ sp-runtime = { version = "48.0.0", default-features = false, features = ["std"] } quantus_ur = { git = "https://github.com/Quantus-Network/quantus_ur.git", tag = "1.6.0" } +# NEAR transactions (borsh wire format, base58 key text, SHA-256 signing hash, +# SHA3-256 access-key handle). Aligned with quantus-cli's near module. +borsh = { version = "1.5", features = ["derive"] } +bs58 = "0.5" +sha2 = "0.10" +sha3 = "0.10" + # ZK proof generation (aligned with quantus-cli / chain) anyhow = "1.0" blake3 = "1.8" @@ -51,7 +58,6 @@ qp-zk-circuits-common = { version = "4.4.0", default-features = false, features qp-wormhole-circuit-builder = { version = "4.4.0" } [dev-dependencies] -sha2 = "0.10" tempfile = "3" # Circuit generation is CPU-intensive; without this it takes ~10min instead of ~30s. diff --git a/quantus_sdk/rust/src/api/mod.rs b/quantus_sdk/rust/src/api/mod.rs index 47a4a587e..355dd54e3 100644 --- a/quantus_sdk/rust/src/api/mod.rs +++ b/quantus_sdk/rust/src/api/mod.rs @@ -1,5 +1,6 @@ pub mod airdrop; pub mod crypto; pub mod multisig; +pub mod near; pub mod ur; pub mod wormhole; diff --git a/quantus_sdk/rust/src/api/near.rs b/quantus_sdk/rust/src/api/near.rs new file mode 100644 index 000000000..ca2297630 --- /dev/null +++ b/quantus_sdk/rust/src/api/near.rs @@ -0,0 +1,758 @@ +//! NEAR transactions for the cold wallet: decode for display, sign with an +//! ML-DSA-65 account. +//! +//! NEAR accepts FIPS 204 ML-DSA-65 access keys and signatures from protocol +//! version 85. A cold-wallet request (signing envelope v2, see +//! `signing_request.dart`) carries the borsh-encoded `TransactionV0` raw, so +//! the device can show every action before signing. The device signs +//! `SHA-256(borsh(tx))` as a *pure* ML-DSA-65 signature with an **empty** +//! context — NEAR verifies without one, so [`crate::signing_context`] must +//! not be applied here — and answers `signature[3309] ‖ public_key[1952]`, +//! the same shape as a Quantus extrinsic response. +//! +//! The wire layout mirrors nearcore (`core/primitives/src/transaction.rs`, +//! `core/crypto/src/signature.rs`) and quantus-cli's `near::protocol`. Enum +//! variants are borsh u8 tags in declaration order; every position must stay. + +use crate::api::crypto::{DilithiumScheme, Keypair}; +use borsh::{BorshDeserialize, BorshSerialize}; +use qp_rusty_crystals_dilithium::ml_dsa_65; +use qp_rusty_crystals_hdwallet::SensitiveBytes32; +use sha2::{Digest, Sha256}; +use sha3::Sha3_256; + +pub const ML_DSA_65_PUBLIC_KEY_LEN: usize = 1952; +pub const ML_DSA_65_SIGNATURE_LEN: usize = 3309; + +/// Domain-separation tag nearcore hashes before the raw public key to form +/// the on-chain access-key handle (`ml-dsa-65-hash:` text form). +const ML_DSA_65_HANDLE_DOMAIN_TAG: &[u8] = b"near:ml-dsa-65-pubkey-hash:v1"; + +// ---- borsh wire types ------------------------------------------------------- + +#[flutter_rust_bridge::frb(ignore)] +#[derive(Debug, Clone, PartialEq, Eq, BorshSerialize, BorshDeserialize)] +enum WirePublicKey { + Ed25519([u8; 32]), + Secp256k1(Box<[u8; 64]>), + MlDsa65(Box<[u8; ML_DSA_65_PUBLIC_KEY_LEN]>), +} + +impl WirePublicKey { + fn text(&self) -> String { + match self { + WirePublicKey::Ed25519(b) => format!("ed25519:{}", bs58::encode(b).into_string()), + WirePublicKey::Secp256k1(b) => { + format!("secp256k1:{}", bs58::encode(b.as_ref()).into_string()) + } + WirePublicKey::MlDsa65(b) => { + format!("ml-dsa-65:{}", bs58::encode(b.as_ref()).into_string()) + } + } + } + + fn bytes(&self) -> Vec { + match self { + WirePublicKey::Ed25519(b) => b.to_vec(), + WirePublicKey::Secp256k1(b) => b.to_vec(), + WirePublicKey::MlDsa65(b) => b.to_vec(), + } + } +} + +#[flutter_rust_bridge::frb(ignore)] +#[derive(Debug, Clone, PartialEq, Eq, BorshSerialize, BorshDeserialize)] +struct WireAccessKey { + nonce: u64, + permission: WireAccessKeyPermission, +} + +#[flutter_rust_bridge::frb(ignore)] +#[derive(Debug, Clone, PartialEq, Eq, BorshSerialize, BorshDeserialize)] +enum WireAccessKeyPermission { + FunctionCall(WireFunctionCallPermission), + FullAccess, +} + +#[flutter_rust_bridge::frb(ignore)] +#[derive(Debug, Clone, PartialEq, Eq, BorshSerialize, BorshDeserialize)] +struct WireFunctionCallPermission { + allowance: Option, + receiver_id: String, + method_names: Vec, +} + +#[flutter_rust_bridge::frb(ignore)] +#[derive(Debug, Clone, PartialEq, Eq, BorshSerialize, BorshDeserialize)] +enum WireAction { + CreateAccount, + DeployContract { + code: Vec, + }, + FunctionCall { + method_name: String, + args: Vec, + gas: u64, + deposit: u128, + }, + Transfer { + deposit: u128, + }, + Stake { + stake: u128, + public_key: WirePublicKey, + }, + AddKey { + public_key: WirePublicKey, + access_key: WireAccessKey, + }, + DeleteKey { + public_key: WirePublicKey, + }, + DeleteAccount { + beneficiary_id: String, + }, +} + +/// nearcore `TransactionV0`: the signable body wallets produce. +#[flutter_rust_bridge::frb(ignore)] +#[derive(Debug, Clone, PartialEq, Eq, BorshSerialize, BorshDeserialize)] +struct WireTransaction { + signer_id: String, + public_key: WirePublicKey, + nonce: u64, + receiver_id: String, + block_hash: [u8; 32], + actions: Vec, +} + +// ---- Dart-facing model ------------------------------------------------------- + +/// The kind of a [`NearAction`]. Everything but `Transfer` and +/// `FunctionCall` changes who controls the account or what code it runs. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum NearActionKind { + CreateAccount, + DeployContract, + FunctionCall, + #[default] + Transfer, + Stake, + AddKey, + DeleteKey, + DeleteAccount, +} + +/// One action of a NEAR transaction, flattened for display. Which fields are +/// set depends on [`NearAction::kind`]; amounts are yoctoNEAR (24 decimals) +/// and keys are NEAR text form (`:`). +/// +/// A flat struct rather than an enum with payloads: that keeps the Dart side +/// a plain class instead of a generated sealed hierarchy. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct NearAction { + pub kind: NearActionKind, + /// `FunctionCall`: the method; `DeleteAccount`: the beneficiary; + /// `AddKey` (function-call key): the contract the key may call. + pub target: Option, + /// `FunctionCall`: raw call arguments (usually JSON). + pub args: Option>, + /// `FunctionCall`: prepaid gas. + pub gas: Option, + /// `Transfer`/`FunctionCall`: attached deposit; `Stake`: staked amount; + /// `AddKey` (function-call key): gas allowance, `None` = unlimited. + pub amount: Option, + /// `Stake`/`AddKey`/`DeleteKey`: the key concerned. + pub public_key: Option, + /// `AddKey`: whether the key gets full access. + pub full_access: bool, + /// `AddKey` (function-call key): the methods it may call; empty = any. + pub method_names: Vec, + /// `DeployContract`: size of the code blob. + pub code_len: Option, + /// `DeployContract`: SHA-256 of the code, how NEAR identifies contract + /// code, so a signer can compare it with the artifact they meant to deploy. + pub code_hash: Option>, +} + +impl From<&WireAction> for NearAction { + fn from(action: &WireAction) -> Self { + match action { + WireAction::CreateAccount => NearAction { + kind: NearActionKind::CreateAccount, + ..Default::default() + }, + WireAction::DeployContract { code } => NearAction { + kind: NearActionKind::DeployContract, + code_len: Some(code.len() as u32), + code_hash: Some(Sha256::digest(code).to_vec()), + ..Default::default() + }, + WireAction::FunctionCall { + method_name, + args, + gas, + deposit, + } => NearAction { + kind: NearActionKind::FunctionCall, + target: Some(method_name.clone()), + args: Some(args.clone()), + gas: Some(*gas), + amount: Some(*deposit), + ..Default::default() + }, + WireAction::Transfer { deposit } => NearAction { + kind: NearActionKind::Transfer, + amount: Some(*deposit), + ..Default::default() + }, + WireAction::Stake { stake, public_key } => NearAction { + kind: NearActionKind::Stake, + amount: Some(*stake), + public_key: Some(public_key.text()), + ..Default::default() + }, + WireAction::AddKey { + public_key, + access_key, + } => match &access_key.permission { + WireAccessKeyPermission::FullAccess => NearAction { + kind: NearActionKind::AddKey, + public_key: Some(public_key.text()), + full_access: true, + ..Default::default() + }, + WireAccessKeyPermission::FunctionCall(p) => NearAction { + kind: NearActionKind::AddKey, + public_key: Some(public_key.text()), + full_access: false, + target: Some(p.receiver_id.clone()), + amount: p.allowance, + method_names: p.method_names.clone(), + ..Default::default() + }, + }, + WireAction::DeleteKey { public_key } => NearAction { + kind: NearActionKind::DeleteKey, + public_key: Some(public_key.text()), + ..Default::default() + }, + WireAction::DeleteAccount { beneficiary_id } => NearAction { + kind: NearActionKind::DeleteAccount, + target: Some(beneficiary_id.clone()), + ..Default::default() + }, + } + } +} + +/// A decoded NEAR transaction, ready to display. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NearTransaction { + pub signer_id: String, + /// The key that must sign, in NEAR text form. + pub public_key: String, + /// Raw bytes of that key; compare against a [`Keypair::public_key`]. + pub public_key_bytes: Vec, + /// Whether the signing key is ML-DSA-65 — the only scheme a Quantus + /// account can sign for. + pub signs_with_ml_dsa_65: bool, + pub nonce: u64, + pub receiver_id: String, + /// Base58, as NEAR tooling prints it. + pub block_hash: String, + /// SHA-256 of the transaction bytes: what gets signed, and the id the + /// NEAR RPC reports (as base58) once submitted. + pub hash: Vec, + pub actions: Vec, +} + +fn decode_wire(bytes: &[u8]) -> Result { + let wire: WireTransaction = + borsh::from_slice(bytes).map_err(|e| format!("Not a borsh NEAR transaction: {e}"))?; + check_account_id("signer", &wire.signer_id)?; + check_account_id("receiver", &wire.receiver_id)?; + for action in &wire.actions { + match action { + WireAction::DeleteAccount { beneficiary_id } => { + check_account_id("beneficiary", beneficiary_id)? + } + WireAction::AddKey { access_key, .. } => { + if let WireAccessKeyPermission::FunctionCall(p) = &access_key.permission { + check_account_id("key receiver", &p.receiver_id)? + } + } + _ => {} + } + } + Ok(wire) +} + +/// NEAR's account id rules: 2–64 chars of `a-z 0-9 _ - .`, separators never +/// doubled or at either end of a part. The chain refuses anything else, and +/// the check also keeps every account id shown to a signer plain ASCII. +fn check_account_id(role: &str, id: &str) -> Result<(), String> { + let valid = (2..=64).contains(&id.len()) + && id.split('.').all(|part| { + !part.is_empty() + && !part.starts_with(['-', '_']) + && !part.ends_with(['-', '_']) + && !part.contains("--") + && !part.contains("__") + && !part.contains("-_") + && !part.contains("_-") + && part + .bytes() + .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-' || b == b'_') + }); + if valid { + Ok(()) + } else { + Err(format!("Not a NEAR account id ({role}): {id:?}")) + } +} + +/// Decode a borsh `TransactionV0`. Refuses trailing bytes. +#[flutter_rust_bridge::frb(sync)] +pub fn decode_near_transaction(transaction: Vec) -> Result { + let wire = decode_wire(&transaction)?; + Ok(NearTransaction { + signer_id: wire.signer_id.clone(), + public_key: wire.public_key.text(), + public_key_bytes: wire.public_key.bytes(), + signs_with_ml_dsa_65: matches!(wire.public_key, WirePublicKey::MlDsa65(_)), + nonce: wire.nonce, + receiver_id: wire.receiver_id.clone(), + block_hash: bs58::encode(wire.block_hash).into_string(), + hash: Sha256::digest(&transaction).to_vec(), + actions: wire.actions.iter().map(NearAction::from).collect(), + }) +} + +/// SHA-256 of the transaction bytes: the message NEAR signs and verifies. +#[flutter_rust_bridge::frb(sync)] +pub fn near_transaction_hash(transaction: Vec) -> Vec { + Sha256::digest(&transaction).to_vec() +} + +fn require_ml_dsa_65(keypair: &Keypair) -> Result<(), String> { + if keypair.scheme != DilithiumScheme::MlDsa65 { + return Err("NEAR accepts ML-DSA-65 keys only; this account is ML-DSA-87".to_string()); + } + Ok(()) +} + +/// Sign a borsh `TransactionV0` for NEAR. Refuses unless `keypair` is +/// ML-DSA-65 *and* is the key the transaction declares. Returns +/// `signature ‖ public_key` (3309 + 1952 bytes). +#[flutter_rust_bridge::frb(sync)] +pub fn sign_near_transaction( + keypair: &Keypair, + transaction: Vec, + entropy: Option<[u8; 32]>, +) -> Result, String> { + require_ml_dsa_65(keypair)?; + let wire = decode_wire(&transaction)?; + if wire.public_key.bytes() != keypair.public_key { + return Err(format!( + "Transaction is signed by {}, not by this account", + wire.public_key.text() + )); + } + + let secret = ml_dsa_65::SecretKey::from_bytes(&keypair.secret_key) + .map_err(|e| format!("Secret key does not parse: {e:?}"))?; + let mut entropy = entropy; + let hedge = entropy.as_mut().map(SensitiveBytes32::new); + let hash = Sha256::digest(&transaction); + // Pure ML-DSA, no context: NEAR's verifier passes none. + let signature = secret + .sign(&hash, None, hedge.as_ref()) + .map_err(|e| format!("Signing failed: {e:?}"))?; + + let mut response = Vec::with_capacity(ML_DSA_65_SIGNATURE_LEN + ML_DSA_65_PUBLIC_KEY_LEN); + response.extend_from_slice(signature.as_ref()); + response.extend_from_slice(&keypair.public_key); + Ok(response) +} + +/// Whether `signature` is a valid pure ML-DSA-65 signature by `public_key` +/// over the transaction, as the NEAR runtime checks it. +#[flutter_rust_bridge::frb(sync)] +pub fn verify_near_signature( + public_key: Vec, + transaction: Vec, + signature: Vec, +) -> bool { + let Ok(public) = ml_dsa_65::PublicKey::from_bytes(&public_key) else { + return false; + }; + public.verify(&Sha256::digest(&transaction), &signature, None) +} + +/// The account's key in NEAR text form, `ml-dsa-65:`. This is the +/// `--signer-public-key` near-cli-rs takes and what an `AddKey` carries. +#[flutter_rust_bridge::frb(sync)] +pub fn near_public_key_text(keypair: &Keypair) -> Result { + require_ml_dsa_65(keypair)?; + Ok(format!( + "ml-dsa-65:{}", + bs58::encode(&keypair.public_key).into_string() + )) +} + +/// The on-chain handle of the key, `ml-dsa-65-hash:`: what +/// `view_access_key_list` shows, SHA3-256 of a domain tag and the key. +#[flutter_rust_bridge::frb(sync)] +pub fn near_public_key_handle(keypair: &Keypair) -> Result { + require_ml_dsa_65(keypair)?; + let mut hasher = Sha3_256::new(); + hasher.update(ML_DSA_65_HANDLE_DOMAIN_TAG); + hasher.update(&keypair.public_key); + Ok(format!( + "ml-dsa-65-hash:{}", + bs58::encode(hasher.finalize()).into_string() + )) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::api::crypto::generate_keypair_from_seed; + + /// near-cli-rs 0.30.1 `sign-later` output for a 1 NEAR transfer + /// alice.testnet → bob.testnet, ed25519 key 0x11×32, nonce 42, block hash + /// 0x22×32. The same vector pins quantus-cli's encoder. + const NEAR_CLI_UNSIGNED_B64: &str = "DQAAAGFsaWNlLnRlc3RuZXQAEREREREREREREREREREREREREREREREREREREREREREqAAAAAAAAAAsAAABib2IudGVzdG5ldCIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiAQAAAAMAAACh7czOG8LTAAAAAAAA"; + const NEAR_CLI_HASH_HEX: &str = + "be358ec90e89b70256db586f72c4a280daa1199ba6f91398e9d034b72a7a6c9f"; + + fn b64(s: &str) -> Vec { + // Minimal base64 decoder: keeps the test free of a new dependency. + const ALPHABET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + let mut out = Vec::new(); + let mut buf = 0u32; + let mut bits = 0; + for &c in s.as_bytes() { + if c == b'=' { + break; + } + let v = ALPHABET.iter().position(|&a| a == c).expect("base64 char") as u32; + buf = (buf << 6) | v; + bits += 6; + if bits >= 8 { + bits -= 8; + out.push((buf >> bits) as u8); + buf &= (1 << bits) - 1; + } + } + out + } + + fn ml_dsa_65_keypair(seed: u8) -> Keypair { + let mut seed = [seed; 32]; + let mut entropy = SensitiveBytes32::new(&mut seed); + let keypair = ml_dsa_65::Keypair::generate(&mut entropy); + Keypair { + public_key: keypair.public().to_bytes().to_vec(), + secret_key: keypair.secret().to_bytes().to_vec(), + scheme: DilithiumScheme::MlDsa65, + } + } + + fn tx_for(keypair: &Keypair) -> Vec { + let key: [u8; ML_DSA_65_PUBLIC_KEY_LEN] = keypair.public_key.as_slice().try_into().unwrap(); + borsh::to_vec(&WireTransaction { + signer_id: "vault.alice.near".to_string(), + public_key: WirePublicKey::MlDsa65(Box::new(key)), + nonce: 101, + receiver_id: "v2.ref-finance.near".to_string(), + block_hash: [0x22; 32], + actions: vec![WireAction::FunctionCall { + method_name: "storage_deposit".to_string(), + args: br#"{"registration_only":true}"#.to_vec(), + gas: 30_000_000_000_000, + deposit: 125 * 10u128.pow(21), + }], + }) + .unwrap() + } + + #[test] + fn decodes_near_cli_sign_later_output() { + let bytes = b64(NEAR_CLI_UNSIGNED_B64); + let tx = decode_near_transaction(bytes.clone()).unwrap(); + assert_eq!(tx.signer_id, "alice.testnet"); + assert_eq!(tx.receiver_id, "bob.testnet"); + assert_eq!(tx.nonce, 42); + assert_eq!( + tx.public_key, + "ed25519:29d2S7vB453rNYFdR5Ycwt7y9haRT5fwVwL9zTmBhfV2" + ); + assert_eq!(tx.public_key_bytes, vec![0x11; 32]); + assert!(!tx.signs_with_ml_dsa_65); + assert_eq!( + tx.block_hash, + "3JF3sEqM796hk5WFqA6EtmEwJQ9quALszsfJyvXNQKy3" + ); + assert_eq!(hex::encode(&tx.hash), NEAR_CLI_HASH_HEX); + assert_eq!( + tx.actions, + vec![NearAction { + kind: NearActionKind::Transfer, + amount: Some(10u128.pow(24)), + ..Default::default() + }] + ); + assert_eq!(near_transaction_hash(bytes), tx.hash); + } + + #[test] + fn rejects_account_ids_the_chain_would_refuse() { + for good in ["alice.testnet", "a1", "sub.a-b_c.near", &"a".repeat(64)] { + assert!(check_account_id("t", good).is_ok(), "{good}"); + } + for bad in [ + "a", + "Alice.testnet", + "alice..testnet", + ".alice", + "alice.", + "-alice", + "alice-", + "ali--ce", + "ali_-ce", + "alice\u{202E}testnet", + "alice testnet", + &"a".repeat(65), + ] { + assert!(check_account_id("t", bad).is_err(), "{bad:?}"); + } + + let mut bytes = b64(NEAR_CLI_UNSIGNED_B64); + // Flip the first byte of "alice.testnet" to an uppercase 'A'. + bytes[4] = b'A'; + let err = decode_near_transaction(bytes).unwrap_err(); + assert!(err.contains("signer"), "{err}"); + } + + #[test] + fn rejects_trailing_and_truncated_bytes() { + let bytes = b64(NEAR_CLI_UNSIGNED_B64); + let mut extended = bytes.clone(); + extended.push(0); + assert!(decode_near_transaction(extended).is_err()); + assert!(decode_near_transaction(bytes[..bytes.len() - 1].to_vec()).is_err()); + assert!(decode_near_transaction(Vec::new()).is_err()); + } + + #[test] + fn decodes_every_action_kind() { + let key = WirePublicKey::MlDsa65(Box::new([0x33; ML_DSA_65_PUBLIC_KEY_LEN])); + let ed = WirePublicKey::Ed25519([0x11; 32]); + let bytes = borsh::to_vec(&WireTransaction { + signer_id: "a.near".to_string(), + public_key: key.clone(), + nonce: 1, + receiver_id: "b.near".to_string(), + block_hash: [0; 32], + actions: vec![ + WireAction::CreateAccount, + WireAction::DeployContract { code: vec![0; 10] }, + WireAction::Transfer { deposit: 5 }, + WireAction::Stake { + stake: 7, + public_key: ed.clone(), + }, + WireAction::AddKey { + public_key: key.clone(), + access_key: WireAccessKey { + nonce: 0, + permission: WireAccessKeyPermission::FullAccess, + }, + }, + WireAction::AddKey { + public_key: ed.clone(), + access_key: WireAccessKey { + nonce: 0, + permission: WireAccessKeyPermission::FunctionCall( + WireFunctionCallPermission { + allowance: Some(250), + receiver_id: "c.near".to_string(), + method_names: vec!["ping".to_string()], + }, + ), + }, + }, + WireAction::DeleteKey { + public_key: ed.clone(), + }, + WireAction::DeleteAccount { + beneficiary_id: "d.near".to_string(), + }, + ], + }) + .unwrap(); + + let tx = decode_near_transaction(bytes).unwrap(); + assert!(tx.signs_with_ml_dsa_65); + assert!(tx.public_key.starts_with("ml-dsa-65:")); + assert_eq!(tx.actions.len(), 8); + let kinds: Vec = tx.actions.iter().map(|a| a.kind).collect(); + assert_eq!( + kinds, + vec![ + NearActionKind::CreateAccount, + NearActionKind::DeployContract, + NearActionKind::Transfer, + NearActionKind::Stake, + NearActionKind::AddKey, + NearActionKind::AddKey, + NearActionKind::DeleteKey, + NearActionKind::DeleteAccount, + ] + ); + assert_eq!(tx.actions[1].code_len, Some(10)); + assert_eq!( + tx.actions[1].code_hash.as_deref(), + Some(Sha256::digest([0u8; 10]).as_slice()) + ); + assert_eq!(tx.actions[2].amount, Some(5)); + assert_eq!( + (tx.actions[3].amount, tx.actions[3].public_key.as_deref()), + (Some(7), Some(ed.text().as_str())) + ); + assert_eq!( + tx.actions[4], + NearAction { + kind: NearActionKind::AddKey, + public_key: Some(key.text()), + full_access: true, + ..Default::default() + } + ); + assert_eq!( + tx.actions[5], + NearAction { + kind: NearActionKind::AddKey, + public_key: Some(ed.text()), + full_access: false, + target: Some("c.near".to_string()), + amount: Some(250), + method_names: vec!["ping".to_string()], + ..Default::default() + } + ); + assert_eq!(tx.actions[6].public_key, Some(ed.text())); + assert_eq!(tx.actions[7].target, Some("d.near".to_string())); + } + + #[test] + fn function_call_action_carries_method_args_gas_and_deposit() { + let keypair = ml_dsa_65_keypair(7); + let tx = decode_near_transaction(tx_for(&keypair)).unwrap(); + assert_eq!(tx.signer_id, "vault.alice.near"); + assert_eq!(tx.receiver_id, "v2.ref-finance.near"); + assert_eq!(tx.public_key_bytes, keypair.public_key); + assert!(tx.signs_with_ml_dsa_65); + assert_eq!( + tx.actions, + vec![NearAction { + kind: NearActionKind::FunctionCall, + target: Some("storage_deposit".to_string()), + args: Some(br#"{"registration_only":true}"#.to_vec()), + gas: Some(30_000_000_000_000), + amount: Some(125 * 10u128.pow(21)), + ..Default::default() + }] + ); + } + + #[test] + fn signs_pure_ml_dsa_65_over_the_hash_and_appends_the_key() { + let keypair = ml_dsa_65_keypair(7); + let tx = tx_for(&keypair); + + let response = sign_near_transaction(&keypair, tx.clone(), Some([9; 32])).unwrap(); + assert_eq!( + response.len(), + ML_DSA_65_SIGNATURE_LEN + ML_DSA_65_PUBLIC_KEY_LEN + ); + let (signature, public) = response.split_at(ML_DSA_65_SIGNATURE_LEN); + assert_eq!(public, keypair.public_key.as_slice()); + + assert!(verify_near_signature( + public.to_vec(), + tx.clone(), + signature.to_vec() + )); + + // Pure signature: verifies with no context and fails under the + // Quantus extrinsic context. + let verifier = ml_dsa_65::PublicKey::from_bytes(public).unwrap(); + let hash = Sha256::digest(&tx); + assert!(verifier.verify(&hash, signature, None)); + assert!(!verifier.verify(&hash, signature, Some(crate::signing_context::EXTRINSIC))); + + // Bound to this exact transaction. + let mut other = tx.clone(); + let nonce_offset = 4 + "vault.alice.near".len() + 1 + ML_DSA_65_PUBLIC_KEY_LEN; + other[nonce_offset] ^= 1; + assert!(!verify_near_signature( + public.to_vec(), + other, + signature.to_vec() + )); + + // Deterministic signing (no hedge) is also accepted. + let det = sign_near_transaction(&keypair, tx.clone(), None).unwrap(); + assert!(verify_near_signature( + keypair.public_key.clone(), + tx, + det[..ML_DSA_65_SIGNATURE_LEN].to_vec() + )); + } + + #[test] + fn refuses_other_keys_and_ml_dsa_87_accounts() { + let keypair = ml_dsa_65_keypair(7); + let other = ml_dsa_65_keypair(8); + let tx = tx_for(&keypair); + + let err = sign_near_transaction(&other, tx.clone(), None).unwrap_err(); + assert!(err.contains("not by this account"), "{err}"); + + let ml87 = generate_keypair_from_seed(vec![1; 32]); + assert_eq!(ml87.scheme, DilithiumScheme::MlDsa87); + let err = sign_near_transaction(&ml87, tx, None).unwrap_err(); + assert!(err.contains("ML-DSA-65"), "{err}"); + assert!(near_public_key_text(&ml87).is_err()); + assert!(near_public_key_handle(&ml87).is_err()); + } + + #[test] + fn key_text_and_handle_match_nearcore() { + let mut keypair = ml_dsa_65_keypair(7); + keypair.public_key = vec![0x42; ML_DSA_65_PUBLIC_KEY_LEN]; + + let text = near_public_key_text(&keypair).unwrap(); + assert!(text.starts_with("ml-dsa-65:")); + assert_eq!( + bs58::decode(&text["ml-dsa-65:".len()..]) + .into_vec() + .unwrap(), + keypair.public_key + ); + + // Golden from quantus-cli (SHA3-256 over tag ‖ key), cross-checked + // against near-cli-rs's "pub key hash" display. + let handle = near_public_key_handle(&keypair).unwrap(); + let handle_bytes = bs58::decode(&handle["ml-dsa-65-hash:".len()..]) + .into_vec() + .unwrap(); + assert_eq!( + hex::encode(handle_bytes), + "0ef8ccba4bb1a8859f1cc3d17c4d9d35712b8ddedd3875906559c50f7dd86505" + ); + } +} diff --git a/quantus_sdk/rust/src/frb_generated.rs b/quantus_sdk/rust/src/frb_generated.rs index 10d93ed92..217391b61 100644 --- a/quantus_sdk/rust/src/frb_generated.rs +++ b/quantus_sdk/rust/src/frb_generated.rs @@ -39,7 +39,7 @@ flutter_rust_bridge::frb_generated_boilerplate!( default_rust_auto_opaque = RustAutoOpaqueMoi, ); pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_VERSION: &str = "2.12.0"; -pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_CONTENT_HASH: i32 = -1962506674; +pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_CONTENT_HASH: i32 = -91816623; // Section: executor @@ -468,6 +468,36 @@ fn wire__crate__api__wormhole__decode_leaf_transfer_count_impl( }, ) } +fn wire__crate__api__near__decode_near_transaction_impl( + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) -> flutter_rust_bridge::for_generated::WireSyncRust2DartSse { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_sync::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "decode_near_transaction", + port: None, + mode: flutter_rust_bridge::for_generated::FfiCallMode::Sync, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_transaction = >::sse_decode(&mut deserializer); + deserializer.end(); + transform_result_sse::<_, String>((move || { + let output_ok = crate::api::near::decode_near_transaction(api_transaction)?; + Ok(output_ok) + })()) + }, + ) +} fn wire__crate__api__ur__decode_ur_impl( ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, rust_vec_len_: i32, @@ -922,6 +952,162 @@ fn wire__crate__api__ur__max_ur_parts_impl( }, ) } +fn wire__crate__api__near__near_action_default_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_normal::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "near_action_default", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + deserializer.end(); + move |context| { + transform_result_sse::<_, ()>((move || { + let output_ok = Result::<_, ()>::Ok(crate::api::near::NearAction::default())?; + Ok(output_ok) + })()) + } + }, + ) +} +fn wire__crate__api__near__near_action_kind_default_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_normal::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "near_action_kind_default", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + deserializer.end(); + move |context| { + transform_result_sse::<_, ()>((move || { + let output_ok = + Result::<_, ()>::Ok(crate::api::near::NearActionKind::default())?; + Ok(output_ok) + })()) + } + }, + ) +} +fn wire__crate__api__near__near_public_key_handle_impl( + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) -> flutter_rust_bridge::for_generated::WireSyncRust2DartSse { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_sync::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "near_public_key_handle", + port: None, + mode: flutter_rust_bridge::for_generated::FfiCallMode::Sync, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_keypair = ::sse_decode(&mut deserializer); + deserializer.end(); + transform_result_sse::<_, String>((move || { + let output_ok = crate::api::near::near_public_key_handle(&api_keypair)?; + Ok(output_ok) + })()) + }, + ) +} +fn wire__crate__api__near__near_public_key_text_impl( + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) -> flutter_rust_bridge::for_generated::WireSyncRust2DartSse { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_sync::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "near_public_key_text", + port: None, + mode: flutter_rust_bridge::for_generated::FfiCallMode::Sync, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_keypair = ::sse_decode(&mut deserializer); + deserializer.end(); + transform_result_sse::<_, String>((move || { + let output_ok = crate::api::near::near_public_key_text(&api_keypair)?; + Ok(output_ok) + })()) + }, + ) +} +fn wire__crate__api__near__near_transaction_hash_impl( + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) -> flutter_rust_bridge::for_generated::WireSyncRust2DartSse { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_sync::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "near_transaction_hash", + port: None, + mode: flutter_rust_bridge::for_generated::FfiCallMode::Sync, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_transaction = >::sse_decode(&mut deserializer); + deserializer.end(); + transform_result_sse::<_, ()>((move || { + let output_ok = + Result::<_, ()>::Ok(crate::api::near::near_transaction_hash(api_transaction))?; + Ok(output_ok) + })()) + }, + ) +} fn wire__crate__api__multisig__predict_multisig_address_impl( ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, rust_vec_len_: i32, @@ -1165,6 +1351,42 @@ fn wire__crate__api__crypto__sign_message_with_pubkey_impl( }, ) } +fn wire__crate__api__near__sign_near_transaction_impl( + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) -> flutter_rust_bridge::for_generated::WireSyncRust2DartSse { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_sync::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "sign_near_transaction", + port: None, + mode: flutter_rust_bridge::for_generated::FfiCallMode::Sync, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_keypair = ::sse_decode(&mut deserializer); + let api_transaction = >::sse_decode(&mut deserializer); + let api_entropy = >::sse_decode(&mut deserializer); + deserializer.end(); + transform_result_sse::<_, String>((move || { + let output_ok = crate::api::near::sign_near_transaction( + &api_keypair, + api_transaction, + api_entropy, + )?; + Ok(output_ok) + })()) + }, + ) +} fn wire__crate__api__crypto__signature_bytes_impl( ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, rust_vec_len_: i32, @@ -1294,6 +1516,42 @@ fn wire__crate__api__crypto__verify_message_impl( }, ) } +fn wire__crate__api__near__verify_near_signature_impl( + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) -> flutter_rust_bridge::for_generated::WireSyncRust2DartSse { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_sync::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "verify_near_signature", + port: None, + mode: flutter_rust_bridge::for_generated::FfiCallMode::Sync, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_public_key = >::sse_decode(&mut deserializer); + let api_transaction = >::sse_decode(&mut deserializer); + let api_signature = >::sse_decode(&mut deserializer); + deserializer.end(); + transform_result_sse::<_, ()>((move || { + let output_ok = Result::<_, ()>::Ok(crate::api::near::verify_near_signature( + api_public_key, + api_transaction, + api_signature, + ))?; + Ok(output_ok) + })()) + }, + ) +} fn wire__crate__api__wormhole__wormhole_compute_output_amount_impl( ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, rust_vec_len_: i32, @@ -1394,6 +1652,14 @@ impl SseDecode for String { } } +impl SseDecode for u128 { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + let mut inner = ::sse_decode(deserializer); + return inner.parse().unwrap(); + } +} + impl SseDecode for crate::api::airdrop::AirdropMatch { // Codec=Sse (Serialization based), see doc to use other codecs fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { @@ -1521,6 +1787,18 @@ impl SseDecode for Vec> { } } +impl SseDecode for Vec { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + let mut len_ = ::sse_decode(deserializer); + let mut ans_ = Vec::with_capacity(len_ as usize); + for idx_ in 0..len_ { + ans_.push(::sse_decode(deserializer)); + } + return ans_; + } +} + impl SseDecode for Vec { // Codec=Sse (Serialization based), see doc to use other codecs fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { @@ -1545,6 +1823,78 @@ impl SseDecode for crate::api::wormhole::MerkleProcessed { } } +impl SseDecode for crate::api::near::NearAction { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + let mut var_kind = ::sse_decode(deserializer); + let mut var_target = >::sse_decode(deserializer); + let mut var_args = >>::sse_decode(deserializer); + let mut var_gas = >::sse_decode(deserializer); + let mut var_amount = >::sse_decode(deserializer); + let mut var_publicKey = >::sse_decode(deserializer); + let mut var_fullAccess = ::sse_decode(deserializer); + let mut var_methodNames = >::sse_decode(deserializer); + let mut var_codeLen = >::sse_decode(deserializer); + let mut var_codeHash = >>::sse_decode(deserializer); + return crate::api::near::NearAction { + kind: var_kind, + target: var_target, + args: var_args, + gas: var_gas, + amount: var_amount, + public_key: var_publicKey, + full_access: var_fullAccess, + method_names: var_methodNames, + code_len: var_codeLen, + code_hash: var_codeHash, + }; + } +} + +impl SseDecode for crate::api::near::NearActionKind { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + let mut inner = ::sse_decode(deserializer); + return match inner { + 0 => crate::api::near::NearActionKind::CreateAccount, + 1 => crate::api::near::NearActionKind::DeployContract, + 2 => crate::api::near::NearActionKind::FunctionCall, + 3 => crate::api::near::NearActionKind::Transfer, + 4 => crate::api::near::NearActionKind::Stake, + 5 => crate::api::near::NearActionKind::AddKey, + 6 => crate::api::near::NearActionKind::DeleteKey, + 7 => crate::api::near::NearActionKind::DeleteAccount, + _ => unreachable!("Invalid variant for NearActionKind: {}", inner), + }; + } +} + +impl SseDecode for crate::api::near::NearTransaction { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + let mut var_signerId = ::sse_decode(deserializer); + let mut var_publicKey = ::sse_decode(deserializer); + let mut var_publicKeyBytes = >::sse_decode(deserializer); + let mut var_signsWithMlDsa65 = ::sse_decode(deserializer); + let mut var_nonce = ::sse_decode(deserializer); + let mut var_receiverId = ::sse_decode(deserializer); + let mut var_blockHash = ::sse_decode(deserializer); + let mut var_hash = >::sse_decode(deserializer); + let mut var_actions = >::sse_decode(deserializer); + return crate::api::near::NearTransaction { + signer_id: var_signerId, + public_key: var_publicKey, + public_key_bytes: var_publicKeyBytes, + signs_with_ml_dsa_65: var_signsWithMlDsa65, + nonce: var_nonce, + receiver_id: var_receiverId, + block_hash: var_blockHash, + hash: var_hash, + actions: var_actions, + }; + } +} + impl SseDecode for Option { // Codec=Sse (Serialization based), see doc to use other codecs fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { @@ -1556,6 +1906,17 @@ impl SseDecode for Option { } } +impl SseDecode for Option { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + if (::sse_decode(deserializer)) { + return Some(::sse_decode(deserializer)); + } else { + return None; + } + } +} + impl SseDecode for Option { // Codec=Sse (Serialization based), see doc to use other codecs fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { @@ -1567,6 +1928,17 @@ impl SseDecode for Option { } } +impl SseDecode for Option { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + if (::sse_decode(deserializer)) { + return Some(::sse_decode(deserializer)); + } else { + return None; + } + } +} + impl SseDecode for Option> { // Codec=Sse (Serialization based), see doc to use other codecs fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { @@ -1743,18 +2115,22 @@ fn pde_ffi_dispatcher_primary_impl( rust_vec_len, data_len, ), - 17 => wire__crate__api__wormhole__ensure_circuit_binaries_impl( + 18 => wire__crate__api__wormhole__ensure_circuit_binaries_impl( port, ptr, rust_vec_len, data_len, ), - 18 => { + 19 => { wire__crate__api__airdrop__find_airdrop_matches_impl(port, ptr, rust_vec_len, data_len) } - 23 => wire__crate__api__wormhole__generate_proof_impl(port, ptr, rust_vec_len, data_len), - 24 => wire__crate__api__crypto__init_app_impl(port, ptr, rust_vec_len, data_len), - 29 => wire__crate__api__airdrop__prove_airdrop_wormhole_impl( + 24 => wire__crate__api__wormhole__generate_proof_impl(port, ptr, rust_vec_len, data_len), + 25 => wire__crate__api__crypto__init_app_impl(port, ptr, rust_vec_len, data_len), + 29 => wire__crate__api__near__near_action_default_impl(port, ptr, rust_vec_len, data_len), + 30 => { + wire__crate__api__near__near_action_kind_default_impl(port, ptr, rust_vec_len, data_len) + } + 35 => wire__crate__api__airdrop__prove_airdrop_wormhole_impl( port, ptr, rust_vec_len, @@ -1784,36 +2160,42 @@ fn pde_ffi_dispatcher_sync_impl( 13 => { wire__crate__api__wormhole__decode_leaf_transfer_count_impl(ptr, rust_vec_len, data_len) } - 14 => wire__crate__api__ur__decode_ur_impl(ptr, rust_vec_len, data_len), - 15 => wire__crate__api__crypto__derive_wormhole_impl(ptr, rust_vec_len, data_len), - 16 => wire__crate__api__ur__encode_ur_impl(ptr, rust_vec_len, data_len), - 19 => wire__crate__api__crypto__first_hash_to_address_impl(ptr, rust_vec_len, data_len), - 20 => wire__crate__api__crypto__generate_derived_keypair_impl(ptr, rust_vec_len, data_len), - 21 => wire__crate__api__crypto__generate_keypair_impl(ptr, rust_vec_len, data_len), - 22 => { + 14 => wire__crate__api__near__decode_near_transaction_impl(ptr, rust_vec_len, data_len), + 15 => wire__crate__api__ur__decode_ur_impl(ptr, rust_vec_len, data_len), + 16 => wire__crate__api__crypto__derive_wormhole_impl(ptr, rust_vec_len, data_len), + 17 => wire__crate__api__ur__encode_ur_impl(ptr, rust_vec_len, data_len), + 20 => wire__crate__api__crypto__first_hash_to_address_impl(ptr, rust_vec_len, data_len), + 21 => wire__crate__api__crypto__generate_derived_keypair_impl(ptr, rust_vec_len, data_len), + 22 => wire__crate__api__crypto__generate_keypair_impl(ptr, rust_vec_len, data_len), + 23 => { wire__crate__api__crypto__generate_keypair_from_seed_impl(ptr, rust_vec_len, data_len) } - 25 => wire__crate__api__ur__is_complete_ur_impl(ptr, rust_vec_len, data_len), - 26 => wire__crate__api__ur__max_ur_part_chars_impl(ptr, rust_vec_len, data_len), - 27 => wire__crate__api__ur__max_ur_parts_impl(ptr, rust_vec_len, data_len), - 28 => { + 26 => wire__crate__api__ur__is_complete_ur_impl(ptr, rust_vec_len, data_len), + 27 => wire__crate__api__ur__max_ur_part_chars_impl(ptr, rust_vec_len, data_len), + 28 => wire__crate__api__ur__max_ur_parts_impl(ptr, rust_vec_len, data_len), + 31 => wire__crate__api__near__near_public_key_handle_impl(ptr, rust_vec_len, data_len), + 32 => wire__crate__api__near__near_public_key_text_impl(ptr, rust_vec_len, data_len), + 33 => wire__crate__api__near__near_transaction_hash_impl(ptr, rust_vec_len, data_len), + 34 => { wire__crate__api__multisig__predict_multisig_address_impl(ptr, rust_vec_len, data_len) } - 30 => wire__crate__api__crypto__public_key_bytes_impl(ptr, rust_vec_len, data_len), - 31 => wire__crate__api__crypto__secret_key_bytes_impl(ptr, rust_vec_len, data_len), - 32 => wire__crate__api__crypto__set_default_ss58_prefix_impl(ptr, rust_vec_len, data_len), - 33 => wire__crate__api__crypto__sign_message_impl(ptr, rust_vec_len, data_len), - 34 => wire__crate__api__crypto__sign_message_with_pubkey_impl(ptr, rust_vec_len, data_len), - 35 => wire__crate__api__crypto__signature_bytes_impl(ptr, rust_vec_len, data_len), - 36 => wire__crate__api__crypto__ss58_to_account_id_impl(ptr, rust_vec_len, data_len), - 37 => wire__crate__api__crypto__to_account_id_impl(ptr, rust_vec_len, data_len), - 38 => wire__crate__api__crypto__verify_message_impl(ptr, rust_vec_len, data_len), - 39 => wire__crate__api__wormhole__wormhole_compute_output_amount_impl( + 36 => wire__crate__api__crypto__public_key_bytes_impl(ptr, rust_vec_len, data_len), + 37 => wire__crate__api__crypto__secret_key_bytes_impl(ptr, rust_vec_len, data_len), + 38 => wire__crate__api__crypto__set_default_ss58_prefix_impl(ptr, rust_vec_len, data_len), + 39 => wire__crate__api__crypto__sign_message_impl(ptr, rust_vec_len, data_len), + 40 => wire__crate__api__crypto__sign_message_with_pubkey_impl(ptr, rust_vec_len, data_len), + 41 => wire__crate__api__near__sign_near_transaction_impl(ptr, rust_vec_len, data_len), + 42 => wire__crate__api__crypto__signature_bytes_impl(ptr, rust_vec_len, data_len), + 43 => wire__crate__api__crypto__ss58_to_account_id_impl(ptr, rust_vec_len, data_len), + 44 => wire__crate__api__crypto__to_account_id_impl(ptr, rust_vec_len, data_len), + 45 => wire__crate__api__crypto__verify_message_impl(ptr, rust_vec_len, data_len), + 46 => wire__crate__api__near__verify_near_signature_impl(ptr, rust_vec_len, data_len), + 47 => wire__crate__api__wormhole__wormhole_compute_output_amount_impl( ptr, rust_vec_len, data_len, ), - 40 => wire__crate__api__wormhole__zk_circuits_version_impl(ptr, rust_vec_len, data_len), + 48 => wire__crate__api__wormhole__zk_circuits_version_impl(ptr, rust_vec_len, data_len), _ => unreachable!(), } } @@ -1948,6 +2330,87 @@ impl flutter_rust_bridge::IntoIntoDart } } // Codec=Dco (DartCObject based), see doc to use other codecs +impl flutter_rust_bridge::IntoDart for crate::api::near::NearAction { + fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi { + [ + self.kind.into_into_dart().into_dart(), + self.target.into_into_dart().into_dart(), + self.args.into_into_dart().into_dart(), + self.gas.into_into_dart().into_dart(), + self.amount.into_into_dart().into_dart(), + self.public_key.into_into_dart().into_dart(), + self.full_access.into_into_dart().into_dart(), + self.method_names.into_into_dart().into_dart(), + self.code_len.into_into_dart().into_dart(), + self.code_hash.into_into_dart().into_dart(), + ] + .into_dart() + } +} +impl flutter_rust_bridge::for_generated::IntoDartExceptPrimitive for crate::api::near::NearAction {} +impl flutter_rust_bridge::IntoIntoDart + for crate::api::near::NearAction +{ + fn into_into_dart(self) -> crate::api::near::NearAction { + self + } +} +// Codec=Dco (DartCObject based), see doc to use other codecs +impl flutter_rust_bridge::IntoDart for crate::api::near::NearActionKind { + fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi { + match self { + Self::CreateAccount => 0.into_dart(), + Self::DeployContract => 1.into_dart(), + Self::FunctionCall => 2.into_dart(), + Self::Transfer => 3.into_dart(), + Self::Stake => 4.into_dart(), + Self::AddKey => 5.into_dart(), + Self::DeleteKey => 6.into_dart(), + Self::DeleteAccount => 7.into_dart(), + _ => unreachable!(), + } + } +} +impl flutter_rust_bridge::for_generated::IntoDartExceptPrimitive + for crate::api::near::NearActionKind +{ +} +impl flutter_rust_bridge::IntoIntoDart + for crate::api::near::NearActionKind +{ + fn into_into_dart(self) -> crate::api::near::NearActionKind { + self + } +} +// Codec=Dco (DartCObject based), see doc to use other codecs +impl flutter_rust_bridge::IntoDart for crate::api::near::NearTransaction { + fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi { + [ + self.signer_id.into_into_dart().into_dart(), + self.public_key.into_into_dart().into_dart(), + self.public_key_bytes.into_into_dart().into_dart(), + self.signs_with_ml_dsa_65.into_into_dart().into_dart(), + self.nonce.into_into_dart().into_dart(), + self.receiver_id.into_into_dart().into_dart(), + self.block_hash.into_into_dart().into_dart(), + self.hash.into_into_dart().into_dart(), + self.actions.into_into_dart().into_dart(), + ] + .into_dart() + } +} +impl flutter_rust_bridge::for_generated::IntoDartExceptPrimitive + for crate::api::near::NearTransaction +{ +} +impl flutter_rust_bridge::IntoIntoDart + for crate::api::near::NearTransaction +{ + fn into_into_dart(self) -> crate::api::near::NearTransaction { + self + } +} +// Codec=Dco (DartCObject based), see doc to use other codecs impl flutter_rust_bridge::IntoDart for crate::api::wormhole::ProofInput { fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi { [ @@ -2075,6 +2538,13 @@ impl SseEncode for String { } } +impl SseEncode for u128 { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + ::sse_encode(self.to_string(), serializer); + } +} + impl SseEncode for crate::api::airdrop::AirdropMatch { // Codec=Sse (Serialization based), see doc to use other codecs fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { @@ -2176,6 +2646,16 @@ impl SseEncode for Vec> { } } +impl SseEncode for Vec { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + ::sse_encode(self.len() as _, serializer); + for item in self { + ::sse_encode(item, serializer); + } + } +} + impl SseEncode for Vec { // Codec=Sse (Serialization based), see doc to use other codecs fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { @@ -2194,6 +2674,59 @@ impl SseEncode for crate::api::wormhole::MerkleProcessed { } } +impl SseEncode for crate::api::near::NearAction { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + ::sse_encode(self.kind, serializer); + >::sse_encode(self.target, serializer); + >>::sse_encode(self.args, serializer); + >::sse_encode(self.gas, serializer); + >::sse_encode(self.amount, serializer); + >::sse_encode(self.public_key, serializer); + ::sse_encode(self.full_access, serializer); + >::sse_encode(self.method_names, serializer); + >::sse_encode(self.code_len, serializer); + >>::sse_encode(self.code_hash, serializer); + } +} + +impl SseEncode for crate::api::near::NearActionKind { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + ::sse_encode( + match self { + crate::api::near::NearActionKind::CreateAccount => 0, + crate::api::near::NearActionKind::DeployContract => 1, + crate::api::near::NearActionKind::FunctionCall => 2, + crate::api::near::NearActionKind::Transfer => 3, + crate::api::near::NearActionKind::Stake => 4, + crate::api::near::NearActionKind::AddKey => 5, + crate::api::near::NearActionKind::DeleteKey => 6, + crate::api::near::NearActionKind::DeleteAccount => 7, + _ => { + unimplemented!(""); + } + }, + serializer, + ); + } +} + +impl SseEncode for crate::api::near::NearTransaction { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + ::sse_encode(self.signer_id, serializer); + ::sse_encode(self.public_key, serializer); + >::sse_encode(self.public_key_bytes, serializer); + ::sse_encode(self.signs_with_ml_dsa_65, serializer); + ::sse_encode(self.nonce, serializer); + ::sse_encode(self.receiver_id, serializer); + ::sse_encode(self.block_hash, serializer); + >::sse_encode(self.hash, serializer); + >::sse_encode(self.actions, serializer); + } +} + impl SseEncode for Option { // Codec=Sse (Serialization based), see doc to use other codecs fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { @@ -2204,6 +2737,16 @@ impl SseEncode for Option { } } +impl SseEncode for Option { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + ::sse_encode(self.is_some(), serializer); + if let Some(value) = self { + ::sse_encode(value, serializer); + } + } +} + impl SseEncode for Option { // Codec=Sse (Serialization based), see doc to use other codecs fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { @@ -2214,6 +2757,16 @@ impl SseEncode for Option { } } +impl SseEncode for Option { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + ::sse_encode(self.is_some(), serializer); + if let Some(value) = self { + ::sse_encode(value, serializer); + } + } +} + impl SseEncode for Option> { // Codec=Sse (Serialization based), see doc to use other codecs fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { diff --git a/quantus_sdk/test/models/near_public_key_export_test.dart b/quantus_sdk/test/models/near_public_key_export_test.dart new file mode 100644 index 000000000..8ef40beaf --- /dev/null +++ b/quantus_sdk/test/models/near_public_key_export_test.dart @@ -0,0 +1,41 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:quantus_sdk/quantus_sdk.dart'; + +void main() { + const address = 'qzmNaLjPU7hcvkjHpGmrVDPD9y12vdAFimCSrP1GkhVFJMaUq'; + const key = 'ml-dsa-65:3xTqD5Fh2kLm9nPq'; + + group('NearPublicKeyExport', () { + test('encodes to the self-describing JSON a reader dispatches on', () { + final json = jsonDecode(utf8.decode(const NearPublicKeyExport(address: address, nearPublicKey: key).encode())); + expect(json, {'v': 1, 'kind': 'near-public-key', 'address': address, 'near_public_key': key}); + }); + + test('round-trips', () { + const export = NearPublicKeyExport(address: address, nearPublicKey: key); + final decoded = NearPublicKeyExport.decode(export.encode()); + expect(decoded.address, address); + expect(decoded.nearPublicKey, key); + }); + + test('rejects a wrong version, kind, key prefix, missing or extra key, and non-JSON', () { + Uint8List bytes(Map json) => Uint8List.fromList(utf8.encode(jsonEncode(json))); + const good = {'v': 1, 'kind': 'near-public-key', 'address': address, 'near_public_key': key}; + for (final bad in [ + {...good, 'v': 2}, + {...good, 'kind': 'quantus-public-key'}, + {...good, 'near_public_key': 'ed25519:abc'}, + {...good, 'address': ''}, + {...good}..remove('address'), + {...good, 'extra': 1}, + ]) { + expect(() => NearPublicKeyExport.decode(bytes(bad)), throwsFormatException, reason: '$bad'); + } + expect(() => NearPublicKeyExport.decode(Uint8List.fromList([0xff, 0x00])), throwsFormatException); + expect(() => NearPublicKeyExport.decode(Uint8List.fromList(utf8.encode('[1]'))), throwsFormatException); + }); + }); +} diff --git a/quantus_sdk/test/models/signing_request_test.dart b/quantus_sdk/test/models/signing_request_test.dart index c82c8414f..1b6a65d4b 100644 --- a/quantus_sdk/test/models/signing_request_test.dart +++ b/quantus_sdk/test/models/signing_request_test.dart @@ -75,4 +75,75 @@ void main() { ); }); }); + + group('NearSigningRequest (version 2)', () { + final transaction = Uint8List.fromList([0x0d, 0, 0, 0, 0x61]); + final nearJson = {'v': 2, 'chain': 'near', 'network': 'mainnet', 'payload': '0x0d00000061'}; + + test('round-trips the network and the transaction', () { + final decoded = NearSigningRequest.decode( + NearSigningRequest(network: 'testnet', transaction: transaction).encode(), + ); + + expect(decoded.network, 'testnet'); + expect(decoded.transaction, transaction); + }); + + test('carries exactly the keys the CLI writes', () { + final json = jsonDecode(utf8.decode(NearSigningRequest(network: 'mainnet', transaction: transaction).encode())); + + expect(json, nearJson); + }); + + test('is what AnySigningRequest.decode returns for v2, and v1 stays v1', () { + final near = AnySigningRequest.decode(envelope(nearJson)); + expect(near, isA().having((r) => r.network, 'network', 'mainnet')); + + final quantus = AnySigningRequest.decode(SigningRequest(signer: signer, payload: payload).encode()); + expect(quantus, isA().having((r) => r.signer, 'signer', signer)); + + expect( + () => AnySigningRequest.decode(envelope({'v': 3, 'payload': '0x00'})), + throwsA(isA().having((e) => e.requested, 'requested', 3)), + ); + expect(() => AnySigningRequest.decode(payload), throwsFormatException); + }); + + test('rejects another chain, version 1, an empty network, a signer key, a missing key', () { + expect(() => NearSigningRequest.decode(envelope({...nearJson, 'chain': 'solana'})), throwsFormatException); + expect( + () => NearSigningRequest.decode(envelope({...nearJson, 'v': 1})), + throwsA(isA()), + ); + expect(() => NearSigningRequest.decode(envelope({...nearJson, 'network': ''})), throwsFormatException); + expect(() => NearSigningRequest.decode(envelope({...nearJson, 'signer': signer})), throwsFormatException); + expect(() => NearSigningRequest.decode(envelope({...nearJson}..remove('network'))), throwsFormatException); + expect(() => NearSigningRequest.decode(envelope({...nearJson, 'payload': '0x'})), throwsFormatException); + expect(() => NearSigningRequest.decode(envelope({...nearJson, 'payload': 'abcd'})), throwsFormatException); + }); + + test('accepts only the exact supported network labels, never a lookalike', () { + for (final network in NearSigningRequest.supportedNetworks) { + expect(NearSigningRequest.decode(envelope({...nearJson, 'network': network})).network, network); + } + // A trailing space, different case, or a zero-width character would + // read as "testnet" on screen while disabling the testnet checks. + for (final lookalike in [ + 'testnet ', + ' testnet', + 'Testnet', + 'TESTNET', + 'test\u200Bnet', + 'mainnet\n', + 'localnet', + ]) { + expect( + () => NearSigningRequest.decode(envelope({...nearJson, 'network': lookalike})), + throwsFormatException, + reason: jsonEncode(lookalike), + ); + } + expect(() => NearSigningRequest.decode(envelope({...nearJson, 'network': 1})), throwsFormatException); + }); + }); }