Add support for view only wallets to most coins

This commit is contained in:
julian 2024-11-12 18:45:17 -06:00 committed by julian-CStack
parent 3da57bc150
commit 6ff539e71b
38 changed files with 1665 additions and 556 deletions

View file

@ -0,0 +1,164 @@
import 'dart:convert';
import '../../wallets/wallet/wallet_mixin_interfaces/extended_keys_interface.dart';
import 'key_data_interface.dart';
// do not remove or change the order of these enum values
enum ViewOnlyWalletType {
cryptonote,
addressOnly,
xPub;
}
sealed class ViewOnlyWalletData with KeyDataInterface {
@override
final String walletId;
ViewOnlyWalletType get type;
ViewOnlyWalletData({
required this.walletId,
});
static ViewOnlyWalletData fromJsonEncodedString(
String jsonEncodedString, {
required String walletId,
}) {
final map = jsonDecode(jsonEncodedString) as Map;
final json = Map<String, dynamic>.from(map);
final type = ViewOnlyWalletType.values[json["type"] as int];
switch (type) {
case ViewOnlyWalletType.cryptonote:
return CryptonoteViewOnlyWalletData.fromJsonEncodedString(
jsonEncodedString,
walletId: walletId,
);
case ViewOnlyWalletType.addressOnly:
return AddressViewOnlyWalletData.fromJsonEncodedString(
jsonEncodedString,
walletId: walletId,
);
case ViewOnlyWalletType.xPub:
return ExtendedKeysViewOnlyWalletData.fromJsonEncodedString(
jsonEncodedString,
walletId: walletId,
);
}
}
String toJsonEncodedString();
}
class CryptonoteViewOnlyWalletData extends ViewOnlyWalletData {
@override
final type = ViewOnlyWalletType.cryptonote;
final String address;
final String privateViewKey;
CryptonoteViewOnlyWalletData({
required super.walletId,
required this.address,
required this.privateViewKey,
});
static CryptonoteViewOnlyWalletData fromJsonEncodedString(
String jsonEncodedString, {
required String walletId,
}) {
final map = jsonDecode(jsonEncodedString) as Map;
final json = Map<String, dynamic>.from(map);
return CryptonoteViewOnlyWalletData(
walletId: walletId,
address: json["address"] as String,
privateViewKey: json["privateViewKey"] as String,
);
}
@override
String toJsonEncodedString() => jsonEncode({
"type": type.index,
"address": address,
"privateViewKey": privateViewKey,
});
}
class AddressViewOnlyWalletData extends ViewOnlyWalletData {
@override
final type = ViewOnlyWalletType.addressOnly;
final String address;
AddressViewOnlyWalletData({
required super.walletId,
required this.address,
});
static AddressViewOnlyWalletData fromJsonEncodedString(
String jsonEncodedString, {
required String walletId,
}) {
final map = jsonDecode(jsonEncodedString) as Map;
final json = Map<String, dynamic>.from(map);
return AddressViewOnlyWalletData(
walletId: walletId,
address: json["address"] as String,
);
}
@override
String toJsonEncodedString() => jsonEncode({
"type": type.index,
"address": address,
});
}
class ExtendedKeysViewOnlyWalletData extends ViewOnlyWalletData {
@override
final type = ViewOnlyWalletType.xPub;
final List<XPub> xPubs;
ExtendedKeysViewOnlyWalletData({
required super.walletId,
required List<XPub> xPubs,
}) : xPubs = List.unmodifiable(xPubs);
static ExtendedKeysViewOnlyWalletData fromJsonEncodedString(
String jsonEncodedString, {
required String walletId,
}) {
final map = jsonDecode(jsonEncodedString) as Map;
final json = Map<String, dynamic>.from(map);
return ExtendedKeysViewOnlyWalletData(
walletId: walletId,
xPubs: List<Map<String, dynamic>>.from((json["xPubs"] as List))
.map(
(e) => XPub(
path: e["path"] as String,
encoded: e["encoded"] as String,
),
)
.toList(growable: false),
);
}
@override
String toJsonEncodedString() => jsonEncode({
"type": type.index,
"xPubs": [
...xPubs.map(
(e) => {
"path": e.path,
"encoded": e.encoded,
},
),
],
});
}

View file

@ -25,6 +25,7 @@ import '../../../utilities/name_generator.dart';
import '../../../utilities/text_styles.dart';
import '../../../utilities/util.dart';
import '../../../wallets/crypto_currency/crypto_currency.dart';
import '../../../wallets/crypto_currency/interfaces/view_only_option_currency_interface.dart';
import '../../../wallets/crypto_currency/intermediate/frost_currency.dart';
import '../../../wallets/isar/models/wallet_info.dart';
import '../../../widgets/background.dart';
@ -104,7 +105,9 @@ class _NameYourWalletViewState extends ConsumerState<NameYourWalletView> {
case AddWalletType.New:
unawaited(
Navigator.of(context).pushNamed(
coin.hasMnemonicPassphraseSupport
coin.possibleMnemonicLengths.length > 1 ||
coin.hasMnemonicPassphraseSupport ||
coin is ViewOnlyOptionCurrencyInterface
? NewWalletOptionsView.routeName
: NewWalletRecoveryPhraseWarningView.routeName,
arguments: Tuple2(

View file

@ -12,9 +12,11 @@ import '../../../utilities/constants.dart';
import '../../../utilities/text_styles.dart';
import '../../../utilities/util.dart';
import '../../../wallets/crypto_currency/crypto_currency.dart';
import '../../../wallets/crypto_currency/interfaces/view_only_option_currency_interface.dart';
import '../../../widgets/background.dart';
import '../../../widgets/conditional_parent.dart';
import '../../../widgets/custom_buttons/app_bar_icon_button.dart';
import '../../../widgets/custom_buttons/checkbox_text_button.dart';
import '../../../widgets/desktop/desktop_app_bar.dart';
import '../../../widgets/desktop/desktop_scaffold.dart';
import '../../../widgets/desktop/primary_button.dart';
@ -25,8 +27,12 @@ import '../new_wallet_recovery_phrase_warning_view/new_wallet_recovery_phrase_wa
import '../restore_wallet_view/restore_options_view/sub_widgets/mobile_mnemonic_length_selector.dart';
import '../restore_wallet_view/sub_widgets/mnemonic_word_count_select_sheet.dart';
final pNewWalletOptions =
StateProvider<({String mnemonicPassphrase, int mnemonicWordsCount})?>(
final pNewWalletOptions = StateProvider<
({
String mnemonicPassphrase,
int mnemonicWordsCount,
bool convertToViewOnly,
})?>(
(ref) => null,
);
@ -59,6 +65,8 @@ class _NewWalletOptionsViewState extends ConsumerState<NewWalletOptionsView> {
bool hidePassword = true;
NewWalletOptions _selectedOptions = NewWalletOptions.Default;
bool _convertToViewOnly = false;
@override
void initState() {
passwordController = TextEditingController();
@ -210,7 +218,7 @@ class _NewWalletOptionsViewState extends ConsumerState<NewWalletOptionsView> {
if (_selectedOptions == NewWalletOptions.Advanced)
Column(
children: [
if (Util.isDesktop)
if (Util.isDesktop && lengths.length > 1)
DropdownButtonHideUnderline(
child: DropdownButton2<int>(
value: ref
@ -265,7 +273,7 @@ class _NewWalletOptionsViewState extends ConsumerState<NewWalletOptionsView> {
),
),
),
if (!Util.isDesktop)
if (!Util.isDesktop && lengths.length > 1)
MobileMnemonicLengthSelector(
chooseMnemonicLength: () {
showModalBottomSheet<dynamic>(
@ -284,9 +292,11 @@ class _NewWalletOptionsViewState extends ConsumerState<NewWalletOptionsView> {
);
},
),
if (widget.coin.hasMnemonicPassphraseSupport)
const SizedBox(
height: 24,
),
if (widget.coin.hasMnemonicPassphraseSupport)
RoundedWhiteContainer(
child: Center(
child: Text(
@ -303,9 +313,11 @@ class _NewWalletOptionsViewState extends ConsumerState<NewWalletOptionsView> {
),
),
),
if (widget.coin.hasMnemonicPassphraseSupport)
const SizedBox(
height: 8,
),
if (widget.coin.hasMnemonicPassphraseSupport)
ClipRRect(
borderRadius: BorderRadius.circular(
Constants.size.circularBorderRadius,
@ -369,6 +381,20 @@ class _NewWalletOptionsViewState extends ConsumerState<NewWalletOptionsView> {
),
),
),
if (widget.coin is ViewOnlyOptionCurrencyInterface)
const SizedBox(
height: 24,
),
if (widget.coin is ViewOnlyOptionCurrencyInterface)
CheckboxTextButton(
label: "Convert to view only wallet. "
"You will only be shown the seed phrase once. "
"Save it somewhere. "
"If you lose it you will lose access to any funds in this wallet.",
onChanged: (value) {
_convertToViewOnly = value;
},
),
],
),
if (!Util.isDesktop) const Spacer(),
@ -383,6 +409,7 @@ class _NewWalletOptionsViewState extends ConsumerState<NewWalletOptionsView> {
mnemonicWordsCount:
ref.read(mnemonicWordCountStateProvider.state).state,
mnemonicPassphrase: passwordController.text,
convertToViewOnly: _convertToViewOnly,
);
} else {
ref.read(pNewWalletOptions.notifier).state = null;

View file

@ -582,7 +582,12 @@ class _NewWalletRecoveryPhraseWarningViewState
)
.state!
.mnemonicPassphrase;
} else {}
} else {
// this may not be epiccash specific?
if (coin is Epiccash) {
mnemonicPassphrase = "";
}
}
wordCount = ref
.read(

View file

@ -24,6 +24,7 @@ import '../../../../utilities/text_styles.dart';
import '../../../../utilities/util.dart';
import '../../../../wallets/crypto_currency/crypto_currency.dart';
import '../../../../wallets/crypto_currency/interfaces/view_only_option_currency_interface.dart';
import '../../../../wallets/crypto_currency/intermediate/cryptonote_currency.dart';
import '../../../../widgets/conditional_parent.dart';
import '../../../../widgets/custom_buttons/app_bar_icon_button.dart';
import '../../../../widgets/custom_buttons/checkbox_text_button.dart';
@ -656,7 +657,7 @@ class ViewOnlyRestoreOption extends StatefulWidget {
class _ViewOnlyRestoreOptionState extends State<ViewOnlyRestoreOption> {
@override
Widget build(BuildContext context) {
final showDateOption = widget.coin is ViewOnlyOptionCurrencyInterface;
final showDateOption = widget.coin is CryptonoteCurrency;
return Column(
children: [
if (showDateOption)

View file

@ -4,32 +4,41 @@ import 'dart:io';
import 'package:cs_monero/src/deprecated/get_height_by_date.dart'
as cs_monero_deprecated;
import 'package:dropdown_button2/dropdown_button2.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_svg/svg.dart';
import 'package:wakelock_plus/wakelock_plus.dart';
import '../../../models/keys/view_only_wallet_data.dart';
import '../../../pages_desktop_specific/desktop_home_view.dart';
import '../../../pages_desktop_specific/my_stack_view/exit_to_my_stack_button.dart';
import '../../../providers/db/main_db_provider.dart';
import '../../../providers/global/secure_store_provider.dart';
import '../../../providers/providers.dart';
import '../../../themes/stack_colors.dart';
import '../../../utilities/assets.dart';
import '../../../utilities/barcode_scanner_interface.dart';
import '../../../utilities/clipboard_interface.dart';
import '../../../utilities/constants.dart';
import '../../../utilities/text_styles.dart';
import '../../../utilities/util.dart';
import '../../../wallets/crypto_currency/crypto_currency.dart';
import '../../../wallets/crypto_currency/interfaces/electrumx_currency_interface.dart';
import '../../../wallets/crypto_currency/intermediate/bip39_hd_currency.dart';
import '../../../wallets/crypto_currency/intermediate/cryptonote_currency.dart';
import '../../../wallets/isar/models/wallet_info.dart';
import '../../../wallets/wallet/impl/epiccash_wallet.dart';
import '../../../wallets/wallet/impl/monero_wallet.dart';
import '../../../wallets/wallet/impl/wownero_wallet.dart';
import '../../../wallets/wallet/wallet.dart';
import '../../../wallets/wallet/wallet_mixin_interfaces/view_only_option_interface.dart';
import '../../../wallets/wallet/wallet_mixin_interfaces/extended_keys_interface.dart';
import '../../../widgets/custom_buttons/app_bar_icon_button.dart';
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 '../../home_view/home_view.dart';
import 'confirm_recovery_dialog.dart';
import 'sub_widgets/restore_failed_dialog.dart';
@ -66,7 +75,10 @@ class _RestoreViewOnlyWalletViewState
late final TextEditingController addressController;
late final TextEditingController viewKeyController;
late String _currentDropDownValue;
bool _enableRestoreButton = false;
bool _addressOnly = false;
bool _buttonLock = false;
@ -106,6 +118,21 @@ class _RestoreViewOnlyWalletViewState
WalletInfoKeys.isViewOnlyKey: true,
};
final ViewOnlyWalletType viewOnlyWalletType;
if (widget.coin is Bip39HDCurrency) {
if (widget.coin is Firo) {
otherDataJson.addAll(
{
WalletInfoKeys.lelantusCoinIsarRescanRequired: false,
WalletInfoKeys.enableLelantusScanning:
widget.enableLelantusScanning,
},
);
}
viewOnlyWalletType = _addressOnly
? ViewOnlyWalletType.addressOnly
: ViewOnlyWalletType.xPub;
} else if (widget.coin is CryptonoteCurrency) {
if (widget.restoreFromDate != null) {
if (widget.coin is Monero) {
height = cs_monero_deprecated.getMoneroHeightByDate(
@ -117,19 +144,17 @@ class _RestoreViewOnlyWalletViewState
date: widget.restoreFromDate!,
);
}
if (height < 0) {
height = 0;
}
if (height < 0) height = 0;
}
if (widget.coin is Firo) {
otherDataJson.addAll(
{
WalletInfoKeys.lelantusCoinIsarRescanRequired: false,
WalletInfoKeys.enableLelantusScanning: widget.enableLelantusScanning,
},
viewOnlyWalletType = ViewOnlyWalletType.cryptonote;
} else {
throw Exception(
"Unsupported view only wallet currency type found: ${widget.coin.runtimeType}",
);
}
otherDataJson[WalletInfoKeys.viewOnlyTypeIndexKey] =
viewOnlyWalletType.index;
if (!Platform.isLinux && !Util.isDesktop) await WakelockPlus.enable();
@ -166,6 +191,43 @@ class _RestoreViewOnlyWalletViewState
);
}
final ViewOnlyWalletData viewOnlyData;
switch (viewOnlyWalletType) {
case ViewOnlyWalletType.cryptonote:
if (addressController.text.isEmpty ||
viewKeyController.text.isEmpty) {
throw Exception("Missing address and/or private view key fields");
}
viewOnlyData = CryptonoteViewOnlyWalletData(
walletId: info.walletId,
address: addressController.text,
privateViewKey: viewKeyController.text,
);
break;
case ViewOnlyWalletType.addressOnly:
if (addressController.text.isEmpty) {
throw Exception("Address is empty");
}
viewOnlyData = AddressViewOnlyWalletData(
walletId: info.walletId,
address: addressController.text,
);
break;
case ViewOnlyWalletType.xPub:
viewOnlyData = ExtendedKeysViewOnlyWalletData(
walletId: info.walletId,
xPubs: [
XPub(
path: _currentDropDownValue,
encoded: viewKeyController.text,
),
],
);
break;
}
var node = ref
.read(nodeServiceChangeNotifierProvider)
.getPrimaryNodeFor(currency: widget.coin);
@ -185,10 +247,7 @@ class _RestoreViewOnlyWalletViewState
secureStorageInterface: ref.read(secureStoreProvider),
nodeService: ref.read(nodeServiceChangeNotifierProvider),
prefs: ref.read(prefsChangeNotifierProvider),
viewOnlyData: ViewOnlyWalletData(
address: addressController.text,
privateViewKey: viewKeyController.text,
),
viewOnlyData: viewOnlyData,
);
// TODO: extract interface with isRestore param
@ -278,11 +337,27 @@ class _RestoreViewOnlyWalletViewState
super.initState();
addressController = TextEditingController();
viewKeyController = TextEditingController();
if (widget.coin is Bip39HDCurrency) {
_currentDropDownValue = (widget.coin as Bip39HDCurrency)
.supportedHardenedDerivationPaths
.last;
}
}
@override
void dispose() {
addressController.dispose();
viewKeyController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final isDesktop = Util.isDesktop;
final isElectrumX = widget.coin is ElectrumXCurrencyInterface;
return MasterScaffold(
isDesktop: isDesktop,
appBar: isDesktop
@ -339,30 +414,154 @@ class _RestoreViewOnlyWalletViewState
? STextStyles.desktopH2(context)
: STextStyles.pageTitleH1(context),
),
if (isElectrumX)
SizedBox(
height: isDesktop ? 24 : 16,
),
if (isElectrumX)
SizedBox(
height: isDesktop ? 56 : 48,
width: isDesktop ? 490 : null,
child: Toggle(
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,
onValueChanged: (value) {
setState(() {
_addressOnly = value;
});
},
decoration: BoxDecoration(
color: Colors.transparent,
borderRadius: BorderRadius.circular(
Constants.size.circularBorderRadius,
),
),
),
),
SizedBox(
height: isDesktop ? 24 : 16,
),
if (!isElectrumX || _addressOnly)
FullTextField(
key: const Key("viewOnlyAddressRestoreFieldKey"),
label: "Address",
controller: addressController,
onChanged: (newValue) {
if (isElectrumX) {
viewKeyController.text = "";
setState(() {
_enableRestoreButton = newValue.isNotEmpty;
});
} else {
setState(() {
_enableRestoreButton = newValue.isNotEmpty &&
viewKeyController.text.isNotEmpty;
});
}
},
),
if (!isElectrumX)
SizedBox(
height: isDesktop ? 16 : 12,
),
if (isElectrumX && !_addressOnly)
DropdownButtonHideUnderline(
child: DropdownButton2<String>(
value: _currentDropDownValue,
items: [
...(widget.coin as Bip39HDCurrency)
.supportedHardenedDerivationPaths
.map(
(e) => DropdownMenuItem(
value: e,
child: Text(
e,
style: STextStyles.w500_14(context),
),
),
),
],
onChanged: (value) {
if (value is String) {
setState(() {
_currentDropDownValue = value;
});
}
},
isExpanded: true,
buttonStyleData: ButtonStyleData(
decoration: BoxDecoration(
color: Theme.of(context)
.extension<StackColors>()!
.textFieldDefaultBG,
borderRadius: BorderRadius.circular(
Constants.size.circularBorderRadius,
),
),
),
iconStyleData: IconStyleData(
icon: Padding(
padding: const EdgeInsets.only(right: 10),
child: SvgPicture.asset(
Assets.svg.chevronDown,
width: 12,
height: 6,
color: Theme.of(context)
.extension<StackColors>()!
.textFieldActiveSearchIconRight,
),
),
),
dropdownStyleData: DropdownStyleData(
offset: const Offset(0, -10),
elevation: 0,
decoration: BoxDecoration(
color: Theme.of(context)
.extension<StackColors>()!
.textFieldDefaultBG,
borderRadius: BorderRadius.circular(
Constants.size.circularBorderRadius,
),
),
),
menuItemStyleData: const MenuItemStyleData(
padding: EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
),
),
),
if (isElectrumX && !_addressOnly)
SizedBox(
height: isDesktop ? 16 : 12,
),
if (!isElectrumX || !_addressOnly)
FullTextField(
label: "View Key",
key: const Key("viewOnlyKeyRestoreFieldKey"),
label:
"${isElectrumX ? "Extended" : "Private View"} Key",
controller: viewKeyController,
onChanged: (value) {
if (isElectrumX) {
addressController.text = "";
setState(() {
_enableRestoreButton = value.isNotEmpty;
});
} else {
setState(() {
_enableRestoreButton = value.isNotEmpty &&
addressController.text.isNotEmpty;
});
}
},
),
if (!isDesktop) const Spacer(),

View file

@ -9,13 +9,17 @@
*/
import 'dart:async';
import 'dart:convert';
import 'dart:math';
import 'package:cs_monero/src/deprecated/get_height_by_date.dart'
as cs_monero_deprecated;
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:tuple/tuple.dart';
import '../../../models/keys/view_only_wallet_data.dart';
import '../../../notifications/show_flush_bar.dart';
import '../../../pages_desktop_specific/desktop_home_view.dart';
import '../../../pages_desktop_specific/my_stack_view/exit_to_my_stack_button.dart';
@ -25,14 +29,25 @@ import '../../../providers/providers.dart';
import '../../../themes/stack_colors.dart';
import '../../../utilities/assets.dart';
import '../../../utilities/constants.dart';
import '../../../utilities/logger.dart';
import '../../../utilities/show_loading.dart';
import '../../../utilities/text_styles.dart';
import '../../../utilities/util.dart';
import '../../../wallets/crypto_currency/coins/ethereum.dart';
import '../../../wallets/crypto_currency/crypto_currency.dart';
import '../../../wallets/crypto_currency/intermediate/bip39_hd_currency.dart';
import '../../../wallets/isar/models/wallet_info.dart';
import '../../../wallets/isar/providers/wallet_info_provider.dart';
import '../../../wallets/wallet/impl/epiccash_wallet.dart';
import '../../../wallets/wallet/impl/monero_wallet.dart';
import '../../../wallets/wallet/impl/wownero_wallet.dart';
import '../../../wallets/wallet/intermediate/lib_monero_wallet.dart';
import '../../../wallets/wallet/wallet.dart';
import '../../../wallets/wallet/wallet_mixin_interfaces/extended_keys_interface.dart';
import '../../../wallets/wallet/wallet_mixin_interfaces/view_only_option_interface.dart';
import '../../../widgets/custom_buttons/app_bar_icon_button.dart';
import '../../../widgets/desktop/desktop_app_bar.dart';
import '../../../widgets/desktop/desktop_scaffold.dart';
import '../../../widgets/stack_dialog.dart';
import '../../home_view/home_view.dart';
import '../add_token_view/edit_wallet_tokens_view.dart';
import '../new_wallet_options/new_wallet_options_view.dart';
@ -64,46 +79,25 @@ class _VerifyRecoveryPhraseViewState
extends ConsumerState<VerifyRecoveryPhraseView>
// with WidgetsBindingObserver
{
late Wallet _wallet;
late String _walletId;
late CryptoCurrency _coin;
late List<String> _mnemonic;
late final bool isDesktop;
@override
void initState() {
_wallet = widget.wallet;
_walletId = widget.wallet.walletId;
_coin = widget.wallet.cryptoCurrency;
_mnemonic = widget.mnemonic;
isDesktop = Util.isDesktop;
// WidgetsBinding.instance?.addObserver(this);
super.initState();
}
@override
dispose() {
// WidgetsBinding.instance?.removeObserver(this);
super.dispose();
}
// @override
// void didChangeAppLifecycleState(AppLifecycleState state) {
// switch (state) {
// case AppLifecycleState.inactive:
// debugPrint(
// "VerifyRecoveryPhraseView ========================= Inactive");
// break;
// case AppLifecycleState.paused:
// debugPrint("VerifyRecoveryPhraseView ========================= Paused");
// break;
// case AppLifecycleState.resumed:
// debugPrint(
// "VerifyRecoveryPhraseView ========================= Resumed");
// break;
// case AppLifecycleState.detached:
// debugPrint(
// "VerifyRecoveryPhraseView ========================= Detached");
// break;
// }
// }
Future<bool> _verifyMnemonicPassphrase() async {
final result = await showDialog<String?>(
context: context,
@ -113,6 +107,153 @@ class _VerifyRecoveryPhraseViewState
return result == "verified";
}
Future<void> _convertToViewOnly() async {
int height = 0;
final Map<String, dynamic> otherDataJson = {
WalletInfoKeys.isViewOnlyKey: true,
};
final ViewOnlyWalletType viewOnlyWalletType;
if (widget.wallet is ExtendedKeysInterface) {
if (widget.wallet.cryptoCurrency is Firo) {
otherDataJson.addAll(
{
WalletInfoKeys.lelantusCoinIsarRescanRequired: false,
WalletInfoKeys.enableLelantusScanning: false,
},
);
}
viewOnlyWalletType = ViewOnlyWalletType.xPub;
} else if (widget.wallet is LibMoneroWallet) {
if (widget.wallet.cryptoCurrency is Monero) {
height = cs_monero_deprecated.getMoneroHeightByDate(
date: DateTime.now().subtract(const Duration(days: 7)),
);
}
if (widget.wallet.cryptoCurrency is Wownero) {
height = cs_monero_deprecated.getWowneroHeightByDate(
date: DateTime.now().subtract(const Duration(days: 7)),
);
}
if (height < 0) height = 0;
viewOnlyWalletType = ViewOnlyWalletType.cryptonote;
} else {
throw Exception(
"Unsupported view only wallet type found: ${widget.wallet.runtimeType}",
);
}
otherDataJson[WalletInfoKeys.viewOnlyTypeIndexKey] =
viewOnlyWalletType.index;
final voInfo = WalletInfo.createNew(
coin: _coin,
name: widget.wallet.info.name,
restoreHeight: height,
otherDataJsonString: jsonEncode(otherDataJson),
);
final ViewOnlyWalletData viewOnlyData;
if (widget.wallet is ExtendedKeysInterface) {
final extendedKeyInfo =
await (widget.wallet as ExtendedKeysInterface).getXPubs();
final testPath = (_coin as Bip39HDCurrency).constructDerivePath(
derivePathType: (_coin as Bip39HDCurrency).defaultDerivePathType,
chain: 0,
index: 0,
);
XPub? xPub;
for (final pub in extendedKeyInfo.xpubs) {
if (testPath.startsWith(pub.path)) {
xPub = pub;
break;
}
}
if (xPub == null) {
throw Exception("Default derivation path not matched in xPubs");
}
viewOnlyData = ExtendedKeysViewOnlyWalletData(
walletId: voInfo.walletId,
xPubs: [xPub],
);
} else if (widget.wallet is LibMoneroWallet) {
final w = widget.wallet as LibMoneroWallet;
final info = await w
.hackToCreateNewViewOnlyWalletDataFromNewlyCreatedWalletThisFunctionShouldNotBeCalledUnlessYouKnowWhatYouAreDoing();
final address = info.$1;
final privateViewKey = info.$2;
viewOnlyData = CryptonoteViewOnlyWalletData(
walletId: voInfo.walletId,
address: address,
privateViewKey: privateViewKey,
);
} else {
throw Exception(
"Unsupported view only wallet type found: ${widget.wallet.runtimeType}",
);
}
final voWallet = await Wallet.create(
walletInfo: voInfo,
mainDB: ref.read(mainDBProvider),
secureStorageInterface: ref.read(secureStoreProvider),
nodeService: ref.read(nodeServiceChangeNotifierProvider),
prefs: ref.read(prefsChangeNotifierProvider),
viewOnlyData: viewOnlyData,
);
try {
// TODO: extract interface with isRestore param
switch (voWallet.runtimeType) {
case const (EpiccashWallet):
await (voWallet as EpiccashWallet).init(isRestore: true);
break;
case const (MoneroWallet):
await (voWallet as MoneroWallet).init(isRestore: true);
break;
case const (WowneroWallet):
await (voWallet as WowneroWallet).init(isRestore: true);
break;
default:
await voWallet.init();
}
await voWallet.recover(isRescan: false);
// don't remove this setMnemonicVerified thing
await voWallet.info.setMnemonicVerified(
isar: ref.read(mainDBProvider).isar,
);
ref.read(pWallets).addWallet(voWallet);
await ref.read(pWallets).deleteWallet(
widget.wallet.info,
ref.read(secureStoreProvider),
);
} catch (e) {
await ref.read(pWallets).deleteWallet(
widget.wallet.info,
ref.read(secureStoreProvider),
);
await ref.read(pWallets).deleteWallet(
voWallet.info,
ref.read(secureStoreProvider),
);
rethrow;
}
}
Future<void> _continue(bool isMatch) async {
if (isMatch) {
if (ref.read(pNewWalletOptions) != null &&
@ -124,11 +265,11 @@ class _VerifyRecoveryPhraseViewState
}
}
await ref.read(pWalletInfo(_wallet.walletId)).setMnemonicVerified(
await ref.read(pWalletInfo(widget.wallet.walletId)).setMnemonicVerified(
isar: ref.read(mainDBProvider).isar,
);
ref.read(pWallets).addWallet(_wallet);
ref.read(pWallets).addWallet(widget.wallet);
final isCreateSpecialEthWallet =
ref.read(createSpecialEthWalletRoutingFlag);
@ -142,6 +283,51 @@ class _VerifyRecoveryPhraseViewState
.state;
}
if (mounted &&
ref.read(pNewWalletOptions)?.convertToViewOnly == true &&
widget.wallet is ViewOnlyOptionInterface) {
try {
Exception? ex;
await showLoading(
whileFuture: _convertToViewOnly(),
context: context,
message: "Converting to view only wallet",
rootNavigator: Util.isDesktop,
onException: (e) {
ex = e;
},
);
if (ex != null) {
throw ex!;
}
} catch (e, s) {
Logging.instance.log(
"$e\n$s",
level: LogLevel.Fatal,
);
if (mounted) {
await showDialog<void>(
context: context,
builder: (_) => StackOkDialog(
title: e.toString(),
desktopPopRootNavigator: Util.isDesktop,
),
);
}
if (mounted) {
Navigator.of(context).popUntil(
ModalRoute.withName(
NewWalletRecoveryPhraseView.routeName,
),
);
}
return;
}
}
if (mounted) {
if (isDesktop) {
if (isCreateSpecialEthWallet) {
@ -156,7 +342,7 @@ class _VerifyRecoveryPhraseViewState
DesktopHomeView.routeName,
),
);
if (widget.wallet.info.coin is Ethereum) {
if (_coin is Ethereum) {
unawaited(
Navigator.of(context).pushNamed(
EditWalletTokensView.routeName,
@ -179,7 +365,7 @@ class _VerifyRecoveryPhraseViewState
(route) => false,
),
);
if (widget.wallet.info.coin is Ethereum) {
if (_coin is Ethereum) {
unawaited(
Navigator.of(context).pushNamed(
EditWalletTokensView.routeName,
@ -269,7 +455,7 @@ class _VerifyRecoveryPhraseViewState
Future<void> delete() async {
await ref.read(pWallets).deleteWallet(
_wallet.info,
widget.wallet.info,
ref.read(secureStoreProvider),
);
}
@ -299,7 +485,7 @@ class _VerifyRecoveryPhraseViewState
trailing: ExitToMyStackButton(
onPressed: () async {
await delete();
if (mounted) {
if (context.mounted) {
Navigator.of(context).popUntil(
ModalRoute.withName(DesktopHomeView.routeName),
);

View file

@ -148,6 +148,7 @@ class _AddressDetailsViewState extends ConsumerState<AddressDetailsView> {
@override
Widget build(BuildContext context) {
final coin = ref.watch(pWalletCoin(widget.walletId));
final wallet = ref.watch(pWallets).getWallet(widget.walletId);
return ConditionalParent(
condition: !isDesktop,
builder: (child) => Background(
@ -383,13 +384,11 @@ class _AddressDetailsViewState extends ConsumerState<AddressDetailsView> {
detail: address.zSafeFrost.toString(),
button: Container(),
),
if (ref.watch(pWallets).getWallet(widget.walletId)
is Bip39HDWallet)
if (wallet is Bip39HDWallet && !wallet.isViewOnly)
const _Div(
height: 12,
),
if (ref.watch(pWallets).getWallet(widget.walletId)
is Bip39HDWallet)
if (wallet is Bip39HDWallet && !wallet.isViewOnly)
AddressPrivateKey(
walletId: widget.walletId,
address: address,

View file

@ -18,6 +18,7 @@ import 'package:flutter_svg/flutter_svg.dart';
import 'package:isar/isar.dart';
import '../../models/isar/models/isar_models.dart';
import '../../models/keys/view_only_wallet_data.dart';
import '../../notifications/show_flush_bar.dart';
import '../../providers/db/main_db_provider.dart';
import '../../providers/providers.dart';
@ -36,6 +37,7 @@ import '../../wallets/wallet/intermediate/bip39_hd_wallet.dart';
import '../../wallets/wallet/wallet_mixin_interfaces/bcash_interface.dart';
import '../../wallets/wallet/wallet_mixin_interfaces/multi_address_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';
import '../../widgets/conditional_parent.dart';
import '../../widgets/custom_buttons/app_bar_icon_button.dart';
@ -189,6 +191,16 @@ class _ReceiveViewState extends ConsumerState<ReceiveView> {
clipboard = widget.clipboard;
final wallet = ref.read(pWallets).getWallet(walletId);
_supportsSpark = wallet is SparkInterface;
if (wallet is ViewOnlyOptionInterface && wallet.isViewOnly) {
_showMultiType = false;
} else {
_showMultiType = _supportsSpark ||
(wallet is! BCashInterface &&
wallet is Bip39HDWallet &&
wallet.supportedAddressTypes.length > 1);
}
_showMultiType = _supportsSpark ||
(wallet is! BCashInterface &&
wallet is Bip39HDWallet &&
@ -265,6 +277,18 @@ class _ReceiveViewState extends ConsumerState<ReceiveView> {
address = ref.watch(pWalletReceivingAddress(walletId));
}
final wallet =
ref.watch(pWallets.select((value) => value.getWallet(walletId)));
final bool canGen;
if (wallet is ViewOnlyOptionInterface &&
wallet.isViewOnly &&
wallet.viewOnlyType == ViewOnlyWalletType.addressOnly) {
canGen = false;
} else {
canGen = (wallet is MultiAddressInterface || _supportsSpark);
}
return Background(
child: Scaffold(
backgroundColor: Theme.of(context).extension<StackColors>()!.background,
@ -553,17 +577,11 @@ class _ReceiveViewState extends ConsumerState<ReceiveView> {
);
},
),
if (ref.watch(
pWallets.select((value) => value.getWallet(walletId)),
) is MultiAddressInterface ||
_supportsSpark)
if (canGen)
const SizedBox(
height: 12,
),
if (ref.watch(
pWallets.select((value) => value.getWallet(walletId)),
) is MultiAddressInterface ||
_supportsSpark)
if (canGen)
SecondaryButton(
label: "Generate new address",
onPressed: _supportsSpark &&

View file

@ -27,6 +27,7 @@ import '../../../../../models/exchange/change_now/exchange_transaction.dart';
import '../../../../../models/exchange/response_objects/trade.dart';
import '../../../../../models/isar/models/contact_entry.dart';
import '../../../../../models/isar/models/transaction_note.dart';
import '../../../../../models/keys/view_only_wallet_data.dart';
import '../../../../../models/node_model.dart';
import '../../../../../models/stack_restoring_ui_state.dart';
import '../../../../../models/trade_wallet_lookup.dart';
@ -58,6 +59,7 @@ import '../../../../../wallets/wallet/intermediate/lib_monero_wallet.dart';
import '../../../../../wallets/wallet/wallet.dart';
import '../../../../../wallets/wallet/wallet_mixin_interfaces/mnemonic_interface.dart';
import '../../../../../wallets/wallet/wallet_mixin_interfaces/private_key_interface.dart';
import '../../../../../wallets/wallet/wallet_mixin_interfaces/view_only_option_interface.dart';
class PreRestoreState {
final Set<String> walletIds;
@ -312,7 +314,10 @@ abstract class SWB {
backupWallet['isFavorite'] = wallet.info.isFavourite;
backupWallet['otherDataJsonString'] = wallet.info.otherDataJsonString;
if (wallet is MnemonicInterface) {
if (wallet is ViewOnlyOptionInterface && wallet.isViewOnly) {
backupWallet['viewOnlyWalletDataKey'] =
(await wallet.getViewOnlyWalletData()).toJsonEncodedString();
} else if (wallet is MnemonicInterface) {
backupWallet['mnemonic'] = await wallet.getMnemonic();
backupWallet['mnemonicPassphrase'] =
await wallet.getMnemonicPassphrase();
@ -419,7 +424,16 @@ abstract class SWB {
String? mnemonic, mnemonicPassphrase, privateKey;
if (walletbackup['mnemonic'] == null) {
ViewOnlyWalletData? viewOnlyData;
if (info.isViewOnly) {
final viewOnlyDataEncoded =
walletbackup['viewOnlyWalletDataKey'] as String;
viewOnlyData = ViewOnlyWalletData.fromJsonEncodedString(
viewOnlyDataEncoded,
walletId: info.walletId,
);
} else if (walletbackup['mnemonic'] == null) {
// probably private key based
if (walletbackup['privateKey'] != null) {
privateKey = walletbackup['privateKey'] as String;
@ -486,6 +500,7 @@ abstract class SWB {
mnemonic: mnemonic,
mnemonicPassphrase: mnemonicPassphrase,
privateKey: privateKey,
viewOnlyData: viewOnlyData,
);
if (wallet is MoneroWallet /*|| wallet is WowneroWallet doesn't work.*/) {

View file

@ -0,0 +1,86 @@
import 'package:flutter/material.dart';
import '../../../models/keys/view_only_wallet_data.dart';
import '../../../utilities/util.dart';
import '../../../widgets/custom_buttons/simple_copy_button.dart';
import '../../../widgets/detail_item.dart';
import '../../wallet_view/transaction_views/transaction_details_view.dart';
class ViewOnlyWalletDataWidget extends StatelessWidget {
const ViewOnlyWalletDataWidget({
super.key,
required this.data,
});
final ViewOnlyWalletData data;
@override
Widget build(BuildContext context) {
return switch (data) {
final CryptonoteViewOnlyWalletData e => Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
DetailItem(
title: "Address",
detail: e.address,
button: Util.isDesktop
? IconCopyButton(
data: e.address,
)
: SimpleCopyButton(
data: e.address,
),
),
const SizedBox(
height: 16,
),
DetailItem(
title: "Private view key",
detail: e.privateViewKey,
button: Util.isDesktop
? IconCopyButton(
data: e.privateViewKey,
)
: SimpleCopyButton(
data: e.privateViewKey,
),
),
],
),
final AddressViewOnlyWalletData e => Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
DetailItem(
title: "Address",
detail: e.address,
button: Util.isDesktop
? IconCopyButton(
data: e.address,
)
: SimpleCopyButton(
data: e.address,
),
),
],
),
final ExtendedKeysViewOnlyWalletData e => Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
...e.xPubs.map(
(xPub) => DetailItem(
title: xPub.path,
detail: xPub.encoded,
button: Util.isDesktop
? IconCopyButton(
data: xPub.encoded,
)
: SimpleCopyButton(
data: xPub.encoded,
),
),
),
],
),
};
}
}

View file

@ -17,6 +17,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../../app_config.dart';
import '../../../../models/keys/cw_key_data.dart';
import '../../../../models/keys/key_data_interface.dart';
import '../../../../models/keys/view_only_wallet_data.dart';
import '../../../../models/keys/xpriv_data.dart';
import '../../../../notifications/show_flush_bar.dart';
import '../../../../themes/stack_colors.dart';
@ -39,6 +40,7 @@ import '../../../../widgets/rounded_white_container.dart';
import '../../../../widgets/stack_dialog.dart';
import '../../../add_wallet_views/new_wallet_recovery_phrase_view/sub_widgets/mnemonic_table.dart';
import '../../../wallet_view/transaction_views/transaction_details_view.dart';
import '../../sub_widgets/view_only_wallet_data_widget.dart';
import 'cn_wallet_keys.dart';
import 'wallet_xprivs.dart';
@ -426,7 +428,7 @@ class _FrostKeys extends StatelessWidget {
}
}
class MobileKeyDataView extends StatelessWidget {
class MobileKeyDataView extends ConsumerWidget {
const MobileKeyDataView({
super.key,
required this.walletId,
@ -441,7 +443,7 @@ class MobileKeyDataView extends StatelessWidget {
final KeyDataInterface keyData;
@override
Widget build(BuildContext context) {
Widget build(BuildContext context, WidgetRef ref) {
return Background(
child: Scaffold(
backgroundColor: Theme.of(context).extension<StackColors>()!.background,
@ -455,6 +457,7 @@ class MobileKeyDataView extends StatelessWidget {
"Wallet ${switch (keyData.runtimeType) {
const (XPrivData) => "xpriv(s)",
const (CWKeyData) => "keys",
const (ViewOnlyWalletData) => "keys",
_ => throw UnimplementedError(
"Don't forget to add your KeyDataInterface here!",
),
@ -483,6 +486,10 @@ class MobileKeyDataView extends StatelessWidget {
walletId: walletId,
cwKeyData: keyData as CWKeyData,
),
const (ViewOnlyWalletData) =>
ViewOnlyWalletDataWidget(
data: keyData as ViewOnlyWalletData,
),
_ => throw UnimplementedError(
"Don't forget to add your KeyDataInterface here!",
),

View file

@ -49,7 +49,7 @@ class WalletXPrivsState extends ConsumerState<WalletXPrivs> {
late String _currentDropDownValue;
String _current(String key) =>
widget.xprivData.xprivs.firstWhere((e) => e.path == key).xpriv;
widget.xprivData.xprivs.firstWhere((e) => e.path == key).encoded;
Future<void> _copy() async {
await widget.clipboardInterface.setData(

View file

@ -19,6 +19,7 @@ import '../../../db/hive/db.dart';
import '../../../db/sqlite/firo_cache.dart';
import '../../../models/epicbox_config_model.dart';
import '../../../models/keys/key_data_interface.dart';
import '../../../models/keys/view_only_wallet_data.dart';
import '../../../notifications/show_flush_bar.dart';
import '../../../providers/global/wallets_provider.dart';
import '../../../providers/ui/transaction_filter_provider.dart';
@ -99,8 +100,13 @@ class _WalletSettingsViewState extends ConsumerState<WalletSettingsView> {
void initState() {
walletId = widget.walletId;
coin = widget.coin;
xPubEnabled =
ref.read(pWallets).getWallet(walletId) is ExtendedKeysInterface;
final wallet = ref.read(pWallets).getWallet(walletId);
if (wallet is ViewOnlyOptionInterface && wallet.isViewOnly) {
xPubEnabled = false;
} else {
xPubEnabled = wallet is ExtendedKeysInterface;
}
xpub = "";
@ -166,6 +172,15 @@ class _WalletSettingsViewState extends ConsumerState<WalletSettingsView> {
@override
Widget build(BuildContext context) {
debugPrint("BUILD: $runtimeType");
final wallet = ref.read(pWallets).getWallet(widget.walletId);
bool canBackup = true;
if (wallet is ViewOnlyOptionInterface &&
wallet.isViewOnly &&
wallet.viewOnlyType == ViewOnlyWalletType.addressOnly) {
canBackup = false;
}
return Background(
child: Scaffold(
backgroundColor: Theme.of(context).extension<StackColors>()!.background,
@ -248,9 +263,11 @@ class _WalletSettingsViewState extends ConsumerState<WalletSettingsView> {
);
},
),
if (canBackup)
const SizedBox(
height: 8,
),
if (canBackup)
Consumer(
builder: (_, ref, __) {
return SettingsListButton(
@ -258,10 +275,6 @@ class _WalletSettingsViewState extends ConsumerState<WalletSettingsView> {
iconSize: 16,
title: "Wallet backup",
onPressed: () async {
final wallet = ref
.read(pWallets)
.getWallet(widget.walletId);
// TODO: [prio=med] take wallets that don't have a mnemonic into account
List<String>? mnemonic;
@ -274,7 +287,6 @@ class _WalletSettingsViewState extends ConsumerState<WalletSettingsView> {
String keys
})? prevGen,
})? frostWalletData;
ViewOnlyWalletData? voData;
if (wallet is BitcoinFrostWallet) {
final futures = [
wallet.getSerializedKeys(),
@ -301,34 +313,44 @@ class _WalletSettingsViewState extends ConsumerState<WalletSettingsView> {
);
}
} else {
if (wallet is MnemonicInterface) {
if (wallet
is ViewOnlyOptionInterface &&
wallet.isViewOnly) {
voData = await wallet
.getViewOnlyWalletData();
} else if (wallet
is MnemonicInterface) {
!(wallet
as ViewOnlyOptionInterface)
.isViewOnly) {
mnemonic = await wallet
.getMnemonicAsWords();
}
}
}
KeyDataInterface? keyData;
if (wallet is ExtendedKeysInterface) {
if (wallet
is ViewOnlyOptionInterface &&
wallet.isViewOnly) {
keyData = await wallet
.getViewOnlyWalletData();
} else if (wallet
is ExtendedKeysInterface) {
keyData = await wallet.getXPrivs();
} else if (wallet is LibMoneroWallet) {
} else if (wallet
is LibMoneroWallet) {
keyData = await wallet.getKeys();
}
if (context.mounted) {
if (voData != null) {
if (keyData != null &&
wallet
is ViewOnlyOptionInterface) {
await Navigator.push(
context,
RouteGenerator.getRoute(
shouldUseMaterialRoute:
RouteGenerator
.useMaterialPageRoute,
builder: (_) => LockscreenView(
builder: (_) =>
LockscreenView(
routeOnSuccessArguments: (
walletId: walletId,
keyData: keyData,
@ -357,7 +379,8 @@ class _WalletSettingsViewState extends ConsumerState<WalletSettingsView> {
shouldUseMaterialRoute:
RouteGenerator
.useMaterialPageRoute,
builder: (_) => LockscreenView(
builder: (_) =>
LockscreenView(
routeOnSuccessArguments: (
walletId: walletId,
mnemonic: mnemonic ?? [],

View file

@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../../app_config.dart';
import '../../../../models/keys/view_only_wallet_data.dart';
import '../../../../pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/delete_wallet_keys_popup.dart';
import '../../../../providers/global/secure_store_provider.dart';
import '../../../../providers/global/wallets_provider.dart';
@ -10,16 +11,13 @@ import '../../../../themes/stack_colors.dart';
import '../../../../utilities/text_styles.dart';
import '../../../../utilities/util.dart';
import '../../../../wallets/isar/providers/wallet_info_provider.dart';
import '../../../../wallets/wallet/wallet_mixin_interfaces/view_only_option_interface.dart';
import '../../../../widgets/conditional_parent.dart';
import '../../../../widgets/custom_buttons/app_bar_icon_button.dart';
import '../../../../widgets/custom_buttons/simple_copy_button.dart';
import '../../../../widgets/desktop/primary_button.dart';
import '../../../../widgets/detail_item.dart';
import '../../../../widgets/rounded_white_container.dart';
import '../../../../widgets/stack_dialog.dart';
import '../../../home_view/home_view.dart';
import '../../../wallet_view/transaction_views/tx_v2/transaction_v2_details_view.dart';
import '../../sub_widgets/view_only_wallet_data_widget.dart';
class DeleteViewOnlyWalletKeysView extends ConsumerStatefulWidget {
const DeleteViewOnlyWalletKeysView({
@ -161,33 +159,8 @@ class _DeleteViewOnlyWalletKeysViewState
const SizedBox(
height: 24,
),
if (widget.data.address != null)
DetailItem(
title: "Address",
detail: widget.data.address!,
button: Util.isDesktop
? IconCopyButton(
data: widget.data.address!,
)
: SimpleCopyButton(
data: widget.data.address!,
),
),
if (widget.data.address != null)
const SizedBox(
height: 16,
),
if (widget.data.privateViewKey != null)
DetailItem(
title: "Private view key",
detail: widget.data.privateViewKey!,
button: Util.isDesktop
? IconCopyButton(
data: widget.data.privateViewKey!,
)
: SimpleCopyButton(
data: widget.data.privateViewKey!,
),
ViewOnlyWalletDataWidget(
data: widget.data,
),
if (!Util.isDesktop) const Spacer(),
const SizedBox(

View file

@ -12,6 +12,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../../app_config.dart';
import '../../../../models/keys/view_only_wallet_data.dart';
import '../../../../providers/providers.dart';
import '../../../../themes/stack_colors.dart';
import '../../../../utilities/text_styles.dart';

View file

@ -11,6 +11,7 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../../models/keys/view_only_wallet_data.dart';
import '../../../../providers/db/main_db_provider.dart';
import '../../../../providers/providers.dart';
import '../../../../route_generator.dart';
@ -23,6 +24,7 @@ import '../../../../wallets/wallet/wallet_mixin_interfaces/lelantus_interface.da
import '../../../../wallets/wallet/wallet_mixin_interfaces/multi_address_interface.dart';
import '../../../../wallets/wallet/wallet_mixin_interfaces/rbf_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';
import '../../../../widgets/custom_buttons/app_bar_icon_button.dart';
import '../../../../widgets/custom_buttons/draggable_switch_button.dart';
@ -133,6 +135,12 @@ class _WalletSettingsWalletSettingsViewState
@override
Widget build(BuildContext context) {
final wallet = ref.watch(pWallets).getWallet(widget.walletId);
final isViewOnlyNoAddressGen = wallet is ViewOnlyOptionInterface &&
wallet.isViewOnly &&
wallet.viewOnlyType == ViewOnlyWalletType.addressOnly;
return Background(
child: Scaffold(
backgroundColor: Theme.of(context).extension<StackColors>()!.background,
@ -189,13 +197,11 @@ class _WalletSettingsWalletSettingsViewState
),
),
),
if (ref.watch(pWallets).getWallet(widget.walletId)
is RbfInterface)
if (wallet is RbfInterface)
const SizedBox(
height: 8,
),
if (ref.watch(pWallets).getWallet(widget.walletId)
is RbfInterface)
if (wallet is RbfInterface)
RoundedWhiteContainer(
padding: const EdgeInsets.all(0),
child: RawMaterialButton(
@ -227,13 +233,11 @@ class _WalletSettingsWalletSettingsViewState
),
),
),
if (ref.watch(pWallets).getWallet(widget.walletId)
is MultiAddressInterface)
if (wallet is MultiAddressInterface && !isViewOnlyNoAddressGen)
const SizedBox(
height: 8,
),
if (ref.watch(pWallets).getWallet(widget.walletId)
is MultiAddressInterface)
if (wallet is MultiAddressInterface && !isViewOnlyNoAddressGen)
RoundedWhiteContainer(
padding: const EdgeInsets.all(0),
child: RawMaterialButton(
@ -278,13 +282,11 @@ class _WalletSettingsWalletSettingsViewState
),
),
),
if (ref.watch(pWallets).getWallet(widget.walletId)
is LelantusInterface)
if (wallet is LelantusInterface)
const SizedBox(
height: 8,
),
if (ref.watch(pWallets).getWallet(widget.walletId)
is LelantusInterface)
if (wallet is LelantusInterface)
RoundedWhiteContainer(
padding: const EdgeInsets.all(0),
child: RawMaterialButton(
@ -316,13 +318,11 @@ class _WalletSettingsWalletSettingsViewState
),
),
),
if (ref.watch(pWallets).getWallet(widget.walletId)
is SparkInterface)
if (wallet is SparkInterface)
const SizedBox(
height: 8,
),
if (ref.watch(pWallets).getWallet(widget.walletId)
is SparkInterface)
if (wallet is SparkInterface)
RoundedWhiteContainer(
padding: const EdgeInsets.all(0),
child: RawMaterialButton(

View file

@ -57,7 +57,7 @@ class XPubViewState extends ConsumerState<XPubView> {
late String _currentDropDownValue;
String _current(String key) =>
widget.xpubData.xpubs.firstWhere((e) => e.path == key).xpub;
widget.xpubData.xpubs.firstWhere((e) => e.path == key).encoded;
Future<void> _copy() async {
await widget.clipboardInterface.setData(

View file

@ -186,6 +186,21 @@ class WalletSummaryInfo extends ConsumerWidget {
),
),
const Spacer(),
if (ref.watch(pWalletInfo(walletId)).isViewOnly)
FittedBox(
fit: BoxFit.scaleDown,
child: SelectableText(
"(View only)",
style: STextStyles.pageTitleH1(context).copyWith(
fontSize: 18,
color: Theme.of(context)
.extension<StackColors>()!
.textFavoriteCard
.withOpacity(0.7),
),
),
),
const Spacer(),
FittedBox(
fit: BoxFit.scaleDown,
child: SelectableText(

View file

@ -21,6 +21,7 @@ import 'package:isar/isar.dart';
import '../../../db/sqlite/firo_cache.dart';
import '../../../models/isar/models/blockchain_data/v2/transaction_v2.dart';
import '../../../models/isar/models/isar_models.dart';
import '../../../models/keys/view_only_wallet_data.dart';
import '../../../pages/add_wallet_views/add_token_view/edit_wallet_tokens_view.dart';
import '../../../pages/token_view/my_tokens_view.dart';
import '../../../pages/wallet_view/sub_widgets/transactions_list.dart';
@ -44,6 +45,7 @@ import '../../../utilities/wallet_tools.dart';
import '../../../wallets/isar/providers/wallet_info_provider.dart';
import '../../../wallets/wallet/impl/banano_wallet.dart';
import '../../../wallets/wallet/impl/firo_wallet.dart';
import '../../../wallets/wallet/wallet_mixin_interfaces/view_only_option_interface.dart';
import '../../../widgets/custom_buttons/app_bar_icon_button.dart';
import '../../../widgets/custom_buttons/blue_text_button.dart';
import '../../../widgets/desktop/desktop_app_bar.dart';
@ -168,6 +170,11 @@ class _DesktopWalletViewState extends ConsumerState<DesktopWalletView> {
final monke = wallet is BananoWallet ? wallet.getMonkeyImageBytes() : null;
// if the view only wallet watches a single address there are no keys of any kind
final showKeysButton = !(wallet is ViewOnlyOptionInterface &&
wallet.isViewOnly &&
wallet.viewOnlyType == ViewOnlyWalletType.addressOnly);
return DesktopScaffold(
appBar: DesktopAppBar(
background: Theme.of(context).extension<StackColors>()!.popupBG,
@ -216,6 +223,19 @@ class _DesktopWalletViewState extends ConsumerState<DesktopWalletView> {
),
),
),
if (ref.watch(pWalletInfo(widget.walletId)).isViewOnly)
const SizedBox(
width: 20,
),
if (ref.watch(pWalletInfo(widget.walletId)).isViewOnly)
Text(
"(View only)",
style: STextStyles.desktopTextExtraSmall(context).copyWith(
color: Theme.of(context)
.extension<StackColors>()!
.textFieldActiveSearchIconLeft,
),
),
if (kDebugMode) const Spacer(),
if (kDebugMode)
Column(
@ -312,9 +332,11 @@ class _DesktopWalletViewState extends ConsumerState<DesktopWalletView> {
walletId: widget.walletId,
eventBus: eventBus,
),
if (showKeysButton)
const SizedBox(
width: 2,
),
if (showKeysButton)
WalletKeysButton(
walletId: widget.walletId,
),

View file

@ -19,6 +19,7 @@ import 'package:isar/isar.dart';
import 'package:tuple/tuple.dart';
import '../../../../models/isar/models/isar_models.dart';
import '../../../../models/keys/view_only_wallet_data.dart';
import '../../../../notifications/show_flush_bar.dart';
import '../../../../pages/receive_view/generate_receiving_uri_qr_code_view.dart';
import '../../../../providers/db/main_db_provider.dart';
@ -40,6 +41,7 @@ import '../../../../wallets/wallet/intermediate/bip39_hd_wallet.dart';
import '../../../../wallets/wallet/wallet_mixin_interfaces/bcash_interface.dart';
import '../../../../wallets/wallet/wallet_mixin_interfaces/multi_address_interface.dart';
import '../../../../wallets/wallet/wallet_mixin_interfaces/spark_interface.dart';
import '../../../../wallets/wallet/wallet_mixin_interfaces/view_only_option_interface.dart';
import '../../../../widgets/conditional_parent.dart';
import '../../../../widgets/custom_buttons/app_bar_icon_button.dart';
import '../../../../widgets/custom_loading_overlay.dart';
@ -185,10 +187,15 @@ class _DesktopReceiveState extends ConsumerState<DesktopReceive> {
clipboard = widget.clipboard;
final wallet = ref.read(pWallets).getWallet(walletId);
supportsSpark = ref.read(pWallets).getWallet(walletId) is SparkInterface;
if (wallet is ViewOnlyOptionInterface && wallet.isViewOnly) {
showMultiType = false;
} else {
showMultiType = supportsSpark ||
(wallet is! BCashInterface &&
wallet is Bip39HDWallet &&
wallet.supportedAddressTypes.length > 1);
}
_walletAddressTypes.add(wallet.info.mainAddressType);
@ -259,6 +266,18 @@ class _DesktopReceiveState extends ConsumerState<DesktopReceive> {
address = ref.watch(pWalletReceivingAddress(walletId));
}
final wallet =
ref.watch(pWallets.select((value) => value.getWallet(walletId)));
final bool canGen;
if (wallet is ViewOnlyOptionInterface &&
wallet.isViewOnly &&
wallet.viewOnlyType == ViewOnlyWalletType.addressOnly) {
canGen = false;
} else {
canGen = (wallet is MultiAddressInterface || supportsSpark);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
@ -430,16 +449,12 @@ class _DesktopReceiveState extends ConsumerState<DesktopReceive> {
),
),
if (ref.watch(pWallets.select((value) => value.getWallet(walletId)))
is MultiAddressInterface ||
supportsSpark)
if (canGen)
const SizedBox(
height: 20,
),
if (ref.watch(pWallets.select((value) => value.getWallet(walletId)))
is MultiAddressInterface ||
supportsSpark)
if (canGen)
SecondaryButton(
buttonHeight: ButtonHeight.l,
onPressed: supportsSpark &&

View file

@ -14,6 +14,7 @@ import 'package:flutter_svg/flutter_svg.dart';
import '../../../../../app_config.dart';
import '../../../../../db/sqlite/firo_cache.dart';
import '../../../../../models/keys/view_only_wallet_data.dart';
import '../../../../../providers/db/main_db_provider.dart';
import '../../../../../providers/global/prefs_provider.dart';
import '../../../../../providers/global/wallets_provider.dart';
@ -240,6 +241,9 @@ class _MoreFeaturesDialogState extends ConsumerState<MoreFeaturesDialog> {
);
final isViewOnly = wallet is ViewOnlyOptionInterface && wallet.isViewOnly;
final isViewOnlyNoAddressGen = wallet is ViewOnlyOptionInterface &&
wallet.isViewOnly &&
wallet.viewOnlyType == ViewOnlyWalletType.addressOnly;
return DesktopDialog(
child: Column(
@ -386,6 +390,7 @@ class _MoreFeaturesDialogState extends ConsumerState<MoreFeaturesDialog> {
),
),
// reuseAddress preference.
if (!isViewOnlyNoAddressGen)
_MoreFeaturesItemBase(
onPressed: _switchReuseAddressToggled,
child: Row(

View file

@ -83,7 +83,9 @@ class _UnlockWalletKeysDesktopState
.verifyPassphrase(passwordController.text);
if (verified) {
if (mounted) {
Navigator.of(context, rootNavigator: true).pop();
}
final wallet = ref.read(pWallets).getWallet(widget.walletId);
({String keys, String config})? frostData;
@ -101,7 +103,8 @@ class _UnlockWalletKeysDesktopState
throw Exception("FIXME ~= see todo in code");
}
} else {
if (wallet is ViewOnlyOptionInterface) {
if (wallet is ViewOnlyOptionInterface &&
(wallet as ViewOnlyOptionInterface).isViewOnly) {
// TODO: is something needed here?
} else {
words = await wallet.getMnemonicAsWords();
@ -109,7 +112,9 @@ class _UnlockWalletKeysDesktopState
}
KeyDataInterface? keyData;
if (wallet is ExtendedKeysInterface) {
if (wallet is ViewOnlyOptionInterface && wallet.isViewOnly) {
keyData = await wallet.getViewOnlyWalletData();
} else if (wallet is ExtendedKeysInterface) {
keyData = await wallet.getXPrivs();
} else if (wallet is LibMoneroWallet) {
keyData = await wallet.getKeys();
@ -127,10 +132,12 @@ class _UnlockWalletKeysDesktopState
);
}
} else {
if (mounted) {
Navigator.of(context, rootNavigator: true).pop();
}
await Future<void>.delayed(const Duration(milliseconds: 300));
if (mounted) {
unawaited(
showFloatingFlushBar(
type: FlushBarType.warning,
@ -140,6 +147,7 @@ class _UnlockWalletKeysDesktopState
);
}
}
}
@override
void initState() {
@ -332,7 +340,10 @@ class _UnlockWalletKeysDesktopState
.verifyPassphrase(passwordController.text);
if (verified) {
Navigator.of(context, rootNavigator: true).pop();
if (context.mounted) {
Navigator.of(context, rootNavigator: true)
.pop();
}
({String keys, String config})? frostData;
List<String>? words;
@ -352,7 +363,9 @@ class _UnlockWalletKeysDesktopState
throw Exception("FIXME ~= see todo in code");
}
} else {
if (wallet is ViewOnlyOptionInterface) {
if (wallet is ViewOnlyOptionInterface &&
(wallet as ViewOnlyOptionInterface)
.isViewOnly) {
// TODO: is something needed here?
} else {
words = await wallet.getMnemonicAsWords();
@ -360,7 +373,10 @@ class _UnlockWalletKeysDesktopState
}
KeyDataInterface? keyData;
if (wallet is ExtendedKeysInterface) {
if (wallet is ViewOnlyOptionInterface &&
wallet.isViewOnly) {
keyData = await wallet.getViewOnlyWalletData();
} else if (wallet is ExtendedKeysInterface) {
keyData = await wallet.getXPrivs();
} else if (wallet is LibMoneroWallet) {
keyData = await wallet.getKeys();
@ -379,12 +395,15 @@ class _UnlockWalletKeysDesktopState
);
}
} else {
Navigator.of(context, rootNavigator: true).pop();
if (context.mounted) {
Navigator.of(context, rootNavigator: true)
.pop();
}
await Future<void>.delayed(
const Duration(milliseconds: 300),
);
if (context.mounted) {
unawaited(
showFloatingFlushBar(
type: FlushBarType.warning,
@ -394,6 +413,7 @@ class _UnlockWalletKeysDesktopState
);
}
}
}
: null,
),
),

View file

@ -16,9 +16,11 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../../models/keys/cw_key_data.dart';
import '../../../../models/keys/key_data_interface.dart';
import '../../../../models/keys/view_only_wallet_data.dart';
import '../../../../models/keys/xpriv_data.dart';
import '../../../../notifications/show_flush_bar.dart';
import '../../../../pages/add_wallet_views/new_wallet_recovery_phrase_view/sub_widgets/mnemonic_table.dart';
import '../../../../pages/settings_views/sub_widgets/view_only_wallet_data_widget.dart';
import '../../../../pages/settings_views/wallet_settings_view/wallet_backup_views/cn_wallet_keys.dart';
import '../../../../pages/settings_views/wallet_settings_view/wallet_backup_views/wallet_xprivs.dart';
import '../../../../pages/wallet_view/transaction_views/transaction_details_view.dart';
@ -180,7 +182,14 @@ class WalletKeysDesktopPopup extends ConsumerWidget {
],
)
: keyData != null
? CustomTabView(
? keyData is ViewOnlyWalletData
? Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: ViewOnlyWalletDataWidget(
data: keyData as ViewOnlyWalletData,
),
)
: CustomTabView(
titles: [
if (words.isNotEmpty) "Mnemonic",
if (keyData is XPrivData) "XPriv(s)",

View file

@ -31,6 +31,7 @@ import '../../../../wallets/crypto_currency/intermediate/frost_currency.dart';
import '../../../../wallets/crypto_currency/intermediate/nano_currency.dart';
import '../../../../wallets/isar/providers/wallet_info_provider.dart';
import '../../../../wallets/wallet/wallet_mixin_interfaces/extended_keys_interface.dart';
import '../../../../wallets/wallet/wallet_mixin_interfaces/view_only_option_interface.dart';
import '../../../addresses/desktop_wallet_addresses_view.dart';
import '../../../lelantus_coins/lelantus_coins_view.dart';
import '../../../spark_coins/spark_coins_view.dart';
@ -295,8 +296,12 @@ class WalletOptionsPopupMenu extends ConsumerWidget {
final firoDebug = kDebugMode && (coin is Firo);
final bool xpubEnabled =
ref.watch(pWallets).getWallet(walletId) is ExtendedKeysInterface;
final wallet = ref.watch(pWallets).getWallet(walletId);
bool xpubEnabled = wallet is ExtendedKeysInterface;
if (wallet is ViewOnlyOptionInterface && wallet.isViewOnly) {
xpubEnabled = false;
}
final bool canChangeRep = coin is NanoCurrency;

View file

@ -23,6 +23,7 @@ import 'models/isar/models/contact_entry.dart';
import 'models/isar/models/isar_models.dart';
import 'models/isar/ordinal.dart';
import 'models/keys/key_data_interface.dart';
import 'models/keys/view_only_wallet_data.dart';
import 'models/paynym/paynym_account_lite.dart';
import 'models/send_view_auto_fill_data.dart';
import 'pages/add_wallet_views/add_token_view/add_custom_token_view.dart';
@ -205,7 +206,6 @@ import 'wallets/crypto_currency/intermediate/frost_currency.dart';
import 'wallets/models/tx_data.dart';
import 'wallets/wallet/wallet.dart';
import 'wallets/wallet/wallet_mixin_interfaces/extended_keys_interface.dart';
import 'wallets/wallet/wallet_mixin_interfaces/view_only_option_interface.dart';
import 'widgets/choose_coin_view.dart';
import 'widgets/frost_scaffold.dart';

View file

@ -84,6 +84,9 @@ class Wallets {
key: Wallet.mnemonicPassphraseKey(walletId: walletId),
);
await secureStorage.delete(key: Wallet.privateKeyKey(walletId: walletId));
await secureStorage.delete(
key: Wallet.getViewOnlyWalletDataSecStoreKey(walletId: walletId),
);
if (info.coin is Wownero) {
await lib_monero_compat.deleteWalletFiles(

View file

@ -92,7 +92,7 @@ class Wownero extends CryptonoteCurrency {
bool get hasMnemonicPassphraseSupport => false;
@override
List<int> get possibleMnemonicLengths => [defaultSeedPhraseLength, 25];
List<int> get possibleMnemonicLengths => [defaultSeedPhraseLength, 16, 25];
@override
BigInt get satsPerCoin => BigInt.from(100000000000);

View file

@ -5,9 +5,11 @@ import 'package:flutter/foundation.dart';
import '../../../models/isar/models/blockchain_data/address.dart';
import '../../../utilities/amount/amount.dart';
import '../../../utilities/enums/derive_path_type_enum.dart';
import '../interfaces/view_only_option_currency_interface.dart';
import 'bip39_currency.dart';
abstract class Bip39HDCurrency extends Bip39Currency {
abstract class Bip39HDCurrency extends Bip39Currency
implements ViewOnlyOptionCurrencyInterface {
Bip39HDCurrency(super.network);
coinlib.Network get networkParams;
@ -39,6 +41,25 @@ abstract class Bip39HDCurrency extends Bip39Currency {
}
}
List<String> get supportedHardenedDerivationPaths {
final paths = supportedDerivationPathTypes.map(
(e) => (
path: e,
addressType: e.getAddressType(),
),
);
return paths.map((e) {
final path = constructDerivePath(
derivePathType: e.path,
chain: 0,
index: 0,
);
// trim unhardened
return path.substring(0, path.lastIndexOf("'") + 1);
}).toList();
}
static String convertBytesToScriptHash(Uint8List bytes) {
final hash = sha256.convert(bytes.toList(growable: false)).toString();

View file

@ -6,6 +6,7 @@ import 'package:uuid/uuid.dart';
import '../../../app_config.dart';
import '../../../models/balance.dart';
import '../../../models/isar/models/blockchain_data/address.dart';
import '../../../models/keys/view_only_wallet_data.dart';
import '../../crypto_currency/crypto_currency.dart';
import '../isar_id_interface.dart';
import 'wallet_info_meta.dart';
@ -121,6 +122,13 @@ class WalletInfo implements IsarId {
bool get isViewOnly =>
otherData[WalletInfoKeys.isViewOnlyKey] as bool? ?? false;
@ignore
ViewOnlyWalletType? get viewOnlyWalletType {
final index = otherData[WalletInfoKeys.viewOnlyTypeIndexKey] as int?;
if (index == null) return null;
return ViewOnlyWalletType.values[index];
}
Future<bool> isMnemonicVerified(Isar isar) async =>
(await isar.walletInfoMeta.where().walletIdEqualTo(walletId).findFirst())
?.isMnemonicVerified ==
@ -517,4 +525,5 @@ abstract class WalletInfoKeys {
static const String enableOptInRbf = "enableOptInRbfKey";
static const String reuseAddress = "reuseAddressKey";
static const String isViewOnlyKey = "isViewOnlyKey";
static const String viewOnlyTypeIndexKey = "viewOnlyTypeIndexKey";
}

View file

@ -1,4 +1,5 @@
import 'package:isar/isar.dart';
import '../isar_id_interface.dart';
part 'wallet_info_meta.g.dart';

View file

@ -6,15 +6,17 @@ import 'package:isar/isar.dart';
import '../../../models/balance.dart';
import '../../../models/isar/models/blockchain_data/address.dart';
import '../../../models/keys/view_only_wallet_data.dart';
import '../../../utilities/amount/amount.dart';
import '../../../utilities/enums/derive_path_type_enum.dart';
import '../../../utilities/extensions/extensions.dart';
import '../../crypto_currency/intermediate/bip39_hd_currency.dart';
import '../wallet_mixin_interfaces/multi_address_interface.dart';
import '../wallet_mixin_interfaces/view_only_option_interface.dart';
import 'bip39_wallet.dart';
abstract class Bip39HDWallet<T extends Bip39HDCurrency> extends Bip39Wallet<T>
with MultiAddressInterface<T> {
with MultiAddressInterface<T>, ViewOnlyOptionInterface<T> {
Bip39HDWallet(super.cryptoCurrency);
Set<AddressType> get supportedAddressTypes =>
@ -116,7 +118,9 @@ abstract class Bip39HDWallet<T extends Bip39HDCurrency> extends Bip39Wallet<T>
@override
Future<void> checkSaveInitialReceivingAddress() async {
final current = await getCurrentChangeAddress();
if (isViewOnly && viewOnlyType == ViewOnlyWalletType.addressOnly) return;
final current = await getCurrentReceivingAddress();
if (current == null) {
final address = await _generateAddress(
chain: 0, // receiving
@ -174,15 +178,29 @@ abstract class Bip39HDWallet<T extends Bip39HDCurrency> extends Bip39Wallet<T>
required int index,
required DerivePathType derivePathType,
}) async {
final root = await getRootHDNode();
final derivationPath = cryptoCurrency.constructDerivePath(
derivePathType: derivePathType,
chain: chain,
index: index,
);
final keys = root.derivePath(derivationPath);
final coinlib.HDKey keys;
if (isViewOnly) {
final idx = derivationPath.lastIndexOf("'/");
final path = derivationPath.substring(idx + 2);
final data =
await getViewOnlyWalletData() as ExtendedKeysViewOnlyWalletData;
final xPub = data.xPubs.firstWhere(
(e) => derivationPath.startsWith(e.path),
);
final node = coinlib.HDPublicKey.decode(xPub.encoded);
keys = node.derivePath(path);
} else {
final root = await getRootHDNode();
keys = root.derivePath(derivationPath);
}
final data = cryptoCurrency.getAddressForPublicKey(
publicKey: keys.publicKey,
@ -205,7 +223,8 @@ abstract class Bip39HDWallet<T extends Bip39HDCurrency> extends Bip39Wallet<T>
value: convertAddressString(data.address.toString()),
publicKey: keys.publicKey.data,
derivationIndex: index,
derivationPath: DerivationPath()..value = derivationPath,
derivationPath:
isViewOnly ? null : (DerivationPath()..value = derivationPath),
type: data.addressType,
subType: subType,
);

View file

@ -18,6 +18,7 @@ import '../../../models/isar/models/blockchain_data/v2/input_v2.dart';
import '../../../models/isar/models/blockchain_data/v2/output_v2.dart';
import '../../../models/isar/models/blockchain_data/v2/transaction_v2.dart';
import '../../../models/keys/cw_key_data.dart';
import '../../../models/keys/view_only_wallet_data.dart';
import '../../../models/paymint/fee_object_model.dart';
import '../../../services/event_bus/events/global/blocks_remaining_event.dart';
import '../../../services/event_bus/events/global/refresh_percent_changed_event.dart';
@ -41,7 +42,8 @@ import 'cryptonote_wallet.dart';
abstract class LibMoneroWallet<T extends CryptonoteCurrency>
extends CryptonoteWallet<T>
implements MultiAddressInterface<T>, ViewOnlyOptionInterface<T> {
with ViewOnlyOptionInterface<T>
implements MultiAddressInterface<T> {
@override
int get isarTransactionVersion => 2;
@ -91,8 +93,16 @@ abstract class LibMoneroWallet<T extends CryptonoteCurrency>
.walletIdEqualTo(walletId)
.watch(fireImmediately: true)
.listen((utxos) async {
try {
await onUTXOsChanged(utxos);
await updateBalance(shouldUpdateUtxos: false);
} catch (e, s) {
lib_monero.Logging.log?.i(
"_startInit",
error: e,
stackTrace: s,
);
}
});
});
}
@ -286,6 +296,22 @@ abstract class LibMoneroWallet<T extends CryptonoteCurrency>
);
}
Future<(String, String)>
hackToCreateNewViewOnlyWalletDataFromNewlyCreatedWalletThisFunctionShouldNotBeCalledUnlessYouKnowWhatYouAreDoing() async {
final path = await pathForWallet(name: walletId, type: compatType);
final String password;
try {
password = (await secureStorageInterface.read(
key: lib_monero_compat.libMoneroWalletPasswordKey(walletId),
))!;
} catch (e, s) {
throw Exception("Password not found $e, $s");
}
loadWallet(path: path, password: password);
final wallet = libMoneroWallet!;
return (wallet.getAddress().value, wallet.getPrivateViewKey());
}
@override
Future<void> init({bool? isRestore}) async {
final path = await pathForWallet(
@ -1301,19 +1327,11 @@ abstract class LibMoneroWallet<T extends CryptonoteCurrency>
// ============== View only ==================================================
@override
bool get isViewOnly => info.isViewOnly;
@override
Future<void> recoverViewOnly() async {
await refreshMutex.protect(() async {
final jsonEncodedString = await secureStorageInterface.read(
key: Wallet.getViewOnlyWalletDataSecStoreKey(
walletId: walletId,
),
);
final data = ViewOnlyWalletData.fromJsonEncodedString(jsonEncodedString!);
final data =
await getViewOnlyWalletData() as CryptonoteViewOnlyWalletData;
try {
final height = max(info.restoreHeight, 0);
@ -1338,8 +1356,8 @@ abstract class LibMoneroWallet<T extends CryptonoteCurrency>
final wallet = await getRestoredFromViewKeyWallet(
path: path,
password: password,
address: data.address!,
privateViewKey: data.privateViewKey!,
address: data.address,
privateViewKey: data.privateViewKey,
height: height,
);
@ -1387,14 +1405,6 @@ abstract class LibMoneroWallet<T extends CryptonoteCurrency>
});
}
@override
Future<ViewOnlyWalletData> getViewOnlyWalletData() async {
return ViewOnlyWalletData(
address: libMoneroWallet!.getAddress().value,
privateViewKey: libMoneroWallet!.getPrivateViewKey(),
);
}
// ============== Private ====================================================
StreamSubscription<TorConnectionStatusChangedEvent>? _torStatusListener;

View file

@ -7,6 +7,7 @@ import 'package:mutex/mutex.dart';
import '../../db/isar/main_db.dart';
import '../../models/isar/models/blockchain_data/address.dart';
import '../../models/isar/models/ethereum/eth_contract.dart';
import '../../models/keys/view_only_wallet_data.dart';
import '../../models/node_model.dart';
import '../../models/paymint/fee_object_model.dart';
import '../../services/event_bus/events/global/node_connection_status_changed_event.dart';
@ -161,7 +162,7 @@ abstract class Wallet<T extends CryptoCurrency> {
prefs: prefs,
);
if (wallet is ViewOnlyOptionInterface) {
if (wallet is ViewOnlyOptionInterface && walletInfo.isViewOnly) {
await secureStorageInterface.write(
key: getViewOnlyWalletDataSecStoreKey(walletId: walletInfo.walletId),
value: viewOnlyData!.toJsonEncodedString(),
@ -583,6 +584,9 @@ abstract class Wallet<T extends CryptoCurrency> {
if (!tAlive) throw Exception("refresh alive ping failure");
}
final viewOnly = this is ViewOnlyOptionInterface &&
(this as ViewOnlyOptionInterface).isViewOnly;
try {
// this acquire should be almost instant due to above check.
// Slight possibility of race but should be irrelevant
@ -607,7 +611,7 @@ abstract class Wallet<T extends CryptoCurrency> {
// TODO: [prio=low] handle this differently. Extra modification of this file for coin specific functionality should be avoided.
final Set<String> codesToCheck = {};
_checkAlive();
if (this is PaynymInterface) {
if (this is PaynymInterface && !viewOnly) {
// isSegwit does not matter here at all
final myCode =
await (this as PaynymInterface).getPaymentCode(isSegwit: false);
@ -654,9 +658,11 @@ abstract class Wallet<T extends CryptoCurrency> {
// TODO: [prio=low] handle this differently. Extra modification of this file for coin specific functionality should be avoided.
if (this is MultiAddressInterface) {
if (info.otherData[WalletInfoKeys.reuseAddress] != true) {
await (this as MultiAddressInterface)
.checkChangeAddressForTransactions();
}
}
_checkAlive();
GlobalEventBus.instance.fire(RefreshPercentChangedEvent(0.3, walletId));
if (this is SparkInterface) {
@ -681,7 +687,7 @@ abstract class Wallet<T extends CryptoCurrency> {
await fetchFuture;
// TODO: [prio=low] handle this differently. Extra modification of this file for coin specific functionality should be avoided.
if (this is PaynymInterface && codesToCheck.isNotEmpty) {
if (!viewOnly && this is PaynymInterface && codesToCheck.isNotEmpty) {
_checkAlive();
await (this as PaynymInterface)
.checkForNotificationTransactionsTo(codesToCheck);

View file

@ -13,6 +13,7 @@ import '../../../models/isar/models/blockchain_data/v2/input_v2.dart';
import '../../../models/isar/models/blockchain_data/v2/output_v2.dart';
import '../../../models/isar/models/blockchain_data/v2/transaction_v2.dart';
import '../../../models/isar/models/isar_models.dart';
import '../../../models/keys/view_only_wallet_data.dart';
import '../../../models/paymint/fee_object_model.dart';
import '../../../models/signing_data.dart';
import '../../../utilities/amount/amount.dart';
@ -32,9 +33,10 @@ import '../intermediate/bip39_hd_wallet.dart';
import 'cpfp_interface.dart';
import 'paynym_interface.dart';
import 'rbf_interface.dart';
import 'view_only_option_interface.dart';
mixin ElectrumXInterface<T extends ElectrumXCurrencyInterface>
on Bip39HDWallet<T> {
on Bip39HDWallet<T> implements ViewOnlyOptionInterface<T> {
late ElectrumXClient electrumXClient;
late CachedElectrumXClient electrumXCachedClient;
@ -137,7 +139,9 @@ mixin ElectrumXInterface<T extends ElectrumXCurrencyInterface>
(e.used != true) &&
(canCPFP ||
e.isConfirmed(
currentChainHeight, cryptoCurrency.minConfirms)),
currentChainHeight,
cryptoCurrency.minConfirms,
)),
)
.toList();
final spendableSatoshiValue =
@ -944,7 +948,7 @@ mixin ElectrumXInterface<T extends ElectrumXCurrencyInterface>
Future<({List<Address> addresses, int index})> checkGapsBatched(
int txCountBatchSize,
coinlib.HDPrivateKey root,
coinlib.HDKey node,
DerivePathType type,
int chain,
) async {
@ -969,7 +973,14 @@ mixin ElectrumXInterface<T extends ElectrumXCurrencyInterface>
index: index + j,
);
final keys = root.derivePath(derivePath);
final coinlib.HDKey keys;
if (isViewOnly) {
final idx = derivePath.lastIndexOf("'/");
final path = derivePath.substring(idx + 2);
keys = node.derivePath(path);
} else {
keys = node.derivePath(derivePath);
}
final addressData = cryptoCurrency.getAddressForPublicKey(
publicKey: keys.publicKey,
@ -986,7 +997,8 @@ mixin ElectrumXInterface<T extends ElectrumXCurrencyInterface>
publicKey: keys.publicKey.data,
type: addressData.addressType,
derivationIndex: index + j,
derivationPath: DerivationPath()..value = derivePath,
derivationPath:
isViewOnly ? null : (DerivationPath()..value = derivePath),
subType:
chain == 0 ? AddressSubType.receiving : AddressSubType.change,
);
@ -1025,13 +1037,14 @@ mixin ElectrumXInterface<T extends ElectrumXCurrencyInterface>
}
Future<({List<Address> addresses, int index})> checkGapsLinearly(
coinlib.HDPrivateKey root,
coinlib.HDKey node,
DerivePathType type,
int chain,
) async {
final List<Address> addressArray = [];
int gapCounter = 0;
int index = 0;
for (; gapCounter < cryptoCurrency.maxUnusedAddressGap; index++) {
Logging.instance.log(
"index: $index, \t GapCounter chain=$chain ${type.name}: $gapCounter",
@ -1043,7 +1056,16 @@ mixin ElectrumXInterface<T extends ElectrumXCurrencyInterface>
chain: chain,
index: index,
);
final keys = root.derivePath(derivePath);
final coinlib.HDKey keys;
if (isViewOnly) {
final idx = derivePath.lastIndexOf("'/");
final path = derivePath.substring(idx + 2);
keys = node.derivePath(path);
} else {
keys = node.derivePath(derivePath);
}
final addressData = cryptoCurrency.getAddressForPublicKey(
publicKey: keys.publicKey,
derivePathType: type,
@ -1059,7 +1081,8 @@ mixin ElectrumXInterface<T extends ElectrumXCurrencyInterface>
publicKey: keys.publicKey.data,
type: addressData.addressType,
derivationIndex: index,
derivationPath: DerivationPath()..value = derivePath,
derivationPath:
isViewOnly ? null : (DerivationPath()..value = derivePath),
subType: chain == 0 ? AddressSubType.receiving : AddressSubType.change,
);
@ -1331,6 +1354,10 @@ mixin ElectrumXInterface<T extends ElectrumXCurrencyInterface>
@override
Future<void> checkReceivingAddressForTransactions() async {
if (isViewOnly && viewOnlyType == ViewOnlyWalletType.addressOnly) {
return;
}
if (info.otherData[WalletInfoKeys.reuseAddress] == true) {
try {
throw Exception();
@ -1382,6 +1409,21 @@ mixin ElectrumXInterface<T extends ElectrumXCurrencyInterface>
@override
Future<void> checkChangeAddressForTransactions() async {
if (isViewOnly && viewOnlyType == ViewOnlyWalletType.addressOnly) {
return;
}
if (info.otherData[WalletInfoKeys.reuseAddress] == true) {
try {
throw Exception();
} catch (_, s) {
Logging.instance.log(
"checkChangeAddressForTransactions called but reuse address flag set: $s",
level: LogLevel.Error,
);
}
}
try {
final currentChange = await getCurrentChangeAddress();
@ -1419,6 +1461,11 @@ mixin ElectrumXInterface<T extends ElectrumXCurrencyInterface>
@override
Future<void> recover({required bool isRescan}) async {
if (isViewOnly) {
await recoverViewOnly(isRescan: isRescan);
return;
}
final root = await getRootHDNode();
final List<Future<({int index, List<Address> addresses})>> receiveFutures =
@ -1918,5 +1965,219 @@ mixin ElectrumXInterface<T extends ElectrumXCurrencyInterface>
return address;
}
// ============== View only ==================================================
@override
Future<void> recoverViewOnly({bool isRescan = false}) async {
final data = await getViewOnlyWalletData();
final coinlib.HDKey? root;
if (data is AddressViewOnlyWalletData) {
root = null;
} else {
if ((data as ExtendedKeysViewOnlyWalletData).xPubs.length != 1) {
throw Exception(
"Only single xpub view only wallets are currently supported",
);
}
root = coinlib.HDPublicKey.decode(data.xPubs.first.encoded);
}
final List<Future<({int index, List<Address> addresses})>> receiveFutures =
[];
final List<Future<({int index, List<Address> addresses})>> changeFutures =
[];
const receiveChain = 0;
const changeChain = 1;
const txCountBatchSize = 12;
try {
await refreshMutex.protect(() async {
if (isRescan) {
// clear cache
await electrumXCachedClient.clearSharedTransactionCache(
cryptoCurrency: info.coin,
);
// clear blockchain info
await mainDB.deleteWalletBlockchainData(walletId);
}
final List<Address> addressesToStore = [];
if (root != null) {
// receiving addresses
Logging.instance.log(
"checking receiving addresses...",
level: LogLevel.Info,
);
final canBatch = await serverCanBatch;
for (final type in cryptoCurrency.supportedDerivationPathTypes) {
final path = cryptoCurrency.constructDerivePath(
derivePathType: type,
chain: 0,
index: 0,
);
if (path.startsWith(
(data as ExtendedKeysViewOnlyWalletData).xPubs.first.path,
)) {
receiveFutures.add(
canBatch
? checkGapsBatched(
txCountBatchSize,
root,
type,
receiveChain,
)
: checkGapsLinearly(
root,
type,
receiveChain,
),
);
}
}
// change addresses
Logging.instance.log(
"checking change addresses...",
level: LogLevel.Info,
);
for (final type in cryptoCurrency.supportedDerivationPathTypes) {
final path = cryptoCurrency.constructDerivePath(
derivePathType: type,
chain: 0,
index: 0,
);
if (path.startsWith(
(data as ExtendedKeysViewOnlyWalletData).xPubs.first.path,
)) {
changeFutures.add(
canBatch
? checkGapsBatched(
txCountBatchSize,
root,
type,
changeChain,
)
: checkGapsLinearly(
root,
type,
changeChain,
),
);
}
}
// io limitations may require running these linearly instead
final futuresResult = await Future.wait([
Future.wait(receiveFutures),
Future.wait(changeFutures),
]);
final receiveResults = futuresResult[0];
final changeResults = futuresResult[1];
int highestReceivingIndexWithHistory = 0;
for (final tuple in receiveResults) {
if (tuple.addresses.isEmpty) {
await checkReceivingAddressForTransactions();
} else {
highestReceivingIndexWithHistory = max(
tuple.index,
highestReceivingIndexWithHistory,
);
addressesToStore.addAll(tuple.addresses);
}
}
int highestChangeIndexWithHistory = 0;
// If restoring a wallet that never sent any funds with change, then set changeArray
// manually. If we didn't do this, it'd store an empty array.
for (final tuple in changeResults) {
if (tuple.addresses.isEmpty) {
await checkChangeAddressForTransactions();
} else {
highestChangeIndexWithHistory = max(
tuple.index,
highestChangeIndexWithHistory,
);
addressesToStore.addAll(tuple.addresses);
}
}
// remove extra addresses to help minimize risk of creating a large gap
addressesToStore.removeWhere(
(e) =>
e.subType == AddressSubType.change &&
e.derivationIndex > highestChangeIndexWithHistory,
);
addressesToStore.removeWhere(
(e) =>
e.subType == AddressSubType.receiving &&
e.derivationIndex > highestReceivingIndexWithHistory,
);
} else {
final clAddress = coinlib.Address.fromString(
(data as AddressViewOnlyWalletData).address,
cryptoCurrency.networkParams,
);
final AddressType addressType;
switch (clAddress.runtimeType) {
case const (coinlib.P2PKHAddress):
addressType = AddressType.p2pkh;
break;
case const (coinlib.P2SHAddress):
addressType = AddressType.p2sh;
break;
case const (coinlib.P2WPKHAddress):
addressType = AddressType.p2wpkh;
break;
case const (coinlib.P2TRAddress):
addressType = AddressType.p2tr;
break;
default:
throw Exception(
"Unsupported address type: ${clAddress.runtimeType}",
);
}
addressesToStore.add(
Address(
walletId: walletId,
value: clAddress.toString(),
publicKey: [],
derivationIndex: -1,
derivationPath: null,
type: addressType,
subType: AddressSubType.receiving,
),
);
}
await mainDB.updateOrPutAddresses(addressesToStore);
});
unawaited(refresh());
} catch (e, s) {
Logging.instance.log(
"Exception rethrown from electrumx_mixin recoverViewOnly(): $e\n$s",
level: LogLevel.Info,
);
rethrow;
}
}
// ===========================================================================
}

View file

@ -2,35 +2,35 @@ import '../../../models/keys/xpriv_data.dart';
import '../../crypto_currency/interfaces/electrumx_currency_interface.dart';
import 'electrumx_interface.dart';
typedef XPub = ({String path, String xpub});
typedef XPriv = ({String path, String xpriv});
abstract class XKey {
XKey({required this.path});
final String path;
}
class XPub extends XKey {
XPub({required super.path, required this.encoded});
final String encoded;
}
class XPriv extends XKey {
XPriv({required super.path, required this.encoded});
final String encoded;
}
mixin ExtendedKeysInterface<T extends ElectrumXCurrencyInterface>
on ElectrumXInterface<T> {
Future<({List<XPub> xpubs, String fingerprint})> getXPubs() async {
final paths = cryptoCurrency.supportedDerivationPathTypes.map(
(e) => (
path: e,
addressType: e.getAddressType(),
),
);
final paths = cryptoCurrency.supportedHardenedDerivationPaths;
final master = await getRootHDNode();
final fingerprint = master.fingerprint.toRadixString(16);
final futures = paths.map((e) async {
String path = cryptoCurrency.constructDerivePath(
derivePathType: e.path,
chain: 0,
index: 0,
);
// trim chain and address index
path = path.substring(0, path.lastIndexOf("'") + 1);
final futures = paths.map((path) async {
final node = master.derivePath(path);
return (
return XPub(
path: path,
xpub: node.hdPublicKey.encode(
encoded: node.hdPublicKey.encode(
cryptoCurrency.networkParams.pubHDPrefix,
// 0x04b24746,
),
@ -44,29 +44,17 @@ mixin ExtendedKeysInterface<T extends ElectrumXCurrencyInterface>
}
Future<XPrivData> getXPrivs() async {
final paths = cryptoCurrency.supportedDerivationPathTypes.map(
(e) => (
path: e,
addressType: e.getAddressType(),
),
);
final paths = cryptoCurrency.supportedHardenedDerivationPaths;
final master = await getRootHDNode();
final fingerprint = master.fingerprint.toRadixString(16);
final futures = paths.map((e) async {
String path = cryptoCurrency.constructDerivePath(
derivePathType: e.path,
chain: 0,
index: 0,
);
// trim chain and address index
path = path.substring(0, path.lastIndexOf("'") + 1);
final futures = paths.map((path) async {
final node = master.derivePath(path);
return (
return XPriv(
path: path,
xpriv: node.encode(
encoded: node.encode(
cryptoCurrency.networkParams.privHDPrefix,
),
);
@ -76,9 +64,9 @@ mixin ExtendedKeysInterface<T extends ElectrumXCurrencyInterface>
walletId: walletId,
fingerprint: fingerprint,
xprivs: [
(
XPriv(
path: "Master",
xpriv: master.encode(
encoded: master.encode(
cryptoCurrency.networkParams.privHDPrefix,
),
),

View file

@ -1,34 +1,27 @@
import 'dart:convert';
import '../../../models/keys/view_only_wallet_data.dart';
import '../../crypto_currency/interfaces/view_only_option_currency_interface.dart';
import '../wallet.dart';
class ViewOnlyWalletData {
final String? address;
final String? privateViewKey;
ViewOnlyWalletData({required this.address, required this.privateViewKey});
factory ViewOnlyWalletData.fromJsonEncodedString(String jsonEncodedString) {
final map = jsonDecode(jsonEncodedString) as Map;
final json = Map<String, dynamic>.from(map);
return ViewOnlyWalletData(
address: json["address"] as String?,
privateViewKey: json["privateViewKey"] as String?,
);
}
String toJsonEncodedString() => jsonEncode({
"address": address,
"privateViewKey": privateViewKey,
});
}
mixin ViewOnlyOptionInterface<T extends ViewOnlyOptionCurrencyInterface>
on Wallet<T> {
bool get isViewOnly;
ViewOnlyWalletType get viewOnlyType => info.viewOnlyWalletType!;
bool get isViewOnly => info.isViewOnly;
Future<void> recoverViewOnly();
Future<ViewOnlyWalletData> getViewOnlyWalletData();
Future<ViewOnlyWalletData> getViewOnlyWalletData() async {
if (!isViewOnly) {
throw Exception("This is not a view only wallet");
}
final encoded = await secureStorageInterface.read(
key: Wallet.getViewOnlyWalletDataSecStoreKey(walletId: walletId),
);
return ViewOnlyWalletData.fromJsonEncodedString(
encoded!,
walletId: walletId,
);
}
}

View file

@ -94,7 +94,7 @@ class StackDialog extends StatelessWidget {
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Flexible(
child: Text(
child: SelectableText(
title,
style: STextStyles.pageTitleH2(context),
),
@ -110,7 +110,7 @@ class StackDialog extends StatelessWidget {
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
SelectableText(
message!,
style: STextStyles.smallMed14(context),
),