What changed, and why it matters
This commit adds a new 'Sign/Verify message' feature to Stack Wallet, letting users cryptographically sign messages with their wallet keys and verify signatures from others. It also corrects the message-prefix byte lengths for Dogecoin, Firo, and Namecoin so signatures match what other software expects. There is no clear security bug in the diff, but the signing code is new and touches private keys, so it deserves careful review.
Review the new signing path for safe key handling, ensure derivationPath is validated before use, confirm the message-prefix cleanup helper is correct for all coin variants, and test cross-compatibility of signatures against reference implementations for Dogecoin, Firo, and Namecoin.
Security signals we found
New private-key operation added (message signing)
BIP32 derivation uses address.derivationPath!.value without visible validation in the diff
Message-prefix length corrections for Dogecoin, Firo, Namecoin
View-only check prevents signing but not verification
No visible input sanitization beyond address normalization for verifyMessage
Evidence from the diff
The patch introduces SignVerifyInterface and wires it into ElectrumXInterface-based wallets. signMessage derives the private key for the chosen address via BIP32 derivation and produces a base64 compact ECDSA signature using coinlib.MessageSignature.sign. verifyMessage decodes a base64 signature and checks it against a normalized address. The patch also fixes messagePrefix length bytes: Dogecoin/Namecoin changed from 0x18 to 0x19, Firo from 0x18 to 0x16, matching the actual string lengths. A helper _cleanEncodedPrefixLength strips the embedded length because coinlib recalculates it. UI additions include SigningView, SignMessageForm, VerifyMessageForm, AddressList, and route/desktop integration. View-only wallets are blocked from signing.
Changed components
lib/wallets/wallet/wallet_mixin_interfaces/electrumx_interface.dartlib/wallets/wallet/wallet_mixin_interfaces/sign_verify_interface.dartlib/wallets/crypto_currency/coins/dogecoin.dartlib/wallets/crypto_currency/coins/firo.dartlib/wallets/crypto_currency/coins/namecoin.dartlib/pages/signing/signing_view.dartlib/pages/signing/sub_widgets/sign_message_tab.dartlib/pages/signing/sub_widgets/verify_message_tab.dartlib/pages/signing/sub_widgets/address_list.dartInspect captured patch +1360 / −204
diff --git a/lib/pages/receive_view/addresses/address_card.dart b/lib/pages/receive_view/addresses/address_card.dart
index 901cd57..f8b6ec8 100644
--- a/lib/pages/receive_view/addresses/address_card.dart
+++ b/lib/pages/receive_view/addresses/address_card.dart
@@ -50,6 +50,7 @@ class AddressCard extends ConsumerStatefulWidget {
required this.coin,
this.onPressed,
this.clipboard = const ClipboardWrapper(),
+ this.compact = false,
});
final int addressId;
@@ -57,6 +58,7 @@ class AddressCard extends ConsumerStatefulWidget {
final CryptoCurrency coin;
final ClipboardInterface clipboard;
final VoidCallback? onPressed;
+ final bool compact;
@override
ConsumerState<AddressCard> createState() => _AddressCardState();
@@ -142,11 +144,10 @@ class _AddressCardState extends ConsumerState<AddressCard> {
@override
void initState() {
- address =
- MainDB.instance.isar.addresses
- .where()
- .idEqualTo(widget.addressId)
- .findFirstSync()!;
+ address = MainDB.instance.isar.addresses
+ .where()
+ .idEqualTo(widget.addressId)
+ .findFirstSync()!;
label = MainDB.instance.getAddressLabelSync(widget.walletId, address.value);
Id? id = label?.id;
@@ -155,12 +156,11 @@ class _AddressCardState extends ConsumerState<AddressCard> {
walletId: widget.walletId,
addressString: address.value,
value: "",
- tags:
- address.subType == AddressSubType.receiving
- ? ["receiving"]
- : address.subType == AddressSubType.change
- ? ["change"]
- : null,
+ tags: address.subType == AddressSubType.receiving
+ ? ["receiving"]
+ : address.subType == AddressSubType.change
+ ? ["change"]
+ : null,
);
id = MainDB.instance.putAddressLabelSync(label!);
}
@@ -181,20 +181,19 @@ class _AddressCardState extends ConsumerState<AddressCard> {
}
return ConditionalParent(
- condition: isDesktop,
- builder:
- (child) => Row(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- SvgPicture.file(
- File(ref.watch(coinIconProvider(widget.coin))),
- width: 32,
- height: 32,
- ),
- const SizedBox(width: 12),
- Expanded(child: child),
- ],
+ condition: isDesktop && !widget.compact,
+ builder: (child) => Row(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ SvgPicture.file(
+ File(ref.watch(coinIconProvider(widget.coin))),
+ width: 32,
+ height: 32,
),
+ const SizedBox(width: 12),
+ Expanded(child: child),
+ ],
+ ),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@@ -230,129 +229,124 @@ class _AddressCardState extends ConsumerState<AddressCard> {
),
],
),
- const SizedBox(height: 10),
- Row(
- children: [
- CustomTextButton(
- text: "Copy address",
- onTap: () {
- widget.clipboard
- .setData(ClipboardData(text: address.value))
- .then((value) {
- if (context.mounted) {
- unawaited(
- showFloatingFlushBar(
- type: FlushBarType.info,
- message: "Copied to clipboard",
- context: context,
- ),
- );
- }
- });
- },
- ),
- const SizedBox(width: 16),
- CustomTextButton(
- text: "Show QR code",
- onTap: () async {
- await showDialog<void>(
- context: context,
- builder: (_) {
- return StackDialogBase(
- child: Column(
- children: [
- if (label!.value.isNotEmpty)
- Text(
- label!.value,
- style: STextStyles.w600_18(context),
+ if (!widget.compact) const SizedBox(height: 10),
+ if (!widget.compact)
+ Row(
+ children: [
+ CustomTextButton(
+ text: "Copy address",
+ onTap: () {
+ widget.clipboard
+ .setData(ClipboardData(text: address.value))
+ .then((value) {
+ if (context.mounted) {
+ unawaited(
+ showFloatingFlushBar(
+ type: FlushBarType.info,
+ message: "Copied to clipboard",
+ context: context,
),
- if (label!.value.isNotEmpty)
- const SizedBox(height: 8),
- Text(
- address.value,
- style: STextStyles.w500_16(
- context,
- ).copyWith(
- color:
- Theme.of(context)
- .extension<StackColors>()!
- .textSubtitle1,
+ );
+ }
+ });
+ },
+ ),
+ const SizedBox(width: 16),
+ CustomTextButton(
+ text: "Show QR code",
+ onTap: () async {
+ await showDialog<void>(
+ context: context,
+ builder: (_) {
+ return StackDialogBase(
+ child: Column(
+ children: [
+ if (label!.value.isNotEmpty)
+ Text(
+ label!.value,
+ style: STextStyles.w600_18(context),
+ ),
+ if (label!.value.isNotEmpty)
+ const SizedBox(height: 8),
+ Text(
+ address.value,
+ style: STextStyles.w500_16(context)
+ .copyWith(
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .textSubtitle1,
+ ),
),
- ),
- const SizedBox(height: 16),
- Center(
- child: RepaintBoundary(
- key: _qrKey,
- child: QR(
- data: AddressUtils.buildUriString(
- widget.coin.uriScheme,
- address.value,
- {},
+ const SizedBox(height: 16),
+ Center(
+ child: RepaintBoundary(
+ key: _qrKey,
+ child: QR(
+ data: AddressUtils.buildUriString(
+ widget.coin.uriScheme,
+ address.value,
+ {},
+ ),
+ size: 220,
),
- size: 220,
),
),
- ),
- const SizedBox(height: 16),
- Row(
- children: [
- if (!isDesktop)
- Expanded(
- child: SecondaryButton(
- label: "Share",
- buttonHeight:
- isDesktop
- ? ButtonHeight.l
- : null,
- icon: SvgPicture.asset(
- Assets.svg.share,
- width: 14,
- height: 14,
- color:
- Theme.of(context)
- .extension<StackColors>()!
- .buttonTextSecondary,
+ const SizedBox(height: 16),
+ Row(
+ children: [
+ if (!isDesktop)
+ Expanded(
+ child: SecondaryButton(
+ label: "Share",
+ buttonHeight: isDesktop
+ ? ButtonHeight.l
+ : null,
+ icon: SvgPicture.asset(
+ Assets.svg.share,
+ width: 14,
+ height: 14,
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .buttonTextSecondary,
+ ),
+ onPressed: () async {
+ await _capturePng(false);
+ },
),
- onPressed: () async {
- await _capturePng(false);
- },
),
- ),
- if (isDesktop)
- Expanded(
- child: PrimaryButton(
- buttonHeight:
- isDesktop
- ? ButtonHeight.l
- : null,
- onPressed: () async {
- // TODO: add save functionality instead of share
- // save works on linux at the moment
- await _capturePng(true);
- },
- label: "Save",
- icon: SvgPicture.asset(
- Assets.svg.arrowDown,
- width: 20,
- height: 20,
- color:
- Theme.of(context)
- .extension<StackColors>()!
- .buttonTextPrimary,
+ if (isDesktop)
+ Expanded(
+ child: PrimaryButton(
+ buttonHeight: isDesktop
+ ? ButtonHeight.l
+ : null,
+ onPressed: () async {
+ // TODO: add save functionality instead of share
+ // save works on linux at the moment
+ await _capturePng(true);
+ },
+ label: "Save",
+ icon: SvgPicture.asset(
+ Assets.svg.arrowDown,
+ width: 20,
+ height: 20,
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .buttonTextPrimary,
+ ),
),
),
- ),
- ],
- ),
- ],
- ),
- );
- },
- );
- },
- ),
- ],
- ),
+ ],
+ ),
+ ],
+ ),
+ );
+ },
+ );
+ },
+ ),
+ ],
+ ),
// if (label!.tags != null && label!.tags!.isNotEmpty)
// Wrap(
// spacing: 10,
diff --git a/lib/pages/signing/signing_view.dart b/lib/pages/signing/signing_view.dart
new file mode 100644
index 0000000..9afaf36
--- /dev/null
+++ b/lib/pages/signing/signing_view.dart
@@ -0,0 +1,89 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+
+import '../../themes/stack_colors.dart';
+import '../../utilities/text_styles.dart';
+import '../../utilities/util.dart';
+import '../../widgets/background.dart';
+import '../../widgets/conditional_parent.dart';
+import '../../widgets/custom_buttons/app_bar_icon_button.dart';
+import '../../widgets/custom_tab_view.dart';
+import '../../widgets/stack_dialog.dart';
+import 'sub_widgets/sign_message_tab.dart';
+import 'sub_widgets/verify_message_tab.dart';
+
+class SigningView extends ConsumerStatefulWidget {
+ const SigningView({super.key, required this.walletId});
+
+ final String walletId;
+
+ static const String routeName = "/signingView";
+
+ @override
+ ConsumerState<SigningView> createState() => _SigningViewState();
+}
+
+class _SigningViewState extends ConsumerState<SigningView> {
+ @override
+ Widget build(BuildContext context) {
+ debugPrint("BUILD: $runtimeType");
+
+ // keep auto dispose providers alive
+ ref.listen(pSignIsValid, (_, __) {});
+ ref.listen(pVerifyIsValid, (_, __) {});
+
+ return ConditionalParent(
+ condition: !Util.isDesktop,
+ builder: (child) => Background(
+ child: Scaffold(
+ backgroundColor: Theme.of(
+ context,
+ ).extension<StackColors>()!.background,
+ appBar: AppBar(
+ leading: AppBarBackButton(
+ onPressed: () {
+ Navigator.of(context).pop();
+ },
+ ),
+ title: Text(
+ "Sign / Verify",
+ style: STextStyles.navBarTitle(context),
+ ),
+ ),
+ body: SafeArea(child: child),
+ ),
+ ),
+ child: CustomTabView(
+ titles: const ["Sign message", "Verify message"],
+ children: [
+ SignMessageForm(
+ key: const Key("_SignMessageFormKey"),
+ walletId: widget.walletId,
+ ),
+ VerifyMessageForm(
+ key: const Key("_VerifyMessageFormKey"),
+ walletId: widget.walletId,
+ ),
+ ],
+ ),
+ );
+ }
+}
+
+Future<void> showSignVerifyError(Exception e, {required BuildContext context}) {
+ String message = e.toString().trim();
+ const exceptionPrefix = "Exception:";
+ while (message.startsWith(exceptionPrefix) &&
+ message.length > exceptionPrefix.length) {
+ message = message.substring(exceptionPrefix.length).trim();
+ }
+ return showDialog(
+ context: context,
+ builder: (context) => StackOkDialog(
+ title: "Error",
+ message: message,
+ maxWidth: Util.isDesktop ? 400 : null,
+ desktopPopRootNavigator: Util.isDesktop,
+ ),
+ );
+}
diff --git a/lib/pages/signing/sub_widgets/address_list.dart b/lib/pages/signing/sub_widgets/address_list.dart
new file mode 100644
index 0000000..66d4635
--- /dev/null
+++ b/lib/pages/signing/sub_widgets/address_list.dart
@@ -0,0 +1,218 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'package:isar_community/isar.dart';
+
+import '../../../models/isar/models/address_label.dart';
+import '../../../models/isar/models/blockchain_data/address.dart';
+import '../../../providers/db/main_db_provider.dart';
+import '../../../themes/stack_colors.dart';
+import '../../../utilities/constants.dart';
+import '../../../utilities/text_styles.dart';
+import '../../../utilities/util.dart';
+import '../../../wallets/isar/providers/wallet_info_provider.dart';
+import '../../../widgets/background.dart';
+import '../../../widgets/custom_buttons/app_bar_icon_button.dart';
+import '../../receive_view/addresses/address_card.dart';
+
+class AddressList extends ConsumerStatefulWidget {
+ const AddressList({super.key, required this.walletId});
+
+ final String walletId;
+
+ @override
+ ConsumerState<AddressList> createState() => _AddressListState();
+}
+
+class _AddressListState extends ConsumerState<AddressList> {
+ String _searchString = "";
+
+ late final TextEditingController _searchController;
+ final searchFieldFocusNode = FocusNode();
+
+ List<Id> _search(String term) {
+ if (term.isEmpty) {
+ return ref
+ .read(mainDBProvider)
+ .getAddresses(widget.walletId)
+ .filter()
+ .group(
+ (q) => q
+ .subTypeEqualTo(AddressSubType.change)
+ .or()
+ .subTypeEqualTo(AddressSubType.receiving)
+ .or()
+ .subTypeEqualTo(AddressSubType.paynymReceive)
+ .or()
+ .subTypeEqualTo(AddressSubType.paynymNotification),
+ )
+ .and()
+ .not()
+ .typeEqualTo(AddressType.nonWallet)
+ .and()
+ .group(
+ (q) => q
+ .group(
+ (q2) => q2
+ .typeEqualTo(AddressType.frostMS)
+ .and()
+ .zSafeFrostEqualTo(true),
+ )
+ .or()
+ .not()
+ .typeEqualTo(AddressType.frostMS),
+ )
+ .sortByDerivationIndex()
+ .idProperty()
+ .findAllSync();
+ }
+
+ final labels = ref
+ .read(mainDBProvider)
+ .getAddressLabels(widget.walletId)
+ .filter()
+ .group(
+ (q) => q
+ .valueContains(term, caseSensitive: false)
+ .or()
+ .addressStringContains(term, caseSensitive: false)
+ .or()
+ .group(
+ (q) => q.tagsIsNotNull().and().tagsElementContains(
+ term,
+ caseSensitive: false,
+ ),
+ ),
+ )
+ .findAllSync();
+
+ if (labels.isEmpty) {
+ return [];
+ }
+
+ return ref
+ .read(mainDBProvider)
+ .getAddresses(widget.walletId)
+ .filter()
+ .anyOf<AddressLabel, Address>(
+ labels,
+ (q, e) => q.valueEqualTo(e.addressString),
+ )
+ .group(
+ (q) => q
+ .subTypeEqualTo(AddressSubType.change)
+ .or()
+ .subTypeEqualTo(AddressSubType.receiving)
+ .or()
+ .subTypeEqualTo(AddressSubType.paynymReceive)
+ .or()
+ .subTypeEqualTo(AddressSubType.paynymNotification),
+ )
+ .and()
+ .not()
+ .typeEqualTo(AddressType.nonWallet)
+ .and()
+ .group(
+ (q) => q
+ .group(
+ (q2) => q2
+ .typeEqualTo(AddressType.frostMS)
+ .and()
+ .zSafeFrostEqualTo(true),
+ )
+ .or()
+ .not()
+ .typeEqualTo(AddressType.frostMS),
+ )
+ .sortByDerivationIndex()
+ .idProperty()
+ .findAllSync();
+ }
+
+ @override
+ void initState() {
+ _searchController = TextEditingController();
+
+ super.initState();
+ }
+
+ @override
+ void dispose() {
+ _searchController.dispose();
+ searchFieldFocusNode.dispose();
+ super.dispose();
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final coin = ref.watch(pWalletCoin(widget.walletId));
+
+ final ids = _search(_searchString);
+
+ return ListView.separated(
+ shrinkWrap: true,
+ itemCount: ids.length,
+ separatorBuilder: (_, __) => Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 12),
+ child: Util.isDesktop
+ ? Container(
+ height: 1,
+ color: Theme.of(
+ context,
+ ).extension<StackColors>()!.textFieldDefaultBG,
+ )
+ : const SizedBox(height: 2),
+ ),
+ itemBuilder: (_, index) => Padding(
+ padding: const EdgeInsets.all(4),
+ child: AddressCard(
+ key: Key("addressCardDesktop_key_${ids[index]}"),
+ walletId: widget.walletId,
+ compact: true,
+ addressId: ids[index],
+ coin: coin,
+ onPressed: () => Navigator.of(
+ context,
+ ).pop(ref.read(mainDBProvider).isar.addresses.getSync(ids[index])!),
+ ),
+ ),
+ );
+ }
+}
+
+class CompactAddressListView extends StatelessWidget {
+ const CompactAddressListView({super.key, required this.walletId});
+
+ final String walletId;
+
+ static const routeName = "/compactAddressListView";
+
+ @override
+ Widget build(BuildContext context) {
+ return Background(
+ child: Scaffold(
+ backgroundColor: Theme.of(context).extension<StackColors>()!.background,
+ appBar: AppBar(
+ leading: AppBarBackButton(
+ onPressed: () {
+ Navigator.of(context).pop();
+ },
+ ),
+ title: Text(
+ "Choose address",
+ style: STextStyles.navBarTitle(context),
+ ),
+ ),
+ body: SafeArea(
+ child: Padding(
+ padding: EdgeInsets.only(
+ bottom: Constants.size.standardPadding,
+ left: Constants.size.standardPadding,
+ right: Constants.size.standardPadding,
+ ),
+ child: AddressList(walletId: walletId),
+ ),
+ ),
+ ),
+ );
+ }
+}
diff --git a/lib/pages/signing/sub_widgets/sign_message_tab.dart b/lib/pages/signing/sub_widgets/sign_message_tab.dart
new file mode 100644
index 0000000..a4b0d92
--- /dev/null
+++ b/lib/pages/signing/sub_widgets/sign_message_tab.dart
@@ -0,0 +1,267 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+
+import '../../../models/isar/models/isar_models.dart';
+import '../../../providers/global/wallets_provider.dart';
+import '../../../themes/stack_colors.dart';
+import '../../../utilities/constants.dart';
+import '../../../utilities/if_not_already.dart';
+import '../../../utilities/show_loading.dart';
+import '../../../utilities/text_styles.dart';
+import '../../../utilities/util.dart';
+import '../../../wallets/wallet/wallet_mixin_interfaces/sign_verify_interface.dart';
+import '../../../widgets/conditional_parent.dart';
+import '../../../widgets/custom_buttons/blue_text_button.dart';
+import '../../../widgets/custom_buttons/simple_copy_button.dart';
+import '../../../widgets/desktop/desktop_dialog_close_button.dart';
+import '../../../widgets/desktop/primary_button.dart';
+import '../../../widgets/detail_item.dart';
+import '../../../widgets/dialogs/s_dialog.dart';
+import '../../../widgets/rounded_container.dart';
+import '../../../widgets/textfields/adaptive_text_field.dart';
+import '../signing_view.dart';
+import 'address_list.dart';
+
+final class _SignState {
+ final String message, signature;
+ final Address? address;
+
+ _SignState({
+ required this.address,
+ required this.message,
+ required this.signature,
+ });
+
+ bool get isValid => message.isNotEmpty && address != null;
+
+ _SignState copyWith({String? message, String? signature}) {
+ return _SignState(
+ address: address,
+ message: message ?? this.message,
+ signature: signature ?? this.signature,
+ );
+ }
+
+ _SignState copyWithAddress(Address? address) {
+ return _SignState(address: address, message: message, signature: signature);
+ }
+
+ @override
+ String toString() =>
+ "_SignState(address: $address, message: $message, signature: $signature)";
+}
+
+final _pSignState = StateProvider.autoDispose((ref) {
+ return _SignState(address: null, message: "", signature: "");
+});
+
+final pSignIsValid = Provider.autoDispose(
+ (ref) => ref.watch(_pSignState).isValid,
+);
+
+class SignMessageForm extends ConsumerStatefulWidget {
+ const SignMessageForm({super.key, required this.walletId});
+
+ final String walletId;
+
+ @override
+ ConsumerState<SignMessageForm> createState() => _SignMessageFormState();
+}
+
+class _SignMessageFormState extends ConsumerState<SignMessageForm> {
+ final messageController = TextEditingController();
+
+ late final VoidCallback _chooseAddress;
+ late final VoidCallback _sign;
+
+ TextStyle _getStyle(BuildContext context) {
+ return Util.isDesktop
+ ? STextStyles.desktopTextExtraExtraSmall(context).copyWith(
+ color: Theme.of(
+ context,
+ ).extension<StackColors>()!.textFieldActiveSearchIconRight,
+ )
+ : STextStyles.smallMed12(context);
+ }
+
+ @override
+ void initState() {
+ super.initState();
+
+ messageController.text = ref.read(_pSignState).message;
+
+ _chooseAddress = IfNotAlreadyAsync(() async {
+ final Address? address;
+
+ if (Util.isDesktop) {
+ address = await showDialog<Address>(
+ context: context,
+ builder: (context) {
+ return SDialog(
+ contentCanScroll: false,
+ child: ConditionalParent(
+ condition: Util.isDesktop,
+ builder: (child) => SizedBox(width: 600, child: child),
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ if (Util.isDesktop)
+ Row(
+ mainAxisAlignment: MainAxisAlignment.spaceBetween,
+ children: [
+ Padding(
+ padding: const EdgeInsets.all(32),
+ child: Text(
+ "Choose address",
+ style: STextStyles.desktopH3(context),
+ textAlign: TextAlign.center,
+ ),
+ ),
+ const DesktopDialogCloseButton(),
+ ],
+ ),
+ Expanded(
+ child: ConditionalParent(
+ condition: Util.isDesktop,
+ builder: (child) => Padding(
+ padding: const EdgeInsets.only(
+ top: 10,
+ left: 32,
+ right: 32,
+ bottom: 32,
+ ),
+ child: RoundedContainer(
+ padding: EdgeInsets.zero,
+ color: Colors.transparent,
+ borderColor: Theme.of(
+ context,
+ ).extension<StackColors>()!.textFieldDefaultBG,
+ child: child,
+ ),
+ ),
+
+ child: AddressList(walletId: widget.walletId),
+ ),
+ ),
+ ],
+ ),
+ ),
+ );
+ },
+ );
+ } else {
+ address = await Navigator.of(context).pushNamed<Address>(
+ CompactAddressListView.routeName,
+ arguments: widget.walletId,
+ );
+ }
+
+ if (address != null &&
+ address.value != ref.read(_pSignState).address?.value &&
+ mounted) {
+ ref.read(_pSignState.notifier).state = ref
+ .read(_pSignState)
+ .copyWithAddress(address)
+ .copyWith(signature: "");
+ }
+ }).execute;
+
+ _sign = IfNotAlreadyAsync(() async {
+ Exception? ex;
+
+ final state = ref.read(_pSignState);
+ final signature = await showLoading(
+ whileFuture:
+ (ref.read(pWallets).getWallet(widget.walletId)
+ as SignVerifyInterface)
+ .signMessage(state.message, address: state.address!),
+ context: context,
+ message: "Signing...",
+ delay: const Duration(seconds: 1),
+ onException: (e) => ex = e,
+ );
+
+ if (mounted && ex != null) {
+ await showSignVerifyError(ex!, context: context);
+ } else if (signature != null && mounted) {
+ ref.read(_pSignState.notifier).state = state.copyWith(
+ signature: signature,
+ );
+ }
+ }).execute;
+ }
+
+ @override
+ void dispose() {
+ messageController.dispose();
+ super.dispose();
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return ConditionalParent(
+ condition: !Util.isDesktop,
+ builder: (child) => Padding(
+ padding: EdgeInsets.all(Constants.size.standardPadding),
+ child: child,
+ ),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ SizedBox(height: Util.isDesktop ? 20 : 12),
+ SelectableText("Message", style: _getStyle(context)),
+ SizedBox(height: Util.isDesktop ? 10 : 8),
+ AdaptiveTextField(
+ controller: messageController,
+ showPasteClearButton: true,
+ maxLines: 1,
+ onChangedComprehensive: (_) {
+ if (mounted) {
+ ref.read(_pSignState.notifier).state = ref
+ .read(_pSignState)
+ .copyWith(message: messageController.text, signature: "");
+ }
+ },
+ ),
+ SizedBox(height: Util.isDesktop ? 20 : 12),
+
+ DetailItem(
+ title: "Address",
+ titleStyle: _getStyle(context),
+ detail:
+ ref.watch(_pSignState.select((s) => s.address))?.value ?? "",
+ showEmptyDetail: true,
+ detailPlaceholder: "n/a",
+ noPadding: Util.isDesktop,
+ button: CustomTextButton(
+ text: "Choose address",
+ onTap: _chooseAddress,
+ ),
+ ),
+ SizedBox(height: Util.isDesktop ? 20 : 12),
+
+ DetailItem(
+ title: "Signature",
+ titleStyle: _getStyle(context),
+ detail: ref.watch(_pSignState.select((s) => s.signature)),
+ showEmptyDetail: true,
+ detailPlaceholder: "n/a",
+ noPadding: Util.isDesktop,
+ button: ref.watch(_pSignState.select((s) => s.signature)).isEmpty
+ ? null
+ : SimpleCopyButton(data: ref.read(_pSignState).signature),
+ ),
+
+ const SizedBox(height: 32),
+
+ PrimaryButton(
+ buttonHeight: Util.isDesktop ? ButtonHeight.l : null,
+ label: "Sign",
+ enabled: ref.watch(pSignIsValid),
+ onPressed: ref.watch(pSignIsValid) ? _sign : null,
+ ),
+ ],
+ ),
+ );
+ }
+}
diff --git a/lib/pages/signing/sub_widgets/verify_message_tab.dart b/lib/pages/signing/sub_widgets/verify_message_tab.dart
new file mode 100644
index 0000000..08b4e59
--- /dev/null
+++ b/lib/pages/signing/sub_widgets/verify_message_tab.dart
@@ -0,0 +1,202 @@
+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/constants.dart';
+import '../../../utilities/if_not_already.dart';
+import '../../../utilities/show_loading.dart';
+import '../../../utilities/text_styles.dart';
+import '../../../utilities/util.dart';
+import '../../../wallets/wallet/wallet_mixin_interfaces/sign_verify_interface.dart';
+import '../../../widgets/conditional_parent.dart';
+import '../../../widgets/desktop/primary_button.dart';
+import '../../../widgets/stack_dialog.dart';
+import '../../../widgets/textfields/adaptive_text_field.dart';
+import '../signing_view.dart';
+
+final class _VerifyState {
+ final String address, message, signature;
+
+ _VerifyState({
+ required this.address,
+ required this.message,
+ required this.signature,
+ });
+
+ bool get isValid =>
+ message.isNotEmpty && signature.isNotEmpty && address.isNotEmpty;
+
+ _VerifyState copyWith({String? address, String? message, String? signature}) {
+ return _VerifyState(
+ address: address ?? this.address,
+ message: message ?? this.message,
+ signature: signature ?? this.signature,
+ );
+ }
+
+ @override
+ String toString() =>
+ "_VerifyState(address: $address, message: $message, signature: $signature)";
+}
+
+final _pVerifyState = StateProvider.autoDispose((ref) {
+ return _VerifyState(address: "", message: "", signature: "");
+});
+
+final pVerifyIsValid = Provider.autoDispose(
+ (ref) => ref.watch(_pVerifyState).isValid,
+);
+
+class VerifyMessageForm extends ConsumerStatefulWidget {
+ const VerifyMessageForm({super.key, required this.walletId});
+
+ final String walletId;
+
+ @override
+ ConsumerState<VerifyMessageForm> createState() => _VerifyMessageFormState();
+}
+
+class _VerifyMessageFormState extends ConsumerState<VerifyMessageForm> {
+ final messageController = TextEditingController();
+ final addressController = TextEditingController();
+ final signatureController = TextEditingController();
+
+ late final VoidCallback _verify;
+
+ TextStyle _getStyle(BuildContext context) {
+ return Util.isDesktop
+ ? STextStyles.desktopTextExtraExtraSmall(context).copyWith(
+ color: Theme.of(
+ context,
+ ).extension<StackColors>()!.textFieldActiveSearchIconRight,
+ )
+ : STextStyles.smallMed12(context);
+ }
+
+ @override
+ void initState() {
+ super.initState();
+
+ addressController.text = ref.read(_pVerifyState).address;
+ messageController.text = ref.read(_pVerifyState).message;
+ signatureController.text = ref.read(_pVerifyState).signature;
+
+ _verify = IfNotAlreadyAsync(() async {
+ Exception? ex;
+
+ final verified = await showLoading(
+ whileFuture:
+ (ref.read(pWallets).getWallet(widget.walletId)
+ as SignVerifyInterface)
+ .verifyMessage(
+ messageController.text,
+ address: addressController.text,
+ signature: signatureController.text,
+ ),
+ context: context,
+ message: "Verifying...",
+ delay: const Duration(seconds: 1),
+ onException: (e) => ex = e,
+ );
+
+ if (mounted) {
+ if (ex != null) {
+ await showSignVerifyError(ex!, context: context);
+ } else {
+ await showDialog<void>(
+ context: context,
+ builder: (context) => StackOkDialog(
+ title: verified == true
+ ? "Verification succeeded"
+ : "Verification failed",
+ maxWidth: Util.isDesktop ? 400 : null,
+ desktopPopRootNavigator: Util.isDesktop,
+ ),
+ );
+ }
+ }
+ }).execute;
+ }
+
+ @override
+ void dispose() {
+ messageController.dispose();
+ addressController.dispose();
+ signatureController.dispose();
+ super.dispose();
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return ConditionalParent(
+ condition: !Util.isDesktop,
+ builder: (child) => Padding(
+ padding: EdgeInsets.all(Constants.size.standardPadding),
+ child: child,
+ ),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ SizedBox(height: Util.isDesktop ? 20 : 12),
+
+ SelectableText("Message", style: _getStyle(context)),
+ SizedBox(height: Util.isDesktop ? 10 : 8),
+ AdaptiveTextField(
+ controller: messageController,
+ showPasteClearButton: true,
+ maxLines: 1,
+ onChangedComprehensive: (_) {
+ if (mounted) {
+ ref.read(_pVerifyState.notifier).state = ref
+ .read(_pVerifyState)
+ .copyWith(message: messageController.text);
+ }
+ },
+ ),
+ SizedBox(height: Util.isDesktop ? 20 : 12),
+
+ SelectableText("Address", style: _getStyle(context)),
+ SizedBox(height: Util.isDesktop ? 10 : 8),
+ AdaptiveTextField(
+ controller: addressController,
+ showPasteClearButton: true,
+ maxLines: 1,
+ onChangedComprehensive: (_) {
+ if (mounted) {
+ ref.read(_pVerifyState.notifier).state = ref
+ .read(_pVerifyState)
+ .copyWith(address: addressController.text);
+ }
+ },
+ ),
+ SizedBox(height: Util.isDesktop ? 20 : 12),
+
+ SelectableText("Signature", style: _getStyle(context)),
+ SizedBox(height: Util.isDesktop ? 10 : 8),
+ AdaptiveTextField(
+ controller: signatureController,
+ showPasteClearButton: true,
+ maxLines: 1,
+ onChangedComprehensive: (_) {
+ if (mounted) {
+ ref.read(_pVerifyState.notifier).state = ref
+ .read(_pVerifyState)
+ .copyWith(signature: signatureController.text);
+ }
+ },
+ ),
+
+ const SizedBox(height: 32),
+
+ PrimaryButton(
+ buttonHeight: Util.isDesktop ? ButtonHeight.l : null,
+ label: "Verify",
+ enabled: ref.watch(pVerifyIsValid),
+ onPressed: ref.watch(pVerifyIsValid) ? _verify : null,
+ ),
+ ],
+ ),
+ );
+ }
+}
diff --git a/lib/pages/wallet_view/wallet_view.dart b/lib/pages/wallet_view/wallet_view.dart
index b7929f1..6483be9 100644
--- a/lib/pages/wallet_view/wallet_view.dart
+++ b/lib/pages/wallet_view/wallet_view.dart
@@ -60,6 +60,7 @@ import '../../wallets/wallet/wallet_mixin_interfaces/coin_control_interface.dart
import '../../wallets/wallet/wallet_mixin_interfaces/mweb_interface.dart';
import '../../wallets/wallet/wallet_mixin_interfaces/ordinals_interface.dart';
import '../../wallets/wallet/wallet_mixin_interfaces/paynym_interface.dart';
+import '../../wallets/wallet/wallet_mixin_interfaces/sign_verify_interface.dart';
import '../../wallets/wallet/wallet_mixin_interfaces/spark_interface.dart';
import '../../wallets/wallet/wallet_mixin_interfaces/view_only_option_interface.dart';
import '../../widgets/background.dart';
@@ -103,6 +104,7 @@ import '../send_view/frost_ms/frost_send_view.dart';
import '../send_view/send_view.dart';
import '../settings_views/wallet_settings_view/wallet_network_settings_view/wallet_network_settings_view.dart';
import '../settings_views/wallet_settings_view/wallet_settings_view.dart';
+import '../signing/signing_view.dart';
import '../spark_names/spark_names_home_view.dart';
import '../token_view/my_tokens_view.dart';
import 'sub_widgets/transactions_list.dart';
@@ -1129,6 +1131,24 @@ class _WalletViewState extends ConsumerState<WalletView> {
);
},
),
+ if (wallet is SignVerifyInterface && !viewOnly)
+ WalletNavigationBarItemData(
+ icon: SvgPicture.asset(
+ Assets.svg.pencil,
+ height: 20,
+ width: 20,
+ color: Theme.of(
+ context,
+ ).extension<StackColors>()!.bottomNavIconIcon,
+ ),
+ label: "Sign/Verify",
+ onTap: () {
+ Navigator.of(context).pushNamed(
+ SigningView.routeName,
+ arguments: widget.walletId,
+ );
+ },
+ ),
if (wallet is CoinControlInterface &&
ref.watch(
prefsChangeNotifierProvider.select(
diff --git a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_wallet_features.dart b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_wallet_features.dart
index 052569a..4a709a0 100644
--- a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_wallet_features.dart
+++ b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_wallet_features.dart
@@ -25,6 +25,7 @@ import '../../../../pages/namecoin_names/namecoin_names_home_view.dart';
import '../../../../pages/paynym/paynym_claim_view.dart';
import '../../../../pages/paynym/paynym_home_view.dart';
import '../../../../pages/salvium_stake/salvium_create_stake_view.dart';
+import '../../../../pages/signing/signing_view.dart';
import '../../../../pages/spark_names/spark_names_home_view.dart';
import '../../../../providers/desktop/current_desktop_menu_item.dart';
import '../../../../providers/global/paynym_api_provider.dart';
@@ -51,6 +52,7 @@ import '../../../../wallets/wallet/wallet_mixin_interfaces/mweb_interface.dart';
import '../../../../wallets/wallet/wallet_mixin_interfaces/ordinals_interface.dart';
import '../../../../wallets/wallet/wallet_mixin_interfaces/paynym_interface.dart';
import '../../../../wallets/wallet/wallet_mixin_interfaces/rbf_interface.dart';
+import '../../../../wallets/wallet/wallet_mixin_interfaces/sign_verify_interface.dart';
import '../../../../wallets/wallet/wallet_mixin_interfaces/spark_interface.dart';
import '../../../../wallets/wallet/wallet_mixin_interfaces/view_only_option_interface.dart';
import '../../../../widgets/custom_loading_overlay.dart';
@@ -88,6 +90,7 @@ enum WalletFeature {
namecoinName("Domains", "Namecoin DNS"),
sparkNames("Names", "Spark names"),
salviumStaking("Staking", "Staking"),
+ sign("Sign/Verify", "Sign / Verify messages"),
// special cases
clearSparkCache("", ""),
@@ -417,6 +420,38 @@ class _DesktopWalletFeaturesState extends ConsumerState<DesktopWalletFeatures> {
);
}
+ Future<void> _onSignPressed() async {
+ await showDialog<void>(
+ context: context,
+ builder: (context) => DesktopDialog(
+ maxWidth: 580,
+ maxHeight: double.infinity,
+ child: Column(
+ children: [
+ Row(
+ mainAxisAlignment: MainAxisAlignment.spaceBetween,
+ children: [
+ Padding(
+ padding: const EdgeInsets.only(left: 32),
+ child: Text(
+ "Sign/Verify",
+ style: STextStyles.desktopH3(context),
+ ),
+ ),
+ const DesktopDialogCloseButton(),
+ ],
+ ),
+ Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 32),
+ child: SigningView(walletId: widget.walletId),
+ ),
+ const SizedBox(height: 32),
+ ],
+ ),
+ ),
+ );
+ }
+
List<(WalletFeature, String, FutureOr<void> Function())> _getOptions(
Wallet wallet,
bool showExchange,
@@ -455,6 +490,9 @@ class _DesktopWalletFeaturesState extends ConsumerState<DesktopWalletFeatures> {
_onSalviumStakePressed,
),
+ if (wallet is SignVerifyInterface && !isViewOnly)
+ (WalletFeature.sign, Assets.svg.pencil, _onSignPressed),
+
if (showCoinControl)
(
WalletFeature.coinControl,
diff --git a/lib/route_generator.dart b/lib/route_generator.dart
index b44550b..e7565bc 100644
--- a/lib/route_generator.dart
+++ b/lib/route_generator.dart
@@ -152,6 +152,8 @@ import 'pages/settings_views/wallet_settings_view/wallet_settings_wallet_setting
import 'pages/settings_views/wallet_settings_view/wallet_settings_wallet_settings/spark_info.dart';
import 'pages/settings_views/wallet_settings_view/wallet_settings_wallet_settings/wallet_settings_wallet_settings_view.dart';
import 'pages/settings_views/wallet_settings_view/wallet_settings_wallet_settings/xpub_view.dart';
+import 'pages/signing/signing_view.dart';
+import 'pages/signing/sub_widgets/address_list.dart';
import 'pages/spark_names/buy_spark_name_view.dart';
import 'pages/spark_names/confirm_spark_name_transaction_view.dart';
import 'pages/spark_names/spark_names_home_view.dart';
@@ -427,6 +429,26 @@ class RouteGenerator {
}
return _routeError("${settings.name} invalid args: ${args.toString()}");
+ case SigningView.routeName:
+ if (args is String) {
+ return getRoute(
+ shouldUseMaterialRoute: useMaterialPageRoute,
+ builder: (_) => SigningView(walletId: args),
+ settings: RouteSettings(name: settings.name),
+ );
+ }
+ return _routeError("${settings.name} invalid args: ${args.toString()}");
+
+ case CompactAddressListView.routeName:
+ if (args is String) {
+ return getRoute<Address>(
+ shouldUseMaterialRoute: useMaterialPageRoute,
+ builder: (_) => CompactAddressListView(walletId: args),
+ settings: RouteSettings(name: settings.name),
+ );
+ }
+ return _routeError("${settings.name} invalid args: ${args.toString()}");
+
case CreateNewFrostMsWalletView.routeName:
if (args is ({String walletName, FrostCurrency frostCurrency})) {
return getRoute(
@@ -2513,7 +2535,7 @@ class RouteGenerator {
}
}
- static Route<dynamic> getRoute({
+ static Route<T> getRoute<T>({
bool shouldUseMaterialRoute = useMaterialPageRoute,
required Widget Function(BuildContext) builder,
String? title,
@@ -2522,14 +2544,14 @@ class RouteGenerator {
bool fullscreenDialog = false,
}) {
if (shouldUseMaterialRoute) {
- return MaterialPageRoute(
+ return MaterialPageRoute<T>(
builder: builder,
settings: settings,
maintainState: maintainState,
fullscreenDialog: fullscreenDialog,
);
} else {
- return CupertinoPageRoute(
+ return CupertinoPageRoute<T>(
builder: builder,
settings: settings,
title: title,
@@ -2539,7 +2561,7 @@ class RouteGenerator {
}
}
- static Route<dynamic> createSlideTransitionRoute(Widget viewToInsert) {
+ static Route<T> createSlideTransitionRoute<T>(Widget viewToInsert) {
return PageRouteBuilder(
pageBuilder: (context, animation, secondaryAnimation) => viewToInsert,
transitionsBuilder: (context, animation, secondaryAnimation, child) {
@@ -2557,7 +2579,7 @@ class RouteGenerator {
);
}
- static Route<dynamic> _routeError(String message) {
+ static Route<T> _routeError<T>(String message) {
// Replace with robust ErrorView page
final Widget errorView = Scaffold(
appBar: AppBar(
@@ -2571,7 +2593,7 @@ class RouteGenerator {
),
);
- return getRoute(
+ return getRoute<T>(
shouldUseMaterialRoute: useMaterialPageRoute,
builder: (_) => errorView,
);
diff --git a/lib/utilities/if_not_already.dart b/lib/utilities/if_not_already.dart
new file mode 100644
index 0000000..664fc9a
--- /dev/null
+++ b/lib/utilities/if_not_already.dart
@@ -0,0 +1,38 @@
+import 'dart:async';
+
+class IfNotAlready {
+ final void Function() _function;
+
+ bool _locked = false;
+
+ IfNotAlready(this._function);
+
+ void execute() {
+ if (_locked) return;
+ _locked = true;
+ try {
+ _function();
+ } finally {
+ _locked = false;
+ }
+ }
+}
+
+class IfNotAlreadyAsync {
+ final Future<void> Function() _function;
+
+ bool _locked = false;
+
+ IfNotAlreadyAsync(this._function);
+
+ Future<void> execute() async {
+ if (!_locked) {
+ _locked = true;
+ try {
+ await _function();
+ } finally {
+ _locked = false;
+ }
+ }
+ }
+}
diff --git a/lib/wallets/crypto_currency/coins/dogecoin.dart b/lib/wallets/crypto_currency/coins/dogecoin.dart
index 1d281f5..375ce6b 100644
--- a/lib/wallets/crypto_currency/coins/dogecoin.dart
+++ b/lib/wallets/crypto_currency/coins/dogecoin.dart
@@ -137,7 +137,7 @@ class Dogecoin extends Bip39HDCurrency with ElectrumXCurrencyInterface {
privHDPrefix: 0x02fac398,
pubHDPrefix: 0x02facafd,
bech32Hrp: "doge",
- messagePrefix: '\x18Dogecoin Signed Message:\n',
+ messagePrefix: '\x19Dogecoin Signed Message:\n',
minFee: BigInt.from(1), // Not used in stack wallet currently
minOutput: dustLimit.raw, // Not used in stack wallet currently
feePerKb: BigInt.from(1), // Not used in stack wallet currently
@@ -150,7 +150,7 @@ class Dogecoin extends Bip39HDCurrency with ElectrumXCurrencyInterface {
privHDPrefix: 0x04358394,
pubHDPrefix: 0x043587cf,
bech32Hrp: "tdge",
- messagePrefix: "\x18Dogecoin Signed Message:\n",
+ messagePrefix: "\x19Dogecoin Signed Message:\n",
minFee: BigInt.from(1), // Not used in stack wallet currently
minOutput: dustLimit.raw, // Not used in stack wallet currently
feePerKb: BigInt.from(1), // Not used in stack wallet currently
diff --git a/lib/wallets/crypto_currency/coins/firo.dart b/lib/wallets/crypto_currency/coins/firo.dart
index f432bd7..21d57b6 100644
--- a/lib/wallets/crypto_currency/coins/firo.dart
+++ b/lib/wallets/crypto_currency/coins/firo.dart
@@ -107,7 +107,7 @@ class Firo extends Bip39HDCurrency with ElectrumXCurrencyInterface {
privHDPrefix: 0x0488ade4,
pubHDPrefix: 0x0488b21e,
bech32Hrp: "bc",
- messagePrefix: '\x18Zcoin Signed Message:\n',
+ messagePrefix: '\x16Zcoin Signed Message:\n',
minFee: BigInt.from(1), // Not used in stack wallet currently
minOutput: dustLimit.raw, // Not used in stack wallet currently
feePerKb: BigInt.from(1), // Not used in stack wallet currently
@@ -120,7 +120,7 @@ class Firo extends Bip39HDCurrency with ElectrumXCurrencyInterface {
privHDPrefix: 0x04358394,
pubHDPrefix: 0x043587cf,
bech32Hrp: "tb",
- messagePrefix: "\x18Zcoin Signed Message:\n",
+ messagePrefix: "\x16Zcoin Signed Message:\n",
minFee: BigInt.from(1), // Not used in stack wallet currently
minOutput: dustLimit.raw, // Not used in stack wallet currently
feePerKb: BigInt.from(1), // Not used in stack wallet currently
diff --git a/lib/wallets/crypto_currency/coins/namecoin.dart b/lib/wallets/crypto_currency/coins/namecoin.dart
index 7945e8b..464eb0b 100644
--- a/lib/wallets/crypto_currency/coins/namecoin.dart
+++ b/lib/wallets/crypto_currency/coins/namecoin.dart
@@ -148,11 +148,10 @@ class Namecoin extends Bip39HDCurrency with ElectrumXCurrencyInterface {
return (address: addr, addressType: AddressType.p2pkh);
case DerivePathType.bip49:
- final p2wpkhScript =
- coinlib.P2WPKHAddress.fromPublicKey(
- publicKey,
- hrp: networkParams.bech32Hrp,
- ).program.script;
+ final p2wpkhScript = coinlib.P2WPKHAddress.fromPublicKey(
+ publicKey,
+ hrp: networkParams.bech32Hrp,
+ ).program.script;
final addr = coinlib.P2SHAddress.fromRedeemScript(
p2wpkhScript,
@@ -186,7 +185,7 @@ class Namecoin extends Bip39HDCurrency with ElectrumXCurrencyInterface {
privHDPrefix: 0x0488ade4,
pubHDPrefix: 0x0488b21e,
bech32Hrp: "nc",
- messagePrefix: '\x18Namecoin Signed Message:\n',
+ messagePrefix: '\x19Namecoin Signed Message:\n',
minFee: BigInt.from(1), // Not used in stack wallet currently
minOutput: dustLimit.raw, // Not used in stack wallet currently
feePerKb: BigInt.from(1), // Not used in stack wallet currently
diff --git a/lib/wallets/wallet/wallet_mixin_interfaces/electrumx_interface.dart b/lib/wallets/wallet/wallet_mixin_interfaces/electrumx_interface.dart
index 0af68f2..05170c9 100644
--- a/lib/wallets/wallet/wallet_mixin_interfaces/electrumx_interface.dart
+++ b/lib/wallets/wallet/wallet_mixin_interfaces/electrumx_interface.dart
@@ -1,4 +1,5 @@
import 'dart:async';
+import 'dart:convert';
import 'dart:math';
import 'dart:typed_data';
@@ -36,11 +37,12 @@ import 'cpfp_interface.dart';
import 'mweb_interface.dart';
import 'paynym_interface.dart';
import 'rbf_interface.dart';
+import 'sign_verify_interface.dart';
import 'view_only_option_interface.dart';
mixin ElectrumXInterface<T extends ElectrumXCurrencyInterface>
on Bip39HDWallet<T>
- implements ViewOnlyOptionInterface<T> {
+ implements ViewOnlyOptionInterface<T>, SignVerifyInterface {
late ElectrumXClient electrumXClient;
late CachedElectrumXClient electrumXCachedClient;
@@ -2033,6 +2035,57 @@ mixin ElectrumXInterface<T extends ElectrumXCurrencyInterface>
}
}
+ @override
+ Future<String> signMessage(
+ final String message, {
+ required final Address address,
+ }) async {
+ if (isViewOnly) {
+ throw Exception("Cannot sign a message in a view only wallet");
+ }
+
+ final root = await getRootHDNode();
+ final keyPair = root.derivePath(address.derivationPath!.value);
+
+ final signed = coinlib.MessageSignature.sign(
+ key: keyPair.privateKey,
+ message: message,
+ prefix: _cleanEncodedPrefixLength(
+ cryptoCurrency.networkParams.messagePrefix,
+ ),
+ );
+
+ return base64Encode(signed.signature.compact);
+ }
+
+ @override
+ Future<bool> verifyMessage(
+ final String message, {
+ required final String address,
+ required final String signature,
+ }) async {
+ final signed = coinlib.MessageSignature.fromBase64(signature);
+
+ coinlib.Address clAddress;
+ try {
+ clAddress = coinlib.Address.fromString(
+ normalizeAddress(address),
+ cryptoCurrency.networkParams,
+ );
+ } catch (e, s) {
+ Logging.instance.i("$e\n$s");
+ return false;
+ }
+
+ return signed.verifyAddress(
+ address: clAddress,
+ message: message,
+ prefix: _cleanEncodedPrefixLength(
+ cryptoCurrency.networkParams.messagePrefix,
+ ),
+ );
+ }
+
// ===========================================================================
// ========== Interface functions ============================================
@@ -2054,6 +2107,22 @@ mixin ElectrumXInterface<T extends ElectrumXCurrencyInterface>
// ===========================================================================
// ========== private helpers ================================================
+ String _cleanEncodedPrefixLength(String prefix) {
+ final messagePrefixBytes =
+ cryptoCurrency.networkParams.messagePrefix.toUint8ListFromUtf8;
+ // Check if prefix already has length encoded and remove as coinlib
+ // recalculates it. Really not ideal....
+ // TODO: clean up cryptoCurrency.networkParams.messagePrefix once its
+ // determined that every usage of messagePrefix does not expect the length
+ // prefixed.
+ final ignoreFirstByte =
+ messagePrefixBytes.first == messagePrefixBytes.length - 1;
+ return (ignoreFirstByte
+ ? messagePrefixBytes.sublist(1)
+ : messagePrefixBytes)
+ .toUtf8String;
+ }
+
List<UTXO> _spendableUTXOs(List<UTXO> utxos) {
return utxos
.where(
diff --git a/lib/wallets/wallet/wallet_mixin_interfaces/sign_verify_interface.dart b/lib/wallets/wallet/wallet_mixin_interfaces/sign_verify_interface.dart
new file mode 100644
index 0000000..fdceb34
--- /dev/null
+++ b/lib/wallets/wallet/wallet_mixin_interfaces/sign_verify_interface.dart
@@ -0,0 +1,14 @@
+import '../../../models/isar/models/blockchain_data/address.dart';
+
+mixin SignVerifyInterface {
+ Future<String> signMessage(
+ final String message, {
+ required final Address address,
+ });
+
+ Future<bool> verifyMessage(
+ final String message, {
+ required final String address,
+ required final String signature,
+ });
+}
diff --git a/lib/widgets/detail_item.dart b/lib/widgets/detail_item.dart
index 91a6625..b45a36d 100644
--- a/lib/widgets/detail_item.dart
+++ b/lib/widgets/detail_item.dart
@@ -12,12 +12,15 @@ class DetailItem extends StatelessWidget {
required this.title,
required this.detail,
this.button,
+ this.titleStyle,
this.overrideDetailTextColor,
this.showEmptyDetail = true,
this.horizontal = false,
this.disableSelectableText = false,
this.borderColor,
this.expandDetail = false,
+ this.detailPlaceholder,
+ this.noPadding = false,
});
final String title;
@@ -29,6 +32,9 @@ class DetailItem extends StatelessWidget {
final Color? overrideDetailTextColor;
final Color? borderColor;
final bool expandDetail;
+ final String? detailPlaceholder;
+ final TextStyle? titleStyle;
+ final bool noPadding;
@override
Widget build(BuildContext context) {
@@ -41,7 +47,7 @@ class DetailItem extends StatelessWidget {
}
if (detail.isEmpty && showEmptyDetail) {
- _detail = "$title will appear here";
+ _detail = detailPlaceholder ?? "$title will appear here";
detailStyle = detailStyle.copyWith(
color: Theme.of(context).extension<StackColors>()!.textSubtitle3,
);
@@ -51,14 +57,17 @@ class DetailItem extends StatelessWidget {
horizontal: horizontal,
borderColor: borderColor,
expandDetail: expandDetail,
- title:
- disableSelectableText
- ? Text(title, style: STextStyles.itemSubtitle(context))
- : SelectableText(title, style: STextStyles.itemSubtitle(context)),
- detail:
- disableSelectableText
- ? Text(_detail, style: detailStyle)
- : SelectableText(_detail, style: detailStyle),
+ noPadding: noPadding,
+ title: disableSelectableText
+ ? Text(title, style: titleStyle ?? STextStyles.itemSubtitle(context))
+ : SelectableText(
+ title,
+ style: titleStyle ?? STextStyles.itemSubtitle(context),
+ ),
+ detail: disableSelectableText
+ ? Text(_detail, style: detailStyle)
+ : SelectableText(_detail, style: detailStyle),
+ button: button,
);
}
}
@@ -72,6 +81,7 @@ class DetailItemBase extends StatelessWidget {
this.horizontal = false,
this.borderColor,
this.expandDetail = false,
+ this.noPadding = false,
});
final Widget title;
@@ -80,53 +90,55 @@ class DetailItemBase extends StatelessWidget {
final bool horizontal;
final Color? borderColor;
final bool expandDetail;
+ final bool noPadding;
@override
Widget build(BuildContext context) {
return ConditionalParent(
condition: !Util.isDesktop || borderColor != null,
- builder:
- (child) => RoundedWhiteContainer(
- padding:
- Util.isDesktop
- ? const EdgeInsets.all(16)
- : const EdgeInsets.all(12),
- borderColor: borderColor,
- child: child,
- ),
+ builder: (child) => RoundedWhiteContainer(
+ padding: noPadding
+ ? EdgeInsets.zero
+ : Util.isDesktop
+ ? const EdgeInsets.all(16)
+ : const EdgeInsets.all(12),
+ borderColor: borderColor,
+ child: child,
+ ),
child: ConditionalParent(
condition: Util.isDesktop && borderColor == null,
- builder:
- (child) => Padding(padding: const EdgeInsets.all(16), child: child),
- child:
- horizontal
- ? Row(
- mainAxisAlignment: MainAxisAlignment.spaceBetween,
- children: [
- title,
- if (expandDetail) const SizedBox(width: 16),
- ConditionalParent(
- condition: expandDetail,
- builder: (child) => Expanded(child: child),
- child: detail,
- ),
- ],
- )
- : Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- Row(
- mainAxisAlignment: MainAxisAlignment.spaceBetween,
- children: [title, button ?? Container()],
- ),
- const SizedBox(height: 5),
- ConditionalParent(
- condition: expandDetail,
- builder: (child) => Expanded(child: child),
- child: detail,
- ),
- ],
- ),
+ builder: (child) => Padding(
+ padding: noPadding ? EdgeInsets.zero : const EdgeInsets.all(16),
+ child: child,
+ ),
+ child: horizontal
+ ? Row(
+ mainAxisAlignment: MainAxisAlignment.spaceBetween,
+ children: [
+ title,
+ if (expandDetail) const SizedBox(width: 16),
+ ConditionalParent(
+ condition: expandDetail,
+ builder: (child) => Expanded(child: child),
+ child: detail,
+ ),
+ ],
+ )
+ : Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Row(
+ mainAxisAlignment: MainAxisAlignment.spaceBetween,
+ children: [title, button ?? Container()],
+ ),
+ const SizedBox(height: 5),
+ ConditionalParent(
+ condition: expandDetail,
+ builder: (child) => Expanded(child: child),
+ child: detail,
+ ),
+ ],
+ ),
),
);
}
diff --git a/lib/widgets/textfields/adaptive_text_field.dart b/lib/widgets/textfields/adaptive_text_field.dart
new file mode 100644
index 0000000..e57746a
--- /dev/null
+++ b/lib/widgets/textfields/adaptive_text_field.dart
@@ -0,0 +1,174 @@
+import 'package:flutter/material.dart';
+import 'package:flutter/services.dart';
+
+import '../../utilities/constants.dart';
+import '../../utilities/text_styles.dart';
+import '../../utilities/util.dart';
+import '../icon_widgets/clipboard_icon.dart';
+import '../icon_widgets/x_icon.dart';
+import '../stack_text_field.dart';
+import '../textfield_icon_button.dart';
+
+class AdaptiveTextField extends StatefulWidget {
+ const AdaptiveTextField({
+ super.key,
+ this.labelText,
+ this.controller,
+ this.focusNode,
+ this.autocorrect,
+ this.readOnly = false,
+ this.enableSuggestions = true,
+ this.onChanged,
+ this.onChangedComprehensive,
+ this.onSubmitted,
+ this.suffixIcons,
+ this.contentPadding,
+ this.minLines,
+ this.maxLines,
+ this.showPasteClearButton = false,
+ });
+
+ final String? labelText;
+
+ final TextEditingController? controller;
+ final FocusNode? focusNode;
+ final bool? autocorrect;
+ final EdgeInsets? contentPadding;
+ final int? minLines;
+ final int? maxLines;
+
+ final bool readOnly;
+ final bool enableSuggestions;
+
+ final void Function(String)? onChanged;
+ final void Function(String)? onChangedComprehensive;
+ final void Function(String)? onSubmitted;
+
+ /// This will be ignored if [suffixIcons] is not null!
+ final bool showPasteClearButton;
+
+ /// If this is not null, [showPasteClearButton] will be ignored.
+ final List<Widget>? suffixIcons;
+
+ @override
+ State<AdaptiveTextField> createState() => _AdaptiveTextFieldState();
+}
+
+class _AdaptiveTextFieldState extends State<AdaptiveTextField> {
+ late final FocusNode _focusNode;
+ late final bool _focusFlag;
+
+ TextEditingController? _controller;
+ TextEditingController get controller => widget.controller ?? _controller!;
+
+ String _cache = "";
+
+ @override
+ void initState() {
+ super.initState();
+
+ if (widget.controller == null) {
+ _controller = TextEditingController();
+ } else if (widget.onChangedComprehensive != null) {
+ widget.controller!.addListener(() {
+ if (widget.controller!.text != _cache) {
+ _cache = widget.controller!.text;
+ widget.onChangedComprehensive!.call(_cache);
+ }
+ });
+ }
+
+ if (widget.focusNode == null) {
+ _focusFlag = true;
+ _focusNode = FocusNode();
+ } else {
+ _focusFlag = false;
+ _focusNode = widget.focusNode!;
+ }
+ }
+
+ @override
+ void dispose() {
+ if (_focusFlag) _focusNode.dispose();
+ _controller?.dispose();
+
+ super.dispose();
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return ClipRRect(
+ borderRadius: BorderRadius.circular(Constants.size.circularBorderRadius),
+ child: TextField(
+ minLines: widget.minLines,
+ maxLines: widget.maxLines,
+ style: Util.isDesktop
+ ? STextStyles.field(context).copyWith(fontSize: 16)
+ : STextStyles.field(context),
+ controller: controller,
+ focusNode: _focusNode,
+ onChanged: widget.onChanged,
+ readOnly: widget.readOnly,
+ autocorrect: widget.autocorrect,
+ enableSuggestions: widget.enableSuggestions,
+ onSubmitted: widget.onSubmitted,
+ decoration:
+ standardInputDecoration(
+ widget.labelText,
+ _focusNode,
+ context,
+ ).copyWith(
+ contentPadding:
+ widget.contentPadding ??
+ (Util.isDesktop
+ ? const EdgeInsets.only(
+ left: 12,
+ top: 11,
+ bottom: 12,
+ right: 5,
+ )
+ : const EdgeInsets.only(
+ left: 10,
+ top: 12,
+ bottom: 8,
+ right: 5,
+ )),
+ suffixIcon: widget.suffixIcons?.isNotEmpty == true
+ ? Padding(
+ padding: controller.text.isEmpty
+ ? const EdgeInsets.only(right: 8)
+ : const EdgeInsets.only(right: 0),
+ child: UnconstrainedBox(
+ child: Row(
+ mainAxisAlignment: MainAxisAlignment.spaceAround,
+ children: widget.suffixIcons!,
+ ),
+ ),
+ )
+ : widget.showPasteClearButton
+ ? TextFieldIconButton(
+ onTap: () async {
+ if (controller.text.isEmpty) {
+ final ClipboardData? data = await Clipboard.getData(
+ Clipboard.kTextPlain,
+ );
+ if (data?.text != null && data!.text!.isNotEmpty) {
+ final content = data.text!.trim();
+ controller.text = content;
+ }
+ } else {
+ controller.text = "";
+ }
+
+ if (mounted) setState(() {});
+ },
+ child: controller.text.isNotEmpty
+ ? const XIcon()
+ : const ClipboardIcon(),
+ )
+ : null,
+ ),
+ ),
+ );
+ }
+}
Why this scored 28/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.