From 7b89f7a8e50dfb95abddb5d2a9b12af7d073c466 Mon Sep 17 00:00:00 2001 From: Navid Rahimi Date: Thu, 3 Sep 2026 19:50:09 +0330 Subject: [PATCH] Spark Names: add ownership proof generation --- .../sub_widgets/spark_name_details.dart | 177 ++++++++++++++++++ .../spark_interface.dart | 90 ++++++++- .../interfaces/lib_spark_interface.dart | 8 + pubspec.lock | 6 +- .../templates/pubspec.template.yaml | 4 +- ...IRO_lib_spark_interface_impl.template.dart | 16 ++ 6 files changed, 293 insertions(+), 8 deletions(-) diff --git a/lib/pages/spark_names/sub_widgets/spark_name_details.dart b/lib/pages/spark_names/sub_widgets/spark_name_details.dart index 6a5c75a753..6060ac1385 100644 --- a/lib/pages/spark_names/sub_widgets/spark_name_details.dart +++ b/lib/pages/spark_names/sub_widgets/spark_name_details.dart @@ -1,3 +1,5 @@ +import 'dart:convert'; + import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -7,10 +9,12 @@ import '../../../providers/db/drift_provider.dart'; import '../../../providers/db/main_db_provider.dart'; import '../../../providers/global/wallets_provider.dart'; import '../../../themes/stack_colors.dart'; +import '../../../utilities/show_loading.dart'; import '../../../utilities/text_styles.dart'; import '../../../utilities/util.dart'; import '../../../wallets/isar/providers/wallet_info_provider.dart'; import '../../../wallets/wallet/wallet_mixin_interfaces/spark_interface.dart'; +import '../../../wl_gen/interfaces/lib_spark_interface.dart'; import '../../../widgets/background.dart'; import '../../../widgets/conditional_parent.dart'; import '../../../widgets/custom_buttons/app_bar_icon_button.dart'; @@ -19,6 +23,8 @@ import '../../../widgets/desktop/desktop_dialog_close_button.dart'; import '../../../widgets/desktop/primary_button.dart'; import '../../../widgets/dialogs/s_dialog.dart'; import '../../../widgets/rounded_container.dart'; +import '../../../widgets/textfields/adaptive_text_field.dart'; +import '../../signing/signing_view.dart'; import '../../wallet_view/transaction_views/transaction_details_view.dart' as tvd; import '../buy_spark_name_view.dart'; @@ -131,6 +137,32 @@ class _SparkNameDetailsViewState extends ConsumerState { } } + Future _proveOwnership() async { + if (_lock) return; + _lock = true; + try { + await showDialog( + context: context, + builder: (context) => SDialog( + margin: Util.isDesktop + ? null + : EdgeInsets.fromLTRB( + 16, + 16, + 16, + 16 + MediaQuery.viewInsetsOf(context).bottom, + ), + child: _SparkNameOwnershipProofDialog( + walletId: widget.walletId, + address: name.address, + ), + ), + ); + } finally { + _lock = false; + } + } + @override void initState() { super.initState(); @@ -435,6 +467,18 @@ class _SparkNameDetailsViewState extends ConsumerState { ], ), ), + if (!_isViewOnlyWallet) ...[ + const _Div(), + Padding( + padding: Util.isDesktop + ? const EdgeInsets.all(16) + : EdgeInsets.zero, + child: PrimaryButton( + label: "Prove ownership", + onPressed: _proveOwnership, + ), + ), + ], ], ); }, @@ -444,6 +488,139 @@ class _SparkNameDetailsViewState extends ConsumerState { } } +class _SparkNameOwnershipProofDialog extends ConsumerStatefulWidget { + const _SparkNameOwnershipProofDialog({ + required this.walletId, + required this.address, + }); + + final String walletId; + final String address; + + @override + ConsumerState<_SparkNameOwnershipProofDialog> createState() => + _SparkNameOwnershipProofDialogState(); +} + +class _SparkNameOwnershipProofDialogState + extends ConsumerState<_SparkNameOwnershipProofDialog> { + final _messageController = TextEditingController(); + + String _proof = ""; + String? _messageError; + bool _isGenerating = false; + + @override + void dispose() { + _messageController.dispose(); + super.dispose(); + } + + void _onMessageChanged(String message) { + final maxLength = libSpark.maxSparkMessageLengthBytes; + final byteLength = utf8.encode(message).length; + + setState(() { + _proof = ""; + _messageError = byteLength > maxLength + ? "Message exceeds $maxLength UTF-8 bytes" + : null; + }); + } + + Future _generateProof() async { + if (_isGenerating || + _messageController.text.isEmpty || + _messageError != null) { + return; + } + + setState(() => _isGenerating = true); + Exception? exception; + final proof = await showLoading( + whileFuture: + (ref.read(pWallets).getWallet(widget.walletId) as SparkInterface) + .createSparkAddressOwnershipProof( + address: widget.address, + message: _messageController.text, + ), + context: context, + message: "Creating proof...", + onException: (e) => exception = e, + ); + + if (!mounted) return; + setState(() => _isGenerating = false); + + if (exception != null) { + await showSignVerifyError(exception!, context: context); + } else if (proof != null) { + setState(() => _proof = proof); + } + } + + @override + Widget build(BuildContext context) { + final canGenerate = + !_isGenerating && + _messageController.text.isNotEmpty && + _messageError == null; + + return SizedBox( + width: Util.isDesktop ? 520 : null, + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + "Prove ownership", + style: Util.isDesktop + ? STextStyles.desktopH3(context) + : STextStyles.pageTitleH2(context), + ), + if (Util.isDesktop) const DesktopDialogCloseButton(), + ], + ), + const SizedBox(height: 20), + Text("Message", style: STextStyles.w500_14(context)), + const SizedBox(height: 8), + AdaptiveTextField( + controller: _messageController, + minLines: 3, + maxLines: 5, + errorText: _messageError, + onChangedComprehensive: _onMessageChanged, + ), + if (_proof.isNotEmpty) ...[ + const SizedBox(height: 20), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text("Proof", style: STextStyles.w500_14(context)), + SimpleCopyButton(data: _proof), + ], + ), + const SizedBox(height: 8), + SelectableText(_proof, style: STextStyles.w500_14(context)), + ], + const SizedBox(height: 24), + PrimaryButton( + label: "Create proof", + enabled: canGenerate, + onPressed: canGenerate ? _generateProof : null, + ), + ], + ), + ), + ); + } +} + class _Div extends StatelessWidget { const _Div({super.key}); diff --git a/lib/wallets/wallet/wallet_mixin_interfaces/spark_interface.dart b/lib/wallets/wallet/wallet_mixin_interfaces/spark_interface.dart index 193bb133f1..0bc39a6c85 100644 --- a/lib/wallets/wallet/wallet_mixin_interfaces/spark_interface.dart +++ b/lib/wallets/wallet/wallet_mixin_interfaces/spark_interface.dart @@ -130,6 +130,25 @@ bool shouldSubtractSparkFeeFromAmount({ void initSparkLogging(Level level) => libSpark.initSparkLogging(level); +({String? proof, String? error}) _createSparkAddressOwnershipProof( + ({String message, String privateKeyHex, int spendKeyIndex, int diversifier}) + args, +) { + try { + return ( + proof: libSpark.createSparkAddressOwnershipProof( + message: args.message, + privateKeyHex: args.privateKeyHex, + spendKeyIndex: args.spendKeyIndex, + diversifier: args.diversifier, + ), + error: null, + ); + } catch (e) { + return (proof: null, error: e.toString()); + } +} + abstract class _SparkIsolate { static Isolate? _isolate; static SendPort? _sendPort; @@ -189,6 +208,8 @@ Future computeWithLibSparkLogging( mixin SparkInterface on Bip39HDWallet, ElectrumXInterface { + static const _sparkNameLookAheadCount = 100; + Address? _currentSparkAddress; String? _viewKeyHex; @@ -1872,12 +1893,10 @@ mixin SparkInterface // some look ahead // TODO revisit this and clean up (track pre gen'd addresses instead of // generating every time) arbitrary number of addresses - const lookAheadCount = 100; - // force unwrap optional should be fine here. If not then the // eclosing function is being called somewhere it probably shouldn't be. int diversifier = _currentSparkAddress!.derivationIndex; - final maxDiversifier = diversifier + lookAheadCount; + final maxDiversifier = diversifier + _sparkNameLookAheadCount; while (diversifier < maxDiversifier) { // change address check @@ -2915,6 +2934,71 @@ mixin SparkInterface return txData; } + Future createSparkAddressOwnershipProof({ + required String address, + required String message, + }) async { + if (isViewOnly) { + throw Exception( + "Cannot create an ownership proof from a view only wallet", + ); + } + + final messageLength = utf8.encode(message).length; + if (messageLength == 0 || + messageLength > libSpark.maxSparkMessageLengthBytes) { + throw Exception( + "Message must contain between 1 and " + "${libSpark.maxSparkMessageLengthBytes} UTF-8 bytes", + ); + } + + Address? sparkAddress = await mainDB.getAddress(walletId, address); + if (sparkAddress == null) { + final currentDiversifier = + (await getCurrentReceivingSparkAddress())?.derivationIndex; + if (currentDiversifier != null) { + var diversifier = currentDiversifier; + final maxDiversifier = diversifier + _sparkNameLookAheadCount; + while (diversifier < maxDiversifier) { + if (diversifier == libSpark.sparkChange) { + diversifier++; + } + final candidate = await _generateSparkAddress(diversifier++); + if (candidate.value == address) { + sparkAddress = candidate; + break; + } + } + } + } + if (sparkAddress == null || sparkAddress.type != AddressType.spark) { + throw Exception("Spark address does not belong to this wallet"); + } + if (sparkAddress.derivationIndex < 0) { + throw Exception("Spark address diversifier is unavailable"); + } + + final root = await getRootHDNode(); + final privateKeyHex = root + .derivePath(sparkDerivationPath) + .privateKey + .data + .toHex; + + final result = + await computeWithLibSparkLogging(_createSparkAddressOwnershipProof, ( + message: message, + privateKeyHex: privateKeyHex, + spendKeyIndex: sparkIndex, + diversifier: sparkAddress.derivationIndex, + )); + if (result.error != null) { + throw Exception(result.error); + } + return result.proof!; + } + @override Future updateBalance() async { // call to super to update transparent balance diff --git a/lib/wl_gen/interfaces/lib_spark_interface.dart b/lib/wl_gen/interfaces/lib_spark_interface.dart index 06ddf07c49..f60f3b0c56 100644 --- a/lib/wl_gen/interfaces/lib_spark_interface.dart +++ b/lib/wl_gen/interfaces/lib_spark_interface.dart @@ -41,6 +41,7 @@ abstract class LibSparkInterface { int get maxNameRegistrationLengthYears; int get maxNameLength; int get maxAdditionalInfoLengthBytes; + int get maxSparkMessageLengthBytes; String get nameRegexString; String get stage3CommunityFundAddressMainNet; String get stage3CommunityFundAddressTestNet; @@ -77,6 +78,13 @@ abstract class LibSparkInterface { required bool ignoreProof, }); + String createSparkAddressOwnershipProof({ + required String message, + required String privateKeyHex, + required int spendKeyIndex, + required int diversifier, + }); + Uint8List getSparkNameCommitment({ required Uint8List serializedSparkNameData, }); diff --git a/pubspec.lock b/pubspec.lock index 6164f3dbbf..fceaa82291 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1035,9 +1035,9 @@ packages: dependency: "direct main" description: path: "." - ref: e017e62766908e9714c5309761183e8be8b37799 - resolved-ref: e017e62766908e9714c5309761183e8be8b37799 - url: "https://github.com/cypherstack/flutter_libsparkmobile.git" + ref: 9d628691842d50cf5c8b69aa3f9cb17d3afd55b0 + resolved-ref: 9d628691842d50cf5c8b69aa3f9cb17d3afd55b0 + url: "https://github.com/navidR/flutter_libsparkmobile.git" source: git version: "0.1.0" flutter_lints: diff --git a/scripts/app_config/templates/pubspec.template.yaml b/scripts/app_config/templates/pubspec.template.yaml index a9161185f8..1de9cc039d 100644 --- a/scripts/app_config/templates/pubspec.template.yaml +++ b/scripts/app_config/templates/pubspec.template.yaml @@ -43,8 +43,8 @@ dependencies: # %%ENABLE_FIRO%% # flutter_libsparkmobile: # git: -# url: https://github.com/cypherstack/flutter_libsparkmobile.git -# ref: e017e62766908e9714c5309761183e8be8b37799 +# url: https://github.com/navidR/flutter_libsparkmobile.git +# ref: 9d628691842d50cf5c8b69aa3f9cb17d3afd55b0 # %%END_ENABLE_FIRO%% # %%ENABLE_EPIC%% diff --git a/tool/wl_templates/FIRO_lib_spark_interface_impl.template.dart b/tool/wl_templates/FIRO_lib_spark_interface_impl.template.dart index 5408f11fa6..39c6966414 100644 --- a/tool/wl_templates/FIRO_lib_spark_interface_impl.template.dart +++ b/tool/wl_templates/FIRO_lib_spark_interface_impl.template.dart @@ -38,6 +38,9 @@ class _LibSparkInterfaceImpl extends LibSparkInterface { @override int get maxAdditionalInfoLengthBytes => kMaxAdditionalInfoLengthBytes; + @override + int get maxSparkMessageLengthBytes => kMaxSparkMessageLengthBytes; + @override int get maxNameLength => kMaxNameLength; @@ -139,6 +142,19 @@ class _LibSparkInterfaceImpl extends LibSparkInterface { ignoreProof: ignoreProof, ); + @override + String createSparkAddressOwnershipProof({ + required String message, + required String privateKeyHex, + required int spendKeyIndex, + required int diversifier, + }) => LibSpark.createSparkAddressOwnershipProof( + message: message, + privateKeyHex: privateKeyHex, + spendKeyIndex: spendKeyIndex, + diversifier: diversifier, + ); + @override Uint8List getSparkNameCommitment({ required Uint8List serializedSparkNameData,