diff --git a/lib/pages/signing/signing_view.dart b/lib/pages/signing/signing_view.dart index 9afaf36d20..627141963e 100644 --- a/lib/pages/signing/signing_view.dart +++ b/lib/pages/signing/signing_view.dart @@ -1,9 +1,11 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../providers/global/wallets_provider.dart'; import '../../themes/stack_colors.dart'; import '../../utilities/text_styles.dart'; import '../../utilities/util.dart'; +import '../../wallets/wallet/wallet_mixin_interfaces/view_only_option_interface.dart'; import '../../widgets/background.dart'; import '../../widgets/conditional_parent.dart'; import '../../widgets/custom_buttons/app_bar_icon_button.dart'; @@ -31,14 +33,16 @@ class _SigningViewState extends ConsumerState { // keep auto dispose providers alive ref.listen(pSignIsValid, (_, __) {}); ref.listen(pVerifyIsValid, (_, __) {}); + final wallet = ref.watch(pWallets).getWallet(widget.walletId); + final viewOnly = wallet is ViewOnlyOptionInterface && wallet.isViewOnly; return ConditionalParent( condition: !Util.isDesktop, builder: (child) => Background( child: Scaffold( - backgroundColor: Theme.of( - context, - ).extension()!.background, + backgroundColor: Theme.of(context) + .extension()! + .background, appBar: AppBar( leading: AppBarBackButton( onPressed: () { @@ -46,20 +50,21 @@ class _SigningViewState extends ConsumerState { }, ), title: Text( - "Sign / Verify", + viewOnly ? "Verify message" : "Sign / Verify", style: STextStyles.navBarTitle(context), ), ), - body: SafeArea(child: child), + body: SafeArea(child: SingleChildScrollView(child: child)), ), ), child: CustomTabView( - titles: const ["Sign message", "Verify message"], + titles: [if (!viewOnly) "Sign message", "Verify message"], children: [ - SignMessageForm( - key: const Key("_SignMessageFormKey"), - walletId: widget.walletId, - ), + if (!viewOnly) + SignMessageForm( + key: const Key("_SignMessageFormKey"), + walletId: widget.walletId, + ), VerifyMessageForm( key: const Key("_VerifyMessageFormKey"), walletId: widget.walletId, diff --git a/lib/pages/signing/sub_widgets/sign_message_tab.dart b/lib/pages/signing/sub_widgets/sign_message_tab.dart index e7970486e9..43142de641 100644 --- a/lib/pages/signing/sub_widgets/sign_message_tab.dart +++ b/lib/pages/signing/sub_widgets/sign_message_tab.dart @@ -19,6 +19,8 @@ import '../../../widgets/detail_item.dart'; import '../../../widgets/dialogs/s_dialog.dart'; import '../../../widgets/rounded_container.dart'; import '../../../widgets/textfields/adaptive_text_field.dart'; +import '../../wallet_view/transaction_views/transaction_details_view.dart' + show IconCopyButton; import '../signing_view.dart'; import 'address_list.dart'; @@ -32,7 +34,7 @@ final class _SignState { required this.signature, }); - bool get isValid => message.isNotEmpty && address != null; + bool get isValid => message.trim().isNotEmpty && address != null; _SignState copyWith({String? message, String? signature}) { return _SignState( @@ -77,9 +79,9 @@ class _SignMessageFormState extends ConsumerState { TextStyle _getStyle(BuildContext context) { return Util.isDesktop ? STextStyles.desktopTextExtraExtraSmall(context).copyWith( - color: Theme.of( - context, - ).extension()!.textFieldActiveSearchIconRight, + color: Theme.of(context) + .extension()! + .textFieldActiveSearchIconRight, ) : STextStyles.smallMed12(context); } @@ -133,9 +135,9 @@ class _SignMessageFormState extends ConsumerState { child: RoundedContainer( padding: EdgeInsets.zero, color: Colors.transparent, - borderColor: Theme.of( - context, - ).extension()!.textFieldDefaultBG, + borderColor: Theme.of(context) + .extension()! + .textFieldDefaultBG, child: child, ), ), @@ -181,9 +183,11 @@ class _SignMessageFormState extends ConsumerState { onException: (e) => ex = e, ); - if (mounted && ex != null) { + if (!mounted || !identical(ref.read(_pSignState), state)) return; + + if (ex != null) { await showSignVerifyError(ex!, context: context); - } else if (signature != null && mounted) { + } else if (signature != null) { ref.read(_pSignState.notifier).state = state.copyWith( signature: signature, ); @@ -214,7 +218,13 @@ class _SignMessageFormState extends ConsumerState { AdaptiveTextField( controller: messageController, showPasteClearButton: true, - maxLines: 1, + trimPastedText: false, + autocorrect: false, + smartDashesType: SmartDashesType.disabled, + smartQuotesType: SmartQuotesType.disabled, + enableSuggestions: false, + minLines: 1, + maxLines: 5, onChangedComprehensive: (_) { if (mounted) { ref.read(_pSignState.notifier).state = ref @@ -249,6 +259,8 @@ class _SignMessageFormState extends ConsumerState { noPadding: Util.isDesktop, button: ref.watch(_pSignState.select((s) => s.signature)).isEmpty ? null + : Util.isDesktop + ? IconCopyButton(data: ref.read(_pSignState).signature) : SimpleCopyButton(data: ref.read(_pSignState).signature), ), diff --git a/lib/pages/signing/sub_widgets/verify_message_tab.dart b/lib/pages/signing/sub_widgets/verify_message_tab.dart index 0a33183174..86137ed061 100644 --- a/lib/pages/signing/sub_widgets/verify_message_tab.dart +++ b/lib/pages/signing/sub_widgets/verify_message_tab.dart @@ -67,9 +67,9 @@ class _VerifyMessageFormState extends ConsumerState { TextStyle _getStyle(BuildContext context) { return Util.isDesktop ? STextStyles.desktopTextExtraExtraSmall(context).copyWith( - color: Theme.of( - context, - ).extension()!.textFieldActiveSearchIconRight, + color: Theme.of(context) + .extension()! + .textFieldActiveSearchIconRight, ) : STextStyles.smallMed12(context); } @@ -84,6 +84,7 @@ class _VerifyMessageFormState extends ConsumerState { _verify = IfNotAlreadyAsync(() async { Exception? ex; + final state = ref.read(_pVerifyState); final verified = await showLoading( whileFuture: @@ -100,7 +101,7 @@ class _VerifyMessageFormState extends ConsumerState { onException: (e) => ex = e, ); - if (mounted) { + if (mounted && identical(ref.read(_pVerifyState), state)) { if (ex != null) { await showSignVerifyError(ex!, context: context); } else { @@ -145,7 +146,13 @@ class _VerifyMessageFormState extends ConsumerState { AdaptiveTextField( controller: messageController, showPasteClearButton: true, - maxLines: 1, + trimPastedText: false, + autocorrect: false, + smartDashesType: SmartDashesType.disabled, + smartQuotesType: SmartQuotesType.disabled, + enableSuggestions: false, + minLines: 1, + maxLines: 5, onChangedComprehensive: (_) { if (mounted) { ref.read(_pVerifyState.notifier).state = ref 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..87b65c981c 100644 --- a/lib/pages/spark_names/sub_widgets/spark_name_details.dart +++ b/lib/pages/spark_names/sub_widgets/spark_name_details.dart @@ -7,6 +7,7 @@ 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'; @@ -19,6 +20,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 +134,34 @@ class _SparkNameDetailsViewState extends ConsumerState { } } + Future _proveOwnership() async { + if (_lock) return; + _lock = true; + try { + if (Util.isDesktop) { + await showDialog( + context: context, + builder: (context) => Padding( + padding: MediaQuery.viewInsetsOf(context), + child: SDialog( + child: SparkAddressOwnershipProofView( + walletId: widget.walletId, + address: name.address, + ), + ), + ), + ); + } else { + await Navigator.of(context).pushNamed( + SparkAddressOwnershipProofView.routeName, + arguments: (walletId: widget.walletId, address: name.address), + ); + } + } finally { + _lock = false; + } + } + @override void initState() { super.initState(); @@ -227,9 +258,9 @@ class _SparkNameDetailsViewState extends ConsumerState { child: RoundedContainer( padding: EdgeInsets.zero, color: Colors.transparent, - borderColor: Theme.of( - context, - ).extension()!.textFieldDefaultBG, + borderColor: Theme.of(context) + .extension()! + .textFieldDefaultBG, child: child, ), ), @@ -283,9 +314,9 @@ class _SparkNameDetailsViewState extends ConsumerState { Text( "Address", style: STextStyles.w500_14(context).copyWith( - color: Theme.of( - context, - ).extension()!.textSubtitle1, + color: Theme.of(context) + .extension()! + .textSubtitle1, ), ), Util.isDesktop @@ -320,9 +351,9 @@ class _SparkNameDetailsViewState extends ConsumerState { : const EdgeInsets.all(12), color: Util.isDesktop ? Colors.transparent - : Theme.of( - context, - ).extension()!.popupBG, + : Theme.of(context) + .extension()! + .popupBG, child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: @@ -382,17 +413,16 @@ class _SparkNameDetailsViewState extends ConsumerState { Text( "Expiry", style: STextStyles.w500_14(context).copyWith( - color: Theme.of( - context, - ).extension()!.textSubtitle1, + color: Theme.of(context) + .extension()! + .textSubtitle1, ), ), const SizedBox(height: 4), SelectableText( message, - style: STextStyles.w500_14( - context, - ).copyWith(color: color), + style: STextStyles.w500_14(context) + .copyWith(color: color), ), ], ), @@ -422,9 +452,9 @@ class _SparkNameDetailsViewState extends ConsumerState { Text( "Additional info", style: STextStyles.w500_14(context).copyWith( - color: Theme.of( - context, - ).extension()!.textSubtitle1, + color: Theme.of(context) + .extension()! + .textSubtitle1, ), ), const SizedBox(height: 4), @@ -435,6 +465,32 @@ class _SparkNameDetailsViewState extends ConsumerState { ], ), ), + if (!_isViewOnlyWallet) ...[ + const _Div(), + Padding( + padding: Util.isDesktop + ? const EdgeInsets.all(16) + : EdgeInsets.zero, + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: Util.isDesktop ? 70 : 48, + ), + child: TextButton( + style: Theme.of(context) + .extension()! + .getPrimaryEnabledButtonStyle(context), + onPressed: _proveOwnership, + child: Text( + "Prove address ownership", + textAlign: TextAlign.center, + style: Util.isDesktop + ? STextStyles.desktopButtonEnabled(context) + : STextStyles.button(context), + ), + ), + ), + ), + ], ], ); }, @@ -444,6 +500,174 @@ class _SparkNameDetailsViewState extends ConsumerState { } } +class SparkAddressOwnershipProofView extends ConsumerStatefulWidget { + const SparkAddressOwnershipProofView({ + super.key, + required this.walletId, + required this.address, + }); + + final String walletId; + final String address; + + static const routeName = "/sparkAddressOwnershipProof"; + + @override + ConsumerState createState() => + _SparkAddressOwnershipProofViewState(); +} + +class _SparkAddressOwnershipProofViewState + extends ConsumerState { + final _messageController = TextEditingController(); + + String _proof = ""; + bool _isGenerating = false; + + @override + void dispose() { + _messageController.dispose(); + super.dispose(); + } + + void _onMessageChanged(String message) { + setState(() { + _proof = ""; + }); + } + + Future _generateProof() async { + if (_isGenerating || _messageController.text.trim().isEmpty) { + return; + } + + final message = _messageController.text; + setState(() => _isGenerating = true); + Exception? exception; + final proof = await showLoading( + whileFuture: + (ref.read(pWallets).getWallet(widget.walletId) as SparkInterface) + .createSparkAddressOwnershipProof( + address: widget.address, + message: message, + ), + 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 && _messageController.text == message) { + setState(() => _proof = proof); + } + } + + @override + Widget build(BuildContext context) { + final canGenerate = + !_isGenerating && _messageController.text.trim().isNotEmpty; + + return ConditionalParent( + condition: !Util.isDesktop, + builder: (child) => Background( + child: Scaffold( + backgroundColor: Colors.transparent, + appBar: AppBar( + leading: const AppBarBackButton(), + title: Text( + "Prove address ownership", + style: STextStyles.navBarTitle(context), + ), + ), + body: SafeArea(child: SingleChildScrollView(child: child)), + ), + ), + child: SizedBox( + width: Util.isDesktop ? 580 : null, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (Util.isDesktop) + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Padding( + padding: const EdgeInsets.only(left: 32), + child: Text( + "Prove address ownership", + style: STextStyles.desktopH3(context), + ), + ), + ), + const DesktopDialogCloseButton(), + ], + ), + Padding( + padding: EdgeInsets.fromLTRB( + Util.isDesktop ? 32 : 16, + Util.isDesktop ? 10 : 16, + Util.isDesktop ? 32 : 16, + Util.isDesktop ? 32 : 16, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text("Spark address", style: STextStyles.w500_14(context)), + const SizedBox(height: 8), + SelectableText( + widget.address, + style: STextStyles.w500_14(context), + ), + const SizedBox(height: 20), + Text("Message", style: STextStyles.w500_14(context)), + const SizedBox(height: 8), + AdaptiveTextField( + controller: _messageController, + minLines: 3, + maxLines: 5, + autocorrect: false, + smartDashesType: SmartDashesType.disabled, + smartQuotesType: SmartQuotesType.disabled, + enableSuggestions: false, + readOnly: _isGenerating, + onChangedComprehensive: _onMessageChanged, + ), + if (_proof.isNotEmpty) ...[ + const SizedBox(height: 20), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text("Proof", style: STextStyles.w500_14(context)), + Util.isDesktop + ? tvd.IconCopyButton(data: _proof) + : 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/pages/wallet_view/wallet_view.dart b/lib/pages/wallet_view/wallet_view.dart index c40963153d..9ced7e8546 100644 --- a/lib/pages/wallet_view/wallet_view.dart +++ b/lib/pages/wallet_view/wallet_view.dart @@ -446,9 +446,8 @@ class _WalletViewState extends ConsumerState { if (publicBalance <= Amount.zero) { shouldPop = true; if (mounted) { - Navigator.of( - context, - ).popUntil(ModalRoute.withName(WalletView.routeName)); + Navigator.of(context) + .popUntil(ModalRoute.withName(WalletView.routeName)); unawaited( showFloatingFlushBar( type: FlushBarType.info, @@ -468,9 +467,8 @@ class _WalletViewState extends ConsumerState { } shouldPop = true; if (mounted) { - Navigator.of( - context, - ).popUntil(ModalRoute.withName(WalletView.routeName)); + Navigator.of(context) + .popUntil(ModalRoute.withName(WalletView.routeName)); unawaited( showFloatingFlushBar( type: FlushBarType.success, @@ -482,9 +480,8 @@ class _WalletViewState extends ConsumerState { } catch (e) { shouldPop = true; if (mounted) { - Navigator.of( - context, - ).popUntil(ModalRoute.withName(WalletView.routeName)); + Navigator.of(context) + .popUntil(ModalRoute.withName(WalletView.routeName)); await showDialog( context: context, builder: (_) => StackOkDialog( @@ -519,13 +516,12 @@ class _WalletViewState extends ConsumerState { child, Background( child: CustomLoadingOverlay( - message: - "Migration in progress\nThis could take a while\nPlease don't leave this screen", + message: "Migration in progress\nThis could take a while\nPlease don't leave this screen", subMessage: "This only needs to run once per wallet", eventBus: null, - textColor: Theme.of( - context, - ).extension()!.textDark, + textColor: Theme.of(context) + .extension()! + .textDark, actionButton: SecondaryButton( label: "Cancel", onPressed: () async { @@ -568,9 +564,9 @@ class _WalletViewState extends ConsumerState { child: Stack( children: [ Scaffold( - backgroundColor: Theme.of( - context, - ).extension()!.background, + backgroundColor: Theme.of(context) + .extension()! + .background, appBar: AppBar( leading: AppBarBackButton( onPressed: () { @@ -623,9 +619,9 @@ class _WalletViewState extends ConsumerState { key: const Key("walletViewRadioButton"), size: 36, shadows: const [], - color: Theme.of( - context, - ).extension()!.background, + color: Theme.of(context) + .extension()! + .background, icon: _buildNetworkIcon(_currentSyncStatus), onPressed: () { Navigator.of(context).pushNamed( @@ -649,14 +645,13 @@ class _WalletViewState extends ConsumerState { child: AspectRatio( aspectRatio: 1, child: AppBarIconButton( - semanticsLabel: - "Notifications Button. Takes To Notifications Page.", + semanticsLabel: "Notifications Button. Takes To Notifications Page.", key: const Key("walletViewAlertsButton"), size: 36, shadows: const [], - color: Theme.of( - context, - ).extension()!.background, + color: Theme.of(context) + .extension()! + .background, icon: ref.watch( notificationsProvider.select( @@ -768,14 +763,14 @@ class _WalletViewState extends ConsumerState { key: const Key("walletViewSettingsButton"), size: 36, shadows: const [], - color: Theme.of( - context, - ).extension()!.background, + color: Theme.of(context) + .extension()! + .background, icon: SvgPicture.asset( Assets.svg.bars, - color: Theme.of( - context, - ).extension()!.accentColorDark, + color: Theme.of(context) + .extension()! + .accentColorDark, width: 20, height: 20, ), @@ -799,9 +794,9 @@ class _WalletViewState extends ConsumerState { ), body: SafeArea( child: Container( - color: Theme.of( - context, - ).extension()!.background, + color: Theme.of(context) + .extension()! + .background, child: Column( children: [ const SizedBox(height: 10), @@ -849,8 +844,7 @@ class _WalletViewState extends ConsumerState { context: context, builder: (context) => StackDialog( title: "Attention!", - message: - "You're about to privatize all of your public funds.", + message: "You're about to privatize all of your public funds.", leftButton: TextButton( onPressed: () { Navigator.of(context).pop(); @@ -912,9 +906,9 @@ class _WalletViewState extends ConsumerState { "Transactions", style: STextStyles.itemSubtitle(context) .copyWith( - color: Theme.of( - context, - ).extension()!.textDark3, + color: Theme.of(context) + .extension()! + .textDark3, ), ), CustomTextButton( @@ -1139,9 +1133,9 @@ class _WalletViewState extends ConsumerState { Assets.svg.monkey, height: 20, width: 20, - color: Theme.of( - context, - ).extension()!.bottomNavIconIcon, + color: Theme.of(context) + .extension()! + .bottomNavIconIcon, ), label: "MonKey", onTap: () { @@ -1151,17 +1145,18 @@ class _WalletViewState extends ConsumerState { ); }, ), - if (wallet is SignVerifyInterface && !viewOnly) + if (wallet is SignVerifyInterface && + (!viewOnly || wallet is SparkInterface)) WalletNavigationBarItemData( icon: SvgPicture.asset( Assets.svg.pencil, height: 20, width: 20, - color: Theme.of( - context, - ).extension()!.bottomNavIconIcon, + color: Theme.of(context) + .extension()! + .bottomNavIconIcon, ), - label: "Sign/Verify", + label: viewOnly ? "Verify message" : "Sign/Verify", onTap: () { Navigator.of(context).pushNamed( SigningView.routeName, @@ -1213,9 +1208,9 @@ class _WalletViewState extends ConsumerState { height: 20, width: 20, colorFilter: ColorFilter.mode( - Theme.of( - context, - ).extension()!.bottomNavIconIcon, + Theme.of(context) + .extension()! + .bottomNavIconIcon, BlendMode.srcIn, ), ), diff --git a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_wallet_features.dart b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_wallet_features.dart index 4793f0ada3..936e079e10 100644 --- a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_wallet_features.dart +++ b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_wallet_features.dart @@ -149,15 +149,13 @@ class _DesktopWalletFeaturesState extends ConsumerState { } void _onSparkCoinsPressed() { - Navigator.of( - context, - ).pushNamed(SparkCoinsView.routeName, arguments: widget.walletId); + Navigator.of(context) + .pushNamed(SparkCoinsView.routeName, arguments: widget.walletId); } void _onMwebUtxosPressed() { - Navigator.of( - context, - ).pushNamed(MwebUtxosView.routeName, arguments: widget.walletId); + Navigator.of(context) + .pushNamed(MwebUtxosView.routeName, arguments: widget.walletId); } Future _onAnonymizeAllPressed() async { @@ -230,9 +228,8 @@ class _DesktopWalletFeaturesState extends ConsumerState { shouldPop = true; if (context.mounted) { Navigator.of(context, rootNavigator: true).pop(); - Navigator.of( - context, - ).popUntil(ModalRoute.withName(DesktopWalletView.routeName)); + Navigator.of(context) + .popUntil(ModalRoute.withName(DesktopWalletView.routeName)); unawaited( showFloatingFlushBar( type: FlushBarType.info, @@ -253,9 +250,8 @@ class _DesktopWalletFeaturesState extends ConsumerState { shouldPop = true; if (mounted) { Navigator.of(context, rootNavigator: true).pop(); - Navigator.of( - context, - ).popUntil(ModalRoute.withName(DesktopWalletView.routeName)); + Navigator.of(context) + .popUntil(ModalRoute.withName(DesktopWalletView.routeName)); unawaited( showFloatingFlushBar( type: FlushBarType.success, @@ -268,9 +264,8 @@ class _DesktopWalletFeaturesState extends ConsumerState { shouldPop = true; if (mounted) { Navigator.of(context, rootNavigator: true).pop(); - Navigator.of( - context, - ).popUntil(ModalRoute.withName(DesktopWalletView.routeName)); + Navigator.of(context) + .popUntil(ModalRoute.withName(DesktopWalletView.routeName)); await showDialog( context: context, builder: (_) => DesktopDialog( @@ -345,51 +340,43 @@ class _DesktopWalletFeaturesState extends ConsumerState { ) { ref.read(myPaynymAccountStateProvider.state).state = account.value!; - await Navigator.of( - context, - ).pushNamed(PaynymHomeView.routeName, arguments: widget.walletId); + await Navigator.of(context) + .pushNamed(PaynymHomeView.routeName, arguments: widget.walletId); } else { - await Navigator.of( - context, - ).pushNamed(PaynymClaimView.routeName, arguments: widget.walletId); + await Navigator.of(context) + .pushNamed(PaynymClaimView.routeName, arguments: widget.walletId); } } } Future _onMonkeyPressed() async { - await (Navigator.of( - context, - ).pushNamed(MonkeyView.routeName, arguments: widget.walletId)); + await (Navigator.of(context) + .pushNamed(MonkeyView.routeName, arguments: widget.walletId)); } void _onOrdinalsPressed() { - Navigator.of( - context, - ).pushNamed(DesktopOrdinalsView.routeName, arguments: widget.walletId); + Navigator.of(context) + .pushNamed(DesktopOrdinalsView.routeName, arguments: widget.walletId); } void _onFusionPressed() { - Navigator.of( - context, - ).pushNamed(DesktopCashFusionView.routeName, arguments: widget.walletId); + Navigator.of(context) + .pushNamed(DesktopCashFusionView.routeName, arguments: widget.walletId); } void _onChurnPressed() { - Navigator.of( - context, - ).pushNamed(DesktopChurningView.routeName, arguments: widget.walletId); + Navigator.of(context) + .pushNamed(DesktopChurningView.routeName, arguments: widget.walletId); } void _onNamesPressed() { - Navigator.of( - context, - ).pushNamed(NamecoinNamesHomeView.routeName, arguments: widget.walletId); + Navigator.of(context) + .pushNamed(NamecoinNamesHomeView.routeName, arguments: widget.walletId); } void _onSparkNamesPressed() { - Navigator.of( - context, - ).pushNamed(SparkNamesHomeView.routeName, arguments: widget.walletId); + Navigator.of(context) + .pushNamed(SparkNamesHomeView.routeName, arguments: widget.walletId); } Future _onSalviumStakePressed() async { @@ -430,8 +417,9 @@ class _DesktopWalletFeaturesState extends ConsumerState { context: context, builder: (context) => DesktopDialog( maxWidth: 580, - maxHeight: double.infinity, + maxHeight: null, child: Column( + mainAxisSize: MainAxisSize.min, children: [ Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, @@ -446,9 +434,11 @@ class _DesktopWalletFeaturesState extends ConsumerState { const DesktopDialogCloseButton(), ], ), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 32), - child: SigningView(walletId: widget.walletId), + Flexible( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 32), + child: SigningView(walletId: widget.walletId), + ), ), const SizedBox(height: 32), ], @@ -458,9 +448,8 @@ class _DesktopWalletFeaturesState extends ConsumerState { } void _onMasternodesPressed() { - Navigator.of( - context, - ).pushNamed(MasternodesHomeView.routeName, arguments: widget.walletId); + Navigator.of(context) + .pushNamed(MasternodesHomeView.routeName, arguments: widget.walletId); } List<(WalletFeature, String, FutureOr Function())> _getOptions( @@ -502,7 +491,8 @@ class _DesktopWalletFeaturesState extends ConsumerState { _onSalviumStakePressed, ), - if (wallet is SignVerifyInterface && !isViewOnly) + if (wallet is SignVerifyInterface && + (!isViewOnly || wallet is SparkInterface)) (WalletFeature.sign, Assets.svg.pencil, _onSignPressed), if (!isViewOnly && wallet is FiroWallet) @@ -619,9 +609,9 @@ class _DesktopWalletFeaturesState extends ConsumerState { Assets.svg.bars, height: 20, width: 20, - color: Theme.of( - context, - ).extension()!.buttonTextSecondary, + color: Theme.of(context) + .extension()! + .buttonTextSecondary, ), onPressed: () => _onMorePressed([ ...options.sublist(options.length - count), @@ -648,17 +638,17 @@ class _DesktopWalletFeaturesState extends ConsumerState { ), height: 20, width: 20, - color: Theme.of( - context, - ).extension()!.buttonTextSecondary, + color: Theme.of(context) + .extension()! + .buttonTextSecondary, ) : SvgPicture.asset( option.$2, height: 20, width: 20, - color: Theme.of( - context, - ).extension()!.buttonTextSecondary, + color: Theme.of(context) + .extension()! + .buttonTextSecondary, ), onPressed: () => option.$3(), ), diff --git a/lib/route_generator.dart b/lib/route_generator.dart index 6197874811..99cfe112b7 100644 --- a/lib/route_generator.dart +++ b/lib/route_generator.dart @@ -1012,6 +1012,19 @@ class RouteGenerator { } return _routeError("${settings.name} invalid args: ${args.toString()}"); + case SparkAddressOwnershipProofView.routeName: + if (args is ({String walletId, String address})) { + return getRoute( + shouldUseMaterialRoute: useMaterialPageRoute, + builder: (_) => SparkAddressOwnershipProofView( + walletId: args.walletId, + address: args.address, + ), + settings: RouteSettings(name: settings.name), + ); + } + return _routeError("${settings.name} invalid args: ${args.toString()}"); + case FusionProgressView.routeName: if (args is String) { return getRoute( diff --git a/lib/wallets/wallet/wallet_mixin_interfaces/spark_interface.dart b/lib/wallets/wallet/wallet_mixin_interfaces/spark_interface.dart index 193bb133f1..671b029ab7 100644 --- a/lib/wallets/wallet/wallet_mixin_interfaces/spark_interface.dart +++ b/lib/wallets/wallet/wallet_mixin_interfaces/spark_interface.dart @@ -130,6 +130,43 @@ 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()); + } +} + +({bool? valid, String? error}) _verifySparkAddressOwnershipProof( + ({String message, String address, String proof, bool isTestNet}) args, +) { + try { + return ( + valid: libSpark.verifySparkAddressOwnershipProof( + message: args.message, + address: args.address, + proof: args.proof, + isTestNet: args.isTestNet, + ), + error: null, + ); + } catch (e) { + return (valid: null, error: e.toString()); + } +} + abstract class _SparkIsolate { static Isolate? _isolate; static SendPort? _sendPort; @@ -189,6 +226,8 @@ Future computeWithLibSparkLogging( mixin SparkInterface on Bip39HDWallet, ElectrumXInterface { + static const _sparkNameLookAheadCount = 100; + Address? _currentSparkAddress; String? _viewKeyHex; @@ -226,13 +265,15 @@ mixin SparkInterface ); } - final sparkAddress = - await computeWithLibSparkLogging(_getAddressFromFullViewKey, ( - fullViewKeyHex: _viewKeyHex!, - index: sparkIndex, - diversifier: diversifier, - isTestNet: isTestNet, - )); + final sparkAddress = await computeWithLibSparkLogging( + _getAddressFromFullViewKey, + ( + fullViewKeyHex: _viewKeyHex!, + index: sparkIndex, + diversifier: diversifier, + isTestNet: isTestNet, + ), + ); return Address( walletId: walletId, @@ -1349,9 +1390,8 @@ mixin SparkInterface } for (final transaction in transactions) { if (transaction.usedSparkCoins!.length > 1) { - final transactionVersion = btc.Transaction.fromHex( - transaction.raw!, - ).version; + final transactionVersion = btc.Transaction.fromHex(transaction.raw!) + .version; if (!isChaumV2SparkTransactionVersion(transactionVersion)) { throw Exception( "Refusing to broadcast a multi-input Chaum V1 transaction.", @@ -1872,12 +1912,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 +2953,106 @@ mixin SparkInterface return txData; } + @override + Future signMessage(String message, {required Address address}) { + if (address.type == AddressType.spark) { + return createSparkAddressOwnershipProof( + address: address.value, + message: message, + ); + } + return super.signMessage(message, address: address); + } + + @override + Future verifyMessage( + String message, { + required String address, + required String signature, + }) async { + if (!validateSparkAddress(address: address, isTestNet: false) && + !validateSparkAddress(address: address, isTestNet: true)) { + return super.verifyMessage( + message, + address: address, + signature: signature, + ); + } + final result = await computeWithLibSparkLogging( + _verifySparkAddressOwnershipProof, + ( + message: message, + address: address, + proof: signature, + isTestNet: isTestNet, + ), + ); + if (result.error != null) throw Exception(result.error); + return result.valid!; + } + + Future createSparkAddressOwnershipProof({ + required String address, + required String message, + }) async { + if (isViewOnly) { + throw Exception( + "Cannot create an ownership proof from a view only wallet", + ); + } + + if (message.trim().isEmpty) { + throw Exception("Message must not be blank"); + } + + 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/widgets/textfields/adaptive_text_field.dart b/lib/widgets/textfields/adaptive_text_field.dart index 569818d22f..62241fc2c9 100644 --- a/lib/widgets/textfields/adaptive_text_field.dart +++ b/lib/widgets/textfields/adaptive_text_field.dart @@ -19,6 +19,8 @@ class AdaptiveTextField extends StatefulWidget { this.focusNode, this.style, this.autocorrect, + this.smartDashesType, + this.smartQuotesType, this.desktopMed = false, this.readOnly = false, this.enabled = true, @@ -35,6 +37,7 @@ class AdaptiveTextField extends StatefulWidget { this.maxLines, this.inputFormatters, this.showPasteClearButton = false, + this.trimPastedText = true, this.keyboardType, }); @@ -46,6 +49,8 @@ class AdaptiveTextField extends StatefulWidget { final FocusNode? focusNode; final TextStyle? style; final bool? autocorrect; + final SmartDashesType? smartDashesType; + final SmartQuotesType? smartQuotesType; final bool desktopMed; final EdgeInsets? contentPadding; final int? minLines; @@ -63,6 +68,8 @@ class AdaptiveTextField extends StatefulWidget { /// This will be ignored if [suffixIcons] is not null! final bool showPasteClearButton; + final bool trimPastedText; + /// If this is not null, [showPasteClearButton] will be ignored. final List? suffixIcons; @@ -148,6 +155,8 @@ class _AdaptiveTextFieldState extends State { readOnly: widget.readOnly, enabled: widget.enabled, autocorrect: widget.autocorrect, + smartDashesType: widget.smartDashesType, + smartQuotesType: widget.smartQuotesType, enableSuggestions: widget.enableSuggestions, onSubmitted: widget.onSubmitted, keyboardType: widget.keyboardType, @@ -201,7 +210,9 @@ class _AdaptiveTextFieldState extends State { await Clipboard.getData(Clipboard.kTextPlain); if (data?.text != null && data!.text!.isNotEmpty) { - final content = data.text!.trim(); + final content = widget.trimPastedText + ? data.text!.trim() + : data.text!; // Setting controller.text directly skips // inputFormatters, so run them here as a // paste into the (empty) field would. diff --git a/lib/wl_gen/interfaces/lib_spark_interface.dart b/lib/wl_gen/interfaces/lib_spark_interface.dart index 93a2dc6bbb..84ad9488c5 100644 --- a/lib/wl_gen/interfaces/lib_spark_interface.dart +++ b/lib/wl_gen/interfaces/lib_spark_interface.dart @@ -77,6 +77,20 @@ abstract class LibSparkInterface { required bool ignoreProof, }); + String createSparkAddressOwnershipProof({ + required String message, + required String privateKeyHex, + required int spendKeyIndex, + required int diversifier, + }); + + bool verifySparkAddressOwnershipProof({ + required String message, + required String address, + required String proof, + required bool isTestNet, + }); + Uint8List getSparkNameCommitment({ required Uint8List serializedSparkNameData, }); diff --git a/pubspec.lock b/pubspec.lock index 750c80fcd7..1037adb58f 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1023,9 +1023,9 @@ packages: dependency: "direct main" description: path: "." - ref: fe505c29f5dbb5dd94fb986e509506be9e2f37e7 - resolved-ref: fe505c29f5dbb5dd94fb986e509506be9e2f37e7 - url: "https://github.com/cypherstack/flutter_libsparkmobile.git" + ref: "b6cb75914c36f439d0ebc923f99533dd822a2d66" + resolved-ref: "b6cb75914c36f439d0ebc923f99533dd822a2d66" + url: "https://github.com/navidR/flutter_libsparkmobile.git" source: git version: "0.2.0" flutter_lints: diff --git a/scripts/app_config/templates/pubspec.template.yaml b/scripts/app_config/templates/pubspec.template.yaml index 12a7efaa39..dcdf3dbf24 100644 --- a/scripts/app_config/templates/pubspec.template.yaml +++ b/scripts/app_config/templates/pubspec.template.yaml @@ -45,8 +45,8 @@ dependencies: # %%ENABLE_FIRO%% # flutter_libsparkmobile: # git: -# url: https://github.com/cypherstack/flutter_libsparkmobile.git -# ref: fe505c29f5dbb5dd94fb986e509506be9e2f37e7 +# url: https://github.com/navidR/flutter_libsparkmobile.git +# ref: b6cb75914c36f439d0ebc923f99533dd822a2d66 # %%END_ENABLE_FIRO%% # %%ENABLE_EPIC%% diff --git a/test/pages/spark_address_ownership_proof_test.dart b/test/pages/spark_address_ownership_proof_test.dart new file mode 100644 index 0000000000..43ec551968 --- /dev/null +++ b/test/pages/spark_address_ownership_proof_test.dart @@ -0,0 +1,303 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/mockito.dart'; +import 'package:stackwallet/db/drift/database.dart'; +import 'package:stackwallet/db/isar/main_db.dart'; +import 'package:stackwallet/models/isar/models/isar_models.dart'; +import 'package:stackwallet/models/isar/stack_theme.dart'; +import 'package:stackwallet/pages/signing/signing_view.dart'; +import 'package:stackwallet/pages/signing/sub_widgets/sign_message_tab.dart'; +import 'package:stackwallet/pages/signing/sub_widgets/verify_message_tab.dart'; +import 'package:stackwallet/pages/spark_names/sub_widgets/spark_name_details.dart'; +import 'package:stackwallet/pages/wallet_view/transaction_views/transaction_details_view.dart' + show IconCopyButton; +import 'package:stackwallet/providers/global/wallets_provider.dart'; +import 'package:stackwallet/providers/db/drift_provider.dart'; +import 'package:stackwallet/providers/db/main_db_provider.dart'; +import 'package:stackwallet/route_generator.dart'; +import 'package:stackwallet/services/wallets.dart'; +import 'package:stackwallet/themes/stack_colors.dart'; +import 'package:stackwallet/themes/theme_providers.dart'; +import 'package:stackwallet/utilities/util.dart'; +import 'package:stackwallet/utilities/stack_file_system.dart'; +import 'package:stackwallet/wallets/isar/providers/wallet_info_provider.dart'; +import 'package:stackwallet/wallets/crypto_currency/crypto_currency.dart'; +import 'package:stackwallet/wallets/wallet/impl/firo_wallet.dart'; +import 'package:stackwallet/widgets/custom_buttons/simple_copy_button.dart'; +import 'package:stackwallet/widgets/custom_buttons/app_bar_icon_button.dart'; +import 'package:stackwallet/widgets/desktop/desktop_dialog_close_button.dart'; +import 'package:stackwallet/widgets/desktop/primary_button.dart'; +import 'package:stackwallet/widgets/dialogs/s_dialog.dart'; + +import '../sample_data/theme_json.dart'; + +class _ProofWallet extends FiroWallet { + _ProofWallet() : super(CryptoCurrencyNetwork.main); + String? signedMessage; + bool viewOnly = false; + @override + bool get isViewOnly => viewOnly; + @override + Future createSparkAddressOwnershipProof({ + required String address, + required String message, + }) async { + signedMessage = message; + return 'ab' * 130; + } +} + +class _Wallets extends Mock implements Wallets { + final wallet = _ProofWallet(); + @override + FiroWallet getWallet(String walletId) => wallet; +} + +class _LabelDB extends Mock implements MainDB { + @override + AddressLabel? getAddressLabelSync(String walletId, String address) => null; +} + +void main() { + setUpAll(() async { + final directory = await Directory.systemTemp.createTemp('spark-proof-ui-'); + StackFileSystem.setDesktopOverrideDir(directory.path); + addTearDown(() => directory.delete(recursive: true)); + const channel = MethodChannel('plugins.flutter.io/path_provider'); + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + messenger.setMockMethodCallHandler(channel, (_) async => directory.path); + addTearDown(() => messenger.setMockMethodCallHandler(channel, null)); + final font = FontLoader('Inter_500'); + font.addFont(rootBundle.load('google_fonts/Inter-Medium.ttf')); + await font.load(); + }); + + for (final desktop in [false, true]) { + testWidgets( + '${desktop ? "desktop" : "mobile"} proof layout and exact message', + (tester) async { + final oldWidth = Util.screenWidth; + Util.screenWidth = desktop ? 1000 : 390; + addTearDown(() => Util.screenWidth = oldWidth); + await tester.binding.setSurfaceSize( + Size(desktop ? 1000 : 390, desktop ? 600 : 844), + ); + addTearDown(() => tester.binding.setSurfaceSize(null)); + final wallets = _Wallets(); + final view = SparkAddressOwnershipProofView( + walletId: 'test', + address: 'sm1${'a' * 141}', + ); + await tester.pumpWidget( + ProviderScope( + overrides: [ + pWallets.overrideWithValue(wallets), + themeProvider.overrideWithValue( + StateController(StackTheme.fromJson(json: lightThemeJsonMap)), + ), + ], + child: MaterialApp( + theme: ThemeData( + extensions: [ + StackColors.fromStackColorTheme( + StackTheme.fromJson(json: lightThemeJsonMap), + ), + ], + ), + onGenerateRoute: RouteGenerator.generateRoute, + home: desktop + ? SDialog(child: view) + : Builder( + builder: (context) => Scaffold( + body: TextButton( + onPressed: () => Navigator.of(context).pushNamed( + SparkAddressOwnershipProofView.routeName, + arguments: ( + walletId: view.walletId, + address: view.address, + ), + ), + child: const Text('Open proof'), + ), + ), + ), + ), + ), + ); + if (!desktop) { + await tester.tap(find.text('Open proof')); + await tester.pumpAndSettle(); + } + expect(find.byType(AppBar), desktop ? findsNothing : findsOneWidget); + expect( + find.byType(DesktopDialogCloseButton), + desktop ? findsOneWidget : findsNothing, + ); + final field = find.byType(TextField); + expect( + tester.widget(field).smartDashesType, + SmartDashesType.disabled, + ); + expect( + tester.widget(field).smartQuotesType, + SmartQuotesType.disabled, + ); + await tester.enterText(field, ' \n\t'); + await tester.pump(); + expect( + tester.widget(find.byType(PrimaryButton)).enabled, + isFalse, + ); + const message = ' challenge\n '; + await tester.enterText(field, message); + await tester.pump(); + await tester.ensureVisible(find.text('Create proof')); + await tester.tap(find.text('Create proof')); + await tester.pumpAndSettle(); + expect(wallets.wallet.signedMessage, message); + expect( + find.byType(IconCopyButton), + desktop ? findsOneWidget : findsNothing, + ); + expect( + find.byType(SimpleCopyButton), + desktop ? findsNothing : findsOneWidget, + ); + await tester.enterText(field, 'changed'); + await tester.pump(); + expect(find.text('ab' * 130), findsNothing); + expect(tester.takeException(), isNull); + if (!desktop) { + await tester.tap(find.byType(AppBarBackButton)); + await tester.pumpAndSettle(); + expect(find.text('Open proof'), findsOneWidget); + expect(find.byType(SparkAddressOwnershipProofView), findsNothing); + } + }, + ); + } + + for (final ipad in [false, true]) { + testWidgets('ownership action layout on ${ipad ? "iPad" : "small phone"}', ( + tester, + ) async { + final oldWidth = Util.screenWidth; + final oldIpad = Util.isIpad; + Util.screenWidth = ipad ? 1024 : 320; + Util.isIpad = ipad; + addTearDown(() { + Util.screenWidth = oldWidth; + Util.isIpad = oldIpad; + }); + final size = Size(ipad ? 1024 : 320, ipad ? 768 : 568); + await tester.binding.setSurfaceSize(size); + addTearDown(() => tester.binding.setSurfaceSize(null)); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetDevicePixelRatio); + addTearDown(tester.view.resetViewInsets); + final walletId = 'proof-layout-$ipad'; + final db = (await tester.runAsync(() async { + final db = Drift.get(walletId); + await db.customSelect('SELECT 1').get(); + return db; + }))!; + addTearDown(db.close); + try { + final theme = StackTheme.fromJson(json: lightThemeJsonMap); + final view = SparkNameDetailsView( + walletId: walletId, + name: SparkName( + name: 'example', + address: 'sm1${'a' * 141}', + validUntil: 10000, + ), + ); + await tester.pumpWidget( + ProviderScope( + overrides: [ + pWallets.overrideWithValue(_Wallets()), + mainDBProvider.overrideWithValue(_LabelDB()), + pDrift(walletId).overrideWithValue(db), + pWalletChainHeight(walletId).overrideWithValue(0), + themeProvider.overrideWithValue(StateController(theme)), + ], + child: MaterialApp( + theme: ThemeData( + extensions: [StackColors.fromStackColorTheme(theme)], + textButtonTheme: TextButtonThemeData( + style: TextButton.styleFrom(minimumSize: const Size(46, 48)), + ), + ), + builder: (context, child) => MediaQuery( + data: MediaQuery.of(context).copyWith( + size: size, + textScaler: TextScaler.linear(ipad ? 1 : 1.5), + ), + child: child!, + ), + onGenerateRoute: RouteGenerator.generateRoute, + home: ipad ? SDialog(child: view) : view, + ), + ), + ); + await tester.pumpAndSettle(); + final action = find.text('Prove address ownership'); + await tester.ensureVisible(action); + expect(tester.takeException(), isNull); + await tester.tap(action); + await tester.pumpAndSettle(); + if (ipad) { + tester.view.viewInsets = const FakeViewPadding(bottom: 350); + await tester.pumpAndSettle(); + } + await tester.enterText(find.byType(TextField), 'challenge'); + await tester.pumpAndSettle(); + await tester.ensureVisible(find.text('Create proof')); + expect( + tester.getRect(find.text('Create proof')).bottom, + lessThanOrEqualTo(size.height - (ipad ? 350 : 0)), + ); + expect(tester.takeException(), isNull); + } finally { + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pumpAndSettle(); + } + }); + } + + testWidgets('view-only wallet exposes verification without signing', ( + tester, + ) async { + final wallets = _Wallets(); + wallets.wallet.viewOnly = true; + await tester.pumpWidget( + ProviderScope( + overrides: [ + pWallets.overrideWithValue(wallets), + themeProvider.overrideWithValue( + StateController(StackTheme.fromJson(json: lightThemeJsonMap)), + ), + ], + child: MaterialApp( + theme: ThemeData( + extensions: [ + StackColors.fromStackColorTheme( + StackTheme.fromJson(json: lightThemeJsonMap), + ), + ], + ), + home: const Scaffold(body: SigningView(walletId: 'test')), + ), + ), + ); + expect(find.byType(VerifyMessageForm), findsOneWidget); + expect(find.byType(SignMessageForm), findsNothing); + expect(find.text('Sign message'), findsNothing); + expect(tester.takeException(), isNull); + }); +} diff --git a/test/wallets/spark_message_test.dart b/test/wallets/spark_message_test.dart new file mode 100644 index 0000000000..3e0bbe4a0f --- /dev/null +++ b/test/wallets/spark_message_test.dart @@ -0,0 +1,155 @@ +import 'dart:typed_data'; + +import 'package:coinlib_flutter/coinlib_flutter.dart' as coinlib; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/mockito.dart'; +import 'package:stackwallet/db/isar/main_db.dart'; +import 'package:stackwallet/models/isar/models/isar_models.dart'; +import 'package:stackwallet/wallets/crypto_currency/crypto_currency.dart'; +import 'package:stackwallet/wallets/wallet/impl/firo_wallet.dart'; +import 'package:stackwallet/wl_gen/interfaces/lib_spark_interface.dart'; + +class _AddressDB extends Mock implements MainDB { + Address? owned; + + @override + Future getAddress(String walletId, String address) async => + owned?.value == address ? owned : null; +} + +class _Wallet extends FiroWallet { + _Wallet() : super(CryptoCurrencyNetwork.main) { + mainDB = _AddressDB(); + } + + bool viewOnly = false; + @override + bool get isViewOnly => viewOnly; + @override + String get walletId => 'test'; + @override + Future getRootHDNode() async => + coinlib.HDPrivateKey.fromSeed(Uint8List.fromList(List.filled(32, 1))); + @override + Future getCurrentReceivingSparkAddress() async => null; +} + +void main() { + setUpAll(coinlib.loadCoinlib); + + test( + 'Spark sign/verify uses ownership proofs and preserves message bytes', + () async { + final wallet = _Wallet(); + final root = await wallet.getRootHDNode(); + final address = Address( + walletId: wallet.walletId, + value: await libSpark.getAddress( + privateKey: root + .derivePath(wallet.sparkDerivationPath) + .privateKey + .data, + index: wallet.sparkIndex, + diversifier: 0, + ), + publicKey: [], + derivationIndex: 0, + derivationPath: DerivationPath()..value = wallet.sparkDerivationPath, + type: AddressType.spark, + subType: AddressSubType.receiving, + ); + (wallet.mainDB as _AddressDB).owned = address; + const message = ' ownership\nchallenge '; + final proof = await wallet.signMessage(message, address: address); + expect(proof, hasLength(260)); + expect( + await wallet.verifyMessage( + message, + address: address.value, + signature: proof, + ), + isTrue, + ); + expect( + await wallet.verifyMessage( + message.trim(), + address: address.value, + signature: proof, + ), + isFalse, + ); + expect( + await wallet.verifyMessage( + message, + address: address.value, + signature: '${proof}00', + ), + isFalse, + ); + for (final blank in ['', ' ', '\n\t']) { + await expectLater( + wallet.signMessage(blank, address: address), + throwsException, + ); + } + wallet.viewOnly = true; + await expectLater( + wallet.signMessage(message, address: address), + throwsException, + ); + expect( + await wallet.verifyMessage( + message, + address: address.value, + signature: proof, + ), + isTrue, + ); + wallet.viewOnly = false; + (wallet.mainDB as _AddressDB).owned = null; + await expectLater( + wallet.signMessage(message, address: address), + throwsException, + ); + }, + ); + + test( + 'transparent signing and verification retain the Bitcoin-style path', + () async { + final wallet = _Wallet(); + const path = "m/44'/136'/0'/0/0"; + final key = (await wallet.getRootHDNode()).derivePath(path).publicKey; + final value = coinlib.P2PKHAddress.fromPublicKey( + key, + version: wallet.cryptoCurrency.networkParams.p2pkhPrefix, + ).toString(); + final address = Address( + walletId: wallet.walletId, + value: value, + publicKey: key.data, + derivationIndex: 0, + derivationPath: DerivationPath()..value = path, + type: AddressType.p2pkh, + subType: AddressSubType.receiving, + ); + final signature = await wallet.signMessage('message', address: address); + expect( + await wallet.verifyMessage( + 'message', + address: value, + signature: signature, + ), + isTrue, + ); + expect( + await wallet.verifyMessage( + 'different', + address: value, + signature: signature, + ), + isFalse, + ); + }, + ); +} diff --git a/test/widgets/adaptive_text_field_test.dart b/test/widgets/adaptive_text_field_test.dart index 336de822d2..a802953626 100644 --- a/test/widgets/adaptive_text_field_test.dart +++ b/test/widgets/adaptive_text_field_test.dart @@ -9,6 +9,44 @@ import "package:stackwallet/widgets/textfield_icon_button.dart"; import "../sample_data/theme_json.dart"; void main() { + testWidgets("message paste preserves exact whitespace", (tester) async { + final controller = TextEditingController(); + addTearDown(controller.dispose); + const message = " challenge\n "; + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + messenger.setMockMethodCallHandler(SystemChannels.platform, (call) async { + return call.method == "Clipboard.getData" + ? {"text": message} + : null; + }); + addTearDown( + () => messenger.setMockMethodCallHandler(SystemChannels.platform, null), + ); + await tester.pumpWidget( + MaterialApp( + theme: ThemeData( + extensions: [ + StackColors.fromStackColorTheme( + StackTheme.fromJson(json: lightThemeJsonMap), + ), + ], + ), + home: Scaffold( + body: AdaptiveTextField( + controller: controller, + showPasteClearButton: true, + trimPastedText: false, + maxLines: 5, + ), + ), + ), + ); + await tester.tap(find.byType(TextFieldIconButton)); + await tester.pump(); + expect(controller.text, message); + }); + testWidgets("paste trims whitespace and runs input formatters", ( tester, ) async { 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..0edc81ddf4 100644 --- a/tool/wl_templates/FIRO_lib_spark_interface_impl.template.dart +++ b/tool/wl_templates/FIRO_lib_spark_interface_impl.template.dart @@ -139,6 +139,32 @@ 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 + bool verifySparkAddressOwnershipProof({ + required String message, + required String address, + required String proof, + required bool isTestNet, + }) => LibSpark.verifySparkAddressOwnershipProof( + message: message, + address: address, + proof: proof, + isTestNet: isTestNet, + ); + @override Uint8List getSparkNameCommitment({ required Uint8List serializedSparkNameData,