Merge branch 'staging' into fix/desktop-pw-reset
What changed, and why it matters
This commit is a large merge that mainly adds a new 'prove you own a Spark address' feature to the Stack Wallet app, plus some related fixes. It also updates a dependency that handles SOCKS5 proxy connections and changes how the app decides whether it is running on a desktop or phone. The changes look like ordinary feature work rather than an obvious security patch, but a few areas could affect security: the new ownership-proof code uses private keys, the proxy library upgrade could change how Tor/proxy traffic is routed, and the desktop-detection refactor changes platform assumptions across the app.
Treat this as a feature merge requiring normal security review rather than an urgent vulnerability patch. Review the new Spark ownership proof implementation for correct private-key handling and side-channel risks, verify the socks5_proxy 2.x upgrade does not break Tor routing or leak clearnet requests, and confirm the Trocador onion authority change is intentional and consistent with upstream documentation. Run the newly added tests and consider adding tests for the _useTor logic edge cases.
Security signals we found
New cryptographic signing path added: SparkInterface.signMessage now delegates to Spark ownership proof creation using the wallet's private key and spark derivation path.
Ownership proof code rejects view-only wallets and blank messages, and searches a 100-address lookahead for the requested address before signing.
Dependency upgrade: socks5_proxy 1.0.3+dev.3 -> 2.1.1, which may change SOCKS5/Tor proxy behavior; a new test verifies hostname/onion routing through a fake SOCKS server.
Trocador exchange onion authority changed and Tor/proxy routing logic centralized; clearnet callers no longer pass isOnion explicitly.
Util.isDesktop now relies on an injectable platform object, which could affect security-relevant platform checks if misused elsewhere.
AdaptiveTextField gains trimPastedText=false and smart punctuation disable options, used in signing/verify/proof message fields to preserve exact byte content.
Evidence from the diff
The merge adds Spark address ownership proof generation/verification (createSparkAddressOwnershipProof / verifySparkAddressOwnershipProof) wired into the signing UI, route generator, and Spark name details. It also: (1) refactors Util.isDesktop to use an injectable platform.Platform instance, enabling tests to fake the OS; (2) upgrades socks5_proxy from 1.0.3+dev.3 to 2.1.1 and adds an HTTP SOCKS proxy test; (3) updates flutter_libsparkmobile to a newer git ref; (4) changes TrocadorAPI to derive onion-vs-clearnet and proxy settings from a single _useTor getter and updates the onion authority; (5) tweaks AdaptiveTextField to optionally preserve pasted whitespace and disable smart punctuation. No explicit security bug fix is described in the commit message or diff comments.
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/pages/wallet_view/wallet_view.dartlib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_wallet_features.dartlib/services/exchange/trocador/trocador_api.dartlib/services/exchange/trocador/trocador_exchange.dartlib/utilities/util.dartlib/widgets/textfields/adaptive_text_field.dartpubspec.lock / scripts/app_config/templates/pubspec.template.yamlInspect captured patch +1275 / −219
### lib/pages/send_view/send_view.dart
@@ -169,6 +169,16 @@ class _SendViewState extends ConsumerState<SendView> {
noteController.text = paymentData.label!;
}
+ // firo-qt treats message as the spark memo when paying a spark address
+ if (coin is Firo &&
+ paymentData.message != null &&
+ SparkInterface.validateSparkAddress(
+ address: _address!,
+ isTestNet: coin.network.isTestNet,
+ )) {
+ memoController.text = paymentData.message!;
+ }
+
// autofill amount field
if (paymentData.amount != null) {
final amount = Amount.tryParseCanonicalAmount(
### 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_send.dart
@@ -1010,6 +1010,16 @@ class _DesktopSendState extends ConsumerState<DesktopSend> {
_note = paymentData.label;
}
+ // firo-qt treats message as the spark memo when paying a spark address
+ if (coin is Firo &&
+ paymentData.message != null &&
+ SparkInterface.validateSparkAddress(
+ address: _address!,
+ isTestNet: coin.network.isTestNet,
+ )) {
+ memoController.text = paymentData.message!;
+ }
+
// autofill amount field
if (paymentData.amount != null) {
final amount = Amount.tryParseCanonicalAmount(
### 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/services/exchange/trocador/trocador_api.dart
@@ -31,18 +31,17 @@ const kTrocadorRefCode = "9eHm9BkQfS";
abstract class TrocadorAPI {
static const String authority = "api.trocador.app";
static const String onionAuthority =
- "trocadorfyhlu27aefre5u7zri66gudtzdyelymftvr4yjwcxhfaqsid.onion";
+ "65bsisadnxvw4kfz7h7a3jwcyenrhluuj3kd5toslfzxbk5q4m3wy6qd.onion";
static const String markup = "1";
static const String minKYCRating = "C";
static HTTP client = HTTP();
- static Uri _buildUri({
- required String method,
- required bool isOnion,
- Map<String, String>? params,
- }) {
- return isOnion
+ static bool get _useTor =>
+ AppConfig.hasFeature(AppFeature.tor) && Prefs.instance.useTor;
+
+ static Uri _buildUri({required String method, Map<String, String>? params}) {
+ return _useTor
? Uri.http(onionAuthority, method, params)
: Uri.https(authority, method, params);
}
@@ -57,11 +56,7 @@ abstract class TrocadorAPI {
"Content-Type": "application/json",
"API-KEY": kTrocadorApiKey,
},
- proxyInfo: !AppConfig.hasFeature(AppFeature.tor)
- ? null
- : Prefs.instance.useTor
- ? TorService.sharedInstance.getProxyInfo()
- : null,
+ proxyInfo: _useTor ? TorService.sharedInstance.getProxyInfo() : null,
);
code = response.code;
@@ -87,14 +82,8 @@ abstract class TrocadorAPI {
}
/// fetch all supported coins
- static Future<ExchangeResponse<List<TrocadorCoin>>> getCoins({
- required bool isOnion,
- }) async {
- final uri = _buildUri(
- isOnion: isOnion,
- method: "coins",
- params: {"ref": kTrocadorRefCode},
- );
+ static Future<ExchangeResponse<List<TrocadorCoin>>> getCoins() async {
+ final uri = _buildUri(method: "coins", params: {"ref": kTrocadorRefCode});
try {
final json = await _makeGetRequest(uri);
@@ -122,11 +111,9 @@ abstract class TrocadorAPI {
/// get trade info
static Future<ExchangeResponse<TrocadorTrade>> getTrade({
- required bool isOnion,
required String tradeId,
}) async {
final uri = _buildUri(
- isOnion: isOnion,
method: "trade",
params: {"ref": kTrocadorRefCode, "id": tradeId},
);
@@ -149,7 +136,6 @@ abstract class TrocadorAPI {
/// get standard/floating rate
static Future<ExchangeResponse<TrocadorRate>> getNewStandardRate({
- required bool isOnion,
required String fromTicker,
required String fromNetwork,
required String toTicker,
@@ -168,12 +154,11 @@ abstract class TrocadorAPI {
"markup": markup,
};
- return await _getNewRate(isOnion: isOnion, params: params);
+ return await _getNewRate(params: params);
}
/// get fixed rate/payment rate
static Future<ExchangeResponse<TrocadorRate>> getNewPaymentRate({
- required bool isOnion,
required String fromTicker,
required String fromNetwork,
required String toTicker,
@@ -192,14 +177,13 @@ abstract class TrocadorAPI {
"markup": markup,
};
- return await _getNewRate(isOnion: isOnion, params: params);
+ return await _getNewRate(params: params);
}
static Future<ExchangeResponse<TrocadorRate>> _getNewRate({
- required bool isOnion,
required Map<String, String> params,
}) async {
- final uri = _buildUri(isOnion: isOnion, method: "new_rate", params: params);
+ final uri = _buildUri(method: "new_rate", params: params);
try {
final json = await _makeGetRequest(uri);
@@ -219,7 +203,6 @@ abstract class TrocadorAPI {
/// create new floating rate/standard trade
static Future<ExchangeResponse<TrocadorTradeNew>> createNewStandardRateTrade({
- required bool isOnion,
required String? rateId,
required String fromTicker,
required String fromNetwork,
@@ -255,11 +238,10 @@ abstract class TrocadorAPI {
params["id"] = rateId;
}
- return await _getNewTrade(isOnion: isOnion, params: params);
+ return await _getNewTrade(params: params);
}
static Future<ExchangeResponse<TrocadorTradeNew>> createNewPaymentRateTrade({
- required bool isOnion,
required String? rateId,
required String fromTicker,
required String fromNetwork,
@@ -295,18 +277,13 @@ abstract class TrocadorAPI {
params["id"] = rateId;
}
- return await _getNewTrade(isOnion: isOnion, params: params);
+ return await _getNewTrade(params: params);
}
static Future<ExchangeResponse<TrocadorTradeNew>> _getNewTrade({
- required bool isOnion,
required Map<String, String> params,
}) async {
- final uri = _buildUri(
- isOnion: isOnion,
- method: "new_trade",
- params: params,
- );
+ final uri = _buildUri(method: "new_trade", params: params);
try {
final json = await _makeGetRequest(uri);
### lib/services/exchange/trocador/trocador_exchange.dart
@@ -69,7 +69,6 @@ class TrocadorExchange extends Exchange {
}) async {
final response = reversed
? await TrocadorAPI.createNewPaymentRateTrade(
- isOnion: false,
rateId: estimate?.rateId,
fromTicker: from.toLowerCase(),
fromNetwork: onlySupportedNetwork,
@@ -84,7 +83,6 @@ class TrocadorExchange extends Exchange {
isFixedRate: fixedRate,
)
: await TrocadorAPI.createNewStandardRateTrade(
- isOnion: false,
rateId: estimate?.rateId,
fromTicker: from.toLowerCase(),
fromNetwork: onlySupportedNetwork,
@@ -139,7 +137,7 @@ class TrocadorExchange extends Exchange {
Future<ExchangeResponse<List<Currency>>> getAllCurrencies(
bool fixedRate,
) async {
- _cachedCurrencies ??= (await TrocadorAPI.getCoins(isOnion: false)).value;
+ _cachedCurrencies ??= (await TrocadorAPI.getCoins()).value;
_cachedCurrencies?.removeWhere((e) => e.network != onlySupportedNetwork);
@@ -222,15 +220,13 @@ class TrocadorExchange extends Exchange {
) async {
final response = reversed
? await TrocadorAPI.getNewPaymentRate(
- isOnion: false,
fromTicker: from,
fromNetwork: onlySupportedNetwork,
toTicker: to,
toNetwork: onlySupportedNetwork,
toAmount: amount.toString(),
)
: await TrocadorAPI.getNewStandardRate(
- isOnion: false,
fromTicker: from,
fromNetwork: onlySupportedNetwork,
toTicker: to,
@@ -367,10 +363,7 @@ class TrocadorExchange extends Exchange {
@override
Future<ExchangeResponse<Trade>> updateTrade(Trade trade) async {
- final response = await TrocadorAPI.getTrade(
- isOnion: false,
- tradeId: trade.tradeId,
- );
+ final response = await TrocadorAPI.getTrade(tradeId: trade.tradeId);
if (response.value != null) {
final updated = response.value!;
### lib/utilities/util.dart
@@ -16,6 +16,7 @@ import 'package:device_info_plus/device_info_plus.dart';
import 'package:flutter/material.dart';
import 'package:intl/number_symbols.dart';
import 'package:intl/number_symbols_data.dart';
+import 'package:platform/platform.dart' as platform;
import '../app_config.dart';
import '../wallets/wallet/impl/monero_wallet.dart';
@@ -30,6 +31,7 @@ abstract class Util {
static final Map<String, NumberSymbols?> _numberSymbolsCache = {};
+ static platform.Platform layoutPlatform = const platform.LocalPlatform();
static double? screenWidth;
static bool? isIpad;
@@ -84,16 +86,17 @@ abstract class Util {
}
static bool get isDesktop {
+ final p = layoutPlatform;
// special check for running on linux based phones
- if (Platform.isLinux && screenWidth != null && screenWidth! < 800) {
+ if (p.isLinux && screenWidth != null && screenWidth! < 800) {
return false;
}
if (isIpad == true) {
return true;
}
- return Platform.isLinux || Platform.isMacOS || Platform.isWindows;
+ return p.isLinux || p.isMacOS || p.isWindows;
}
static Future<bool> get getIsIPad async {
### 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"
@@ -1903,7 +1903,7 @@ packages:
source: hosted
version: "0.6.0"
platform:
- dependency: transitive
+ dependency: "direct main"
description:
name: platform
sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984"
@@ -2135,10 +2135,10 @@ packages:
dependency: "direct main"
description:
name: socks5_proxy
- sha256: e0cba6917cd374de6f6cb0ce081e50e6efc24c61644b8e9f20c8bf8b91bb0b75
+ sha256: "80fa31a9ebfc0dc8de7b0e568c8d8927b65558ef2c7591cbee5afac814fb8f74"
url: "https://pub.dev"
source: hosted
- version: "1.0.3+dev.3"
+ version: "2.1.1"
socks_socket:
dependency: transitive
description:
### scripts/app_config/templates/pubspec.template.yaml
@@ -21,6 +21,7 @@ dependencies:
flutter:
sdk: flutter
ffi: ^2.0.1
+ platform: ^3.1.6
mutex: ^3.0.0
web_socket_channel: ^2.4.0
@@ -46,7 +47,7 @@ dependencies:
# flutter_libsparkmobile:
# git:
# url: https://github.com/cypherstack/flutter_libsparkmobile.git
-# ref: fe505c29f5dbb5dd94fb986e509506be9e2f37e7
+# ref: 3fdcb21160a39c051a0e73d7ae04f987134ecd5f
# %%END_ENABLE_FIRO%%
# %%ENABLE_EPIC%%
@@ -216,7 +217,7 @@ dependencies:
git:
url: https://github.com/cypherstack/tezart.git
ref: 84c563104f1a19c26e49bafccb7da404b210b666
- socks5_proxy: 1.0.3+dev.3
+ socks5_proxy: ^2.1.1
convert: ^3.1.1
flutter_hooks: ^0.20.3
meta: ^1.9.1
### test/networking/http_socks_proxy_test.dart
@@ -0,0 +1,82 @@
+import 'dart:io';
+import 'dart:typed_data';
+
+import 'package:flutter_test/flutter_test.dart';
+import 'package:stackwallet/networking/http.dart';
+
+// Minimal SOCKS5 server that records the CONNECT target and answers any
+// request with a canned HTTP 200.
+class _FakeSocksServer {
+ late final ServerSocket _server;
+ int? addressType;
+ String? target;
+ int? port;
+
+ int get listeningPort => _server.port;
+
+ Future<void> start() async {
+ _server = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0);
+ _server.listen((socket) {
+ var stage = 0;
+ socket.listen((Uint8List bytes) {
+ switch (stage) {
+ case 0:
+ socket.add([0x05, 0x00]);
+ stage = 1;
+ case 1:
+ addressType = bytes[3];
+ if (addressType == 0x03) {
+ target = String.fromCharCodes(bytes.sublist(5, 5 + bytes[4]));
+ } else {
+ target = bytes.sublist(4, bytes.length - 2).join('.');
+ }
+ port = (bytes[bytes.length - 2] << 8) | bytes[bytes.length - 1];
+ socket.add([0x05, 0x00, 0x00, 0x01, 0, 0, 0, 0, 0, 0]);
+ stage = 2;
+ default:
+ socket.write(
+ 'HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok',
+ );
+ socket.close();
+ }
+ });
+ });
+ }
+
+ Future<void> stop() => _server.close();
+}
+
+void main() {
+ late _FakeSocksServer socks;
+
+ setUp(() async {
+ socks = _FakeSocksServer();
+ await socks.start();
+ });
+
+ tearDown(() => socks.stop());
+
+ Future<Response> get(String host) => const HTTP().get(
+ url: Uri.http(host, '/'),
+ proxyInfo: (host: InternetAddress.loopbackIPv4, port: socks.listeningPort),
+ );
+
+ test('proxied request sends the hostname to the SOCKS5 proxy', () async {
+ final response = await get('example.invalid');
+
+ expect(response.code, 200);
+ expect(socks.addressType, 0x03);
+ expect(socks.target, 'example.invalid');
+ expect(socks.port, 80);
+ });
+
+ test('proxied request can target an onion address', () async {
+ const onion =
+ 'trocadorfyhlu27aefre5u7zri66gudtzdyelymftvr4yjwcxhfaqsid.onion';
+ final response = await get(onion);
+
+ expect(response.code, 200);
+ expect(socks.addressType, 0x03);
+ expect(socks.target, onion);
+ });
+}
### test/pages/spark_address_ownership_proof_test.dart
@@ -0,0 +1,314 @@
+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:platform/platform.dart' as platform;
+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() {
+ setUp(() {
+ final previousPlatform = Util.layoutPlatform;
+ final previousWidth = Util.screenWidth;
+ final previousIsIpad = Util.isIpad;
+ addTearDown(() {
+ Util.layoutPlatform = previousPlatform;
+ Util.screenWidth = previousWidth;
+ Util.isIpad = previousIsIpad;
+ });
+ Util.layoutPlatform = platform.FakePlatform(operatingSystem: 'android');
+ Util.screenWidth = null;
+ Util.isIpad = false;
+ });
+
+ 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 {
+ Util.layoutPlatform = platform.FakePlatform(
+ operatingSystem: desktop ? 'macos' : 'android',
+ );
+ Util.screenWidth = desktop ? 1000 : 390;
+ 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 {
+ Util.layoutPlatform = platform.FakePlatform(operatingSystem: 'ios');
+ Util.screenWidth = ipad ? 1024 : 320;
+ Util.isIpad = ipad;
+ 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/utilities/util_layout_test.dart
@@ -0,0 +1,38 @@
+import 'package:flutter_test/flutter_test.dart';
+import 'package:platform/platform.dart' as platform;
+import 'package:stackwallet/utilities/util.dart';
+
+void main() {
+ setUp(() {
+ final previousPlatform = Util.layoutPlatform;
+ final previousWidth = Util.screenWidth;
+ final previousIsIpad = Util.isIpad;
+ addTearDown(() {
+ Util.layoutPlatform = previousPlatform;
+ Util.screenWidth = previousWidth;
+ Util.isIpad = previousIsIpad;
+ });
+ });
+
+ for (final (os, width, ipad, desktop) in <(String, double?, bool, bool)>[
+ ('android', 390, false, false),
+ ('android', 1000, false, false),
+ ('ios', 390, false, false),
+ ('ios', 1024, false, false),
+ ('ios', 390, true, true),
+ ('ios', 1024, true, true),
+ ('macos', 390, false, true),
+ ('windows', 390, false, true),
+ ('linux', 799, false, false),
+ ('linux', 800, false, true),
+ ('linux', null, false, true),
+ ]) {
+ test('layout for $os at width $width with isIpad=$ipad', () {
+ Util.layoutPlatform = platform.FakePlatform(operatingSystem: os);
+ Util.screenWidth = width;
+ Util.isIpad = ipad;
+
+ expect(Util.isDesktop, desktop);
+ });
+ }
+}
### 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 37/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.