Spark: add address ownership proof signing and verification
What changed, and why it matters
This commit adds a new feature to Stack Wallet that lets Spark (Firo privacy) address owners prove they control an address, and lets others verify that proof. It also fixes a few related UI issues: view-only wallets can now only verify (not sign) Spark proofs, pasted messages keep exact spaces/newlines instead of being trimmed, and the signing/verification screens scroll properly on desktop. There is no direct evidence in the commit that this fixes an active security vulnerability; it reads as a feature addition with some hardening improvements.
Review the implementation of createSparkAddressOwnershipProof in the updated flutter_libsparkmobile dependency (commit 3fdcb21160a39c051a0e73d7ae04f987134ecd5f) to confirm it uses the correct Spark private key and does not leak key material. Also verify that the proof format is non-malleable and that verification rejects testnet/mainnet mismatches. The UI hardening changes look sensible but should be regression-tested on view-only wallets.
Security signals we found
New cryptographic signing/verification API integrated into wallet
View-only wallet restriction added to prevent signing with private keys
Whitespace preservation in pasted messages reduces signature/verification mismatch risk
Dependency bump on flutter_libsparkmobile to commit 3fdcb21160a39c051a0e73d7ae04f987134ecd5f
No explicit security advisory, CVE, or vulnerability description in commit or references
Evidence from the diff
The change introduces createSparkAddressOwnershipProof and verifySparkAddressOwnershipProof bindings to the flutter_libsparkmobile library, exposes them through SparkInterface, and wires them into the existing Sign/Verify UI. SparkInterface.signMessage now produces an ownership proof for spark addresses, and verifyMessage validates it. UI changes include: hiding the Sign tab for view-only Spark wallets, preserving whitespace in pasted messages via trimPastedText=false and disabling smart punctuation, adding SingleChildScrollView to desktop signing dialogs, and adding a ‘Prove address ownership’ action from Spark name details. Tests cover proof generation, verification, whitespace preservation, and view-only behavior.
Changed components
lib/wallets/wallet/wallet_mixin_interfaces/spark_interface.dartlib/wl_gen/interfaces/lib_spark_interface.dartlib/pages/signing/signing_view.dartlib/pages/signing/sub_widgets/sign_message_tab.dartlib/pages/signing/sub_widgets/verify_message_tab.dartlib/pages/spark_names/sub_widgets/spark_name_details.dartlib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_wallet_features.dartlib/widgets/textfields/adaptive_text_field.dartpubspec.lock / flutter_libsparkmobile dependencytool/wl_templates/FIRO_lib_spark_interface_impl.template.dartInspect 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 24/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.