Merge pull request #1439 from navidR/dev/navidr/spark-name-verification
What changed, and why it matters
This commit adds a new feature to Stack Wallet that lets users prove they own a Spark (privacy) address by generating a cryptographic ownership proof. It also improves the sign/verify screens so view-only wallets can still verify proofs, and it fixes message handling so pasted or typed messages keep their exact spaces and line breaks instead of being silently trimmed. The changes are mostly new feature code plus hardening of the UI around message integrity.
Review the new ownership proof code paths for correct private-key handling and isolate serialization. Verify that the bumped flutter_libsparkmobile commit does not introduce unrelated changes. Consider adding a security note in release notes because this feature exposes signing of arbitrary messages tied to Spark addresses.
Security signals we found
New cryptographic proof generation using private key material (privateKeyHex, spendKeyIndex, diversifier) inside an isolate
View-only wallet guard added for proof creation (throws if isViewOnly)
Message whitespace now preserved for pasted/typed challenge messages, preventing proof/verification mismatches caused by silent trimming
State-identity checks added to async sign/verify callbacks to prevent stale UI updates
Dependency bump of flutter_libsparkmobile to commit 3fdcb21160a39c051a0e73d7ae04f987134ecd5f
Evidence from the diff
The merge introduces Spark address ownership proof generation and verification. It wires a new libSpark FFI call (createSparkAddressOwnershipProof / verifySparkAddressOwnershipProof) into the wallet’s SparkInterface, adds a UI flow under Spark name details, and overrides signMessage/verifyMessage for Spark addresses to use these proofs. Additional changes: view-only wallets are allowed to access verification only; AdaptiveTextField gains trimPastedText, smartDashesType/smartQuotesType, and autocorrect controls to preserve message bytes; signing/verify tabs check state identity before updating to avoid race conditions; tests cover proof generation, whitespace preservation, and view-only layout.
Changed components
lib/wallets/wallet/wallet_mixin_interfaces/spark_interface.dartlib/pages/spark_names/sub_widgets/spark_name_details.dartlib/pages/signing/signing_view.dartlib/pages/signing/sub_widgets/sign_message_tab.dartlib/pages/signing/sub_widgets/verify_message_tab.dartlib/widgets/textfields/adaptive_text_field.dartlib/wl_gen/interfaces/lib_spark_interface.darttool/wl_templates/FIRO_lib_spark_interface_impl.template.dartpubspec.lock / flutter_libsparkmobile dependencyInspect captured patch +1097 / −166
### 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,35 +33,38 @@ class _SigningViewState extends ConsumerState<SigningView> {
// 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<StackColors>()!.background,
+ backgroundColor: Theme.of(context)
+ .extension<StackColors>()!
+ .background,
appBar: AppBar(
leading: AppBarBackButton(
onPressed: () {
Navigator.of(context).pop();
},
),
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,
### 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<SignMessageForm> {
TextStyle _getStyle(BuildContext context) {
return Util.isDesktop
? STextStyles.desktopTextExtraExtraSmall(context).copyWith(
- color: Theme.of(
- context,
- ).extension<StackColors>()!.textFieldActiveSearchIconRight,
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .textFieldActiveSearchIconRight,
)
: STextStyles.smallMed12(context);
}
@@ -133,9 +135,9 @@ class _SignMessageFormState extends ConsumerState<SignMessageForm> {
child: RoundedContainer(
padding: EdgeInsets.zero,
color: Colors.transparent,
- borderColor: Theme.of(
- context,
- ).extension<StackColors>()!.textFieldDefaultBG,
+ borderColor: Theme.of(context)
+ .extension<StackColors>()!
+ .textFieldDefaultBG,
child: child,
),
),
@@ -181,9 +183,11 @@ class _SignMessageFormState extends ConsumerState<SignMessageForm> {
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<SignMessageForm> {
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<SignMessageForm> {
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),
),
### lib/pages/signing/sub_widgets/verify_message_tab.dart
@@ -67,9 +67,9 @@ class _VerifyMessageFormState extends ConsumerState<VerifyMessageForm> {
TextStyle _getStyle(BuildContext context) {
return Util.isDesktop
? STextStyles.desktopTextExtraExtraSmall(context).copyWith(
- color: Theme.of(
- context,
- ).extension<StackColors>()!.textFieldActiveSearchIconRight,
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .textFieldActiveSearchIconRight,
)
: STextStyles.smallMed12(context);
}
@@ -84,6 +84,7 @@ class _VerifyMessageFormState extends ConsumerState<VerifyMessageForm> {
_verify = IfNotAlreadyAsync<void>(() async {
Exception? ex;
+ final state = ref.read(_pVerifyState);
final verified = await showLoading(
whileFuture:
@@ -100,7 +101,7 @@ class _VerifyMessageFormState extends ConsumerState<VerifyMessageForm> {
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<VerifyMessageForm> {
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
### 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<SparkNameDetailsView> {
}
}
+ Future<void> _proveOwnership() async {
+ if (_lock) return;
+ _lock = true;
+ try {
+ if (Util.isDesktop) {
+ await showDialog<void>(
+ 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<SparkNameDetailsView> {
child: RoundedContainer(
padding: EdgeInsets.zero,
color: Colors.transparent,
- borderColor: Theme.of(
- context,
- ).extension<StackColors>()!.textFieldDefaultBG,
+ borderColor: Theme.of(context)
+ .extension<StackColors>()!
+ .textFieldDefaultBG,
child: child,
),
),
@@ -283,9 +314,9 @@ class _SparkNameDetailsViewState extends ConsumerState<SparkNameDetailsView> {
Text(
"Address",
style: STextStyles.w500_14(context).copyWith(
- color: Theme.of(
- context,
- ).extension<StackColors>()!.textSubtitle1,
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .textSubtitle1,
),
),
Util.isDesktop
@@ -320,9 +351,9 @@ class _SparkNameDetailsViewState extends ConsumerState<SparkNameDetailsView> {
: const EdgeInsets.all(12),
color: Util.isDesktop
? Colors.transparent
- : Theme.of(
- context,
- ).extension<StackColors>()!.popupBG,
+ : Theme.of(context)
+ .extension<StackColors>()!
+ .popupBG,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment:
@@ -382,17 +413,16 @@ class _SparkNameDetailsViewState extends ConsumerState<SparkNameDetailsView> {
Text(
"Expiry",
style: STextStyles.w500_14(context).copyWith(
- color: Theme.of(
- context,
- ).extension<StackColors>()!.textSubtitle1,
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .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<SparkNameDetailsView> {
Text(
"Additional info",
style: STextStyles.w500_14(context).copyWith(
- color: Theme.of(
- context,
- ).extension<StackColors>()!.textSubtitle1,
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .textSubtitle1,
),
),
const SizedBox(height: 4),
@@ -435,6 +465,32 @@ class _SparkNameDetailsViewState extends ConsumerState<SparkNameDetailsView> {
],
),
),
+ 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<StackColors>()!
+ .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<SparkNameDetailsView> {
}
}
+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<SparkAddressOwnershipProofView> createState() =>
+ _SparkAddressOwnershipProofViewState();
+}
+
+class _SparkAddressOwnershipProofViewState
+ extends ConsumerState<SparkAddressOwnershipProofView> {
+ final _messageController = TextEditingController();
+
+ String _proof = "";
+ bool _isGenerating = false;
+
+ @override
+ void dispose() {
+ _messageController.dispose();
+ super.dispose();
+ }
+
+ void _onMessageChanged(String message) {
+ setState(() {
+ _proof = "";
+ });
+ }
+
+ Future<void> _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});
### lib/pages/wallet_view/wallet_view.dart
@@ -446,9 +446,8 @@ class _WalletViewState extends ConsumerState<WalletView> {
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<WalletView> {
}
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<WalletView> {
} catch (e) {
shouldPop = true;
if (mounted) {
- Navigator.of(
- context,
- ).popUntil(ModalRoute.withName(WalletView.routeName));
+ Navigator.of(context)
+ .popUntil(ModalRoute.withName(WalletView.routeName));
await showDialog<dynamic>(
context: context,
builder: (_) => StackOkDialog(
@@ -519,13 +516,12 @@ class _WalletViewState extends ConsumerState<WalletView> {
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<StackColors>()!.textDark,
+ textColor: Theme.of(context)
+ .extension<StackColors>()!
+ .textDark,
actionButton: SecondaryButton(
label: "Cancel",
onPressed: () async {
@@ -568,9 +564,9 @@ class _WalletViewState extends ConsumerState<WalletView> {
child: Stack(
children: [
Scaffold(
- backgroundColor: Theme.of(
- context,
- ).extension<StackColors>()!.background,
+ backgroundColor: Theme.of(context)
+ .extension<StackColors>()!
+ .background,
appBar: AppBar(
leading: AppBarBackButton(
onPressed: () {
@@ -623,9 +619,9 @@ class _WalletViewState extends ConsumerState<WalletView> {
key: const Key("walletViewRadioButton"),
size: 36,
shadows: const [],
- color: Theme.of(
- context,
- ).extension<StackColors>()!.background,
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .background,
icon: _buildNetworkIcon(_currentSyncStatus),
onPressed: () {
Navigator.of(context).pushNamed(
@@ -649,14 +645,13 @@ class _WalletViewState extends ConsumerState<WalletView> {
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<StackColors>()!.background,
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .background,
icon:
ref.watch(
notificationsProvider.select(
@@ -768,14 +763,14 @@ class _WalletViewState extends ConsumerState<WalletView> {
key: const Key("walletViewSettingsButton"),
size: 36,
shadows: const [],
- color: Theme.of(
- context,
- ).extension<StackColors>()!.background,
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .background,
icon: SvgPicture.asset(
Assets.svg.bars,
- color: Theme.of(
- context,
- ).extension<StackColors>()!.accentColorDark,
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .accentColorDark,
width: 20,
height: 20,
),
@@ -799,9 +794,9 @@ class _WalletViewState extends ConsumerState<WalletView> {
),
body: SafeArea(
child: Container(
- color: Theme.of(
- context,
- ).extension<StackColors>()!.background,
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .background,
child: Column(
children: [
const SizedBox(height: 10),
@@ -849,8 +844,7 @@ class _WalletViewState extends ConsumerState<WalletView> {
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<WalletView> {
"Transactions",
style: STextStyles.itemSubtitle(context)
.copyWith(
- color: Theme.of(
- context,
- ).extension<StackColors>()!.textDark3,
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .textDark3,
),
),
CustomTextButton(
@@ -1139,9 +1133,9 @@ class _WalletViewState extends ConsumerState<WalletView> {
Assets.svg.monkey,
height: 20,
width: 20,
- color: Theme.of(
- context,
- ).extension<StackColors>()!.bottomNavIconIcon,
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .bottomNavIconIcon,
),
label: "MonKey",
onTap: () {
@@ -1151,17 +1145,18 @@ class _WalletViewState extends ConsumerState<WalletView> {
);
},
),
- 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<StackColors>()!.bottomNavIconIcon,
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .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<WalletView> {
height: 20,
width: 20,
colorFilter: ColorFilter.mode(
- Theme.of(
- context,
- ).extension<StackColors>()!.bottomNavIconIcon,
+ Theme.of(context)
+ .extension<StackColors>()!
+ .bottomNavIconIcon,
BlendMode.srcIn,
),
),
### lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_wallet_features.dart
@@ -149,15 +149,13 @@ class _DesktopWalletFeaturesState extends ConsumerState<DesktopWalletFeatures> {
}
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<void> _onAnonymizeAllPressed() async {
@@ -230,9 +228,8 @@ class _DesktopWalletFeaturesState extends ConsumerState<DesktopWalletFeatures> {
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<DesktopWalletFeatures> {
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<DesktopWalletFeatures> {
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<dynamic>(
context: context,
builder: (_) => DesktopDialog(
@@ -345,51 +340,43 @@ class _DesktopWalletFeaturesState extends ConsumerState<DesktopWalletFeatures> {
) {
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<void> _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<void> _onSalviumStakePressed() async {
@@ -430,8 +417,9 @@ class _DesktopWalletFeaturesState extends ConsumerState<DesktopWalletFeatures> {
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<DesktopWalletFeatures> {
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<DesktopWalletFeatures> {
}
void _onMasternodesPressed() {
- Navigator.of(
- context,
- ).pushNamed(MasternodesHomeView.routeName, arguments: widget.walletId);
+ Navigator.of(context)
+ .pushNamed(MasternodesHomeView.routeName, arguments: widget.walletId);
}
List<(WalletFeature, String, FutureOr<void> Function())> _getOptions(
@@ -502,7 +491,8 @@ class _DesktopWalletFeaturesState extends ConsumerState<DesktopWalletFeatures> {
_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<DesktopWalletFeatures> {
Assets.svg.bars,
height: 20,
width: 20,
- color: Theme.of(
- context,
- ).extension<StackColors>()!.buttonTextSecondary,
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .buttonTextSecondary,
),
onPressed: () => _onMorePressed([
...options.sublist(options.length - count),
@@ -648,17 +638,17 @@ class _DesktopWalletFeaturesState extends ConsumerState<DesktopWalletFeatures> {
),
height: 20,
width: 20,
- color: Theme.of(
- context,
- ).extension<StackColors>()!.buttonTextSecondary,
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .buttonTextSecondary,
)
: SvgPicture.asset(
option.$2,
height: 20,
width: 20,
- color: Theme.of(
- context,
- ).extension<StackColors>()!.buttonTextSecondary,
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .buttonTextSecondary,
),
onPressed: () => option.$3(),
),
### 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(
### 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<R> computeWithLibSparkLogging<M, R>(
mixin SparkInterface<T extends ElectrumXCurrencyInterface>
on Bip39HDWallet<T>, ElectrumXInterface<T> {
+ static const _sparkNameLookAheadCount = 100;
+
Address? _currentSparkAddress;
String? _viewKeyHex;
@@ -226,13 +265,15 @@ mixin SparkInterface<T extends ElectrumXCurrencyInterface>
);
}
- 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<T extends ElectrumXCurrencyInterface>
}
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<T extends ElectrumXCurrencyInterface>
// 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<T extends ElectrumXCurrencyInterface>
return txData;
}
+ @override
+ Future<String> 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<bool> 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<String> 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<void> updateBalance() async {
// call to super to update transparent balance
### 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<Widget>? suffixIcons;
@@ -148,6 +155,8 @@ class _AdaptiveTextFieldState extends State<AdaptiveTextField> {
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<AdaptiveTextField> {
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.
### 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,
});
### pubspec.lock
@@ -1023,8 +1023,8 @@ packages:
dependency: "direct main"
description:
path: "."
- ref: fe505c29f5dbb5dd94fb986e509506be9e2f37e7
- resolved-ref: fe505c29f5dbb5dd94fb986e509506be9e2f37e7
+ ref: "3fdcb21160a39c051a0e73d7ae04f987134ecd5f"
+ resolved-ref: "3fdcb21160a39c051a0e73d7ae04f987134ecd5f"
url: "https://github.com/cypherstack/flutter_libsparkmobile.git"
source: git
version: "0.2.0"
### scripts/app_config/templates/pubspec.template.yaml
@@ -46,7 +46,7 @@ dependencies:
# flutter_libsparkmobile:
# git:
# url: https://github.com/cypherstack/flutter_libsparkmobile.git
-# ref: fe505c29f5dbb5dd94fb986e509506be9e2f37e7
+# ref: 3fdcb21160a39c051a0e73d7ae04f987134ecd5f
# %%END_ENABLE_FIRO%%
# %%ENABLE_EPIC%%
### 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<String> 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<TextField>(field).smartDashesType,
+ SmartDashesType.disabled,
+ );
+ expect(
+ tester.widget<TextField>(field).smartQuotesType,
+ SmartQuotesType.disabled,
+ );
+ await tester.enterText(field, ' \n\t');
+ await tester.pump();
+ expect(
+ tester.widget<PrimaryButton>(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);
+ });
+}
### 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<Address?> 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<coinlib.HDPrivateKey> getRootHDNode() async =>
+ coinlib.HDPrivateKey.fromSeed(Uint8List.fromList(List.filled(32, 1)));
+ @override
+ Future<Address?> 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,
+ );
+ },
+ );
+}
### 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"
+ ? <String, dynamic>{"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 {
### 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,Why this scored 34/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.