CW948: feat: bitcoin view-only (#2261)

* feat: bitcoin view-only

* fix: add UR to cw_bitcoin, fix payjoin issue

* feat: add unsignedPsbt to PendingBitcoinTransaction

* wip

* feat: xpub restore from bitcoin URI QR

* feat(dev): debug UR codes [skip ci]

* fix: master fingerprint default in create bitcoin transaction

* fix: handle empty private key in bitcoin transaction building

* fix: show success dialog after btc ur transaction

* fix(ui): remove SP from UI in view only wallet

* fix(cw_bitcoin): tx sending on non-airgap

* fix(cw_bitcoin): disable all non-segwit addresses

* feat(ur): support BBQR for coldcard qr

* flutter 3.27.0 -> 3.27.4

* feat: raw xpub qr support for coldcard q

* fix: update ledger_bitcoin
fix: update switching button

* fix: restore button not showing up after scanning

* feat: commit raw tx (from coldcard Q)

* feat: legacy crypto-psbt support for seed signer

* chore: address comments from review

* fix(cw_bitcoin): disable sp and payjoin for cupcake

* feat: enable DNSSEC BIP353 in PSBT (#2397)

* feat: enable DNSSEC BIP353 in PSBT

* chore: put bitcoin view only behind a featureflag

* fix: update Monero wallet to work with new internal URQR return format

* feat: add "Restore from Cupcake App" option with localized strings

* feat: enable DNSSEC BIP353 in PSBT using dnssec-prover (#2401)

* fix: use dnssec-prover
chore: bump ndk to r28c

* fix: validate DNSSEC proof before updating the UI

* fix: rename button to copy debug info to not hide actions on URQR widget

---------

Co-authored-by: Konstantin Ullrich <konstantinullrich12@gmail.com>
This commit is contained in:
cyan
2025-07-29 17:01:33 +03:00
committed by GitHub
co-authored by Konstantin Ullrich
parent 10499814bb
commit e4806ad141
98 changed files with 796 additions and 247 deletions
+25 -7
View File
@@ -18,6 +18,14 @@ class CWBitcoin extends Bitcoin {
passphrase: passphrase,
);
@override
WalletCredentials createBitcoinWalletFromKeys({
required String name,
required String password,
required String xpub,
}) =>
BitcoinWalletFromKeysCredentials(name: name, password: password, xpub: xpub);
@override
WalletCredentials createBitcoinRestoreWalletFromWIFCredentials(
{required String name,
@@ -62,11 +70,7 @@ class CWBitcoin extends Bitcoin {
final bitcoinWallet = wallet as ElectrumWallet;
final keys = bitcoinWallet.keys;
return <String, String>{
'wif': keys.wif,
'privateKey': keys.privateKey,
'publicKey': keys.publicKey
};
return bitcoinWallet.keys.toJson();
}
@override
@@ -126,7 +130,8 @@ class CWBitcoin extends Bitcoin {
extractedAddress: out.extractedAddress,
isParsedAddress: out.isParsedAddress,
formattedCryptoAmount: out.formattedCryptoAmount,
memo: out.memo))
memo: out.memo,
extra: out.extra))
.toList(),
priority: priority as BitcoinTransactionPriority,
feeRate: bitcoinFeeRate,
@@ -275,7 +280,14 @@ class CWBitcoin extends Bitcoin {
}
@override
List<ReceivePageOption> getBitcoinReceivePageOptions() => BitcoinReceivePageOption.all;
List<ReceivePageOption> getBitcoinReceivePageOptions(Object wallet) {
final bitcoinWallet = wallet as ElectrumWallet;
final keys = bitcoinWallet.keys;
if (keys.privateKey.isEmpty) {
return BitcoinReceivePageOption.allViewOnly;
}
return BitcoinReceivePageOption.all;
}
@override
List<ReceivePageOption> getLitecoinReceivePageOptions() {
@@ -727,6 +739,12 @@ class CWBitcoin extends Bitcoin {
}
}
@override
Future<void> commitPsbtUR(Object wallet, List<String> urCodes) {
final _wallet = wallet as BitcoinWalletBase;
return _wallet.commitPsbtUR(urCodes);
}
@override
String getPayjoinEndpoint(Object wallet) {
final _wallet = wallet as ElectrumWallet;
+1 -1
View File
@@ -978,7 +978,7 @@ Future<void> setup({
getIt.registerFactory(() => AnimatedURModel(getIt.get<AppStore>()));
getIt.registerFactoryParam<AnimatedURPage, String, void>((String urQr, _) =>
getIt.registerFactoryParam<AnimatedURPage, Map<String, String>, void>((Map<String, String> urQr, _) =>
AnimatedURPage(getIt.get<AnimatedURModel>(), urQr: urQr));
getIt.registerFactoryParam<ContactViewModel, ContactRecord?, void>(
+25 -1
View File
@@ -1,3 +1,6 @@
import 'dart:convert';
import 'dart:isolate';
import 'package:basic_utils/basic_utils.dart';
import 'package:cake_wallet/generated/i18n.dart';
import 'package:cake_wallet/src/widgets/alert_with_picker_option.dart';
@@ -5,6 +8,7 @@ import 'package:cake_wallet/src/widgets/picker.dart';
import 'package:cake_wallet/utils/show_pop_up.dart';
import 'package:cw_core/crypto_currency.dart';
import 'package:cw_core/utils/print_verbose.dart';
import 'package:dnssec_proof/dnssec_proof.dart';
import 'package:flutter/material.dart';
class Bip353Record {
@@ -22,6 +26,19 @@ class Bip353Record {
'address': 'On-Chain Address',
};
static Future<String?> fetchDnsProof(String bip353Name) async {
if (bip353Name.startsWith('₿')) {
bip353Name = bip353Name.substring(1);
}
final parts = bip353Name.split('@');
if (parts.length != 2) return null;
final userPart = parts[0];
final domainPart = parts[1];
final bip353Domain = '$userPart.user._bitcoin-payment.$domainPart.';
final proof = await Isolate.run(() => DnsProver.getTxtProof(bip353Domain));
return base64.encode(proof);
}
static Future<Map<String, String>?> fetchUriByCryptoCurrency(
String bip353Name, String asset) async {
try {
@@ -30,7 +47,10 @@ class Bip353Record {
if (parts.length != 2) return null;
final userPart = parts[0];
String userPart = parts[0];
if (userPart.startsWith('₿')) {
userPart = userPart.substring(1);
}
final domainPart = parts[1];
// 2. Construct the correct subdomain: "user._bitcoin-payment.domain"
@@ -42,6 +62,10 @@ class Bip353Record {
RRecordType.TXT,
dnssec: true,
);
final proof = await fetchDnsProof(bip353Name);
if (proof == null) {
throw Exception('DNSSEC proof not found');
}
if (txtRecords == null) return null;
+7 -1
View File
@@ -344,7 +344,13 @@ class AddressResolver {
if (bip353AddressMap != null && bip353AddressMap.isNotEmpty) {
final chosenAddress = await Bip353Record.pickBip353AddressChoice(context, text, bip353AddressMap);
if (chosenAddress != null) {
return ParsedAddress.fetchBip353AddressAddress(address: chosenAddress, name: text);
try {
final dnsProof = await Bip353Record.fetchDnsProof(text);
return ParsedAddress.fetchBip353AddressAddress(address: chosenAddress, name: text, dnsProof: dnsProof);
} catch (e) {
printV('Bip353Record.fetchBip353AddressAddress error: $e');
return ParsedAddress.fetchBip353AddressAddress(address: chosenAddress, name: text);
}
}
}
+4
View File
@@ -26,6 +26,7 @@ class ParsedAddress {
this.profileImageUrl = '',
this.profileName = '',
this.parseFrom = ParseFrom.notParsed,
this.bip353DnsProof,
});
factory ParsedAddress.fetchEmojiAddress({
@@ -59,11 +60,13 @@ class ParsedAddress {
factory ParsedAddress.fetchBip353AddressAddress ({
required String address,
required String name,
String? dnsProof,
}) {
return ParsedAddress(
addresses: [address],
name: name,
parseFrom: ParseFrom.bip353,
bip353DnsProof: dnsProof,
);
}
@@ -178,4 +181,5 @@ class ParsedAddress {
final String profileImageUrl;
final String profileName;
final ParseFrom parseFrom;
final String? bip353DnsProof;
}
+2 -2
View File
@@ -399,9 +399,9 @@ class CWMonero extends Monero {
}
@override
String exportOutputsUR(Object wallet, bool all) {
Map<String, String> exportOutputsUR(Object wallet) {
final moneroWallet = wallet as MoneroWallet;
return moneroWallet.exportOutputsUR(all);
return moneroWallet.exportOutputsUR();
}
@override
@@ -115,7 +115,8 @@ class QRWidget extends StatelessWidget {
),
),
if (addressListViewModel.isPayjoinUnavailable &&
!addressListViewModel.isSilentPayments) ...[
!addressListViewModel.isSilentPayments &&
!addressListViewModel.isCupcake) ...[
GestureDetector(
onTap: () => _onPayjoinInactivePressed(context),
child: Row(
@@ -6,6 +6,7 @@ import 'package:cake_wallet/src/screens/base_page.dart';
import 'package:cake_wallet/src/widgets/alert_with_one_action.dart';
import 'package:cake_wallet/src/widgets/option_tile.dart';
import 'package:cake_wallet/utils/device_info.dart';
import 'package:cake_wallet/utils/feature_flag.dart';
import 'package:cake_wallet/utils/permission_handler.dart';
import 'package:cake_wallet/utils/responsive_layout_util.dart';
import 'package:cake_wallet/utils/show_pop_up.dart';
@@ -53,13 +54,40 @@ class _RestoreOptionsBodyState extends State<_RestoreOptionsBody> {
return true;
}
String get imageRestoreHWPath =>
widget.themeType == ThemeType.dark
? 'assets/images/restore_hw_dark.png'
: 'assets/images/restore_hw.png';
String get imageRestoreCupcakePath =>
widget.themeType == ThemeType.dark
? 'assets/images/restore_cupcake_dark.png'
: 'assets/images/restore_cupcake.png';
String get imageRestoreQRPath =>
widget.themeType == ThemeType.dark
? 'assets/images/restore_qr_dark.png'
: 'assets/images/restore_qr.png';
String get imageRestoreHotWalletPath =>
widget.themeType == ThemeType.dark
? 'assets/images/restore_hot_wallet_dark.png'
: 'assets/images/restore_hot_wallet.png';
String get imageRestoreBackupPath =>
widget.themeType == ThemeType.dark
? 'assets/images/restore_backup_dark.png'
: 'assets/images/restore_backup.png';
@override
Widget build(BuildContext context) {
final imageColor = Theme.of(context).colorScheme.primary;
final imageLedger = Image.asset('assets/images/hardware_wallet/ledger_nano_x.png',
width: 40, color: imageColor);
final imageSeedKeys = Image.asset('assets/images/restore_wallet_image.png', color: imageColor);
final imageBackup = Image.asset('assets/images/backup.png', color: imageColor);
final imageRestoreHW = Image.asset(imageRestoreHWPath, width: 55);
final imageRestoreCupcake = Image.asset(imageRestoreCupcakePath, width: 55);
final imageRestoreQR = Image.asset(imageRestoreQRPath, width: 55);
final imageSeedKeys = Image.asset(imageRestoreHotWalletPath, width: 55);
final imageRestoreBackup = Image.asset(imageRestoreBackupPath, width: 55);
return Center(
child: Container(
@@ -76,15 +104,16 @@ class _RestoreOptionsBodyState extends State<_RestoreOptionsBody> {
title: S.of(context).restore_title_from_seed_keys,
description: S.of(context).restore_description_from_seed_keys,
),
if (widget.isNewInstall)
if (FeatureFlag.hasBitcoinViewOnly && DeviceInfo.instance.isMobile)
Padding(
padding: EdgeInsets.only(top: 24),
child: OptionTile(
key: ValueKey('restore_options_from_backup_button_key'),
onPressed: () => Navigator.pushNamed(context, Routes.restoreFromBackup),
image: imageBackup,
title: S.of(context).restore_title_from_backup,
description: S.of(context).restore_description_from_backup,
key: ValueKey('restore_options_from_cupcake_button_key'),
onPressed: () => _onScanQRCode(context),
image: imageRestoreCupcake,
title: S.of(context).restore_title_from_cupcake,
description: S.of(context).restore_description_from_cupcake,
tag: S.of(context).new_tag,
),
),
if (_doesSupportHardwareWallets)
@@ -94,25 +123,33 @@ class _RestoreOptionsBodyState extends State<_RestoreOptionsBody> {
key: ValueKey('restore_options_from_hardware_wallet_button_key'),
onPressed: () =>
Navigator.pushNamed(context, Routes.restoreWalletFromHardwareWallet),
image: imageLedger,
image: imageRestoreHW,
title: S.of(context).restore_title_from_hardware_wallet,
description: S.of(context).restore_description_from_hardware_wallet,
),
),
if (widget.isNewInstall)
Padding(
padding: EdgeInsets.only(top: 24),
child: OptionTile(
key: ValueKey('restore_options_from_backup_button_key'),
onPressed: () => Navigator.pushNamed(context, Routes.restoreFromBackup),
image: imageRestoreBackup,
title: S.of(context).restore_title_from_backup,
description: S.of(context).restore_description_from_backup,
),
),
if (DeviceInfo.instance.isMobile)
Padding(
padding: EdgeInsets.only(top: 24),
child: OptionTile(
key: ValueKey('restore_options_from_qr_button_key'),
onPressed: () => _onScanQRCode(context),
icon: Icon(
Icons.qr_code_rounded,
color: imageColor,
size: 50,
),
image: imageRestoreQR,
title: S.of(context).scan_qr_code,
description: S.of(context).cold_or_recover_wallet),
)
description: S.of(context).cold_or_recover_wallet,
),
),
],
),
)),
@@ -198,14 +198,14 @@ class WalletRestoreFromKeysFormState extends State<WalletRestoreFromKeysForm> {
Widget _restoreFromKeysFormFields() {
// Decred can only restore a view only wallet with an account pubkey. Other
// fields are not used.
if (widget.walletRestoreViewModel.type == WalletType.decred) {
if (widget.walletRestoreViewModel.onlyViewKeyRestore) {
return Column(
children: [
BaseTextFormField(
controller: viewKeyController,
hintText: S.of(context).view_key_public,
maxLines: null,
)
),
],
);
}
@@ -253,13 +253,14 @@ class WalletRestoreFromKeysFormState extends State<WalletRestoreFromKeysForm> {
maxLines: null,
),
),
BlockchainHeightWidget(
key: blockchainHeightKey,
hasDatePicker: widget.walletRestoreViewModel.type != WalletType.haven,
onHeightChange: (_) => null,
onHeightOrDateEntered: widget.onHeightOrDateEntered,
walletType: widget.walletRestoreViewModel.type,
),
if (widget.walletRestoreViewModel.hasBlockchainHeightSelector)
BlockchainHeightWidget(
key: blockchainHeightKey,
hasDatePicker: widget.walletRestoreViewModel.type != WalletType.haven,
onHeightChange: (_) => null,
onHeightOrDateEntered: widget.onHeightOrDateEntered,
walletType: widget.walletRestoreViewModel.type,
),
],
);
}
@@ -199,7 +199,7 @@ class WalletRestorePage extends BasePage {
credentials['seed'] =
walletRestoreFromSeedFormKey.currentState!.seedWidgetStateKey.currentState!.text;
if (walletRestoreViewModel.hasBlockchainHeightLanguageSelector) {
if (walletRestoreViewModel.hasBlockchainHeightSelector) {
credentials['height'] =
walletRestoreFromSeedFormKey.currentState!.blockchainHeightKey.currentState?.height ??
-1;
@@ -219,7 +219,7 @@ class WalletRestorePage extends BasePage {
credentials['name'] =
walletRestoreFromKeysFormKey.currentState!.nameTextEditingController.text;
credentials['viewKey'] = walletRestoreFromKeysFormKey.currentState!.viewKeyController.text;
if (walletRestoreViewModel.type != WalletType.decred) {
if (!walletRestoreViewModel.onlyViewKeyRestore) {
credentials['address'] =
walletRestoreFromKeysFormKey.currentState!.addressController.text;
credentials['spendKey'] =
@@ -519,7 +519,7 @@ class _WalletRestorePageBodyState extends State<_WalletRestorePageBody>
}
},
onViewKeyEntered: (bool entered) {
if (walletRestoreViewModel.type == WalletType.decred) {
if (widget.walletRestoreViewModel.onlyViewKeyRestore) {
walletRestoreViewModel.isButtonEnabled = entered;
}
},
@@ -536,7 +536,7 @@ class _WalletRestorePageBodyState extends State<_WalletRestorePageBody>
key: widget.walletRestoreFromSeedFormKey,
restoredWallet: walletRestoreViewModel.restoredWallet,
seedSettingsViewModel: widget.seedSettingsViewModel,
displayBlockHeightSelector: widget.walletRestoreViewModel.hasBlockchainHeightLanguageSelector,
displayBlockHeightSelector: widget.walletRestoreViewModel.hasBlockchainHeightSelector,
displayLanguageSelector: widget.walletRestoreViewModel.hasSeedLanguageSelector,
type: widget.walletRestoreViewModel.type,
blockHeightFocusNode: widget.blockHeightFocusNode,
@@ -563,7 +563,7 @@ class _WalletRestorePageBodyState extends State<_WalletRestorePageBody>
}
void _validateOnChange({bool isPolyseed = false}) {
if (!isPolyseed && walletRestoreViewModel.hasBlockchainHeightLanguageSelector) {
if (!isPolyseed && walletRestoreViewModel.hasBlockchainHeightSelector) {
final hasHeight = walletRestoreFromSeedFormKey
.currentState?.blockchainHeightKey.currentState?.restoreHeightController.text.isNotEmpty;
+1 -1
View File
@@ -450,7 +450,7 @@ class SendPage extends BasePage {
}
if (monero!.needExportOutputs(sendViewModel.wallet, amount)) {
await Navigator.of(context).pushNamed(Routes.urqrAnimatedPage,
arguments: 'export-outputs');
arguments: monero!.exportOutputsUR(sendViewModel.wallet));
await Future.delayed(
Duration(seconds: 1)); // wait for monero to refresh the state
}
+85 -37
View File
@@ -1,5 +1,7 @@
import 'dart:async';
import 'package:bbqrdart/bbqrdart.dart';
import 'package:cake_wallet/bitcoin/bitcoin.dart';
import 'package:cake_wallet/entities/qr_scanner.dart';
import 'package:cake_wallet/generated/i18n.dart';
import 'package:cake_wallet/monero/monero.dart';
@@ -7,31 +9,49 @@ import 'package:cake_wallet/src/screens/base_page.dart';
import 'package:cake_wallet/src/screens/receive/widgets/qr_image.dart';
import 'package:cake_wallet/src/widgets/alert_with_one_action.dart';
import 'package:cake_wallet/src/widgets/primary_button.dart';
import 'package:cake_wallet/utils/feature_flag.dart';
import 'package:cake_wallet/utils/show_pop_up.dart';
import 'package:cake_wallet/view_model/animated_ur_model.dart';
import 'package:cw_core/wallet_type.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
// ur:xmr-txunsigned - unsigned transaction
// should show a scanner afterwards.
class AnimatedURPage extends BasePage {
final bool isAll;
AnimatedURPage(this.animatedURmodel, {required String urQr, this.isAll = false}) {
if (urQr == "export-outputs") {
this.urQr = monero!.exportOutputsUR(animatedURmodel.wallet, false);
} else if (urQr == "export-outputs-all") {
this.urQr = monero!.exportOutputsUR(animatedURmodel.wallet, true);
} else {
this.urQr = urQr;
AnimatedURPage(this.animatedURmodel, {
required Map<String, String> this.urQr,
this.isAll = false,
}) {
if (urQr.keys.first.startsWith("export-outputs") && animatedURmodel.wallet.type == WalletType.monero) {
if (animatedURmodel.wallet.type == WalletType.monero) {
urQr = monero!.exportOutputsUR(animatedURmodel.wallet);
} else {
throw UnimplementedError("unable to handle UR: ${urQr.keys.first}");
}
}
for (var key in urQr.keys) {
if (key.isEmpty || urQr[key]!.isEmpty) {
urQr.remove(key);
continue;
}
urQr[key] = urQr[key]!.trim();
}
}
late String urQr;
Map<String, String> urQr = {};
final AnimatedURModel animatedURmodel;
String get urQrType {
final first = urQr.trim().split("\n")[0];
if (urQr.values.first.trim().substring(0, 2) == BBQR.header) {
return BBQR.header;
}
if (urQr.isEmpty) return "unknown";
final first = urQr.values.first.trim().split("\n")[0];
return first.split('/')[0];
}
@@ -43,11 +63,11 @@ class AnimatedURPage extends BasePage {
Padding(
padding: const EdgeInsets.only(top: 64.0),
child: URQR(
frames: urQr.trim().split("\n"),
urqr: urQr,
),
),
if (["ur:xmr-txunsigned", "ur:xmr-output", "ur:psbt", BBQR.header].contains(urQrType)) ...{
SizedBox(height: 32),
if (urQrType == "ur:xmr-txunsigned" || urQrType == "ur:xmr-output")
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: SizedBox(
@@ -60,30 +80,8 @@ class AnimatedURPage extends BasePage {
),
),
),
SizedBox(height: 32),
if (urQrType == "ur:xmr-output" && !isAll) Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: SizedBox(
width: double.maxFinite,
child: PrimaryButton(
onPressed: () => _exportAll(context),
text: "Export all",
color: Theme.of(context).colorScheme.secondary,
textColor: Theme.of(context).colorScheme.onPrimary,
),
),
),
],
);
}
void _exportAll(BuildContext context) {
Navigator.of(context).pushReplacement(
MaterialPageRoute(
builder: (context) {
return AnimatedURPage(animatedURmodel, urQr: "export-outputs-all", isAll: true);
},
),
],
);
}
@@ -106,6 +104,11 @@ class AnimatedURPage extends BasePage {
Navigator.of(context).pop(true);
}
break;
case "ur:psbt": // psbt
final ur = await presentQRScanner(context);
if (ur == null) return;
await bitcoin!.commitPsbtUR(animatedURmodel.wallet, ur.trim().split("\n"));
Navigator.of(context).pop(true);
default:
throw UnimplementedError("unable to handle UR: ${urQrType}");
}
@@ -124,9 +127,9 @@ class AnimatedURPage extends BasePage {
}
class URQR extends StatefulWidget {
URQR({super.key, required this.frames});
URQR({super.key, required this.urqr});
List<String> frames;
final Map<String, String> urqr;
@override
// ignore: library_private_types_in_public_api
@@ -160,6 +163,24 @@ class _URQRState extends State<URQR> {
super.dispose();
}
late String selected = (widget.urqr.isEmpty) ? "unknown" : widget.urqr.keys.first;
int selectedInt = 0;
List<String> get frames {
return widget.urqr[selected]?.split("\n") ?? [];
}
late String nextLabel = widget.urqr.keys.toList()[(selectedInt + 1) % widget.urqr.length] ;
void next() {
final keys = widget.urqr.keys.toList();
setState(() {
selectedInt++;
nextLabel = keys[(selectedInt + 1) % keys.length];
selected = keys[(selectedInt) % keys.length];
});
}
@override
Widget build(BuildContext context) {
return Column(
@@ -168,10 +189,37 @@ class _URQRState extends State<URQR> {
children: [
Center(
child: QrImage(
data: widget.frames[frame % widget.frames.length], version: -1,
data: frames[frame % frames.length],
version: -1,
size: 400,
),
),
if (widget.urqr.values.length > 1)
SizedBox(
width: double.maxFinite,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: SizedBox(
width: double.maxFinite,
child: PrimaryButton(
onPressed: next,
text: nextLabel,
color: Theme.of(context).colorScheme.primary,
textColor: Theme.of(context).colorScheme.onPrimary,
),
),
),
),
if (FeatureFlag.hasDevOptions) ...{
TextButton(
onPressed: () {
Clipboard.setData(ClipboardData(text: """Current frame (${frame % frames.length}): ${frames[frame % frames.length]},
All frames:
- ${frames.join("\n - ")}"""));
},
child: Text("[dev] copy debug info"),
),
}
],
);
}
@@ -82,7 +82,7 @@ class _WalletKeysPageBodyState extends State<WalletKeysPageBody>
showLegacySeedTab = widget.walletKeysViewModel.legacySeedSplit.isNotEmpty;
isLegacySeedOnly = widget.walletKeysViewModel.isLegacySeedOnly;
final totalTabs = 1 + (showKeyTab ? 1 : 0) + (showLegacySeedTab ? 1 : 0);
final totalTabs = (_hasSeeds ? 1 : 0) + (showKeyTab ? 1 : 0) + (showLegacySeedTab ? 1 : 0);
_tabController = TabController(length: totalTabs, vsync: this);
}
@@ -126,7 +126,7 @@ class _WalletKeysPageBodyState extends State<WalletKeysPageBody>
dividerColor: Colors.transparent,
padding: EdgeInsets.zero,
tabs: [
Tab(text: S.of(context).widgets_seed, key: ValueKey('wallet_keys_page_seed')),
if (_hasSeeds) Tab(text: S.of(context).widgets_seed, key: ValueKey('wallet_keys_page_seed')),
if (showKeyTab) Tab(text: S.of(context).keys, key: ValueKey('wallet_keys_page_keys'),),
if (showLegacySeedTab) Tab(text: S.of(context).legacy, key: ValueKey('wallet_keys_page_seed_legacy')),
],
@@ -137,10 +137,11 @@ class _WalletKeysPageBodyState extends State<WalletKeysPageBody>
child: TabBarView(
controller: _tabController,
children: [
Padding(
padding: const EdgeInsets.only(left: 22, right: 22),
child: _buildSeedTab(context, false),
),
if (_hasSeeds)
Padding(
padding: const EdgeInsets.only(left: 22, right: 22),
child: _buildSeedTab(context, false),
),
if (showKeyTab)
Padding(
padding: const EdgeInsets.only(left: 22, right: 22),
+20 -9
View File
@@ -7,6 +7,7 @@ class OptionTile extends StatelessWidget {
this.icon,
required this.title,
required this.description,
this.tag,
super.key,
}) : assert(image != null || icon != null);
@@ -15,6 +16,7 @@ class OptionTile extends StatelessWidget {
final Icon? icon;
final String title;
final String description;
final String? tag;
@override
Widget build(BuildContext context) {
@@ -36,25 +38,34 @@ class OptionTile extends StatelessWidget {
icon ?? image!,
Expanded(
child: Padding(
padding: EdgeInsets.only(left: 16),
padding: EdgeInsets.only(left: 20),
child: Column(
mainAxisSize: MainAxisSize.max,
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
title,
style: Theme.of(context).textTheme.bodyMedium!.copyWith(
fontSize: 20,
fontWeight: FontWeight.w500,
color: Theme.of(context).colorScheme.onSurface,
),
Row(
children: [
ConstrainedBox(
constraints: BoxConstraints(maxWidth: 220),
child: Text(title, style: Theme.of(context).textTheme.titleMedium),
),
if (tag != null) Container(
decoration: BoxDecoration(
border: Border.all(),
borderRadius: BorderRadius.circular(20),
color: Theme.of(context).colorScheme.onSurfaceVariant
),
padding: EdgeInsets.symmetric(horizontal: 5),
margin: EdgeInsets.only(left: 5),
child: Text(tag!))
],
),
Padding(
padding: EdgeInsets.only(top: 5),
child: Text(
description,
style: Theme.of(context).textTheme.bodyMedium!.copyWith(
style: Theme.of(context).textTheme.bodySmall!.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
+1 -1
View File
@@ -45,7 +45,7 @@ class SettingActions {
name: (context) => S.of(context).export_outputs,
image: 'assets/images/monero_menu.png',
onTap: (BuildContext context) {
Navigator.of(context).pushNamed(Routes.urqrAnimatedPage, arguments: 'export-outputs');
Navigator.of(context).pushNamed(Routes.urqrAnimatedPage, arguments: {'export-outputs': 'export-outputs'});
},
);
+1
View File
@@ -9,4 +9,5 @@ class FeatureFlag {
static final bool isInAppTorEnabled = (Platform.isAndroid);
static const int verificationWordsCount = kDebugMode ? 0 : 2;
static const bool hasDevOptions = bool.fromEnvironment('hasDevOptions', defaultValue: kDebugMode);
static const bool hasBitcoinViewOnly = true;
}
@@ -536,7 +536,10 @@ abstract class DashboardViewModelBase with Store {
}
@computed
bool get hasSilentPayments => wallet.type == WalletType.bitcoin && !wallet.isHardwareWallet;
bool get hasSilentPayments =>
wallet.type == WalletType.bitcoin &&
(bitcoin!.getWalletKeys(wallet)["privateKey"] ?? "").isNotEmpty &&
!wallet.isHardwareWallet;
@computed
bool get showSilentPaymentsCard => hasSilentPayments && settingsStore.silentPaymentsCardDisplay;
@@ -22,7 +22,7 @@ abstract class ReceiveOptionViewModelBase with Store {
switch (walletType) {
case WalletType.bitcoin:
_options = [
...bitcoin!.getBitcoinReceivePageOptions(),
...bitcoin!.getBitcoinReceivePageOptions(_wallet),
...ReceivePageOptions.where((element) => element != ReceivePageOption.mainnet)
];
break;
+3 -2
View File
@@ -35,7 +35,7 @@ class RestoredWallet {
factory RestoredWallet.fromKey(Map<String, dynamic> json) {
try {
final codeParsed = jsonDecode(json['raw_qr'].toString());
final codeParsed = jsonDecode(json['raw_qr'].toString());
if (codeParsed["version"] == 0) {
json['address'] = codeParsed["primaryAddress"];
json['view_key'] = codeParsed["privateViewKey"];
@@ -44,6 +44,7 @@ class RestoredWallet {
} catch (e) {
// fine, we don't care, it is only for monero anyway
}
json['view_key'] ??= json['xpub'];
final height = json['height'] as String?;
return RestoredWallet(
restoreMode: json['mode'] as WalletRestoreMode,
@@ -51,7 +52,7 @@ class RestoredWallet {
address: json['address'] as String?,
spendKey: json['spend_key'] as String?,
viewKey: json['view_key'] as String?,
height: height != null ? int.tryParse(height)??0 : 0,
height: height != null ? int.tryParse(height) ?? 0 : 0,
privateKey: json['private_key'] as String?,
);
}
@@ -54,6 +54,8 @@ class WalletRestoreFromQRCode {
final extracted = sortedKeys.firstWhereOrNull((key) => code.toLowerCase().contains(key));
if (code.startsWith("xpub")) return WalletType.bitcoin;
if (extracted == null) {
// Special case for view-only monero wallet
try {
@@ -117,11 +119,15 @@ class WalletRestoreFromQRCode {
formattedUri = seedPhrase != null
? '$walletType:?seed=$seedPhrase'
: throw Exception('Failed to determine valid seed phrase');
: code.startsWith('xpub')
? '$walletType:?xpub=$code'
: throw Exception('Failed to determine valid seed phrase');
} else {
final index = code.indexOf(':');
final query = code.substring(index + 1).replaceAll('?', '&');
formattedUri = '$walletType:?$query';
formattedUri = code.startsWith('xpub')
? '$walletType:?xpub=$code'
:'$walletType:?$query';
}
final uri = Uri.parse(formattedUri);
@@ -158,6 +164,10 @@ class WalletRestoreFromQRCode {
throw Exception('Unexpected restore mode: tx_payment_id is invalid');
}
if (credentials.containsKey("xpub")) {
return WalletRestoreMode.keys;
}
if (credentials['seed'] != null) {
final seedValue = credentials['seed'] as String;
final words = SeedValidator.getWordList(type: type, language: 'english');
+9
View File
@@ -289,6 +289,15 @@ abstract class OutputBase with Store {
}
}
Map<String, dynamic> get extra {
final fields = <String, dynamic>{};
if (parsedAddress.parseFrom == ParseFrom.bip353) {
fields['bip353_name'] = parsedAddress.name;
fields['bip353_proof'] = parsedAddress.bip353DnsProof;
}
return fields;
}
void _setCryptoNumMaximumFractionDigits() {
var maximumFractionDigits = 0;
@@ -603,6 +603,11 @@ abstract class WalletAddressListViewModelBase extends WalletChangeListenerViewMo
bool get isSilentPayments =>
wallet.type == WalletType.bitcoin && bitcoin!.hasSelectedSilentPayments(wallet);
@computed
bool get isCupcake =>
wallet.type == WalletType.bitcoin &&
(bitcoin!.getWalletKeys(wallet)["privateKey"] ?? "").isEmpty;
@computed
bool get isAutoGenerateSubaddressEnabled =>
_settingsStore.autoGenerateSubaddressStatus != AutoGenerateSubaddressStatus.disabled &&
+14 -10
View File
@@ -1,3 +1,4 @@
import 'package:cake_wallet/bitcoin/bitcoin.dart';
import 'package:cake_wallet/generated/i18n.dart';
import 'package:cake_wallet/monero/monero.dart';
import 'package:cake_wallet/src/screens/transaction_details/standart_list_item.dart';
@@ -162,18 +163,21 @@ abstract class WalletKeysViewModelBase with Store {
case WalletType.bitcoin:
case WalletType.litecoin:
case WalletType.bitcoinCash:
final keys = bitcoin!.getWalletKeys(_appStore.wallet!);
items.addAll([
if ((keys['wif']??'').isNotEmpty)
StandartListItem(title: "WIF", value: keys['wif']!),
if ((keys['privateKey']??'').isNotEmpty)
StandartListItem(title: S.current.private_key, value: keys['privateKey']!),
if (keys['publicKey'] != null)
StandartListItem(title: S.current.public_key, value: keys['publicKey']!),
if (keys['xpub'] != null)
StandartListItem(title: "xPub", value: keys['xpub']!),
]);
break;
case WalletType.none:
case WalletType.haven:
// final keys = bitcoin!.getWalletKeys(_appStore.wallet!);
//
// items.addAll([
// if (keys['wif'] != null)
// StandartListItem(title: "WIF", value: keys['wif']!),
// if (keys['privateKey'] != null)
// StandartListItem(title: S.current.private_key, value: keys['privateKey']!),
// if (keys['publicKey'] != null)
// StandartListItem(title: S.current.public_key, value: keys['publicKey']!),
// ]);
break;
}
+38 -16
View File
@@ -11,6 +11,7 @@ import 'package:cake_wallet/solana/solana.dart';
import 'package:cake_wallet/store/app_store.dart';
import 'package:cake_wallet/tron/tron.dart';
import 'package:cake_wallet/decred/decred.dart';
import 'package:cake_wallet/utils/feature_flag.dart';
import 'package:cake_wallet/view_model/restore/restore_mode.dart';
import 'package:cake_wallet/view_model/restore/restore_wallet.dart';
import 'package:cake_wallet/view_model/seed_settings_view_model.dart';
@@ -32,17 +33,7 @@ abstract class WalletRestoreViewModelBase extends WalletCreationVM with Store {
WalletRestoreViewModelBase(AppStore appStore, WalletCreationService walletCreationService,
Box<WalletInfo> walletInfoSource, SeedSettingsViewModel seedSettingsViewModel,
{required WalletType type, this.restoredWallet})
: hasSeedLanguageSelector =
type == WalletType.monero || type == WalletType.haven || type == WalletType.wownero,
hasBlockchainHeightLanguageSelector =
type == WalletType.monero || type == WalletType.haven || type == WalletType.wownero,
hasRestoreFromPrivateKey = type == WalletType.ethereum ||
type == WalletType.polygon ||
type == WalletType.nano ||
type == WalletType.banano ||
type == WalletType.solana ||
type == WalletType.tron,
isButtonEnabled = false,
: isButtonEnabled = restoredWallet != null,
hasPassphrase = false,
mode = restoredWallet?.restoreMode ?? WalletRestoreMode.seed,
super(appStore, walletInfoSource, walletCreationService, seedSettingsViewModel,
@@ -60,9 +51,9 @@ abstract class WalletRestoreViewModelBase extends WalletCreationVM with Store {
case WalletType.ethereum:
case WalletType.polygon:
case WalletType.decred:
case WalletType.bitcoin:
availableModes = [WalletRestoreMode.seed, WalletRestoreMode.keys];
break;
case WalletType.bitcoin:
case WalletType.litecoin:
case WalletType.bitcoinCash:
case WalletType.zano:
@@ -82,9 +73,32 @@ abstract class WalletRestoreViewModelBase extends WalletCreationVM with Store {
static const decredSeedMnemonicLength = 15;
late List<WalletRestoreMode> availableModes;
final bool hasSeedLanguageSelector;
final bool hasBlockchainHeightLanguageSelector;
final bool hasRestoreFromPrivateKey;
late final bool hasSeedLanguageSelector = [
WalletType.monero,
WalletType.haven,
WalletType.wownero
].contains(type);
late final bool hasBlockchainHeightSelector = [
WalletType.monero,
WalletType.haven,
WalletType.wownero
].contains(type);
late final bool hasRestoreFromPrivateKey = [
WalletType.ethereum,
WalletType.polygon,
WalletType.nano,
WalletType.banano,
WalletType.solana,
WalletType.tron
].contains(type);
late final bool onlyViewKeyRestore = [
if (FeatureFlag.hasBitcoinViewOnly) WalletType.bitcoin,
WalletType.decred
].contains(type);
final RestoredWallet? restoredWallet;
@observable
@@ -198,6 +212,13 @@ abstract class WalletRestoreViewModelBase extends WalletCreationVM with Store {
final address = options['address'] as String?;
switch (type) {
case WalletType.bitcoin:
return bitcoin!.createBitcoinWalletFromKeys(
name: name,
password: password,
xpub: viewKey!,
);
case WalletType.monero:
return monero!.createMoneroRestoreWalletFromKeysCredentials(
name: name,
@@ -276,8 +297,9 @@ abstract class WalletRestoreViewModelBase extends WalletCreationVM with Store {
case WalletType.litecoin:
String? mnemonic = credentials['seed'] as String?;
String? passphrase = credentials['passphrase'] as String?;
if (mnemonic == null) break;
return bitcoin!.getDerivationsFromMnemonic(
mnemonic: mnemonic!,
mnemonic: mnemonic,
node: node,
passphrase: passphrase,
);