Implement Firo view key functionality.
What changed, and why it matters
This commit adds the ability to create and use Firo 'view-only' wallets using a Spark view key. A view-only wallet lets someone see incoming transactions and balances without being able to spend funds. The change adds UI screens to display and restore the Spark view key, and updates the wallet logic so Firo wallets can scan Spark transactions using the view key instead of the full private key. It also blocks spending, minting, and fee estimation in view-only mode. There is no direct evidence in the commit of a security vulnerability, but the change touches sensitive key-handling code and should be reviewed carefully.
Treat this as a feature commit requiring security review rather than a confirmed vulnerability. Reviewers should verify: (1) the Spark view key is not logged, persisted insecurely, or leaked via backups; (2) the view-key path cannot be coerced into signing or spending (the added throws look correct but should be audited for completeness); (3) `identifyAndRecoverCoinByFullViewKey` and `getAddressFromFullViewKey` correctly bind the view key to the wallet and network; (4) the `Options` widget selection index math cannot underflow when only two options are present; (5) the `sparkChangeAddress` and `_currentSparkAddress` late fields are always initialized before use; (6) no regression in full-wallet Spark spending/minting behavior. No immediate patch is indicated from the diff alone.
Security signals we found
New view-only wallet type stores and displays a Spark view key (sensitive material) in UI/clipboard/QR code.
View key is used to identify owned Spark coins, replacing per-address private key iteration.
Spend/mint/anonymize/fee-estimation paths now throw for view-only wallets.
ElectrumX transparent address gap checks are skipped for Spark view-only wallets.
Large refactor of Spark address/coin derivation and recovery logic.
Evidence from the diff
The commit implements Firo Spark view-key support across the Stack Wallet Flutter/Dart codebase. Key changes: (1) adds SparkViewOnlyWalletData to the view-only wallet data model; (2) updates restore UI to accept a Spark view key and choose between single-address, xPub, and Spark view-key modes; (3) adds a SparkViewKeyView screen to display the view key as text and QR code; (4) wires the new route and menu items for Spark wallets; (5) refactors SparkInterface to derive a full view key from the BIP39 seed and to generate/identify Spark addresses and coins using that view key; (6) adds guards that throw when view-only wallets attempt spend, mint, anonymize, or fee estimation; (7) updates coin identification to use identifyAndRecoverCoinByFullViewKey instead of per-address private keys; (8) updates ElectrumX scanning to skip transparent address gap checks for Spark view-only wallets. The diff is large (+1135/-397) and includes both UI and wallet-core changes. No CVE, advisory, or vendor security disclosure is present in the supplied materials.
Changed components
lib/models/keys/view_only_wallet_data.dartlib/pages/add_wallet_views/restore_wallet_view/restore_view_only_wallet_view.dartlib/pages/receive_view/receive_view.dartlib/pages/settings_views/sub_widgets/view_only_wallet_data_widget.dartlib/pages/settings_views/wallet_settings_view/wallet_settings_wallet_settings/spark_view_key_view.dartlib/pages/wallet_view/wallet_view.dartlib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_receive.dartlib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/wallet_options_button.dartlib/route_generator.dartlib/wallets/crypto_currency/coins/firo.dartlib/wallets/wallet/impl/firo_wallet.dartlib/wallets/wallet/wallet_mixin_interfaces/electrumx_interface.dartlib/wallets/wallet/wallet_mixin_interfaces/spark_interface.dartlib/widgets/options.dartlib/wl_gen/interfaces/lib_spark_interface.darttool/wl_templates/FIRO_lib_spark_interface_impl.template.dartInspect captured patch +1135 / −397
diff --git a/lib/models/keys/view_only_wallet_data.dart b/lib/models/keys/view_only_wallet_data.dart
index 92c23b0..384985a 100644
--- a/lib/models/keys/view_only_wallet_data.dart
+++ b/lib/models/keys/view_only_wallet_data.dart
@@ -7,7 +7,8 @@ import 'key_data_interface.dart';
enum ViewOnlyWalletType {
cryptonote,
addressOnly,
- xPub;
+ xPub,
+ spark;
}
sealed class ViewOnlyWalletData with KeyDataInterface {
@@ -46,6 +47,12 @@ sealed class ViewOnlyWalletData with KeyDataInterface {
jsonEncodedString,
walletId: walletId,
);
+
+ case ViewOnlyWalletType.spark:
+ return SparkViewOnlyWalletData.fromJsonEncodedString(
+ jsonEncodedString,
+ walletId: walletId,
+ );
}
}
@@ -162,3 +169,34 @@ class ExtendedKeysViewOnlyWalletData extends ViewOnlyWalletData {
],
});
}
+
+class SparkViewOnlyWalletData extends ViewOnlyWalletData {
+ @override
+ final type = ViewOnlyWalletType.spark;
+
+ final String viewKey;
+
+ SparkViewOnlyWalletData({
+ required super.walletId,
+ required this.viewKey,
+ });
+
+ static SparkViewOnlyWalletData fromJsonEncodedString(
+ String jsonEncodedString, {
+ required String walletId,
+ }) {
+ final map = jsonDecode(jsonEncodedString) as Map;
+ final json = Map<String, dynamic>.from(map);
+
+ return SparkViewOnlyWalletData(
+ walletId: walletId,
+ viewKey: json["viewKey"] as String,
+ );
+ }
+
+ @override
+ String toJsonEncodedString() => jsonEncode({
+ "type": type.index,
+ "viewKey": viewKey,
+ });
+}
diff --git a/lib/pages/add_wallet_views/restore_wallet_view/restore_view_only_wallet_view.dart b/lib/pages/add_wallet_views/restore_wallet_view/restore_view_only_wallet_view.dart
index e0d3871..e4709aa 100644
--- a/lib/pages/add_wallet_views/restore_wallet_view/restore_view_only_wallet_view.dart
+++ b/lib/pages/add_wallet_views/restore_wallet_view/restore_view_only_wallet_view.dart
@@ -36,12 +36,13 @@ import '../../../widgets/desktop/desktop_app_bar.dart';
import '../../../widgets/desktop/desktop_scaffold.dart';
import '../../../widgets/desktop/primary_button.dart';
import '../../../widgets/stack_text_field.dart';
-import '../../../widgets/toggle.dart';
+import '../../../widgets/options.dart';
import '../../home_view/home_view.dart';
import 'confirm_recovery_dialog.dart';
import 'sub_widgets/restore_failed_dialog.dart';
import 'sub_widgets/restore_succeeded_dialog.dart';
import 'sub_widgets/restoring_dialog.dart';
+import '../../../wallets/wallet/impl/firo_wallet.dart';
class RestoreViewOnlyWalletView extends ConsumerStatefulWidget {
const RestoreViewOnlyWalletView({
@@ -68,13 +69,13 @@ class _RestoreViewOnlyWalletViewState
extends ConsumerState<RestoreViewOnlyWalletView> {
late final TextEditingController addressController;
late final TextEditingController viewKeyController;
+ late final TextEditingController sparkViewKeyController;
- late String _currentDropDownValue;
+ late ViewOnlyWalletType _walletType;
bool _enableRestoreButton = false;
- bool _addressOnly = false;
-
bool _buttonLock = false;
+ late String _currentDropDownValue;
Future<void> _requestRestore() async {
if (_buttonLock) return;
@@ -107,12 +108,8 @@ class _RestoreViewOnlyWalletViewState
WalletInfoKeys.isViewOnlyKey: true,
};
- final ViewOnlyWalletType viewOnlyWalletType;
+ ViewOnlyWalletType viewOnlyWalletType = _walletType;
if (widget.coin is Bip39HDCurrency) {
- viewOnlyWalletType =
- _addressOnly
- ? ViewOnlyWalletType.addressOnly
- : ViewOnlyWalletType.xPub;
} else if (widget.coin is CryptonoteCurrency) {
viewOnlyWalletType = ViewOnlyWalletType.cryptonote;
} else {
@@ -120,8 +117,7 @@ class _RestoreViewOnlyWalletViewState
"Unsupported view only wallet currency type found: ${widget.coin.runtimeType}",
);
}
- otherDataJson[WalletInfoKeys.viewOnlyTypeIndexKey] =
- viewOnlyWalletType.index;
+ otherDataJson[WalletInfoKeys.viewOnlyTypeIndexKey] = _walletType.index;
if (!Platform.isLinux && !Util.isDesktop) await WakelockPlus.enable();
@@ -192,6 +188,16 @@ class _RestoreViewOnlyWalletViewState
],
);
break;
+
+ case ViewOnlyWalletType.spark:
+ if (sparkViewKeyController.text.isEmpty) {
+ throw Exception("Spark View Key is empty");
+ }
+ viewOnlyData = SparkViewOnlyWalletData(
+ walletId: info.walletId,
+ viewKey: sparkViewKeyController.text,
+ );
+ break;
}
var node = ref
@@ -237,6 +243,10 @@ class _RestoreViewOnlyWalletViewState
await (wallet as XelisWallet).init(isRestore: true);
break;
+ case const (FiroWallet):
+ await (wallet as FiroWallet).init();
+ break;
+
default:
await wallet.init();
}
@@ -314,12 +324,15 @@ class _RestoreViewOnlyWalletViewState
super.initState();
addressController = TextEditingController();
viewKeyController = TextEditingController();
+ sparkViewKeyController = TextEditingController();
if (widget.coin is Bip39HDCurrency) {
- _currentDropDownValue =
- (widget.coin as Bip39HDCurrency)
- .supportedHardenedDerivationPaths
- .last;
+ _currentDropDownValue = (widget.coin as Bip39HDCurrency)
+ .supportedHardenedDerivationPaths
+ .last;
+ _walletType = ViewOnlyWalletType.xPub;
+ } else if (widget.coin is CryptonoteCurrency) {
+ _walletType = ViewOnlyWalletType.cryptonote;
}
}
@@ -327,6 +340,7 @@ class _RestoreViewOnlyWalletViewState
void dispose() {
addressController.dispose();
viewKeyController.dispose();
+ sparkViewKeyController.dispose();
super.dispose();
}
@@ -393,23 +407,25 @@ class _RestoreViewOnlyWalletViewState
if (isElectrumX)
SizedBox(
height: isDesktop ? 56 : 48,
- width: isDesktop ? 490 : null,
- child: Toggle(
+ width: isDesktop ? 490 : double.infinity,
+ child: Options(
key: UniqueKey(),
- onText: "Extended pub key",
- offText: "Single address",
- onColor:
- Theme.of(
- context,
- ).extension<StackColors>()!.popupBG,
- offColor:
- Theme.of(context)
- .extension<StackColors>()!
- .textFieldDefaultBG,
- isOn: _addressOnly,
+ texts: [
+ "Single address",
+ "Extended pub key",
+ if (widget.coin is Firo)
+ isDesktop ? "Spark View Key" : "View Key"
+ ],
+ onColor: Theme.of(context)
+ .extension<StackColors>()!
+ .popupBG,
+ offColor: Theme.of(context)
+ .extension<StackColors>()!
+ .textFieldDefaultBG,
+ selectedIndex: _walletType.index-1,
onValueChanged: (value) {
setState(() {
- _addressOnly = value;
+ _walletType = ViewOnlyWalletType.values[value+1];
});
},
decoration: BoxDecoration(
@@ -420,8 +436,10 @@ class _RestoreViewOnlyWalletViewState
),
),
),
- SizedBox(height: isDesktop ? 24 : 16),
- if (!isElectrumX || _addressOnly)
+ SizedBox(
+ height: isDesktop ? 24 : 16,
+ ),
+ if (!isElectrumX || _walletType == ViewOnlyWalletType.addressOnly)
FullTextField(
key: const Key("viewOnlyAddressRestoreFieldKey"),
label: "Address",
@@ -441,8 +459,11 @@ class _RestoreViewOnlyWalletViewState
}
},
),
- if (!isElectrumX) SizedBox(height: isDesktop ? 16 : 12),
- if (isElectrumX && !_addressOnly)
+ if (!isElectrumX)
+ SizedBox(
+ height: isDesktop ? 16 : 12,
+ ),
+ if (isElectrumX && _walletType == ViewOnlyWalletType.xPub)
DropdownButtonHideUnderline(
child: DropdownButton2<String>(
value: _currentDropDownValue,
@@ -513,9 +534,11 @@ class _RestoreViewOnlyWalletViewState
),
),
),
- if (isElectrumX && !_addressOnly)
- SizedBox(height: isDesktop ? 16 : 12),
- if (!isElectrumX || !_addressOnly)
+ if (isElectrumX && _walletType == ViewOnlyWalletType.xPub)
+ SizedBox(
+ height: isDesktop ? 16 : 12,
+ ),
+ if (!isElectrumX || _walletType == ViewOnlyWalletType.xPub)
FullTextField(
key: const Key("viewOnlyKeyRestoreFieldKey"),
label:
@@ -536,6 +559,21 @@ class _RestoreViewOnlyWalletViewState
}
},
),
+ if (_walletType == ViewOnlyWalletType.spark)
+ SizedBox(
+ height: isDesktop ? 16 : 12,
+ ),
+ if (_walletType == ViewOnlyWalletType.spark)
+ FullTextField(
+ key: const Key("viewOnlySparkViewKeyRestoreFieldKey"),
+ label: "Spark View Key",
+ controller: sparkViewKeyController,
+ onChanged: (value) {
+ setState(() {
+ _enableRestoreButton = value.isNotEmpty;
+ });
+ },
+ ),
if (!isDesktop) const Spacer(),
SizedBox(height: isDesktop ? 24 : 16),
PrimaryButton(
diff --git a/lib/pages/receive_view/receive_view.dart b/lib/pages/receive_view/receive_view.dart
index 5fa78b3..66b8c88 100644
--- a/lib/pages/receive_view/receive_view.dart
+++ b/lib/pages/receive_view/receive_view.dart
@@ -183,16 +183,16 @@ class _ReceiveViewState extends ConsumerState<ReceiveView> {
if (wallet is Bip39HDWallet && wallet is! BCashInterface) {
DerivePathType? type;
if (wallet.isViewOnly && wallet is ExtendedKeysInterface) {
- final voData =
- await wallet.getViewOnlyWalletData()
- as ExtendedKeysViewOnlyWalletData;
+ final voData = await wallet.getViewOnlyWalletData();
for (final t in wallet.cryptoCurrency.supportedDerivationPathTypes) {
final testPath = wallet.cryptoCurrency.constructDerivePath(
derivePathType: t,
chain: 0,
index: 0,
);
- if (testPath.startsWith(voData.xPubs.first.path)) {
+ if (voData is SparkViewOnlyWalletData) {
+ type = t;
+ } else if (testPath.startsWith((voData as ExtendedKeysViewOnlyWalletData).xPubs.first.path)) {
type = t;
break;
}
diff --git a/lib/pages/settings_views/sub_widgets/view_only_wallet_data_widget.dart b/lib/pages/settings_views/sub_widgets/view_only_wallet_data_widget.dart
index 2659860..ef68684 100644
--- a/lib/pages/settings_views/sub_widgets/view_only_wallet_data_widget.dart
+++ b/lib/pages/settings_views/sub_widgets/view_only_wallet_data_widget.dart
@@ -81,6 +81,22 @@ class ViewOnlyWalletDataWidget extends StatelessWidget {
),
],
),
+ final SparkViewOnlyWalletData e => Column(
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ children: [
+ DetailItem(
+ title: "View Key",
+ detail: e.viewKey,
+ button: Util.isDesktop
+ ? IconCopyButton(
+ data: e.viewKey,
+ )
+ : SimpleCopyButton(
+ data: e.viewKey,
+ ),
+ ),
+ ],
+ ),
};
}
}
diff --git a/lib/pages/settings_views/wallet_settings_view/wallet_settings_wallet_settings/spark_view_key_view.dart b/lib/pages/settings_views/wallet_settings_view/wallet_settings_wallet_settings/spark_view_key_view.dart
new file mode 100644
index 0000000..13c24d5
--- /dev/null
+++ b/lib/pages/settings_views/wallet_settings_view/wallet_settings_wallet_settings/spark_view_key_view.dart
@@ -0,0 +1,179 @@
+/*
+ * This file is part of Stack Wallet.
+ *
+ * Copyright (c) 2023 Cypher Stack
+ * All Rights Reserved.
+ * The code is distributed under GPLv3 license, see LICENSE file for details.
+ * Generated by Cypher Stack on 2023-05-26
+ *
+ */
+
+import 'dart:async';
+
+import 'package:flutter/material.dart';
+import 'package:flutter/services.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'package:flutter_svg/svg.dart';
+
+import '../../../../themes/stack_colors.dart';
+import '../../../../utilities/assets.dart';
+import '../../../../utilities/clipboard_interface.dart';
+import '../../../../utilities/constants.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/desktop/desktop_dialog.dart';
+import '../../../../widgets/desktop/desktop_dialog_close_button.dart';
+import '../../../../widgets/desktop/primary_button.dart';
+import '../../../../widgets/detail_item.dart';
+import '../../../../widgets/qr.dart';
+import '../../../../widgets/rounded_white_container.dart';
+import '../../../../notifications/show_flush_bar.dart';
+
+class SparkViewKeyView extends ConsumerStatefulWidget {
+ const SparkViewKeyView({
+ super.key,
+ required this.walletId,
+ required this.sparkViewKeyHex,
+ this.clipboardInterface = const ClipboardWrapper(),
+ });
+
+ final String walletId;
+ final String sparkViewKeyHex;
+ final ClipboardInterface clipboardInterface;
+
+ static const String routeName = "/spark_view_key";
+
+ @override
+ ConsumerState<SparkViewKeyView> createState() => _SparkViewKeyViewState();
+}
+
+class _SparkViewKeyViewState extends ConsumerState<SparkViewKeyView> {
+ Future<void> _copy() async {
+ await widget.clipboardInterface.setData(
+ ClipboardData(text: widget.sparkViewKeyHex),
+ );
+ if (mounted) {
+ unawaited(
+ showFloatingFlushBar(
+ type: FlushBarType.info,
+ message: "Copied to clipboard",
+ iconAsset: Assets.svg.copy,
+ context: context,
+ ),
+ );
+ }
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final bool isDesktop = Util.isDesktop;
+
+ return ConditionalParent(
+ condition: !isDesktop,
+ builder: (child) => Background(
+ child: Scaffold(
+ backgroundColor: Theme.of(context).extension<StackColors>()!.background,
+ appBar: AppBar(
+ leading: AppBarBackButton(
+ onPressed: () async {
+ Navigator.of(context).pop();
+ },
+ ),
+ title: Text(
+ "Spark View Key",
+ style: STextStyles.navBarTitle(context),
+ ),
+ ),
+ body: SafeArea(
+ child: Padding(
+ padding: const EdgeInsets.only(top: 12, left: 16, right: 16),
+ child: LayoutBuilder(
+ builder: (context, constraints) => SingleChildScrollView(
+ child: ConstrainedBox(
+ constraints: BoxConstraints(
+ minHeight: constraints.maxHeight,
+ ),
+ child: IntrinsicHeight(
+ child: Column(
+ children: [
+ Expanded(child: child),
+ const SizedBox(height: 16),
+ ],
+ ),
+ ),
+ ),
+ ),
+ ),
+ ),
+ ),
+ ),
+ ),
+ child: ConditionalParent(
+ condition: isDesktop,
+ builder: (child) => DesktopDialog(
+ maxWidth: 600,
+ maxHeight: double.infinity,
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Row(
+ mainAxisAlignment: MainAxisAlignment.spaceBetween,
+ children: [
+ Padding(
+ padding: const EdgeInsets.only(left: 32),
+ child: Text(
+ "Spark View Key",
+ style: STextStyles.desktopH2(context),
+ ),
+ ),
+ DesktopDialogCloseButton(
+ onPressedOverride: Navigator.of(context, rootNavigator: true).pop,
+ ),
+ ],
+ ),
+ Flexible(
+ child: Padding(
+ padding: const EdgeInsets.fromLTRB(32, 0, 32, 32),
+ child: SingleChildScrollView(child: child),
+ ),
+ ),
+ ],
+ ),
+ ),
+ child: Column(
+ mainAxisSize: Util.isDesktop ? MainAxisSize.min : MainAxisSize.max,
+ crossAxisAlignment: CrossAxisAlignment.center,
+ children: [
+ SizedBox(height: Util.isDesktop ? 12 : 16),
+ QR(
+ data: widget.sparkViewKeyHex,
+ size: Util.isDesktop ? 256 : MediaQuery.of(context).size.width / 1.5,
+ ),
+ SizedBox(height: Util.isDesktop ? 12 : 16),
+ RoundedWhiteContainer(
+ borderColor: Util.isDesktop
+ ? Theme.of(context).extension<StackColors>()!.textFieldDefaultBG
+ : null,
+ child: SelectableText(
+ widget.sparkViewKeyHex,
+ style: STextStyles.w500_14(context),
+ ),
+ ),
+ SizedBox(height: Util.isDesktop ? 12 : 16),
+ if (!Util.isDesktop) const Spacer(),
+ Row(
+ children: [
+ if (Util.isDesktop) const Spacer(),
+ if (Util.isDesktop) const SizedBox(width: 16),
+ Expanded(child: PrimaryButton(label: "Copy", onPressed: _copy)),
+ ],
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+}
\ No newline at end of file
diff --git a/lib/pages/wallet_view/wallet_view.dart b/lib/pages/wallet_view/wallet_view.dart
index b7929f1..d2ce19b 100644
--- a/lib/pages/wallet_view/wallet_view.dart
+++ b/lib/pages/wallet_view/wallet_view.dart
@@ -103,6 +103,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 '../settings_views/wallet_settings_view/wallet_settings_wallet_settings/spark_view_key_view.dart';
import '../spark_names/spark_names_home_view.dart';
import '../token_view/my_tokens_view.dart';
import 'sub_widgets/transactions_list.dart';
@@ -420,6 +421,27 @@ class _WalletViewState extends ConsumerState<WalletView> {
}
}
+ Future<void> _onShowSparkViewKeyPressed(BuildContext context) async {
+ unawaited(
+ showDialog(
+ context: context,
+ builder: (_) => const LoadingIndicator(width: 100),
+ ),
+ );
+
+ final wallet = ref.read(pWallets).getWallet(walletId) as SparkInterface;
+ await wallet.init();
+ final sparkViewKeyHex = wallet.viewKeyHex;
+
+ if (context.mounted) {
+ Navigator.of(context).pop(); // Close loading dialog
+ await Navigator.of(context).pushNamed(
+ SparkViewKeyView.routeName,
+ arguments: (walletId, sparkViewKeyHex),
+ );
+ }
+ }
+
Future<void> attemptAnonymize() async {
bool shouldPop = false;
unawaited(
@@ -621,7 +643,7 @@ class _WalletViewState extends ConsumerState<WalletView> {
context,
).extension<StackColors>()!.background,
icon: _buildNetworkIcon(_currentSyncStatus),
- onPressed: () {
+ onPressed: () {
Navigator.of(context).pushNamed(
WalletNetworkSettingsView.routeName,
arguments: Tuple3(
@@ -996,7 +1018,7 @@ class _WalletViewState extends ConsumerState<WalletView> {
),
SafeArea(
child: WalletNavigationBar(
- items: [
+ items: <WalletNavigationBarItemData>[
WalletNavigationBarItemData(
label: "Receive",
icon: const ReceiveNavIcon(),
@@ -1080,6 +1102,8 @@ class _WalletViewState extends ConsumerState<WalletView> {
icon: const BuyNavIcon(),
onTap: () => _onBuyPressed(context),
),
+ ],
+ moreItems: <WalletNavigationBarItemData>[
if (wallet is SparkInterface)
WalletNavigationBarItemData(
label: "Names",
@@ -1091,8 +1115,22 @@ class _WalletViewState extends ConsumerState<WalletView> {
);
},
),
- ],
- moreItems: [
+ if (wallet is SparkInterface)
+ WalletNavigationBarItemData(
+ label: "Show Spark View Key",
+ icon: SvgPicture.asset(
+ Assets.svg.eye,
+ height: 20,
+ width: 20,
+ colorFilter: ColorFilter.mode(
+ Theme.of(
+ context,
+ ).extension<StackColors>()!.bottomNavIconIcon,
+ BlendMode.srcIn,
+ ),
+ ),
+ onTap: () => _onShowSparkViewKeyPressed(context),
+ ),
if (ref.watch(
pWallets.select(
(value) => value
diff --git a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_receive.dart b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_receive.dart
index 607bf3f..713521b 100644
--- a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_receive.dart
+++ b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_receive.dart
@@ -197,16 +197,16 @@ class _DesktopReceiveState extends ConsumerState<DesktopReceive> {
if (wallet is Bip39HDWallet && wallet is! BCashInterface) {
DerivePathType? type;
if (wallet.isViewOnly && wallet is ExtendedKeysInterface) {
- final voData =
- await wallet.getViewOnlyWalletData()
- as ExtendedKeysViewOnlyWalletData;
+ final voData = await wallet.getViewOnlyWalletData();
for (final t in wallet.cryptoCurrency.supportedDerivationPathTypes) {
final testPath = wallet.cryptoCurrency.constructDerivePath(
derivePathType: t,
chain: 0,
index: 0,
);
- if (testPath.startsWith(voData.xPubs.first.path)) {
+ if (voData is SparkViewOnlyWalletData) {
+ type = t;
+ } else if (testPath.startsWith((voData as ExtendedKeysViewOnlyWalletData).xPubs.first.path)) {
type = t;
break;
}
@@ -327,8 +327,10 @@ class _DesktopReceiveState extends ConsumerState<DesktopReceive> {
isMimblewimblecoin = wallet is MimblewimblecoinWallet;
- if (wallet is ViewOnlyOptionInterface && wallet.isViewOnly) {
+ if (wallet is ViewOnlyOptionInterface && wallet.isViewOnly && wallet.viewOnlyType == ViewOnlyWalletType.spark) {
showMultiType = false;
+ } else if (wallet is ViewOnlyOptionInterface && wallet.isViewOnly) {
+ showMultiType = supportsSpark;
} else {
showMultiType =
supportsSpark ||
@@ -338,7 +340,11 @@ class _DesktopReceiveState extends ConsumerState<DesktopReceive> {
wallet.supportedAddressTypes.length > 1);
}
- _walletAddressTypes.add(wallet.info.mainAddressType);
+ if (wallet is ViewOnlyOptionInterface && wallet.isViewOnly && wallet.viewOnlyType == ViewOnlyWalletType.spark) {
+ _walletAddressTypes.add(AddressType.spark);
+ } else {
+ _walletAddressTypes.add(wallet.info.mainAddressType);
+ }
if (showMultiType) {
if (supportsSpark) {
diff --git a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/wallet_options_button.dart b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/wallet_options_button.dart
index 9a933e5..a3c281f 100644
--- a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/wallet_options_button.dart
+++ b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/wallet_options_button.dart
@@ -18,6 +18,7 @@ import '../../../../pages/settings_views/wallet_settings_view/frost_ms/frost_ms_
import '../../../../pages/settings_views/wallet_settings_view/wallet_settings_wallet_settings/change_representative_view.dart';
import '../../../../pages/settings_views/wallet_settings_view/wallet_settings_wallet_settings/edit_refresh_height_view.dart';
import '../../../../pages/settings_views/wallet_settings_view/wallet_settings_wallet_settings/xpub_view.dart';
+import '../../../../pages/settings_views/wallet_settings_view/wallet_settings_wallet_settings/spark_view_key_view.dart';
import '../../../../providers/global/wallets_provider.dart';
import '../../../../route_generator.dart';
import '../../../../themes/stack_colors.dart';
@@ -31,6 +32,7 @@ import '../../../../wallets/crypto_currency/intermediate/nano_currency.dart';
import '../../../../wallets/isar/providers/wallet_info_provider.dart';
import '../../../../wallets/wallet/intermediate/cryptonote_wallet.dart';
import '../../../../wallets/wallet/wallet_mixin_interfaces/extended_keys_interface.dart';
+import '../../../../wallets/wallet/wallet_mixin_interfaces/spark_interface.dart';
import '../../../../wallets/wallet/wallet_mixin_interfaces/view_only_option_interface.dart';
import '../../../addresses/desktop_wallet_addresses_view.dart';
import 'desktop_delete_wallet_dialog.dart';
@@ -41,7 +43,8 @@ enum _WalletOptions {
changeRepresentative,
showXpub,
frostOptions,
- refreshFromHeight;
+ refreshFromHeight,
+ showSparkKey;
String get prettyName {
switch (this) {
@@ -57,6 +60,8 @@ enum _WalletOptions {
return "FROST settings";
case _WalletOptions.refreshFromHeight:
return "Refresh height";
+ case _WalletOptions.showSparkKey:
+ return "Show Spark View Key";
}
}
}
@@ -136,6 +141,38 @@ class WalletOptionsButton extends ConsumerWidget {
}
}
break;
+ case _WalletOptions.showSparkKey:
+ final wallet = ref.read(pWallets).getWallet(walletId) as SparkInterface;
+ await wallet.init();
+ final sparkViewKeyHex = wallet.viewKeyHex;
+
+ if (context.mounted) {
+ final result = await showDialog<bool?>(
+ context: context,
+ barrierDismissible: false,
+ builder: (context) => Navigator(
+ initialRoute: SparkViewKeyView.routeName,
+ onGenerateRoute: RouteGenerator.generateRoute,
+ onGenerateInitialRoutes: (_, __) {
+ return [
+ RouteGenerator.generateRoute(
+ RouteSettings(
+ name: SparkViewKeyView.routeName,
+ arguments: (walletId, sparkViewKeyHex),
+ ),
+ ),
+ ];
+ },
+ ),
+ );
+
+ if (result == true) {
+ if (context.mounted) {
+ Navigator.of(context).pop();
+ }
+ }
+ }
+ break;
case _WalletOptions.showXpub:
final xpubData = await showLoading(
delay: const Duration(milliseconds: 800),
@@ -286,6 +323,7 @@ class WalletOptionsPopupMenu extends ConsumerWidget {
final bool isFrost = coin is FrostCurrency;
final bool isCN = wallet is CryptonoteWallet;
+ final bool isSpark = wallet is SparkInterface;
return Stack(
children: [
@@ -481,6 +519,45 @@ class WalletOptionsPopupMenu extends ConsumerWidget {
),
),
),
+ if (isSpark)
+ const SizedBox(
+ height: 8,
+ ),
+ if (isSpark)
+ TransparentButton(
+ onPressed: () {
+ Navigator.of(context).pop(_WalletOptions.showSparkKey);
+ },
+ child: Padding(
+ padding: const EdgeInsets.all(8),
+ child: Row(
+ mainAxisAlignment: MainAxisAlignment.start,
+ children: [
+ SvgPicture.asset(
+ Assets.svg.eye,
+ width: 20,
+ height: 20,
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .textFieldActiveSearchIconLeft,
+ ),
+ const SizedBox(width: 14),
+ Expanded(
+ child: Text(
+ _WalletOptions.showSparkKey.prettyName,
+ style: STextStyles.desktopTextExtraExtraSmall(
+ context,
+ ).copyWith(
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .textDark,
+ ),
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
const SizedBox(height: 8),
TransparentButton(
onPressed: onDeletePressed,
diff --git a/lib/route_generator.dart b/lib/route_generator.dart
index b44550b..693940b 100644
--- a/lib/route_generator.dart
+++ b/lib/route_generator.dart
@@ -224,6 +224,7 @@ import 'wallets/wallet/wallet.dart';
import 'wallets/wallet/wallet_mixin_interfaces/extended_keys_interface.dart';
import 'widgets/choose_coin_view.dart';
import 'widgets/frost_scaffold.dart';
+import 'pages/settings_views/wallet_settings_view/wallet_settings_wallet_settings/spark_view_key_view.dart';
/*
* This file contains all the routes for the app.
@@ -2508,6 +2509,19 @@ class RouteGenerator {
// == End of desktop specific routes =====================================
+ case SparkViewKeyView.routeName:
+ if (args is (String, String)) {
+ return getRoute(
+ shouldUseMaterialRoute: useMaterialPageRoute,
+ builder: (_) => SparkViewKeyView(
+ walletId: args.$1,
+ sparkViewKeyHex: args.$2,
+ ),
+ settings: RouteSettings(name: settings.name),
+ );
+ }
+ return _routeError("${settings.name} invalid args: ${args.toString()}");
+
default:
return _routeError("");
}
diff --git a/lib/wallets/crypto_currency/coins/firo.dart b/lib/wallets/crypto_currency/coins/firo.dart
index f432bd7..75bd15c 100644
--- a/lib/wallets/crypto_currency/coins/firo.dart
+++ b/lib/wallets/crypto_currency/coins/firo.dart
@@ -197,10 +197,7 @@ class Firo extends Bip39HDCurrency with ElectrumXCurrencyInterface {
}
bool validateSparkAddress(String address) {
- return SparkInterface.validateSparkAddress(
- address: address,
- isTestNet: network.isTestNet,
- );
+ return SparkInterface.validateSparkAddress(address: address, isTestNet: network.isTestNet);
}
bool isExchangeAddress(String address) {
diff --git a/lib/wallets/wallet/impl/firo_wallet.dart b/lib/wallets/wallet/impl/firo_wallet.dart
index e55052e..5cfc895 100644
--- a/lib/wallets/wallet/impl/firo_wallet.dart
+++ b/lib/wallets/wallet/impl/firo_wallet.dart
@@ -24,6 +24,7 @@ import '../wallet_mixin_interfaces/coin_control_interface.dart';
import '../wallet_mixin_interfaces/electrumx_interface.dart';
import '../wallet_mixin_interfaces/extended_keys_interface.dart';
import '../wallet_mixin_interfaces/spark_interface.dart';
+import '../../../models/keys/view_only_wallet_data.dart';
const sparkStartBlock = 819300; // (approx 18 Jan 2024)
@@ -50,6 +51,17 @@ class FiroWallet<T extends ElectrumXCurrencyInterface> extends Bip39HDWallet<T>
final Set<String> _unconfirmedTxids = {};
+ @override
+ Set<AddressType> get supportedAddressTypes {
+ if (isViewOnly && viewOnlyType == ViewOnlyWalletType.spark) {
+ return {AddressType.spark};
+ } else {
+ final supportedAddressTypes = super.supportedAddressTypes;
+ supportedAddressTypes.add(AddressType.spark);
+ return supportedAddressTypes;
+ }
+ }
+
// ===========================================================================
@override
@@ -345,7 +357,7 @@ class FiroWallet<T extends ElectrumXCurrencyInterface> extends Bip39HDWallet<T>
output = output.copyWith(walletOwns: true);
} else if (isSparkMint && isMySpark) {
wasReceivedInThisWallet = true;
- if (output.addresses.contains(sparkChangeAddress)) {
+ if (output.addresses.contains(sparkChangeAddress.value)) {
changeAmountReceivedInThisWallet += output.value;
} else {
amountReceivedInThisWallet += output.value;
@@ -668,9 +680,26 @@ class FiroWallet<T extends ElectrumXCurrencyInterface> extends Bip39HDWallet<T>
return (blockedReason: blockedReason, blocked: blocked, utxoLabel: label);
}
+ @override
+ Future<List<Address>> fetchAddressesForElectrumXScan() async {
+ return await mainDB
+ .getAddresses(walletId)
+ .filter()
+ .not()
+ .group(
+ (q) => q
+ .typeEqualTo(AddressType.spark)
+ .or()
+ .typeEqualTo(AddressType.nonWallet)
+ .or()
+ .subTypeEqualTo(AddressSubType.nonWallet),
+ )
+ .findAll();
+ }
+
@override
Future<void> recover({required bool isRescan}) async {
- if (isViewOnly) {
+ if (isViewOnly && viewOnlyType != ViewOnlyWalletType.spark) {
await recoverViewOnly(isRescan: isRescan);
return;
}
@@ -684,7 +713,6 @@ class FiroWallet<T extends ElectrumXCurrencyInterface> extends Bip39HDWallet<T>
);
final start = DateTime.now();
- final root = await getRootHDNode();
final List<Future<({int index, List<Address> addresses})>> receiveFutures =
[];
@@ -731,22 +759,26 @@ class FiroWallet<T extends ElectrumXCurrencyInterface> extends Bip39HDWallet<T>
final canBatch = await serverCanBatch;
- for (final type in cryptoCurrency.supportedDerivationPathTypes) {
- receiveFutures.add(
- canBatch
- ? checkGapsBatched(txCountBatchSize, root, type, receiveChain)
- : checkGapsLinearly(root, type, receiveChain),
- );
- }
+ if (!isViewOnly || viewOnlyType != ViewOnlyWalletType.spark) {
+ final root = await getRootHDNode();
- // change addresses
- Logging.instance.d("checking change addresses...");
- for (final type in cryptoCurrency.supportedDerivationPathTypes) {
- changeFutures.add(
- canBatch
- ? checkGapsBatched(txCountBatchSize, root, type, changeChain)
- : checkGapsLinearly(root, type, changeChain),
- );
+ for (final type in cryptoCurrency.supportedDerivationPathTypes) {
+ receiveFutures.add(
+ canBatch
+ ? checkGapsBatched(txCountBatchSize, root, type, receiveChain)
+ : checkGapsLinearly(root, type, receiveChain),
+ );
+ }
+
+ // change addresses
+ Logging.instance.d("checking change addresses...");
+ for (final type in cryptoCurrency.supportedDerivationPathTypes) {
+ changeFutures.add(
+ canBatch
+ ? checkGapsBatched(txCountBatchSize, root, type, changeChain)
+ : checkGapsLinearly(root, type, changeChain),
+ );
+ }
}
// io limitations may require running these linearly instead
diff --git a/lib/wallets/wallet/wallet_mixin_interfaces/electrumx_interface.dart b/lib/wallets/wallet/wallet_mixin_interfaces/electrumx_interface.dart
index 26106e6..c325584 100644
--- a/lib/wallets/wallet/wallet_mixin_interfaces/electrumx_interface.dart
+++ b/lib/wallets/wallet/wallet_mixin_interfaces/electrumx_interface.dart
@@ -1478,7 +1478,9 @@ mixin ElectrumXInterface<T extends ElectrumXCurrencyInterface>
@override
Future<void> checkReceivingAddressForTransactions() async {
- if (isViewOnly && viewOnlyType == ViewOnlyWalletType.addressOnly) {
+ if (isViewOnly &&
+ (viewOnlyType == ViewOnlyWalletType.addressOnly ||
+ viewOnlyType == ViewOnlyWalletType.spark)) {
return;
}
@@ -1533,7 +1535,9 @@ mixin ElectrumXInterface<T extends ElectrumXCurrencyInterface>
@override
Future<void> checkChangeAddressForTransactions() async {
- if (isViewOnly && viewOnlyType == ViewOnlyWalletType.addressOnly) {
+ if (isViewOnly &&
+ (viewOnlyType == ViewOnlyWalletType.addressOnly ||
+ viewOnlyType == ViewOnlyWalletType.spark)) {
return;
}
@@ -2120,7 +2124,7 @@ mixin ElectrumXInterface<T extends ElectrumXCurrencyInterface>
final data = await getViewOnlyWalletData();
final coinlib.HDKey? root;
- if (data is AddressViewOnlyWalletData) {
+ if (data is AddressViewOnlyWalletData || data is SparkViewOnlyWalletData) {
root = null;
} else {
if ((data as ExtendedKeysViewOnlyWalletData).xPubs.length != 1) {
diff --git a/lib/wallets/wallet/wallet_mixin_interfaces/spark_interface.dart b/lib/wallets/wallet/wallet_mixin_interfaces/spark_interface.dart
index d9059a5..03443fc 100644
--- a/lib/wallets/wallet/wallet_mixin_interfaces/spark_interface.dart
+++ b/lib/wallets/wallet/wallet_mixin_interfaces/spark_interface.dart
@@ -11,6 +11,7 @@ import 'package:logger/logger.dart';
import '../../../db/drift/database.dart' show Drift;
import '../../../db/sqlite/firo_cache.dart';
import '../../../models/balance.dart';
+import '../../../models/electrumx_response/spark_models.dart';
import '../../../models/input.dart';
import '../../../models/isar/models/blockchain_data/v2/input_v2.dart';
import '../../../models/isar/models/blockchain_data/v2/output_v2.dart';
@@ -33,6 +34,7 @@ import '../../models/tx_data.dart';
import '../intermediate/bip39_hd_wallet.dart';
import 'cpfp_interface.dart';
import 'electrumx_interface.dart';
+import '../../../models/keys/view_only_wallet_data.dart';
const kDefaultSparkIndex = 1;
@@ -103,22 +105,148 @@ Future<R> computeWithLibSparkLogging<M, R>(
return _SparkIsolate.run(callback, message);
}
-mixin SparkInterface<T extends ElectrumXCurrencyInterface>
- on Bip39HDWallet<T>, ElectrumXInterface<T> {
- String? _sparkChangeAddressCached;
- /// Spark change address. Should generally not be exposed to end users.
- String get sparkChangeAddress {
- if (_sparkChangeAddressCached == null) {
- throw Exception("_sparkChangeAddressCached was not initialized");
+mixin SparkInterface<T extends ElectrumXCurrencyInterface> on Bip39HDWallet<T>, ElectrumXInterface<T> {
+ late Address _currentSparkAddress;
+
+ late String viewKeyHex;
+
+ // Really we should just send change back to the same address.
+ late Address sparkChangeAddress;
+
+ bool get isTestNet {
+ return cryptoCurrency.network.isTestNet;
+ }
+
+ // This is the BIP44 derivation path for the spark private key; spark public keys will have their own derivation path.
+ String get sparkDerivationPath {
+ // NOTE: This is reusing the sparkIndex for backwards compatibility, but these are actually distinct things which do
+ // not have to be the same. sparkIndex has nothing at all to do with the derivation path.
+ if (isTestNet) {
+ return "${libSpark.sparkBaseDerivationPathTestnet}$kDefaultSparkIndex";
+ } else {
+ return "${libSpark.sparkBaseDerivationPath}$kDefaultSparkIndex";
}
- return _sparkChangeAddressCached!;
}
- static bool validateSparkAddress({
- required String address,
- required bool isTestNet,
- }) => libSpark.validateAddress(address: address, isTestNet: isTestNet);
+ // This is the index for the spark key, which is NOT the diversifier or the BIP44 derivation path (which generates the
+ // private key data).
+ int get sparkIndex => kDefaultSparkIndex;
+
+ Future<Address> generateSparkAddress(int diversifier) async {
+ final sparkAddress = await libSpark.getAddressFromFullViewKey(
+ fullViewKeyHex: viewKeyHex,
+ index: sparkIndex,
+ diversifier: diversifier,
+ isTestNet: isTestNet,
+ );
+
+ return Address(
+ walletId: walletId,
+ value: sparkAddress,
+ publicKey: [],
+ derivationIndex: diversifier,
+ derivationPath: DerivationPath()..value = sparkDerivationPath,
+ type: AddressType.spark,
+ subType: AddressSubType.receiving,
+ );
+ }
+
+ static bool validateSparkAddress({required String address, required bool isTestNet}) {
+ return libSpark.validateAddress(address: address, isTestNet: isTestNet);
+ }
+
+ Future<List<SparkCoin>> identifyCoins({
+ required List<dynamic> anonymitySetCoins,
+ required int groupId,
+ }) async {
+ return await computeWithLibSparkLogging(
+ identifyCoinsStatic,
+ (
+ walletId_: walletId,
+ viewKeyHex_: viewKeyHex,
+ isTestNet_: isTestNet,
+ anonymitySetCoins: anonymitySetCoins,
+ groupId: groupId,
+ ),
+ );
+ }
+
+ static Future<List<SparkCoin>> identifyCoinsStatic(({
+ List<dynamic> anonymitySetCoins,
+ int groupId,
+ bool isTestNet_,
+ String viewKeyHex_,
+ String walletId_,
+ }) args) async {
+ final List<SparkCoin> myCoins = [];
+
+ for (final dynData in args.anonymitySetCoins) {
+ final data = List<String>.from(dynData as List);
+
+ if (data.length != 3) {
+ Logging.instance.e("Unexpected serialized coin info found", error: data);
+ continue;
+ }
+
+ final serializedCoinB64 = data[0];
+ final txHash = data[1].toHexReversedFromBase64;
+ final contextB64 = data[2];
+
+ final WrappedLibSparkCoin? coin;
+ try {
+ coin = libSpark.identifyAndRecoverCoinByFullViewKey(
+ serializedCoinB64,
+ fullViewKeyHex: args.viewKeyHex_,
+ context: base64Decode(contextB64),
+ isTestNet: args.isTestNet_,
+ );
+ } catch (e) {
+ Logging.instance.e("Failed to identify coin", error: e);
+ continue;
+ }
+
+ // its ours
+ if (coin != null) {
+ final SparkCoinType coinType;
+ switch (coin.type.value) {
+ case 0:
+ coinType = SparkCoinType.mint;
+ case 1:
+ coinType = SparkCoinType.spend;
+ default:
+ Logging.instance.e("Unknown spark coin type detected", error: coin.type.value);
+ continue;
+ }
+
+ myCoins.add(
+ SparkCoin(
+ walletId: args.walletId_,
+ type: coinType,
+ // isUsed is a placeholder value here; its value is incorrect.
+ isUsed: false,
+ groupId: args.groupId,
+ nonce: coin.nonceHex?.toUint8ListFromHex,
+ address: coin.address!,
+ txHash: txHash,
+ valueIntString: coin.value!.toString(),
+ memo: coin.memo,
+ serialContext: coin.serialContext,
+ diversifierIntString: coin.diversifier!.toString(),
+ encryptedDiversifier: coin.encryptedDiversifier,
+ serial: coin.serial,
+ tag: coin.tag,
+ lTagHash: coin.lTagHash!,
+ height: coin.height,
+ serializedCoinB64: serializedCoinB64,
+ contextB64: contextB64,
+ ),
+ );
+ }
+ }
+
+ return myCoins;
+ }
Future<String> hashTag(String tag) async {
try {
@@ -130,128 +258,90 @@ mixin SparkInterface<T extends ElectrumXCurrencyInterface>
@override
Future<void> init() async {
- try {
- final sparkUsedTagsResetVersion =
- info.otherData[WalletInfoKeys.firoSparkUsedTagsCacheResetVersion]
- as int? ??
- 0;
- if (sparkUsedTagsResetVersion == 0) {
- await info.updateOtherData(
- newEntries: {WalletInfoKeys.firoSparkUsedTagsCacheResetVersion: 1},
- isar: mainDB.isar,
- );
- await FiroCacheCoordinator.clearSharedCache(
- cryptoCurrency.network,
- clearOnlyUsedTagsCache: true,
- );
- }
+ final sparkUsedTagsResetVersion =
+ info.otherData[WalletInfoKeys.firoSparkUsedTagsCacheResetVersion]
+ as int? ??
+ 0;
- Address? address = await getCurrentReceivingSparkAddress();
- if (address == null) {
- address = await generateNextSparkAddress();
- await mainDB.putAddress(address);
- } // TODO add other address types to wallet info?
-
- if (_sparkChangeAddressCached == null) {
- final root = await getRootHDNode();
- final String derivationPath;
- if (cryptoCurrency.network.isTestNet) {
- derivationPath =
- "${libSpark.sparkBaseDerivationPathTestnet}$kDefaultSparkIndex";
- } else {
- derivationPath =
- "${libSpark.sparkBaseDerivationPath}$kDefaultSparkIndex";
- }
- final keys = root.derivePath(derivationPath);
+ if (sparkUsedTagsResetVersion == 0) {
+ await info.updateOtherData(
+ newEntries: {WalletInfoKeys.firoSparkUsedTagsCacheResetVersion: 1},
+ isar: mainDB.isar,
+ );
+ await FiroCacheCoordinator.clearSharedCache(
+ cryptoCurrency.network,
+ clearOnlyUsedTagsCache: true,
+ );
+ }
- _sparkChangeAddressCached = await libSpark.getAddress(
- privateKey: keys.privateKey.data,
- index: kDefaultSparkIndex,
- diversifier: libSpark.sparkChange,
- isTestNet: cryptoCurrency.network.isTestNet,
- );
- }
- } catch (e, s) {
- // do nothing, still allow user into wallet
- Logging.instance.e("$runtimeType init() failed", error: e, stackTrace: s);
+ if (isViewOnly) {
+ final walletData = await getViewOnlyWalletData() as SparkViewOnlyWalletData;
+ viewKeyHex = walletData.viewKey;
+ } else {
+ final root = await getRootHDNode();
+ final privateKey = root.derivePath(sparkDerivationPath).privateKey.data;
+ viewKeyHex = libSpark.getFullViewKeyHexFromPrivateKeyData(privateKeyHex: privateKey.toHex, index: sparkIndex);
}
- // await info.updateReceivingAddress(
- // newAddress: address.value,
- // isar: mainDB.isar,
- // );
+ Address? address = await getCurrentReceivingSparkAddress();
+ if (address == null) {
+ address = await generateSparkAddress(1);
+ await mainDB.putAddress(address);
+ }
+
+ if (address.derivationIndex == -1) {
+ throw Exception("Error finding spark receiving address");
+ }
+
+ _currentSparkAddress = address;
+ sparkChangeAddress = await generateSparkAddress(libSpark.sparkChange);
await super.init();
}
@override
Future<List<Address>> fetchAddressesForElectrumXScan() async {
- final allAddresses = await mainDB
- .getAddresses(walletId)
- .filter()
- .not()
- .group(
- (q) => q
- .typeEqualTo(AddressType.spark)
- .or()
- .typeEqualTo(AddressType.nonWallet)
- .or()
- .subTypeEqualTo(AddressSubType.nonWallet),
- )
- .findAll();
- return allAddresses;
+ return await mainDB
+ .getAddresses(walletId)
+ .filter()
+ .not()
+ .group(
+ (q) => q
+ .typeEqualTo(AddressType.spark)
+ .or()
+ .typeEqualTo(AddressType.nonWallet)
+ .or()
+ .subTypeEqualTo(AddressSubType.nonWallet),
+ )
+ .findAll();
}
Future<Address?> getCurrentReceivingSparkAddress() async {
- return await mainDB.isar.addresses
+ try {
+ // if _currentSparkAddress is not initialized, this will throw.
+ return _currentSparkAddress;
+ } catch (e) {
+ return await mainDB.isar.addresses
.where()
.walletIdEqualTo(walletId)
.filter()
.typeEqualTo(AddressType.spark)
.sortByDerivationIndexDesc()
.findFirst();
+ }
}
Future<Address> generateNextSparkAddress() async {
- final highestStoredDiversifier =
- (await getCurrentReceivingSparkAddress())?.derivationIndex;
-
- // default to starting at 1 if none found
- int diversifier = (highestStoredDiversifier ?? 0) + 1;
- // change address check
- if (diversifier == libSpark.sparkChange) {
- diversifier++;
- }
-
- final root = await getRootHDNode();
- final String derivationPath;
- if (cryptoCurrency.network.isTestNet) {
- derivationPath =
- "${libSpark.sparkBaseDerivationPathTestnet}$kDefaultSparkIndex";
- } else {
- derivationPath = "${libSpark.sparkBaseDerivationPath}$kDefaultSparkIndex";
- }
- final keys = root.derivePath(derivationPath);
-
- final String addressString = await libSpark.getAddress(
- privateKey: keys.privateKey.data,
- index: kDefaultSparkIndex,
- diversifier: diversifier,
- isTestNet: cryptoCurrency.network.isTestNet,
- );
-
- return Address(
- walletId: walletId,
- value: addressString,
- publicKey: keys.publicKey.data,
- derivationIndex: diversifier,
- derivationPath: DerivationPath()..value = derivationPath,
- type: AddressType.spark,
- subType: AddressSubType.receiving,
- );
+ final newAddress = await generateSparkAddress(_currentSparkAddress.derivationIndex + 1);
+ _currentSparkAddress = newAddress;
+ return newAddress;
}
Future<Amount> estimateFeeForSpark(Amount amount) async {
+ if (isViewOnly) {
+ throw Exception("Fee estimation is not supported for view only wallets");
+ }
+
final spendAmount = amount.raw.toInt();
if (spendAmount == 0) {
return Amount(
@@ -296,18 +386,10 @@ mixin SparkInterface<T extends ElectrumXCurrencyInterface>
.toList();
final root = await getRootHDNode();
- final String derivationPath;
- if (cryptoCurrency.network.isTestNet) {
- derivationPath =
- "${libSpark.sparkBaseDerivationPathTestnet}$kDefaultSparkIndex";
- } else {
- derivationPath =
- "${libSpark.sparkBaseDerivationPath}$kDefaultSparkIndex";
- }
- final privateKey = root.derivePath(derivationPath).privateKey.data;
+ final privateKey = root.derivePath(sparkDerivationPath).privateKey.data;
int estimate = await _asyncSparkFeesWrapper(
privateKeyHex: privateKey.toHex,
- index: kDefaultSparkIndex,
+ index: sparkIndex,
sendAmount: spendAmount,
subtractFeeFromAmount: true,
serializedCoins: serializedCoins,
@@ -330,6 +412,10 @@ mixin SparkInterface<T extends ElectrumXCurrencyInterface>
/// Spark to Spark/Transparent (spend) creation
Future<TxData> prepareSendSpark({required TxData txData}) async {
+ if (isViewOnly) {
+ throw Exception("Spending is not supported for view only wallets");
+ }
+
// There should be at least one output.
if (!(txData.recipients?.isNotEmpty == true ||
txData.sparkRecipients?.isNotEmpty == true)) {
@@ -462,14 +548,7 @@ mixin SparkInterface<T extends ElectrumXCurrencyInterface>
.toList();
final root = await getRootHDNode();
- final String derivationPath;
- if (cryptoCurrency.network.isTestNet) {
- derivationPath =
- "${libSpark.sparkBaseDerivationPathTestnet}$kDefaultSparkIndex";
- } else {
- derivationPath = "${libSpark.sparkBaseDerivationPath}$kDefaultSparkIndex";
- }
- final privateKey = root.derivePath(derivationPath).privateKey.data;
+ final privateKey = root.derivePath(sparkDerivationPath).privateKey.data;
final txb = btc.TransactionBuilder(network: _bitcoinDartNetwork);
txb.setLockTime(await chainHeight);
@@ -487,7 +566,7 @@ mixin SparkInterface<T extends ElectrumXCurrencyInterface>
if (isSendAll) {
final estFee = await _asyncSparkFeesWrapper(
privateKeyHex: privateKey.toHex,
- index: kDefaultSparkIndex,
+ index: sparkIndex,
sendAmount: txAmount.raw.toInt(),
subtractFeeFromAmount: true,
serializedCoins: serializedCoins,
@@ -517,7 +596,7 @@ mixin SparkInterface<T extends ElectrumXCurrencyInterface>
fractionDigits: cryptoCurrency.fractionDigits,
),
memo: txData.sparkRecipients![i].memo,
- isChange: sparkChangeAddress == txData.sparkRecipients![i].address,
+ isChange: sparkChangeAddress.value == txData.sparkRecipients![i].address,
));
}
@@ -606,7 +685,7 @@ mixin SparkInterface<T extends ElectrumXCurrencyInterface>
additionalInfo: txData.sparkNameInfo!.additionalInfo,
scalarHex: extractedTx.getId(),
privateKeyHex: privateKey.toHex,
- spendKeyIndex: kDefaultSparkIndex,
+ spendKeyIndex: sparkIndex,
diversifier: txData.sparkNameInfo!.sparkAddress.derivationIndex,
isTestNet: cryptoCurrency.network != CryptoCurrencyNetwork.main,
ignoreProof: true,
@@ -616,7 +695,7 @@ mixin SparkInterface<T extends ElectrumXCurrencyInterface>
final spend = await computeWithLibSparkLogging(_createSparkSend, (
privateKeyHex: privateKey.toHex,
- index: kDefaultSparkIndex,
+ index: sparkIndex,
recipients:
txData.recipients
?.map(
@@ -679,7 +758,7 @@ mixin SparkInterface<T extends ElectrumXCurrencyInterface>
additionalInfo: txData.sparkNameInfo!.additionalInfo,
scalarHex: hash,
privateKeyHex: privateKey.toHex,
- spendKeyIndex: kDefaultSparkIndex,
+ spendKeyIndex: sparkIndex,
diversifier: txData.sparkNameInfo!.sparkAddress.derivationIndex,
isTestNet: cryptoCurrency.network != CryptoCurrencyNetwork.main,
ignoreProof: false,
@@ -783,6 +862,10 @@ mixin SparkInterface<T extends ElectrumXCurrencyInterface>
// this may not be needed for either mints or spends or both
Future<TxData> confirmSendSpark({required TxData txData}) async {
+ if (isViewOnly) {
+ throw Exception("Spending is not supported for view only wallets");
+ }
+
try {
Logging.instance.d("confirmSend txData: $txData");
@@ -822,77 +905,64 @@ mixin SparkInterface<T extends ElectrumXCurrencyInterface>
Set<String> _mempoolTxidsChecked = {};
Future<List<SparkCoin>> _refreshSparkCoinsMempoolCheck({
- required Set<String> privateKeyHexSet,
required int groupId,
}) async {
final start = DateTime.now();
- try {
- // update cache
- _mempoolTxids = await electrumXClient.getMempoolTxids();
- // remove any checked txids that are not in the mempool anymore
- _mempoolTxidsChecked = _mempoolTxidsChecked.intersection(_mempoolTxids);
+ // update cache
+ _mempoolTxids = await electrumXClient.getMempoolTxids();
- // get all unchecked txids currently in mempool
- final txidsToCheck = _mempoolTxids.difference(_mempoolTxidsChecked);
- if (txidsToCheck.isEmpty) {
- return [];
- }
+ // remove any checked txids that are not in the mempool anymore
+ _mempoolTxidsChecked = _mempoolTxidsChecked.intersection(_mempoolTxids);
- // fetch spark data to scan if we own any unconfirmed spark coins
- final sparkDataToCheck = await electrumXClient.getMempoolSparkData(
+ // get all unchecked txids currently in mempool
+ final txidsToCheck = _mempoolTxids.difference(_mempoolTxidsChecked);
+ if (txidsToCheck.isEmpty) {
+ return [];
+ }
+
+ // fetch spark data to scan if we own any unconfirmed spark coins
+ List<SparkMempoolData> sparkDataToCheck = [];
+ try {
+ sparkDataToCheck = await electrumXClient.getMempoolSparkData(
txids: txidsToCheck.toList(),
);
+ } catch (e, s) {
+ Logging.instance.e(
+ "Exception rethrown from _refreshSparkCoinsMempoolCheck(): ",
+ error: e,
+ stackTrace: s,
+ );
+ return [];
+ }
- final Set<String> checkedTxids = {};
- final List<List<String>> rawCoins = [];
-
- for (final data in sparkDataToCheck) {
- for (int i = 0; i < data.coins.length; i++) {
- rawCoins.add([data.coins[i], data.txid, data.serialContext.first]);
- }
+ final Set<String> checkedTxids = {};
+ final List<List<String>> rawCoins = [];
- checkedTxids.add(data.txid);
+ for (final data in sparkDataToCheck) {
+ for (int i = 0; i < data.coins.length; i++) {
+ rawCoins.add([data.coins[i], data.txid, data.serialContext.first]);
}
- final result = <SparkCoin>[];
+ checkedTxids.add(data.txid);
+ }
- // if there is new data we try and identify the coins
- if (rawCoins.isNotEmpty) {
- // run identify off main isolate
- final myCoins = await computeWithLibSparkLogging(_identifyCoins, (
- anonymitySetCoins: rawCoins,
- groupId: groupId,
- privateKeyHexSet: privateKeyHexSet,
- walletId: walletId,
- isTestNet: cryptoCurrency.network.isTestNet,
- ));
+ // if there is new data we try and identify the coins
+ final List<SparkCoin> myCoins = await identifyCoins(
+ anonymitySetCoins: rawCoins,
+ groupId: groupId,
+ );
- // add checked txids after identification
- _mempoolTxidsChecked.addAll(checkedTxids);
- for (final coin in myCoins) {
- final match = sparkDataToCheck.firstWhere(
- (e) => e.serialContext.contains(coin.contextB64!),
- );
- result.add(coin.copyWith(isLocked: match.isLocked));
- }
- }
+ // add checked txids after identification
+ _mempoolTxidsChecked.addAll(checkedTxids);
- return result;
- } catch (e, s) {
- Logging.instance.e(
- "_refreshSparkCoinsMempoolCheck() failed",
- error: e,
- stackTrace: s,
- );
- return [];
- } finally {
- Logging.instance.d(
- "$walletId ${info.name} _refreshSparkCoinsMempoolCheck() run "
- "duration: ${DateTime.now().difference(start)}",
- );
- }
+ Logging.instance.d(
+ "Finished _refreshSparkCoinsMempoolCheck(). "
+ "Duration=${DateTime.now().difference(start)}",
+ );
+
+ return myCoins;
}
// returns next percent
@@ -907,10 +977,12 @@ mixin SparkInterface<T extends ElectrumXCurrencyInterface>
(double startingPercent, double endingPercent)? refreshProgressRange,
) async {
final start = DateTime.now();
+
try {
// start by checking if any previous sets are missing from db and add the
// missing groupIds to the list if sets to check and update
final latestGroupId = await electrumXClient.getSparkLatestCoinId();
+
final List<int> groupIds = [];
if (latestGroupId > 1) {
for (int id = 1; id < latestGroupId; id++) {
@@ -1016,33 +1088,15 @@ mixin SparkInterface<T extends ElectrumXCurrencyInterface>
currentPercent = _triggerEventHelper(currentPercent, percentIncrement);
}
- // get address(es) to get the private key hex strings required for
- // identifying spark coins
- final sparkAddresses = await mainDB.isar.addresses
- .where()
- .walletIdEqualTo(walletId)
- .filter()
- .typeEqualTo(AddressType.spark)
- .findAll();
- final root = await getRootHDNode();
- final Set<String> privateKeyHexSet = sparkAddresses
- .map(
- (e) =>
- root.derivePath(e.derivationPath!.value).privateKey.data.toHex,
- )
- .toSet();
-
// try to identify any coins in the unchecked set data
final List<SparkCoin> newlyIdCoins = [];
for (final groupId in rawCoinsBySetId.keys) {
- final myCoins = await computeWithLibSparkLogging(_identifyCoins, (
- anonymitySetCoins: rawCoinsBySetId[groupId]!,
- groupId: groupId,
- privateKeyHexSet: privateKeyHexSet,
- walletId: walletId,
- isTestNet: cryptoCurrency.network.isTestNet,
- ));
- newlyIdCoins.addAll(myCoins);
+ newlyIdCoins.addAll(
+ await identifyCoins(
+ anonymitySetCoins: rawCoinsBySetId[groupId]!,
+ groupId: groupId,
+ ),
+ );
}
// if any were found, add to database
if (newlyIdCoins.isNotEmpty) {
@@ -1064,10 +1118,10 @@ mixin SparkInterface<T extends ElectrumXCurrencyInterface>
}
// check for spark coins in mempool
- final mempoolMyCoins = await _refreshSparkCoinsMempoolCheck(
- privateKeyHexSet: privateKeyHexSet,
+ final List<SparkCoin> mempoolMyCoins = await _refreshSparkCoinsMempoolCheck(
groupId: latestGroupId,
);
+
// if any were found, add to database
if (mempoolMyCoins.isNotEmpty) {
await mainDB.isar.writeTxn(() async {
@@ -1213,12 +1267,6 @@ mixin SparkInterface<T extends ElectrumXCurrencyInterface>
/// Should only be called within the standard wallet [recover] function due to
/// mutex locking. Otherwise behaviour MAY be undefined.
Future<void> recoverSparkWallet({required int latestSparkCoinId}) async {
- // generate spark addresses if non existing
- if (await getCurrentReceivingSparkAddress() == null) {
- final address = await generateNextSparkAddress();
- await mainDB.putAddress(address);
- }
-
try {
await refreshSparkData(null);
} catch (e, s) {
@@ -1231,6 +1279,10 @@ mixin SparkInterface<T extends ElectrumXCurrencyInterface>
}
}
+ Future<void> recoverViewOnlyWallet() async {
+ await recoverSparkWallet(latestSparkCoinId: 0);
+ }
+
Future<void> refreshSparkNames() async {
try {
Logging.instance.i("Refreshing spark names for $walletId ${info.name}");
@@ -1263,38 +1315,13 @@ mixin SparkInterface<T extends ElectrumXCurrencyInterface>
// TODO revisit this and clean up (track pre gen'd addresses instead of generating every time)
// arbitrary number of addresses
const lookAheadCount = 100;
- final highestStoredDiversifier =
- (await getCurrentReceivingSparkAddress())?.derivationIndex;
-
- final root = await getRootHDNode();
- final String derivationPath;
- if (cryptoCurrency.network.isTestNet) {
- derivationPath =
- "${libSpark.sparkBaseDerivationPathTestnet}$kDefaultSparkIndex";
- } else {
- derivationPath =
- "${libSpark.sparkBaseDerivationPath}$kDefaultSparkIndex";
- }
- final keys = root.derivePath(derivationPath);
-
- // default to starting at 1 if none found
- int diversifier = (highestStoredDiversifier ?? 0) + 1;
+ int diversifier = _currentSparkAddress.derivationIndex;
final maxDiversifier = diversifier + lookAheadCount;
while (diversifier < maxDiversifier) {
- // change address check
- if (diversifier == libSpark.sparkChange) {
- diversifier++;
- }
- final addressString = await libSpark.getAddress(
- privateKey: keys.privateKey.data,
- index: kDefaultSparkIndex,
- diversifier: diversifier,
- isTestNet: cryptoCurrency.network.isTestNet,
- );
-
- myAddresses.add(addressString);
+ final addressString = await generateSparkAddress(diversifier);
+ myAddresses.add(addressString.value);
diversifier++;
}
@@ -1350,6 +1377,10 @@ mixin SparkInterface<T extends ElectrumXCurrencyInterface>
required bool subtractFeeFromAmount,
required bool autoMintAll,
}) async {
+ if (isViewOnly) {
+ throw Exception("Minting is not supported for view only wallets");
+ }
+
// pre checks
if (outputs.isEmpty) {
throw Exception("Cannot mint without some recipients");
@@ -1963,6 +1994,10 @@ mixin SparkInterface<T extends ElectrumXCurrencyInterface>
}
Future<void> anonymizeAllSpark() async {
+ if (isViewOnly) {
+ throw Exception("Anonymizing is not supported for view only wallets");
+ }
+
try {
const subtractFeeFromAmount = true; // must be true for mint all
final currentHeight = await chainHeight;
@@ -2020,6 +2055,10 @@ mixin SparkInterface<T extends ElectrumXCurrencyInterface>
///
/// See https://docs.google.com/document/d/1RG52GoYTZDvKlZz_3G4sQu-PpT6JWSZGHLNswWcrE3o
Future<TxData> prepareSparkMintTransaction({required TxData txData}) async {
+ if (isViewOnly) {
+ throw Exception("Minting is not supported for view only wallets");
+ }
+
try {
if (txData.sparkRecipients?.isNotEmpty != true) {
throw Exception("Missing spark recipients.");
@@ -2123,6 +2162,10 @@ mixin SparkInterface<T extends ElectrumXCurrencyInterface>
}
Future<TxData> confirmSparkMintTransactions({required TxData txData}) async {
+ if (isViewOnly) {
+ throw Exception("Minting is not supported for view only wallets");
+ }
+
final futures = txData.sparkMints!.map((e) => confirmSend(txData: e));
return txData.copyWith(sparkMints: await Future.wait(futures));
}
@@ -2308,79 +2351,6 @@ _createSparkSend(
return spend;
}
-/// Top level function which should be called wrapped in [compute]
-Future<List<SparkCoin>> _identifyCoins(
- ({
- List<dynamic> anonymitySetCoins,
- int groupId,
- Set<String> privateKeyHexSet,
- String walletId,
- bool isTestNet,
- })
- args,
-) async {
- final List<SparkCoin> myCoins = [];
-
- for (final privateKeyHex in args.privateKeyHexSet) {
- for (final dynData in args.anonymitySetCoins) {
- final data = List<String>.from(dynData as List);
-
- if (data.length != 3) {
- throw Exception("Unexpected serialized coin info found");
- }
-
- final serializedCoinB64 = data[0];
- final txHash = data[1].toHexReversedFromBase64;
- final contextB64 = data[2];
-
- final coin = libSpark.identifyAndRecoverCoin(
- serializedCoinB64,
- privateKeyHex: privateKeyHex,
- index: kDefaultSparkIndex,
- context: base64Decode(contextB64),
- isTestNet: args.isTestNet,
- );
-
- // its ours
- if (coin != null) {
- final SparkCoinType coinType;
- switch (coin.type.value) {
- case 0:
- coinType = SparkCoinType.mint;
- case 1:
- coinType = SparkCoinType.spend;
- default:
- throw Exception("Unknown spark coin type detected");
- }
- myCoins.add(
- SparkCoin(
- walletId: args.walletId,
- type: coinType,
- isUsed: false,
- groupId: args.groupId,
- nonce: coin.nonceHex?.toUint8ListFromHex,
- address: coin.address!,
- txHash: txHash,
- valueIntString: coin.value!.toString(),
- memo: coin.memo,
- serialContext: coin.serialContext,
- diversifierIntString: coin.diversifier!.toString(),
- encryptedDiversifier: coin.encryptedDiversifier,
- serial: coin.serial,
- tag: coin.tag,
- lTagHash: coin.lTagHash!,
- height: coin.height,
- serializedCoinB64: serializedCoinB64,
- contextB64: contextB64,
- ),
- );
- }
- }
- }
-
- return myCoins;
-}
-
BigInt _min(BigInt a, BigInt b) {
if (a <= b) {
return a;
diff --git a/lib/widgets/options.dart b/lib/widgets/options.dart
new file mode 100644
index 0000000..36bccd0
--- /dev/null
+++ b/lib/widgets/options.dart
@@ -0,0 +1,249 @@
+/*
+ * This file is part of Stack Wallet.
+ *
+ * Copyright (c) 2023 Cypher Stack
+ * All Rights Reserved.
+ * The code is distributed under GPLv3 license, see LICENSE file for details.
+ * Generated by Cypher Stack on 2023-05-26
+ *
+ */
+
+import 'package:flutter/material.dart';
+import 'package:flutter_svg/svg.dart';
+
+import '../themes/stack_colors.dart';
+import '../utilities/text_styles.dart';
+import '../utilities/util.dart';
+
+class Options extends StatefulWidget {
+ const Options({
+ super.key,
+ this.icons,
+ this.texts,
+ this.onValueChanged,
+ required this.selectedIndex,
+ this.controller,
+ required this.onColor,
+ required this.offColor,
+ this.decoration,
+ });
+
+ final List<String>? icons;
+ final List<String>? texts;
+ final void Function(int)? onValueChanged;
+ final int selectedIndex;
+ final DSBController? controller;
+ final Color onColor;
+ final Color offColor;
+ final BoxDecoration? decoration;
+
+ @override
+ OptionsState createState() => OptionsState();
+}
+
+class OptionsState extends State<Options> {
+ late final BoxDecoration? decoration;
+ late final Color onColor;
+ late final Color offColor;
+ late final DSBController? controller;
+
+ final bool isDesktop = Util.isDesktop;
+
+ late int _selectedIndex;
+ int get selectedIndex => _selectedIndex;
+
+ late ValueNotifier<double> valueListener;
+
+ final tapAnimationDuration = const Duration(milliseconds: 150);
+ bool _isDragging = false;
+
+ @override
+ initState() {
+ onColor = widget.onColor;
+ offColor = widget.offColor;
+ decoration = widget.decoration;
+ controller = widget.controller;
+ _selectedIndex = widget.selectedIndex;
+ valueListener = ValueNotifier(_selectedIndex.toDouble());
+
+ widget.controller?.activate = () {
+ _selectedIndex = (_selectedIndex + 1) % (widget.texts?.length ?? 1);
+ valueListener.value = _selectedIndex.toDouble();
+ };
+ super.initState();
+ }
+
+ @override
+ void dispose() {
+ valueListener.dispose();
+ super.dispose();
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ debugPrint("BUILD: $runtimeType");
+ final optionsCount = widget.texts?.length ?? 1;
+
+ return GestureDetector(
+ onTapDown: (details) {
+ final RenderBox box = context.findRenderObject() as RenderBox;
+ final localPosition = box.globalToLocal(details.globalPosition);
+ final optionsCount = widget.texts?.length ?? 1;
+ final optionWidth = box.size.width / optionsCount;
+ final tappedIndex = (localPosition.dx / optionWidth).floor().clamp(0, optionsCount - 1);
+ if (_selectedIndex != tappedIndex) {
+ _selectedIndex = tappedIndex;
+ widget.onValueChanged?.call(_selectedIndex);
+ valueListener.value = _selectedIndex.toDouble();
+ setState(() {});
+ }
+ },
+ child: LayoutBuilder(
+ builder: (context, constraint) {
+ return Stack(
+ children: [
+ AnimatedBuilder(
+ animation: valueListener,
+ builder: (context, child) {
+ return AnimatedContainer(
+ duration: tapAnimationDuration,
+ height: constraint.maxHeight,
+ width: constraint.maxWidth,
+ decoration: decoration?.copyWith(
+ color: offColor,
+ ),
+ );
+ },
+ ),
+ Builder(
+ builder: (context) {
+ final handle = GestureDetector(
+ key: const Key("draggableSwitchButtonSwitch"),
+ onHorizontalDragStart: (_) => _isDragging = true,
+ onHorizontalDragUpdate: (details) {
+ valueListener.value = (valueListener.value +
+ details.delta.dx / (constraint.maxWidth / optionsCount))
+ .clamp(0.0, optionsCount - 1.0);
+ },
+ onHorizontalDragEnd: (details) {
+ final int oldValue = _selectedIndex;
+ _selectedIndex = valueListener.value.round();
+ if (_selectedIndex != oldValue) {
+ widget.onValueChanged?.call(_selectedIndex);
+ setState(() {});
+ }
+ _isDragging = false;
+ },
+ child: AnimatedBuilder(
+ animation: valueListener,
+ builder: (context, child) {
+ return AnimatedContainer(
+ duration: tapAnimationDuration,
+ height: constraint.maxHeight,
+ width: constraint.maxWidth / optionsCount,
+ decoration: decoration?.copyWith(
+ color: onColor,
+ ),
+ );
+ },
+ ),
+ );
+ return AnimatedBuilder(
+ animation: valueListener,
+ builder: (context, child) {
+ return AnimatedAlign(
+ duration: _isDragging ? Duration.zero : tapAnimationDuration,
+ alignment: Alignment(
+ (valueListener.value * 2 / (optionsCount - 1)) - 1,
+ 0.5,
+ ),
+ child: child,
+ );
+ },
+ child: handle,
+ );
+ },
+ ),
+ IgnorePointer(
+ child: Row(
+ mainAxisAlignment: MainAxisAlignment.spaceEvenly,
+ children: List.generate(optionsCount, (index) {
+ return SizedBox(
+ width: constraint.maxWidth / optionsCount,
+ child: Center(
+ child: Row(
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: [
+ if (widget.icons != null && widget.icons!.length > index)
+ SvgPicture.asset(
+ widget.icons![index],
+ width: 12,
+ height: 14,
+ color: isDesktop
+ ? _selectedIndex != index
+ ? Theme.of(context)
+ .extension<StackColors>()!
+ .accentColorBlue
+ : Theme.of(context)
+ .extension<StackColors>()!
+ .buttonTextSecondary
+ : _selectedIndex != index
+ ? Theme.of(context)
+ .extension<StackColors>()!
+ .textDark
+ : Theme.of(context)
+ .extension<StackColors>()!
+ .textSubtitle1,
+ ),
+ if (widget.icons != null && widget.icons!.length > index)
+ const SizedBox(
+ width: 5,
+ ),
+ Flexible(
+ child: Text(
+ widget.texts?[index] ?? "",
+ textAlign: TextAlign.center,
+ overflow: TextOverflow.ellipsis,
+ maxLines: 1,
+ style: isDesktop
+ ? STextStyles.desktopTextExtraExtraSmall(
+ context,
+ ).copyWith(
+ color: _selectedIndex != index
+ ? Theme.of(context)
+ .extension<StackColors>()!
+ .accentColorBlue
+ : Theme.of(context)
+ .extension<StackColors>()!
+ .buttonTextSecondary,
+ )
+ : STextStyles.smallMed12(context).copyWith(
+ color: _selectedIndex != index
+ ? Theme.of(context)
+ .extension<StackColors>()!
+ .textDark
+ : Theme.of(context)
+ .extension<StackColors>()!
+ .textSubtitle1,
+ ),
+ ),
+ ),
+ ],
+ ),
+ ),
+ );
+ }),
+ ),
+ ),
+ ],
+ );
+ },
+ ),
+ );
+ }
+}
+
+class DSBController {
+ VoidCallback? activate;
+ bool? isOn;
+}
diff --git a/lib/wl_gen/interfaces/lib_spark_interface.dart b/lib/wl_gen/interfaces/lib_spark_interface.dart
index 5f24277..6f0df29 100644
--- a/lib/wl_gen/interfaces/lib_spark_interface.dart
+++ b/lib/wl_gen/interfaces/lib_spark_interface.dart
@@ -61,6 +61,25 @@ abstract class LibSparkInterface {
final bool isTestNet = false,
});
+ WrappedLibSparkCoin? identifyAndRecoverCoinByFullViewKey(
+ final String serializedCoin, {
+ required final String fullViewKeyHex,
+ required final Uint8List context,
+ final bool isTestNet = false,
+ });
+
+ Future<String> getAddressFromFullViewKey({
+ required String fullViewKeyHex,
+ required int index,
+ required int diversifier,
+ bool isTestNet = false,
+ });
+
+ String getFullViewKeyHexFromPrivateKeyData({
+ required String privateKeyHex,
+ required int index,
+ });
+
({
Uint8List serializedSpendPayload,
List<Uint8List> outputScripts,
diff --git a/tool/wl_templates/FIRO_lib_spark_interface_impl.template.dart b/tool/wl_templates/FIRO_lib_spark_interface_impl.template.dart
index 0842a48..f4120a8 100644
--- a/tool/wl_templates/FIRO_lib_spark_interface_impl.template.dart
+++ b/tool/wl_templates/FIRO_lib_spark_interface_impl.template.dart
@@ -178,6 +178,67 @@ class _LibSparkInterfaceImpl extends LibSparkInterface {
);
}
+ @override
+ WrappedLibSparkCoin? identifyAndRecoverCoinByFullViewKey(
+ String serializedCoin, {
+ required String fullViewKeyHex,
+ required Uint8List context,
+ bool isTestNet = false,
+ }) {
+ final coin = LibSpark.identifyAndRecoverCoinByFullViewKey(
+ serializedCoin: serializedCoin,
+ fullViewKeyHex: fullViewKeyHex,
+ context: context,
+ isTestNet: isTestNet,
+ );
+
+ if (coin == null) return null;
+
+ return WrappedLibSparkCoin(
+ type: WrappedLibSparkCoinType.values.firstWhere(
+ (e) => e.value == coin.type.value,
+ ),
+
+ id: coin.id,
+ height: coin.height,
+ isUsed: coin.isUsed,
+ nonceHex: coin.nonceHex,
+ address: coin.address,
+ value: coin.value,
+ serial: coin.serial,
+ memo: coin.memo,
+ txHash: coin.txHash,
+ serialContext: coin.serialContext,
+ diversifier: coin.diversifier,
+ encryptedDiversifier: coin.encryptedDiversifier,
+ tag: coin.tag,
+ lTagHash: coin.lTagHash,
+ serializedCoin: coin.serializedCoin,
+ );
+ }
+
+ @override
+ Future<String> getAddressFromFullViewKey({
+ required String fullViewKeyHex,
+ required int index,
+ required int diversifier,
+ bool isTestNet = false,
+ }) => LibSpark.getAddressFromFullViewKey(
+ fullViewKeyHex: fullViewKeyHex,
+ index: index,
+ diversifier: diversifier,
+ isTestNet: isTestNet,
+ );
+
+ @override
+ String getFullViewKeyHexFromPrivateKeyData({
+ required String privateKeyHex,
+ required int index,
+ }) => LibSpark.getFullViewKeyHexFromPrivateKeyData(
+ privateKeyHex: privateKeyHex,
+ index: index,
+ );
+
@override
({
int fee,
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.