Merge branch 'main' into CW-685-passphrase-support-for-monero-wownero-wallets

This commit is contained in:
cyan 2024-10-14 15:31:45 +02:00 committed by GitHub
commit ac0049c183
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
354 changed files with 12679 additions and 2517 deletions

View file

@ -13,6 +13,9 @@ on:
jobs: jobs:
PR_test_build: PR_test_build:
runs-on: ubuntu-20.04 runs-on: ubuntu-20.04
strategy:
matrix:
api-level: [29]
env: env:
STORE_PASS: test@cake_wallet STORE_PASS: test@cake_wallet
KEY_PASS: test@cake_wallet KEY_PASS: test@cake_wallet
@ -93,6 +96,25 @@ jobs:
cd /opt/android/cake_wallet cd /opt/android/cake_wallet
flutter pub get flutter pub get
- name: Install go and gomobile
run: |
# install go > 1.23:
wget https://go.dev/dl/go1.23.1.linux-amd64.tar.gz
sudo rm -rf /usr/local/go && sudo tar -C /usr/local -xzf go1.23.1.linux-amd64.tar.gz
export PATH=$PATH:/usr/local/go/bin
export PATH=$PATH:~/go/bin
go install golang.org/x/mobile/cmd/gomobile@latest
gomobile init
- name: Build mwebd
run: |
# paths are reset after each step, so we need to set them again:
export PATH=$PATH:/usr/local/go/bin
export PATH=$PATH:~/go/bin
cd /opt/android/cake_wallet/scripts/android/
./build_mwebd.sh --dont-install
- name: Generate KeyStore - name: Generate KeyStore
run: | run: |
cd /opt/android/cake_wallet/android/app cd /opt/android/cake_wallet/android/app

View file

@ -89,6 +89,25 @@ jobs:
cd /opt/android/cake_wallet cd /opt/android/cake_wallet
flutter pub get flutter pub get
- name: Install go and gomobile
run: |
# install go > 1.23:
wget https://go.dev/dl/go1.23.1.linux-amd64.tar.gz
sudo rm -rf /usr/local/go && sudo tar -C /usr/local -xzf go1.23.1.linux-amd64.tar.gz
export PATH=$PATH:/usr/local/go/bin
export PATH=$PATH:~/go/bin
go install golang.org/x/mobile/cmd/gomobile@latest
gomobile init
- name: Build mwebd
run: |
# paths are reset after each step, so we need to set them again:
export PATH=$PATH:/usr/local/go/bin
export PATH=$PATH:~/go/bin
# build mwebd:
cd /opt/android/cake_wallet/scripts/android/
./build_mwebd.sh --dont-install
- name: Generate localization - name: Generate localization
run: | run: |
cd /opt/android/cake_wallet cd /opt/android/cake_wallet

3
.gitignore vendored
View file

@ -171,6 +171,9 @@ macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png
macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png
macos/Runner/Configs/AppInfo.xcconfig macos/Runner/Configs/AppInfo.xcconfig
integration_test/playground.dart
# Monero.dart (Monero_C) # Monero.dart (Monero_C)
scripts/monero_c scripts/monero_c
# iOS generated framework bin # iOS generated framework bin

View file

@ -1,5 +1,6 @@
include: package:lints/recommended.yaml include: package:lints/recommended.yaml
analyzer: analyzer:
exclude: [ exclude: [
build/**, build/**,
@ -83,4 +84,4 @@ linter:
# - unawaited_futures # - unawaited_futures
# - unnecessary_getters_setters # - unnecessary_getters_setters
# - unrelated_type_equality_checks # - unrelated_type_equality_checks
# - valid_regexps # - valid_regexps

View file

@ -1,3 +1,10 @@
- -
uri: bitcoincash.stackwallet.com:50002 uri: bitcoincash.stackwallet.com:50002
is_default: true is_default: true
useSSL: true
-
uri: bch.aftrek.org:50002
useSSL: true
-
uri: node.minisatoshi.cash:50002
useSSL: true

View file

@ -6,3 +6,6 @@
isDefault: true isDefault: true
- -
uri: electrs.cakewallet.com:50001 uri: electrs.cakewallet.com:50001
-
uri: fulcrum.sethforprivacy.com:50002
useSSL: true

BIN
assets/images/cards.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 158 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 KiB

After

Width:  |  Height:  |  Size: 367 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 692 B

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1 KiB

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 845 B

After

Width:  |  Height:  |  Size: 975 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 695 B

After

Width:  |  Height:  |  Size: 366 B

BIN
assets/images/mweb_logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

BIN
assets/images/nanogpt.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 505 KiB

BIN
assets/images/ton_icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7 KiB

View file

@ -4,4 +4,7 @@
useSSL: true useSSL: true
- -
uri: api.mainnet-beta.solana.com:443 uri: api.mainnet-beta.solana.com:443
useSSL: true
-
uri: solana-rpc.publicnode.com:443
useSSL: true useSSL: true

View file

@ -1,2 +1,4 @@
Enhance auto-address generation for Monero Monero enhancements for sending and address generation
Bug fixes and enhancements StealthEx
LetsExchange
Visual enhancements and bug fixes

View file

@ -1,4 +1,9 @@
Enable BIP39 by default for wallet creation also on Bitcoin/Litecoin (Electrum seed type is still accessible through advanced settings page) Add Litecoin MWEB
Improve fee calculation for Bitcoin to protect against overpaying or underpaying Wallet groups (same seed, multiple wallets)
Enhance auto-address generation for Monero Silent Payments enhancements
Bug fixes and enhancements Monero enhancements for sending and address generation
StealthEx
LetsExchange
Replace-By-Fee improvements
ERC20 tokens potential scam detection
Visual enhancements and bug fixes

View file

@ -1,14 +0,0 @@
import 'dart:typed_data';
import 'package:bitcoin_base/bitcoin_base.dart' as bitcoin;
List<int> addressToOutputScript(String address, bitcoin.BasedUtxoNetwork network) {
try {
if (network == bitcoin.BitcoinCashNetwork.mainnet) {
return bitcoin.BitcoinCashAddress(address).baseAddress.toScriptPubKey().toBytes();
}
return bitcoin.addressToOutputScript(address: address, network: network);
} catch (err) {
print(err);
return Uint8List(0);
}
}

View file

@ -1,7 +1,6 @@
import 'dart:convert'; import 'dart:convert';
import 'package:bitcoin_base/bitcoin_base.dart'; import 'package:bitcoin_base/bitcoin_base.dart';
import 'package:cw_bitcoin/script_hash.dart' as sh;
abstract class BaseBitcoinAddressRecord { abstract class BaseBitcoinAddressRecord {
BaseBitcoinAddressRecord( BaseBitcoinAddressRecord(
@ -65,8 +64,8 @@ class BitcoinAddressRecord extends BaseBitcoinAddressRecord {
required super.type, required super.type,
String? scriptHash, String? scriptHash,
required super.network, required super.network,
}) : scriptHash = }) : scriptHash = scriptHash ??
scriptHash ?? (network != null ? sh.scriptHash(address, network: network) : null); (network != null ? BitcoinAddressUtils.scriptHash(address, network: network) : null);
factory BitcoinAddressRecord.fromJSON(String jsonSource, {BasedUtxoNetwork? network}) { factory BitcoinAddressRecord.fromJSON(String jsonSource, {BasedUtxoNetwork? network}) {
final decoded = json.decode(jsonSource) as Map; final decoded = json.decode(jsonSource) as Map;
@ -92,7 +91,7 @@ class BitcoinAddressRecord extends BaseBitcoinAddressRecord {
String getScriptHash(BasedUtxoNetwork network) { String getScriptHash(BasedUtxoNetwork network) {
if (scriptHash != null) return scriptHash!; if (scriptHash != null) return scriptHash!;
scriptHash = sh.scriptHash(address, network: network); scriptHash = BitcoinAddressUtils.scriptHash(address, network: network);
return scriptHash!; return scriptHash!;
} }

View file

@ -8,6 +8,7 @@ import 'package:cw_core/sec_random_native.dart';
import 'package:cw_core/utils/text_normalizer.dart'; import 'package:cw_core/utils/text_normalizer.dart';
const segwit = '100'; const segwit = '100';
const mweb = 'eb';
final wordlist = englishWordlist; final wordlist = englishWordlist;
double logBase(num x, num base) => log(x) / log(base); double logBase(num x, num base) => log(x) / log(base);
@ -125,7 +126,7 @@ Future<Uint8List> mnemonicToSeedBytes(String mnemonic,
return Uint8List.fromList(bytes); return Uint8List.fromList(bytes);
} }
bool matchesAnyPrefix(String mnemonic) => prefixMatches(mnemonic, [segwit]).any((el) => el); bool matchesAnyPrefix(String mnemonic) => prefixMatches(mnemonic, [segwit, mweb]).any((el) => el);
bool validateMnemonic(String mnemonic, {String prefix = segwit}) { bool validateMnemonic(String mnemonic, {String prefix = segwit}) {
try { try {

View file

@ -7,6 +7,7 @@ class BitcoinReceivePageOption implements ReceivePageOption {
static const p2tr = BitcoinReceivePageOption._('Taproot (P2TR)'); static const p2tr = BitcoinReceivePageOption._('Taproot (P2TR)');
static const p2wsh = BitcoinReceivePageOption._('Segwit (P2WSH)'); static const p2wsh = BitcoinReceivePageOption._('Segwit (P2WSH)');
static const p2pkh = BitcoinReceivePageOption._('Legacy (P2PKH)'); static const p2pkh = BitcoinReceivePageOption._('Legacy (P2PKH)');
static const mweb = BitcoinReceivePageOption._('MWEB');
static const silent_payments = BitcoinReceivePageOption._('Silent Payments'); static const silent_payments = BitcoinReceivePageOption._('Silent Payments');
@ -27,6 +28,11 @@ class BitcoinReceivePageOption implements ReceivePageOption {
BitcoinReceivePageOption.p2pkh BitcoinReceivePageOption.p2pkh
]; ];
static const allLitecoin = [
BitcoinReceivePageOption.p2wpkh,
BitcoinReceivePageOption.mweb,
];
BitcoinAddressType toType() { BitcoinAddressType toType() {
switch (this) { switch (this) {
case BitcoinReceivePageOption.p2tr: case BitcoinReceivePageOption.p2tr:
@ -39,6 +45,8 @@ class BitcoinReceivePageOption implements ReceivePageOption {
return P2shAddressType.p2wpkhInP2sh; return P2shAddressType.p2wpkhInP2sh;
case BitcoinReceivePageOption.silent_payments: case BitcoinReceivePageOption.silent_payments:
return SilentPaymentsAddresType.p2sp; return SilentPaymentsAddresType.p2sp;
case BitcoinReceivePageOption.mweb:
return SegwitAddresType.mweb;
case BitcoinReceivePageOption.p2wpkh: case BitcoinReceivePageOption.p2wpkh:
default: default:
return SegwitAddresType.p2wpkh; return SegwitAddresType.p2wpkh;
@ -51,6 +59,8 @@ class BitcoinReceivePageOption implements ReceivePageOption {
return BitcoinReceivePageOption.p2tr; return BitcoinReceivePageOption.p2tr;
case SegwitAddresType.p2wsh: case SegwitAddresType.p2wsh:
return BitcoinReceivePageOption.p2wsh; return BitcoinReceivePageOption.p2wsh;
case SegwitAddresType.mweb:
return BitcoinReceivePageOption.mweb;
case P2pkhAddressType.p2pkh: case P2pkhAddressType.p2pkh:
return BitcoinReceivePageOption.p2pkh; return BitcoinReceivePageOption.p2pkh;
case P2shAddressType.p2wpkhInP2sh: case P2shAddressType.p2wpkhInP2sh:

View file

@ -87,7 +87,7 @@ class LitecoinTransactionPriority extends BitcoinTransactionPriority {
} }
@override @override
String get units => 'Latoshi'; String get units => 'Litoshi';
@override @override
String toString() { String toString() {

View file

@ -10,12 +10,17 @@ class BitcoinNewWalletCredentials extends WalletCredentials {
DerivationType? derivationType, DerivationType? derivationType,
String? derivationPath, String? derivationPath,
String? passphrase, String? passphrase,
this.mnemonic,
String? parentAddress,
}) : super( }) : super(
name: name, name: name,
walletInfo: walletInfo, walletInfo: walletInfo,
password: password, password: password,
passphrase: passphrase, passphrase: passphrase,
parentAddress: parentAddress,
); );
final String? mnemonic;
} }
class BitcoinRestoreWalletFromSeedCredentials extends WalletCredentials { class BitcoinRestoreWalletFromSeedCredentials extends WalletCredentials {

View file

@ -41,7 +41,7 @@ class BitcoinWalletService extends WalletService<
case DerivationType.bip39: case DerivationType.bip39:
final strength = credentials.seedPhraseLength == 24 ? 256 : 128; final strength = credentials.seedPhraseLength == 24 ? 256 : 128;
mnemonic = await MnemonicBip39.generate(strength: strength); mnemonic = credentials.mnemonic ?? await MnemonicBip39.generate(strength: strength);
break; break;
case DerivationType.electrum: case DerivationType.electrum:
default: default:

View file

@ -4,7 +4,6 @@ import 'dart:io';
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:bitcoin_base/bitcoin_base.dart'; import 'package:bitcoin_base/bitcoin_base.dart';
import 'package:cw_bitcoin/bitcoin_amount_format.dart'; import 'package:cw_bitcoin/bitcoin_amount_format.dart';
import 'package:cw_bitcoin/script_hash.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:rxdart/rxdart.dart'; import 'package:rxdart/rxdart.dart';
@ -48,6 +47,7 @@ class ElectrumClient {
final Map<String, SocketTask> _tasks; final Map<String, SocketTask> _tasks;
Map<String, SocketTask> get tasks => _tasks; Map<String, SocketTask> get tasks => _tasks;
final Map<String, String> _errors; final Map<String, String> _errors;
ConnectionStatus _connectionStatus = ConnectionStatus.disconnected;
bool _isConnected; bool _isConnected;
Timer? _aliveTimer; Timer? _aliveTimer;
String unterminatedString; String unterminatedString;
@ -57,11 +57,13 @@ class ElectrumClient {
Future<void> connectToUri(Uri uri, {bool? useSSL}) async { Future<void> connectToUri(Uri uri, {bool? useSSL}) async {
this.uri = uri; this.uri = uri;
this.useSSL = useSSL; if (useSSL != null) {
await connect(host: uri.host, port: uri.port, useSSL: useSSL); this.useSSL = useSSL;
}
await connect(host: uri.host, port: uri.port);
} }
Future<void> connect({required String host, required int port, bool? useSSL}) async { Future<void> connect({required String host, required int port}) async {
_setConnectionStatus(ConnectionStatus.connecting); _setConnectionStatus(ConnectionStatus.connecting);
try { try {
@ -80,15 +82,26 @@ class ElectrumClient {
onBadCertificate: (_) => true, onBadCertificate: (_) => true,
); );
} }
} catch (_) { } catch (e) {
_setConnectionStatus(ConnectionStatus.failed); if (e is HandshakeException) {
useSSL = !(useSSL ?? false);
}
if (_connectionStatus != ConnectionStatus.connecting) {
_setConnectionStatus(ConnectionStatus.failed);
}
return; return;
} }
if (socket == null) { if (socket == null) {
_setConnectionStatus(ConnectionStatus.failed); if (_connectionStatus != ConnectionStatus.connecting) {
_setConnectionStatus(ConnectionStatus.failed);
}
return; return;
} }
_setConnectionStatus(ConnectionStatus.connected); _setConnectionStatus(ConnectionStatus.connected);
socket!.listen( socket!.listen(
@ -103,7 +116,7 @@ class ElectrumClient {
_parseResponse(message); _parseResponse(message);
} }
} catch (e) { } catch (e) {
print(e.toString()); print("socket.listen: $e");
} }
}, },
onError: (Object error) { onError: (Object error) {
@ -112,14 +125,15 @@ class ElectrumClient {
unterminatedString = ''; unterminatedString = '';
}, },
onDone: () { onDone: () {
print("SOCKET CLOSED!!!!!");
unterminatedString = ''; unterminatedString = '';
try { try {
if (host == socket?.address.host) { if (host == socket?.address.host) {
socket?.destroy();
_setConnectionStatus(ConnectionStatus.disconnected); _setConnectionStatus(ConnectionStatus.disconnected);
socket?.destroy();
} }
} catch(e) { } catch (e) {
print(e.toString()); print("onDone: $e");
} }
}, },
cancelOnError: true, cancelOnError: true,
@ -217,25 +231,6 @@ class ElectrumClient {
return []; return [];
}); });
Future<List<Map<String, dynamic>>> getListUnspentWithAddress(
String address, BasedUtxoNetwork network) =>
call(
method: 'blockchain.scripthash.listunspent',
params: [scriptHash(address, network: network)]).then((dynamic result) {
if (result is List) {
return result.map((dynamic val) {
if (val is Map<String, dynamic>) {
val['address'] = address;
return val;
}
return <String, dynamic>{};
}).toList();
}
return [];
});
Future<List<Map<String, dynamic>>> getListUnspent(String scriptHash) => Future<List<Map<String, dynamic>>> getListUnspent(String scriptHash) =>
call(method: 'blockchain.scripthash.listunspent', params: [scriptHash]) call(method: 'blockchain.scripthash.listunspent', params: [scriptHash])
.then((dynamic result) { .then((dynamic result) {
@ -272,16 +267,12 @@ class ElectrumClient {
try { try {
final result = await callWithTimeout( final result = await callWithTimeout(
method: 'blockchain.transaction.get', params: [hash, verbose], timeout: 10000); method: 'blockchain.transaction.get', params: [hash, verbose], timeout: 10000);
if (result is Map<String, dynamic>) { return result;
return result;
}
} on RequestFailedTimeoutException catch (_) { } on RequestFailedTimeoutException catch (_) {
return <String, dynamic>{}; return <String, dynamic>{};
} catch (e) { } catch (e) {
print("getTransaction: ${e.toString()}");
return <String, dynamic>{}; return <String, dynamic>{};
} }
return <String, dynamic>{};
} }
Future<Map<String, dynamic>> getTransactionVerbose({required String hash}) => Future<Map<String, dynamic>> getTransactionVerbose({required String hash}) =>
@ -326,9 +317,8 @@ class ElectrumClient {
await call(method: 'blockchain.block.get_header', params: [height]) as Map<String, dynamic>; await call(method: 'blockchain.block.get_header', params: [height]) as Map<String, dynamic>;
BehaviorSubject<Object>? tweaksSubscribe({required int height, required int count}) { BehaviorSubject<Object>? tweaksSubscribe({required int height, required int count}) {
_id += 1;
return subscribe<Object>( return subscribe<Object>(
id: 'blockchain.tweaks.subscribe:${height + count}', id: 'blockchain.tweaks.subscribe',
method: 'blockchain.tweaks.subscribe', method: 'blockchain.tweaks.subscribe',
params: [height, count, false], params: [height, count, false],
); );
@ -432,7 +422,7 @@ class ElectrumClient {
BehaviorSubject<T>? subscribe<T>( BehaviorSubject<T>? subscribe<T>(
{required String id, required String method, List<Object> params = const []}) { {required String id, required String method, List<Object> params = const []}) {
try { try {
if (socket == null) { if (socket == null || !isConnected) {
return null; return null;
} }
final subscription = BehaviorSubject<T>(); final subscription = BehaviorSubject<T>();
@ -448,7 +438,7 @@ class ElectrumClient {
Future<dynamic> call( Future<dynamic> call(
{required String method, List<Object> params = const [], Function(int)? idCallback}) async { {required String method, List<Object> params = const [], Function(int)? idCallback}) async {
if (socket == null) { if (socket == null || !isConnected) {
return null; return null;
} }
final completer = Completer<dynamic>(); final completer = Completer<dynamic>();
@ -464,7 +454,7 @@ class ElectrumClient {
Future<dynamic> callWithTimeout( Future<dynamic> callWithTimeout(
{required String method, List<Object> params = const [], int timeout = 5000}) async { {required String method, List<Object> params = const [], int timeout = 5000}) async {
try { try {
if (socket == null) { if (socket == null || !isConnected) {
return null; return null;
} }
final completer = Completer<dynamic>(); final completer = Completer<dynamic>();
@ -517,6 +507,12 @@ class ElectrumClient {
void _methodHandler({required String method, required Map<String, dynamic> request}) { void _methodHandler({required String method, required Map<String, dynamic> request}) {
switch (method) { switch (method) {
case 'blockchain.headers.subscribe':
final params = request['params'] as List<dynamic>;
final id = 'blockchain.headers.subscribe';
_tasks[id]?.subject?.add(params.last);
break;
case 'blockchain.scripthash.subscribe': case 'blockchain.scripthash.subscribe':
final params = request['params'] as List<dynamic>; final params = request['params'] as List<dynamic>;
final scripthash = params.first as String?; final scripthash = params.first as String?;
@ -539,6 +535,7 @@ class ElectrumClient {
void _setConnectionStatus(ConnectionStatus status) { void _setConnectionStatus(ConnectionStatus status) {
onConnectionStatusChange?.call(status); onConnectionStatusChange?.call(status);
_connectionStatus = status;
_isConnected = status == ConnectionStatus.connected; _isConnected = status == ConnectionStatus.connected;
} }

View file

@ -7,7 +7,14 @@ class ElectrumBalance extends Balance {
required this.confirmed, required this.confirmed,
required this.unconfirmed, required this.unconfirmed,
required this.frozen, required this.frozen,
}) : super(confirmed, unconfirmed); this.secondConfirmed = 0,
this.secondUnconfirmed = 0,
}) : super(
confirmed,
unconfirmed,
secondAvailable: secondConfirmed,
secondAdditional: secondUnconfirmed,
);
static ElectrumBalance? fromJSON(String? jsonSource) { static ElectrumBalance? fromJSON(String? jsonSource) {
if (jsonSource == null) { if (jsonSource == null) {
@ -25,9 +32,12 @@ class ElectrumBalance extends Balance {
int confirmed; int confirmed;
int unconfirmed; int unconfirmed;
final int frozen; final int frozen;
int secondConfirmed = 0;
int secondUnconfirmed = 0;
@override @override
String get formattedAvailableBalance => bitcoinAmountToString(amount: confirmed - frozen); String get formattedAvailableBalance =>
bitcoinAmountToString(amount: confirmed - frozen);
@override @override
String get formattedAdditionalBalance => bitcoinAmountToString(amount: unconfirmed); String get formattedAdditionalBalance => bitcoinAmountToString(amount: unconfirmed);
@ -38,6 +48,21 @@ class ElectrumBalance extends Balance {
return frozenFormatted == '0.0' ? '' : frozenFormatted; return frozenFormatted == '0.0' ? '' : frozenFormatted;
} }
String toJSON() => @override
json.encode({'confirmed': confirmed, 'unconfirmed': unconfirmed, 'frozen': frozen}); String get formattedSecondAvailableBalance => bitcoinAmountToString(amount: secondConfirmed);
@override
String get formattedSecondAdditionalBalance => bitcoinAmountToString(amount: secondUnconfirmed);
@override
String get formattedFullAvailableBalance =>
bitcoinAmountToString(amount: confirmed + secondConfirmed - frozen);
String toJSON() => json.encode({
'confirmed': confirmed,
'unconfirmed': unconfirmed,
'frozen': frozen,
'secondConfirmed': secondConfirmed,
'secondUnconfirmed': secondUnconfirmed
});
} }

View file

@ -76,8 +76,13 @@ abstract class ElectrumTransactionHistoryBase
final val = entry.value; final val = entry.value;
if (val is Map<String, dynamic>) { if (val is Map<String, dynamic>) {
final tx = ElectrumTransactionInfo.fromJson(val, walletInfo.type); // removing transactions with invalid date
_update(tx); if (val['date'] == 1168650000) {
transactions.remove(entry.key);
} else {
final tx = ElectrumTransactionInfo.fromJson(val, walletInfo.type);
_update(tx);
}
} }
}); });

View file

@ -23,20 +23,25 @@ class ElectrumTransactionBundle {
class ElectrumTransactionInfo extends TransactionInfo { class ElectrumTransactionInfo extends TransactionInfo {
List<BitcoinSilentPaymentsUnspent>? unspents; List<BitcoinSilentPaymentsUnspent>? unspents;
bool isReceivedSilentPayment;
ElectrumTransactionInfo(this.type, ElectrumTransactionInfo(
{required String id, this.type, {
int? height, required String id,
required int amount, int? height,
int? fee, required int amount,
List<String>? inputAddresses, int? fee,
List<String>? outputAddresses, List<String>? inputAddresses,
required TransactionDirection direction, List<String>? outputAddresses,
required bool isPending, required TransactionDirection direction,
required DateTime date, required bool isPending,
required int confirmations, bool isReplaced = false,
String? to, required DateTime date,
this.unspents}) { required int confirmations,
String? to,
this.unspents,
this.isReceivedSilentPayment = false,
}) {
this.id = id; this.id = id;
this.height = height; this.height = height;
this.amount = amount; this.amount = amount;
@ -46,6 +51,7 @@ class ElectrumTransactionInfo extends TransactionInfo {
this.direction = direction; this.direction = direction;
this.date = date; this.date = date;
this.isPending = isPending; this.isPending = isPending;
this.isReplaced = isReplaced;
this.confirmations = confirmations; this.confirmations = confirmations;
this.to = to; this.to = to;
} }
@ -94,6 +100,7 @@ class ElectrumTransactionInfo extends TransactionInfo {
id: id, id: id,
height: height, height: height,
isPending: false, isPending: false,
isReplaced: false,
fee: fee, fee: fee,
direction: direction, direction: direction,
amount: amount, amount: amount,
@ -169,6 +176,7 @@ class ElectrumTransactionInfo extends TransactionInfo {
id: bundle.originalTransaction.txId(), id: bundle.originalTransaction.txId(),
height: height, height: height,
isPending: bundle.confirmations == 0, isPending: bundle.confirmations == 0,
isReplaced: false,
inputAddresses: inputAddresses, inputAddresses: inputAddresses,
outputAddresses: outputAddresses, outputAddresses: outputAddresses,
fee: fee, fee: fee,
@ -192,6 +200,7 @@ class ElectrumTransactionInfo extends TransactionInfo {
direction: parseTransactionDirectionFromInt(data['direction'] as int), direction: parseTransactionDirectionFromInt(data['direction'] as int),
date: DateTime.fromMillisecondsSinceEpoch(data['date'] as int), date: DateTime.fromMillisecondsSinceEpoch(data['date'] as int),
isPending: data['isPending'] as bool, isPending: data['isPending'] as bool,
isReplaced: data['isReplaced'] as bool? ?? false,
confirmations: data['confirmations'] as int, confirmations: data['confirmations'] as int,
inputAddresses: inputAddresses:
inputAddresses.isEmpty ? [] : inputAddresses.map((e) => e.toString()).toList(), inputAddresses.isEmpty ? [] : inputAddresses.map((e) => e.toString()).toList(),
@ -202,6 +211,7 @@ class ElectrumTransactionInfo extends TransactionInfo {
.map((unspent) => .map((unspent) =>
BitcoinSilentPaymentsUnspent.fromJSON(null, unspent as Map<String, dynamic>)) BitcoinSilentPaymentsUnspent.fromJSON(null, unspent as Map<String, dynamic>))
.toList(), .toList(),
isReceivedSilentPayment: data['isReceivedSilentPayment'] as bool? ?? false,
); );
} }
@ -233,6 +243,7 @@ class ElectrumTransactionInfo extends TransactionInfo {
direction: direction, direction: direction,
date: date, date: date,
isPending: isPending, isPending: isPending,
isReplaced: isReplaced ?? false,
inputAddresses: inputAddresses, inputAddresses: inputAddresses,
outputAddresses: outputAddresses, outputAddresses: outputAddresses,
confirmations: info.confirmations); confirmations: info.confirmations);
@ -246,16 +257,18 @@ class ElectrumTransactionInfo extends TransactionInfo {
m['direction'] = direction.index; m['direction'] = direction.index;
m['date'] = date.millisecondsSinceEpoch; m['date'] = date.millisecondsSinceEpoch;
m['isPending'] = isPending; m['isPending'] = isPending;
m['isReplaced'] = isReplaced;
m['confirmations'] = confirmations; m['confirmations'] = confirmations;
m['fee'] = fee; m['fee'] = fee;
m['to'] = to; m['to'] = to;
m['unspents'] = unspents?.map((e) => e.toJson()).toList() ?? []; m['unspents'] = unspents?.map((e) => e.toJson()).toList() ?? [];
m['inputAddresses'] = inputAddresses; m['inputAddresses'] = inputAddresses;
m['outputAddresses'] = outputAddresses; m['outputAddresses'] = outputAddresses;
m['isReceivedSilentPayment'] = isReceivedSilentPayment;
return m; return m;
} }
String toString() { String toString() {
return 'ElectrumTransactionInfo(id: $id, height: $height, amount: $amount, fee: $fee, direction: $direction, date: $date, isPending: $isPending, confirmations: $confirmations, to: $to, unspent: $unspents, inputAddresses: $inputAddresses, outputAddresses: $outputAddresses)'; return 'ElectrumTransactionInfo(id: $id, height: $height, amount: $amount, fee: $fee, direction: $direction, date: $date, isPending: $isPending, isReplaced: $isReplaced, confirmations: $confirmations, to: $to, unspent: $unspents, inputAddresses: $inputAddresses, outputAddresses: $outputAddresses)';
} }
} }

View file

@ -2,7 +2,6 @@ import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'dart:io'; import 'dart:io';
import 'dart:isolate'; import 'dart:isolate';
import 'dart:math';
import 'package:bitcoin_base/bitcoin_base.dart'; import 'package:bitcoin_base/bitcoin_base.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
@ -24,7 +23,6 @@ import 'package:cw_bitcoin/electrum_transaction_info.dart';
import 'package:cw_bitcoin/electrum_wallet_addresses.dart'; import 'package:cw_bitcoin/electrum_wallet_addresses.dart';
import 'package:cw_bitcoin/exceptions.dart'; import 'package:cw_bitcoin/exceptions.dart';
import 'package:cw_bitcoin/pending_bitcoin_transaction.dart'; import 'package:cw_bitcoin/pending_bitcoin_transaction.dart';
import 'package:cw_bitcoin/script_hash.dart';
import 'package:cw_bitcoin/utils.dart'; import 'package:cw_bitcoin/utils.dart';
import 'package:cw_core/crypto_currency.dart'; import 'package:cw_core/crypto_currency.dart';
import 'package:cw_core/node.dart'; import 'package:cw_core/node.dart';
@ -51,8 +49,6 @@ part 'electrum_wallet.g.dart';
class ElectrumWallet = ElectrumWalletBase with _$ElectrumWallet; class ElectrumWallet = ElectrumWalletBase with _$ElectrumWallet;
const int TWEAKS_COUNT = 25;
abstract class ElectrumWalletBase abstract class ElectrumWalletBase
extends WalletBase<ElectrumBalance, ElectrumTransactionHistory, ElectrumTransactionInfo> extends WalletBase<ElectrumBalance, ElectrumTransactionHistory, ElectrumTransactionInfo>
with Store, WalletKeysFile { with Store, WalletKeysFile {
@ -115,11 +111,18 @@ abstract class ElectrumWalletBase
} }
if (seedBytes != null) { if (seedBytes != null) {
return currency == CryptoCurrency.bch switch (currency) {
? bitcoinCashHDWallet(seedBytes) case CryptoCurrency.btc:
: Bip32Slip10Secp256k1.fromSeed(seedBytes).derivePath( case CryptoCurrency.ltc:
case CryptoCurrency.tbtc:
return Bip32Slip10Secp256k1.fromSeed(seedBytes).derivePath(
_hardenedDerivationPath(derivationInfo?.derivationPath ?? electrum_path)) _hardenedDerivationPath(derivationInfo?.derivationPath ?? electrum_path))
as Bip32Slip10Secp256k1; as Bip32Slip10Secp256k1;
case CryptoCurrency.bch:
return bitcoinCashHDWallet(seedBytes);
default:
throw Exception("Unsupported currency");
}
} }
return Bip32Slip10Secp256k1.fromExtendedKey(xpub!); return Bip32Slip10Secp256k1.fromExtendedKey(xpub!);
@ -163,15 +166,20 @@ abstract class ElectrumWalletBase
@observable @observable
SyncStatus syncStatus; SyncStatus syncStatus;
Set<String> get addressesSet => walletAddresses.allAddresses.map((addr) => addr.address).toSet(); Set<String> get addressesSet => walletAddresses.allAddresses
.where((element) => element.type != SegwitAddresType.mweb)
.map((addr) => addr.address)
.toSet();
List<String> get scriptHashes => walletAddresses.addressesByReceiveType List<String> get scriptHashes => walletAddresses.addressesByReceiveType
.map((addr) => scriptHash(addr.address, network: network)) .where((addr) => RegexUtils.addressTypeFromStr(addr.address, network) is! MwebAddress)
.map((addr) => (addr as BitcoinAddressRecord).getScriptHash(network))
.toList(); .toList();
List<String> get publicScriptHashes => walletAddresses.allAddresses List<String> get publicScriptHashes => walletAddresses.allAddresses
.where((addr) => !addr.isHidden) .where((addr) => !addr.isHidden)
.map((addr) => scriptHash(addr.address, network: network)) .where((addr) => RegexUtils.addressTypeFromStr(addr.address, network) is! MwebAddress)
.map((addr) => addr.getScriptHash(network))
.toList(); .toList();
String get xpub => accountHD.publicKey.toExtended; String get xpub => accountHD.publicKey.toExtended;
@ -212,7 +220,7 @@ abstract class ElectrumWalletBase
silentPaymentsScanningActive = active; silentPaymentsScanningActive = active;
if (active) { if (active) {
syncStatus = StartingScanSyncStatus(); syncStatus = AttemptingScanSyncStatus();
final tip = await getUpdatedChainTip(); final tip = await getUpdatedChainTip();
@ -277,6 +285,7 @@ abstract class ElectrumWalletBase
void Function(FlutterErrorDetails)? _onError; void Function(FlutterErrorDetails)? _onError;
Timer? _autoSaveTimer; Timer? _autoSaveTimer;
StreamSubscription<dynamic>? _receiveStream;
Timer? _updateFeeRateTimer; Timer? _updateFeeRateTimer;
static const int _autoSaveInterval = 1; static const int _autoSaveInterval = 1;
@ -290,12 +299,7 @@ abstract class ElectrumWalletBase
} }
@action @action
Future<void> _setListeners( Future<void> _setListeners(int height, {int? chainTipParam, bool? doSingleScan}) async {
int height, {
int? chainTipParam,
bool? doSingleScan,
bool? usingSupportedNode,
}) async {
final chainTip = chainTipParam ?? await getUpdatedChainTip(); final chainTip = chainTipParam ?? await getUpdatedChainTip();
if (chainTip == height) { if (chainTip == height) {
@ -303,7 +307,7 @@ abstract class ElectrumWalletBase
return; return;
} }
syncStatus = StartingScanSyncStatus(); syncStatus = AttemptingScanSyncStatus();
if (_isolate != null) { if (_isolate != null) {
final runningIsolate = await _isolate!; final runningIsolate = await _isolate!;
@ -332,7 +336,8 @@ abstract class ElectrumWalletBase
isSingleScan: doSingleScan ?? false, isSingleScan: doSingleScan ?? false,
)); ));
await for (var message in receivePort) { _receiveStream?.cancel();
_receiveStream = receivePort.listen((var message) async {
if (message is Map<String, ElectrumTransactionInfo>) { if (message is Map<String, ElectrumTransactionInfo>) {
for (final map in message.entries) { for (final map in message.entries) {
final txid = map.key; final txid = map.key;
@ -395,10 +400,16 @@ abstract class ElectrumWalletBase
nodeSupportsSilentPayments = false; nodeSupportsSilentPayments = false;
} }
syncStatus = message.syncStatus; if (message.syncStatus is SyncingSyncStatus) {
var status = message.syncStatus as SyncingSyncStatus;
syncStatus = SyncingSyncStatus(status.blocksLeft, status.ptc);
} else {
syncStatus = message.syncStatus;
}
await walletInfo.updateRestoreHeight(message.height); await walletInfo.updateRestoreHeight(message.height);
} }
} });
} }
void _updateSilentAddressRecord(BitcoinSilentPaymentsUnspent unspent) { void _updateSilentAddressRecord(BitcoinSilentPaymentsUnspent unspent) {
@ -438,9 +449,9 @@ abstract class ElectrumWalletBase
await _setInitialHeight(); await _setInitialHeight();
} }
await _subscribeForUpdates(); await subscribeForUpdates();
await updateTransactions(); await updateTransactions();
await updateAllUnspents(); await updateAllUnspents();
await updateBalance(); await updateBalance();
await updateFeeRates(); await updateFeeRates();
@ -469,8 +480,14 @@ abstract class ElectrumWalletBase
final result = json.decode(response.body) as Map<String, num>; final result = json.decode(response.body) as Map<String, num>;
final slowFee = result['economyFee']?.toInt() ?? 0; final slowFee = result['economyFee']?.toInt() ?? 0;
final mediumFee = result['hourFee']?.toInt() ?? 0; int mediumFee = result['hourFee']?.toInt() ?? 0;
final fastFee = result['fastestFee']?.toInt() ?? 0; int fastFee = result['fastestFee']?.toInt() ?? 0;
if (slowFee == mediumFee) {
mediumFee++;
}
while (fastFee <= mediumFee) {
fastFee++;
}
_feeRates = [slowFee, mediumFee, fastFee]; _feeRates = [slowFee, mediumFee, fastFee];
return; return;
} catch (_) {} } catch (_) {}
@ -545,12 +562,14 @@ abstract class ElectrumWalletBase
try { try {
syncStatus = ConnectingSyncStatus(); syncStatus = ConnectingSyncStatus();
await _receiveStream?.cancel();
await electrumClient.close(); await electrumClient.close();
electrumClient.onConnectionStatusChange = _onConnectionStatusChange; electrumClient.onConnectionStatusChange = _onConnectionStatusChange;
await electrumClient.connectToUri(node.uri, useSSL: node.useSSL); await electrumClient.connectToUri(node.uri, useSSL: node.useSSL);
} catch (e) { } catch (e, stacktrace) {
print(stacktrace);
print(e.toString()); print(e.toString());
syncStatus = FailedSyncStatus(); syncStatus = FailedSyncStatus();
} }
@ -592,7 +611,7 @@ abstract class ElectrumWalletBase
allInputsAmount += utx.value; allInputsAmount += utx.value;
leftAmount = leftAmount - utx.value; leftAmount = leftAmount - utx.value;
final address = addressTypeFromStr(utx.address, network); final address = RegexUtils.addressTypeFromStr(utx.address, network);
ECPrivate? privkey; ECPrivate? privkey;
bool? isSilentPayment = false; bool? isSilentPayment = false;
@ -689,26 +708,15 @@ abstract class ElectrumWalletBase
paysToSilentPayment: hasSilentPayment, paysToSilentPayment: hasSilentPayment,
); );
int estimatedSize; int fee = await calcFee(
if (network is BitcoinCashNetwork) { utxos: utxoDetails.utxos,
estimatedSize = ForkedTransactionBuilder.estimateTransactionSize( outputs: outputs,
utxos: utxoDetails.utxos, network: network,
outputs: outputs, memo: memo,
network: network as BitcoinCashNetwork, feeRate: feeRate,
memo: memo, inputPrivKeyInfos: utxoDetails.inputPrivKeyInfos,
); vinOutpoints: utxoDetails.vinOutpoints,
} else { );
estimatedSize = BitcoinTransactionBuilder.estimateTransactionSize(
utxos: utxoDetails.utxos,
outputs: outputs,
network: network,
memo: memo,
inputPrivKeyInfos: utxoDetails.inputPrivKeyInfos,
vinOutpoints: utxoDetails.vinOutpoints,
);
}
int fee = feeAmountWithFeeRate(feeRate, 0, 0, size: estimatedSize);
if (fee == 0) { if (fee == 0) {
throw BitcoinTransactionNoFeeException(); throw BitcoinTransactionNoFeeException();
@ -795,33 +803,26 @@ abstract class ElectrumWalletBase
throw BitcoinTransactionWrongBalanceException(); throw BitcoinTransactionWrongBalanceException();
} }
final changeAddress = await walletAddresses.getChangeAddress(); final changeAddress = await walletAddresses.getChangeAddress(
final address = addressTypeFromStr(changeAddress, network); outputs: outputs,
utxoDetails: utxoDetails,
);
final address = RegexUtils.addressTypeFromStr(changeAddress, network);
outputs.add(BitcoinOutput( outputs.add(BitcoinOutput(
address: address, address: address,
value: BigInt.from(amountLeftForChangeAndFee), value: BigInt.from(amountLeftForChangeAndFee),
isChange: true,
)); ));
int estimatedSize; int fee = await calcFee(
if (network is BitcoinCashNetwork) { utxos: utxoDetails.utxos,
estimatedSize = ForkedTransactionBuilder.estimateTransactionSize( outputs: outputs,
utxos: utxoDetails.utxos, network: network,
outputs: outputs, memo: memo,
network: network as BitcoinCashNetwork, feeRate: feeRate,
memo: memo, inputPrivKeyInfos: utxoDetails.inputPrivKeyInfos,
); vinOutpoints: utxoDetails.vinOutpoints,
} else { );
estimatedSize = BitcoinTransactionBuilder.estimateTransactionSize(
utxos: utxoDetails.utxos,
outputs: outputs,
network: network,
memo: memo,
inputPrivKeyInfos: utxoDetails.inputPrivKeyInfos,
vinOutpoints: utxoDetails.vinOutpoints,
);
}
int fee = feeAmountWithFeeRate(feeRate, 0, 0, size: estimatedSize);
if (fee == 0) { if (fee == 0) {
throw BitcoinTransactionNoFeeException(); throw BitcoinTransactionNoFeeException();
@ -831,10 +832,16 @@ abstract class ElectrumWalletBase
final lastOutput = outputs.last; final lastOutput = outputs.last;
final amountLeftForChange = amountLeftForChangeAndFee - fee; final amountLeftForChange = amountLeftForChangeAndFee - fee;
print(amountLeftForChangeAndFee);
if (!_isBelowDust(amountLeftForChange)) { if (!_isBelowDust(amountLeftForChange)) {
// Here, lastOutput already is change, return the amount left without the fee to the user's address. // Here, lastOutput already is change, return the amount left without the fee to the user's address.
outputs[outputs.length - 1] = outputs[outputs.length - 1] = BitcoinOutput(
BitcoinOutput(address: lastOutput.address, value: BigInt.from(amountLeftForChange)); address: lastOutput.address,
value: BigInt.from(amountLeftForChange),
isSilentPayment: lastOutput.isSilentPayment,
isChange: true,
);
} else { } else {
// If has change that is lower than dust, will end up with tx rejected by network rules, so estimate again without the added change // If has change that is lower than dust, will end up with tx rejected by network rules, so estimate again without the added change
outputs.removeLast(); outputs.removeLast();
@ -876,7 +883,7 @@ abstract class ElectrumWalletBase
final totalAmount = amount + fee; final totalAmount = amount + fee;
if (totalAmount > balance[currency]!.confirmed) { if (totalAmount > (balance[currency]!.confirmed + balance[currency]!.secondConfirmed)) {
throw BitcoinTransactionWrongBalanceException(); throw BitcoinTransactionWrongBalanceException();
} }
@ -911,6 +918,37 @@ abstract class ElectrumWalletBase
); );
} }
Future<int> calcFee({
required List<UtxoWithAddress> utxos,
required List<BitcoinBaseOutput> outputs,
required BasedUtxoNetwork network,
String? memo,
required int feeRate,
List<ECPrivateInfo>? inputPrivKeyInfos,
List<Outpoint>? vinOutpoints,
}) async {
int estimatedSize;
if (network is BitcoinCashNetwork) {
estimatedSize = ForkedTransactionBuilder.estimateTransactionSize(
utxos: utxos,
outputs: outputs,
network: network,
memo: memo,
);
} else {
estimatedSize = BitcoinTransactionBuilder.estimateTransactionSize(
utxos: utxos,
outputs: outputs,
network: network,
memo: memo,
inputPrivKeyInfos: inputPrivKeyInfos,
vinOutpoints: vinOutpoints,
);
}
return feeAmountWithFeeRate(feeRate, 0, 0, size: estimatedSize);
}
@override @override
Future<PendingTransaction> createTransaction(Object credentials) async { Future<PendingTransaction> createTransaction(Object credentials) async {
try { try {
@ -938,18 +976,27 @@ abstract class ElectrumWalletBase
credentialsAmount += outputAmount; credentialsAmount += outputAmount;
final address = final address = RegexUtils.addressTypeFromStr(
addressTypeFromStr(out.isParsedAddress ? out.extractedAddress! : out.address, network); out.isParsedAddress ? out.extractedAddress! : out.address, network);
final isSilentPayment = address is SilentPaymentAddress;
if (address is SilentPaymentAddress) { if (isSilentPayment) {
hasSilentPayment = true; hasSilentPayment = true;
} }
if (sendAll) { if (sendAll) {
// The value will be changed after estimating the Tx size and deducting the fee from the total to be sent // The value will be changed after estimating the Tx size and deducting the fee from the total to be sent
outputs.add(BitcoinOutput(address: address, value: BigInt.from(0))); outputs.add(BitcoinOutput(
address: address,
value: BigInt.from(0),
isSilentPayment: isSilentPayment,
));
} else { } else {
outputs.add(BitcoinOutput(address: address, value: BigInt.from(outputAmount))); outputs.add(BitcoinOutput(
address: address,
value: BigInt.from(outputAmount),
isSilentPayment: isSilentPayment,
));
} }
} }
@ -1089,7 +1136,8 @@ abstract class ElectrumWalletBase
}); });
} }
unspentCoins.removeWhere((utxo) => estimatedTx.utxos.any((e) => e.utxo.txHash == utxo.hash)); unspentCoins
.removeWhere((utxo) => estimatedTx.utxos.any((e) => e.utxo.txHash == utxo.hash));
await updateBalance(); await updateBalance();
}); });
@ -1126,6 +1174,8 @@ abstract class ElectrumWalletBase
'derivationPath': walletInfo.derivationInfo?.derivationPath, 'derivationPath': walletInfo.derivationInfo?.derivationPath,
'silent_addresses': walletAddresses.silentAddresses.map((addr) => addr.toJSON()).toList(), 'silent_addresses': walletAddresses.silentAddresses.map((addr) => addr.toJSON()).toList(),
'silent_address_index': walletAddresses.currentSilentAddressIndex.toString(), 'silent_address_index': walletAddresses.currentSilentAddressIndex.toString(),
'mweb_addresses': walletAddresses.mwebAddresses.map((addr) => addr.toJSON()).toList(),
'alwaysScan': alwaysScan,
}); });
int feeRate(TransactionPriority priority) { int feeRate(TransactionPriority priority) {
@ -1237,19 +1287,15 @@ abstract class ElectrumWalletBase
@action @action
@override @override
Future<void> rescan({ Future<void> rescan({required int height, bool? doSingleScan}) async {
required int height,
int? chainTip,
ScanData? scanData,
bool? doSingleScan,
}) async {
silentPaymentsScanningActive = true; silentPaymentsScanningActive = true;
_setListeners(height, doSingleScan: doSingleScan); _setListeners(height, doSingleScan: doSingleScan);
} }
@override @override
Future<void> close() async { Future<void> close({required bool shouldCleanup}) async {
try { try {
await _receiveStream?.cancel();
await electrumClient.close(); await electrumClient.close();
} catch (_) {} } catch (_) {}
_autoSaveTimer?.cancel(); _autoSaveTimer?.cancel();
@ -1269,77 +1315,70 @@ abstract class ElectrumWalletBase
}); });
} }
await Future.wait(walletAddresses.allAddresses.map((address) async { // Set the balance of all non-silent payment addresses to 0 before updating
walletAddresses.allAddresses
.where((element) => element.type != SegwitAddresType.mweb)
.forEach((addr) {
if (addr is! BitcoinSilentPaymentAddressRecord) addr.balance = 0;
});
await Future.wait(walletAddresses.allAddresses
.where((element) => element.type != SegwitAddresType.mweb)
.map((address) async {
updatedUnspentCoins.addAll(await fetchUnspent(address)); updatedUnspentCoins.addAll(await fetchUnspent(address));
})); }));
unspentCoins = updatedUnspentCoins; unspentCoins = updatedUnspentCoins;
if (unspentCoinsInfo.isEmpty) { if (unspentCoinsInfo.length != updatedUnspentCoins.length) {
unspentCoins.forEach((coin) => _addCoinInfo(coin)); unspentCoins.forEach((coin) => addCoinInfo(coin));
return; return;
} }
if (unspentCoins.isNotEmpty) { await updateCoins(unspentCoins);
unspentCoins.forEach((coin) {
final coinInfoList = unspentCoinsInfo.values.where((element) =>
element.walletId.contains(id) &&
element.hash.contains(coin.hash) &&
element.vout == coin.vout);
if (coinInfoList.isNotEmpty) {
final coinInfo = coinInfoList.first;
coin.isFrozen = coinInfo.isFrozen;
coin.isSending = coinInfo.isSending;
coin.note = coinInfo.note;
if (coin.bitcoinAddressRecord is! BitcoinSilentPaymentAddressRecord)
coin.bitcoinAddressRecord.balance += coinInfo.value;
} else {
_addCoinInfo(coin);
}
});
}
await _refreshUnspentCoinsInfo(); await _refreshUnspentCoinsInfo();
} }
@action Future<void> updateCoins(List<BitcoinUnspent> newUnspentCoins) async {
Future<void> updateUnspents(BitcoinAddressRecord address) async { if (newUnspentCoins.isEmpty) {
final newUnspentCoins = await fetchUnspent(address); return;
if (newUnspentCoins.isNotEmpty) {
unspentCoins.addAll(newUnspentCoins);
newUnspentCoins.forEach((coin) {
final coinInfoList = unspentCoinsInfo.values.where(
(element) =>
element.walletId.contains(id) &&
element.hash.contains(coin.hash) &&
element.vout == coin.vout,
);
if (coinInfoList.isNotEmpty) {
final coinInfo = coinInfoList.first;
coin.isFrozen = coinInfo.isFrozen;
coin.isSending = coinInfo.isSending;
coin.note = coinInfo.note;
if (coin.bitcoinAddressRecord is! BitcoinSilentPaymentAddressRecord)
coin.bitcoinAddressRecord.balance += coinInfo.value;
} else {
_addCoinInfo(coin);
}
});
} }
newUnspentCoins.forEach((coin) {
final coinInfoList = unspentCoinsInfo.values.where(
(element) =>
element.walletId.contains(id) &&
element.hash.contains(coin.hash) &&
element.vout == coin.vout,
);
if (coinInfoList.isNotEmpty) {
final coinInfo = coinInfoList.first;
coin.isFrozen = coinInfo.isFrozen;
coin.isSending = coinInfo.isSending;
coin.note = coinInfo.note;
if (coin.bitcoinAddressRecord is! BitcoinSilentPaymentAddressRecord)
coin.bitcoinAddressRecord.balance += coinInfo.value;
} else {
addCoinInfo(coin);
}
});
}
@action
Future<void> updateUnspentsForAddress(BitcoinAddressRecord address) async {
final newUnspentCoins = await fetchUnspent(address);
await updateCoins(newUnspentCoins);
} }
@action @action
Future<List<BitcoinUnspent>> fetchUnspent(BitcoinAddressRecord address) async { Future<List<BitcoinUnspent>> fetchUnspent(BitcoinAddressRecord address) async {
final unspents = await electrumClient.getListUnspent(address.getScriptHash(network)); List<Map<String, dynamic>> unspents = [];
List<BitcoinUnspent> updatedUnspentCoins = []; List<BitcoinUnspent> updatedUnspentCoins = [];
unspents = await electrumClient.getListUnspent(address.getScriptHash(network));
await Future.wait(unspents.map((unspent) async { await Future.wait(unspents.map((unspent) async {
try { try {
final coin = BitcoinUnspent.fromJSON(address, unspent); final coin = BitcoinUnspent.fromJSON(address, unspent);
@ -1355,7 +1394,7 @@ abstract class ElectrumWalletBase
} }
@action @action
Future<void> _addCoinInfo(BitcoinUnspent coin) async { Future<void> addCoinInfo(BitcoinUnspent coin) async {
final newInfo = UnspentCoinsInfo( final newInfo = UnspentCoinsInfo(
walletId: id, walletId: id,
hash: coin.hash, hash: coin.hash,
@ -1397,14 +1436,16 @@ abstract class ElectrumWalletBase
} }
} }
Future<bool> canReplaceByFee(ElectrumTransactionInfo tx) async { int transactionVSize(String transactionHex) => BtcTransaction.fromRaw(transactionHex).getVSize();
Future<String?> canReplaceByFee(ElectrumTransactionInfo tx) async {
try { try {
final bundle = await getTransactionExpanded(hash: tx.txHash); final bundle = await getTransactionExpanded(hash: tx.txHash);
_updateInputsAndOutputs(tx, bundle); _updateInputsAndOutputs(tx, bundle);
if (bundle.confirmations > 0) return false; if (bundle.confirmations > 0) return null;
return bundle.originalTransaction.canReplaceByFee; return bundle.originalTransaction.canReplaceByFee ? bundle.originalTransaction.toHex() : null;
} catch (e) { } catch (e) {
return false; return null;
} }
} }
@ -1460,7 +1501,7 @@ abstract class ElectrumWalletBase
final addressRecord = final addressRecord =
walletAddresses.allAddresses.firstWhere((element) => element.address == address); walletAddresses.allAddresses.firstWhere((element) => element.address == address);
final btcAddress = addressTypeFromStr(addressRecord.address, network); final btcAddress = RegexUtils.addressTypeFromStr(addressRecord.address, network);
final privkey = generateECPrivate( final privkey = generateECPrivate(
hd: addressRecord.isHidden ? walletAddresses.sideHd : walletAddresses.mainHd, hd: addressRecord.isHidden ? walletAddresses.sideHd : walletAddresses.mainHd,
index: addressRecord.index, index: addressRecord.index,
@ -1501,7 +1542,7 @@ abstract class ElectrumWalletBase
} }
final address = addressFromOutputScript(out.scriptPubKey, network); final address = addressFromOutputScript(out.scriptPubKey, network);
final btcAddress = addressTypeFromStr(address, network); final btcAddress = RegexUtils.addressTypeFromStr(address, network);
outputs.add(BitcoinOutput(address: btcAddress, value: BigInt.from(out.amount.toInt()))); outputs.add(BitcoinOutput(address: btcAddress, value: BigInt.from(out.amount.toInt())));
} }
@ -1586,6 +1627,13 @@ abstract class ElectrumWalletBase
hasChange: changeOutputs.isNotEmpty, hasChange: changeOutputs.isNotEmpty,
feeRate: newFee.toString(), feeRate: newFee.toString(),
)..addListener((transaction) async { )..addListener((transaction) async {
transactionHistory.transactions.values.forEach((tx) {
if (tx.id == hash) {
tx.isReplaced = true;
tx.isPending = false;
transactionHistory.addOne(tx);
}
});
transactionHistory.addOne(transaction); transactionHistory.addOne(transaction);
await updateBalance(); await updateBalance();
}); });
@ -1597,8 +1645,6 @@ abstract class ElectrumWalletBase
Future<ElectrumTransactionBundle> getTransactionExpanded( Future<ElectrumTransactionBundle> getTransactionExpanded(
{required String hash, int? height}) async { {required String hash, int? height}) async {
String transactionHex; String transactionHex;
// TODO: time is not always available, and calculating it from height is not always accurate.
// Add settings to choose API provider and use and http server instead of electrum for this.
int? time; int? time;
int? confirmations; int? confirmations;
@ -1606,6 +1652,31 @@ abstract class ElectrumWalletBase
if (verboseTransaction.isEmpty) { if (verboseTransaction.isEmpty) {
transactionHex = await electrumClient.getTransactionHex(hash: hash); transactionHex = await electrumClient.getTransactionHex(hash: hash);
if (height != null && height > 0 && await checkIfMempoolAPIIsEnabled()) {
try {
final blockHash = await http.get(
Uri.parse(
"http://mempool.cakewallet.com:8999/api/v1/block-height/$height",
),
);
if (blockHash.statusCode == 200 &&
blockHash.body.isNotEmpty &&
jsonDecode(blockHash.body) != null) {
final blockResponse = await http.get(
Uri.parse(
"http://mempool.cakewallet.com:8999/api/v1/block/${blockHash.body}",
),
);
if (blockResponse.statusCode == 200 &&
blockResponse.body.isNotEmpty &&
jsonDecode(blockResponse.body)['timestamp'] != null) {
time = int.parse(jsonDecode(blockResponse.body)['timestamp'].toString());
}
}
} catch (_) {}
}
} else { } else {
transactionHex = verboseTransaction['hex'] as String; transactionHex = verboseTransaction['hex'] as String;
time = verboseTransaction['time'] as int?; time = verboseTransaction['time'] as int?;
@ -1613,7 +1684,7 @@ abstract class ElectrumWalletBase
} }
if (height != null) { if (height != null) {
if (time == null) { if (time == null && height > 0) {
time = (getDateByBitcoinHeight(height).millisecondsSinceEpoch / 1000).round(); time = (getDateByBitcoinHeight(height).millisecondsSinceEpoch / 1000).round();
} }
@ -1676,12 +1747,14 @@ abstract class ElectrumWalletBase
final Map<String, ElectrumTransactionInfo> historiesWithDetails = {}; final Map<String, ElectrumTransactionInfo> historiesWithDetails = {};
if (type == WalletType.bitcoin) { if (type == WalletType.bitcoin) {
await Future.wait(ADDRESS_TYPES await Future.wait(BITCOIN_ADDRESS_TYPES
.map((type) => fetchTransactionsForAddressType(historiesWithDetails, type))); .map((type) => fetchTransactionsForAddressType(historiesWithDetails, type)));
} else if (type == WalletType.bitcoinCash) { } else if (type == WalletType.bitcoinCash) {
await fetchTransactionsForAddressType(historiesWithDetails, P2pkhAddressType.p2pkh); await Future.wait(BITCOIN_CASH_ADDRESS_TYPES
.map((type) => fetchTransactionsForAddressType(historiesWithDetails, type)));
} else if (type == WalletType.litecoin) { } else if (type == WalletType.litecoin) {
await fetchTransactionsForAddressType(historiesWithDetails, SegwitAddresType.p2wpkh); await Future.wait(LITECOIN_ADDRESS_TYPES
.map((type) => fetchTransactionsForAddressType(historiesWithDetails, type)));
} }
transactionHistory.transactions.values.forEach((tx) async { transactionHistory.transactions.values.forEach((tx) async {
@ -1715,7 +1788,8 @@ abstract class ElectrumWalletBase
final addressesByType = walletAddresses.allAddresses.where((addr) => addr.type == type); final addressesByType = walletAddresses.allAddresses.where((addr) => addr.type == type);
final hiddenAddresses = addressesByType.where((addr) => addr.isHidden == true); final hiddenAddresses = addressesByType.where((addr) => addr.isHidden == true);
final receiveAddresses = addressesByType.where((addr) => addr.isHidden == false); final receiveAddresses = addressesByType.where((addr) => addr.isHidden == false);
walletAddresses.hiddenAddresses.addAll(hiddenAddresses.map((e) => e.address));
await walletAddresses.saveAddressesInBox();
await Future.wait(addressesByType.map((addressRecord) async { await Future.wait(addressesByType.map((addressRecord) async {
final history = await _fetchAddressHistory(addressRecord, await getCurrentChainTip()); final history = await _fetchAddressHistory(addressRecord, await getCurrentChainTip());
@ -1738,7 +1812,7 @@ abstract class ElectrumWalletBase
matchedAddresses.toList(), matchedAddresses.toList(),
addressRecord.isHidden, addressRecord.isHidden,
(address) async { (address) async {
await _subscribeForUpdates(); await subscribeForUpdates();
return _fetchAddressHistory(address, await getCurrentChainTip()) return _fetchAddressHistory(address, await getCurrentChainTip())
.then((history) => history.isNotEmpty ? address.address : null); .then((history) => history.isNotEmpty ? address.address : null);
}, },
@ -1757,6 +1831,8 @@ abstract class ElectrumWalletBase
Future<Map<String, ElectrumTransactionInfo>> _fetchAddressHistory( Future<Map<String, ElectrumTransactionInfo>> _fetchAddressHistory(
BitcoinAddressRecord addressRecord, int? currentHeight) async { BitcoinAddressRecord addressRecord, int? currentHeight) async {
String txid = "";
try { try {
final Map<String, ElectrumTransactionInfo> historiesWithDetails = {}; final Map<String, ElectrumTransactionInfo> historiesWithDetails = {};
@ -1766,7 +1842,7 @@ abstract class ElectrumWalletBase
addressRecord.setAsUsed(); addressRecord.setAsUsed();
await Future.wait(history.map((transaction) async { await Future.wait(history.map((transaction) async {
final txid = transaction['tx_hash'] as String; txid = transaction['tx_hash'] as String;
final height = transaction['height'] as int; final height = transaction['height'] as int;
final storedTx = transactionHistory.transactions[txid]; final storedTx = transactionHistory.transactions[txid];
@ -1797,13 +1873,18 @@ abstract class ElectrumWalletBase
} }
return historiesWithDetails; return historiesWithDetails;
} catch (e) { } catch (e, stacktrace) {
print(e.toString()); _onError?.call(FlutterErrorDetails(
exception: "$txid - $e",
stack: stacktrace,
library: this.runtimeType.toString(),
));
return {}; return {};
} }
} }
Future<void> updateTransactions() async { Future<void> updateTransactions() async {
print("updateTransactions() called!");
try { try {
if (_isTransactionUpdating) { if (_isTransactionUpdating) {
return; return;
@ -1827,24 +1908,28 @@ abstract class ElectrumWalletBase
} }
} }
Future<void> _subscribeForUpdates() async { Future<void> subscribeForUpdates() async {
final unsubscribedScriptHashes = walletAddresses.allAddresses.where( final unsubscribedScriptHashes = walletAddresses.allAddresses.where(
(address) => !_scripthashesUpdateSubject.containsKey(address.getScriptHash(network)), (address) =>
!_scripthashesUpdateSubject.containsKey(address.getScriptHash(network)) &&
address.type != SegwitAddresType.mweb,
); );
await Future.wait(unsubscribedScriptHashes.map((address) async { await Future.wait(unsubscribedScriptHashes.map((address) async {
final sh = address.getScriptHash(network); final sh = address.getScriptHash(network);
await _scripthashesUpdateSubject[sh]?.close(); if (!(_scripthashesUpdateSubject[sh]?.isClosed ?? true)) {
await _scripthashesUpdateSubject[sh]?.close();
}
_scripthashesUpdateSubject[sh] = await electrumClient.scripthashUpdate(sh); _scripthashesUpdateSubject[sh] = await electrumClient.scripthashUpdate(sh);
_scripthashesUpdateSubject[sh]?.listen((event) async { _scripthashesUpdateSubject[sh]?.listen((event) async {
try { try {
await updateUnspents(address); await updateUnspentsForAddress(address);
await updateBalance(); await updateBalance();
await _fetchAddressHistory(address, await getCurrentChainTip()); await _fetchAddressHistory(address, await getCurrentChainTip());
} catch (e, s) { } catch (e, s) {
print(e.toString()); print("sub error: $e");
_onError?.call(FlutterErrorDetails( _onError?.call(FlutterErrorDetails(
exception: e, exception: e,
stack: s, stack: s,
@ -1855,12 +1940,14 @@ abstract class ElectrumWalletBase
})); }));
} }
Future<ElectrumBalance> _fetchBalances() async { Future<ElectrumBalance> fetchBalances() async {
final addresses = walletAddresses.allAddresses.toList(); final addresses = walletAddresses.allAddresses
.where((address) => RegexUtils.addressTypeFromStr(address.address, network) is! MwebAddress)
.toList();
final balanceFutures = <Future<Map<String, dynamic>>>[]; final balanceFutures = <Future<Map<String, dynamic>>>[];
for (var i = 0; i < addresses.length; i++) { for (var i = 0; i < addresses.length; i++) {
final addressRecord = addresses[i]; final addressRecord = addresses[i];
final sh = scriptHash(addressRecord.address, network: network); final sh = addressRecord.getScriptHash(network);
final balanceFuture = electrumClient.getBalance(sh); final balanceFuture = electrumClient.getBalance(sh);
balanceFutures.add(balanceFuture); balanceFutures.add(balanceFuture);
} }
@ -1869,6 +1956,18 @@ abstract class ElectrumWalletBase
var totalConfirmed = 0; var totalConfirmed = 0;
var totalUnconfirmed = 0; var totalUnconfirmed = 0;
unspentCoinsInfo.values.forEach((info) {
unspentCoins.forEach((element) {
if (element.hash == info.hash &&
element.vout == info.vout &&
info.isFrozen &&
element.bitcoinAddressRecord.address == info.address &&
element.value == info.value) {
totalFrozen += element.value;
}
});
});
if (hasSilentPaymentsScanning) { if (hasSilentPaymentsScanning) {
// Add values from unspent coins that are not fetched by the address list // Add values from unspent coins that are not fetched by the address list
// i.e. scanned silent payments // i.e. scanned silent payments
@ -1894,32 +1993,25 @@ abstract class ElectrumWalletBase
totalConfirmed += confirmed; totalConfirmed += confirmed;
totalUnconfirmed += unconfirmed; totalUnconfirmed += unconfirmed;
addressRecord.balance = confirmed + unconfirmed;
if (confirmed > 0 || unconfirmed > 0) { if (confirmed > 0 || unconfirmed > 0) {
addressRecord.setAsUsed(); addressRecord.setAsUsed();
} }
} }
return ElectrumBalance( return ElectrumBalance(
confirmed: totalConfirmed, unconfirmed: totalUnconfirmed, frozen: totalFrozen); confirmed: totalConfirmed,
unconfirmed: totalUnconfirmed,
frozen: totalFrozen,
);
} }
Future<void> updateBalance() async { Future<void> updateBalance() async {
balance[currency] = await _fetchBalances(); print("updateBalance() called!");
balance[currency] = await fetchBalances();
await save(); await save();
} }
String getChangeAddress() {
const minCountOfHiddenAddresses = 5;
final random = Random();
var addresses = walletAddresses.allAddresses.where((addr) => addr.isHidden).toList();
if (addresses.length < minCountOfHiddenAddresses) {
addresses = walletAddresses.allAddresses.toList();
}
return addresses[random.nextInt(addresses.length)].address;
}
@override @override
void setExceptionHandler(void Function(FlutterErrorDetails) onError) => _onError = onError; void setExceptionHandler(void Function(FlutterErrorDetails) onError) => _onError = onError;
@ -1968,7 +2060,7 @@ abstract class ElectrumWalletBase
List<int> possibleRecoverIds = [0, 1]; List<int> possibleRecoverIds = [0, 1];
final baseAddress = addressTypeFromStr(address, network); final baseAddress = RegexUtils.addressTypeFromStr(address, network);
for (int recoveryId in possibleRecoverIds) { for (int recoveryId in possibleRecoverIds) {
final pubKey = sig.recoverPublicKey(messageHash, Curves.generatorSecp256k1, recoveryId); final pubKey = sig.recoverPublicKey(messageHash, Curves.generatorSecp256k1, recoveryId);
@ -2061,7 +2153,8 @@ abstract class ElectrumWalletBase
_isTryingToConnect = true; _isTryingToConnect = true;
Timer(Duration(seconds: 5), () { Timer(Duration(seconds: 5), () {
if (this.syncStatus is NotConnectedSyncStatus || this.syncStatus is LostConnectionSyncStatus) { if (this.syncStatus is NotConnectedSyncStatus ||
this.syncStatus is LostConnectionSyncStatus) {
this.electrumClient.connectToUri( this.electrumClient.connectToUri(
node!.uri, node!.uri,
useSSL: node!.useSSL ?? false, useSSL: node!.useSSL ?? false,
@ -2192,21 +2285,22 @@ Future<void> startRefresh(ScanData scanData) async {
BehaviorSubject<Object>? tweaksSubscription = null; BehaviorSubject<Object>? tweaksSubscription = null;
final syncingStatus = scanData.isSingleScan
? SyncingSyncStatus(1, 0)
: SyncingSyncStatus.fromHeightValues(scanData.chainTip, initialSyncHeight, syncHeight);
// Initial status UI update, send how many blocks left to scan
scanData.sendPort.send(SyncResponse(syncHeight, syncingStatus));
final electrumClient = scanData.electrumClient; final electrumClient = scanData.electrumClient;
await electrumClient.connectToUri( await electrumClient.connectToUri(
scanData.node?.uri ?? Uri.parse("tcp://electrs.cakewallet.com:50001"), scanData.node?.uri ?? Uri.parse("tcp://electrs.cakewallet.com:50001"),
useSSL: scanData.node?.useSSL ?? false, useSSL: scanData.node?.useSSL ?? false,
); );
int getCountPerRequest(int syncHeight) {
if (scanData.isSingleScan) {
return 1;
}
final amountLeft = scanData.chainTip - syncHeight + 1;
return amountLeft;
}
if (tweaksSubscription == null) { if (tweaksSubscription == null) {
final count = scanData.isSingleScan ? 1 : TWEAKS_COUNT;
final receiver = Receiver( final receiver = Receiver(
scanData.silentAddress.b_scan.toHex(), scanData.silentAddress.b_scan.toHex(),
scanData.silentAddress.B_spend.toHex(), scanData.silentAddress.B_spend.toHex(),
@ -2215,16 +2309,45 @@ Future<void> startRefresh(ScanData scanData) async {
scanData.labelIndexes.length, scanData.labelIndexes.length,
); );
tweaksSubscription = await electrumClient.tweaksSubscribe(height: syncHeight, count: count); // Initial status UI update, send how many blocks in total to scan
tweaksSubscription?.listen((t) async { final initialCount = getCountPerRequest(syncHeight);
final tweaks = t as Map<String, dynamic>; scanData.sendPort.send(SyncResponse(syncHeight, StartingScanSyncStatus(syncHeight)));
if (tweaks["message"] != null) { tweaksSubscription = await electrumClient.tweaksSubscribe(
height: syncHeight,
count: initialCount,
);
Future<void> listenFn(t) async {
final tweaks = t as Map<String, dynamic>;
final msg = tweaks["message"];
// success or error msg
final noData = msg != null;
if (noData) {
// re-subscribe to continue receiving messages, starting from the next unscanned height // re-subscribe to continue receiving messages, starting from the next unscanned height
electrumClient.tweaksSubscribe(height: syncHeight + 1, count: count); final nextHeight = syncHeight + 1;
final nextCount = getCountPerRequest(nextHeight);
if (nextCount > 0) {
tweaksSubscription?.close();
final nextTweaksSubscription = electrumClient.tweaksSubscribe(
height: nextHeight,
count: nextCount,
);
nextTweaksSubscription?.listen(listenFn);
}
return; return;
} }
// Continuous status UI update, send how many blocks left to scan
final syncingStatus = scanData.isSingleScan
? SyncingSyncStatus(1, 0)
: SyncingSyncStatus.fromHeightValues(scanData.chainTip, initialSyncHeight, syncHeight);
scanData.sendPort.send(SyncResponse(syncHeight, syncingStatus));
final blockHeight = tweaks.keys.first; final blockHeight = tweaks.keys.first;
final tweakHeight = int.parse(blockHeight); final tweakHeight = int.parse(blockHeight);
@ -2259,11 +2382,13 @@ Future<void> startRefresh(ScanData scanData) async {
fee: 0, fee: 0,
direction: TransactionDirection.incoming, direction: TransactionDirection.incoming,
isPending: false, isPending: false,
isReplaced: false,
date: scanData.network == BitcoinNetwork.mainnet date: scanData.network == BitcoinNetwork.mainnet
? getDateByBitcoinHeight(tweakHeight) ? getDateByBitcoinHeight(tweakHeight)
: DateTime.now(), : DateTime.now(),
confirmations: scanData.chainTip - tweakHeight + 1, confirmations: scanData.chainTip - tweakHeight + 1,
unspents: [], unspents: [],
isReceivedSilentPayment: true,
); );
addToWallet.forEach((label, value) { addToWallet.forEach((label, value) {
@ -2318,16 +2443,6 @@ Future<void> startRefresh(ScanData scanData) async {
} catch (_) {} } catch (_) {}
syncHeight = tweakHeight; syncHeight = tweakHeight;
scanData.sendPort.send(
SyncResponse(
syncHeight,
SyncingSyncStatus.fromHeightValues(
scanData.chainTip,
initialSyncHeight,
syncHeight,
),
),
);
if (tweakHeight >= scanData.chainTip || scanData.isSingleScan) { if (tweakHeight >= scanData.chainTip || scanData.isSingleScan) {
if (tweakHeight >= scanData.chainTip) if (tweakHeight >= scanData.chainTip)
@ -2343,7 +2458,9 @@ Future<void> startRefresh(ScanData scanData) async {
await tweaksSubscription!.close(); await tweaksSubscription!.close();
await electrumClient.close(); await electrumClient.close();
} }
}); }
tweaksSubscription?.listen(listenFn);
} }
if (tweaksSubscription == null) { if (tweaksSubscription == null) {
@ -2373,6 +2490,8 @@ class EstimatedTxResult {
final int fee; final int fee;
final int amount; final int amount;
final bool spendsSilentPayment; final bool spendsSilentPayment;
// final bool sendsToSilentPayment;
final bool hasChange; final bool hasChange;
final bool isSendAll; final bool isSendAll;
final String? memo; final String? memo;
@ -2386,31 +2505,6 @@ class PublicKeyWithDerivationPath {
final String publicKey; final String publicKey;
} }
BitcoinBaseAddress addressTypeFromStr(String address, BasedUtxoNetwork network) {
if (network is BitcoinCashNetwork) {
if (!address.startsWith("bitcoincash:") &&
(address.startsWith("q") || address.startsWith("p"))) {
address = "bitcoincash:$address";
}
return BitcoinCashAddress(address).baseAddress;
}
if (P2pkhAddress.regex.hasMatch(address)) {
return P2pkhAddress.fromAddress(address: address, network: network);
} else if (P2shAddress.regex.hasMatch(address)) {
return P2shAddress.fromAddress(address: address, network: network);
} else if (P2wshAddress.regex.hasMatch(address)) {
return P2wshAddress.fromAddress(address: address, network: network);
} else if (P2trAddress.regex.hasMatch(address)) {
return P2trAddress.fromAddress(address: address, network: network);
} else if (SilentPaymentAddress.regex.hasMatch(address)) {
return SilentPaymentAddress.fromAddress(address);
} else {
return P2wpkhAddress.fromAddress(address: address, network: network);
}
}
BitcoinAddressType _getScriptType(BitcoinBaseAddress type) { BitcoinAddressType _getScriptType(BitcoinBaseAddress type) {
if (type is P2pkhAddress) { if (type is P2pkhAddress) {
return P2pkhAddressType.p2pkh; return P2pkhAddressType.p2pkh;
@ -2420,6 +2514,8 @@ BitcoinAddressType _getScriptType(BitcoinBaseAddress type) {
return SegwitAddresType.p2wsh; return SegwitAddresType.p2wsh;
} else if (type is P2trAddress) { } else if (type is P2trAddress) {
return SegwitAddresType.p2tr; return SegwitAddresType.p2tr;
} else if (type is MwebAddress) {
return SegwitAddresType.mweb;
} else if (type is SilentPaymentsAddresType) { } else if (type is SilentPaymentsAddresType) {
return SilentPaymentsAddresType.p2sp; return SilentPaymentsAddresType.p2sp;
} else { } else {

View file

@ -1,6 +1,9 @@
import 'dart:io' show Platform;
import 'package:bitcoin_base/bitcoin_base.dart'; import 'package:bitcoin_base/bitcoin_base.dart';
import 'package:blockchain_utils/blockchain_utils.dart'; import 'package:blockchain_utils/blockchain_utils.dart';
import 'package:cw_bitcoin/bitcoin_address_record.dart'; import 'package:cw_bitcoin/bitcoin_address_record.dart';
import 'package:cw_bitcoin/electrum_wallet.dart';
import 'package:cw_core/wallet_addresses.dart'; import 'package:cw_core/wallet_addresses.dart';
import 'package:cw_core/wallet_info.dart'; import 'package:cw_core/wallet_info.dart';
import 'package:cw_core/wallet_type.dart'; import 'package:cw_core/wallet_type.dart';
@ -10,7 +13,7 @@ part 'electrum_wallet_addresses.g.dart';
class ElectrumWalletAddresses = ElectrumWalletAddressesBase with _$ElectrumWalletAddresses; class ElectrumWalletAddresses = ElectrumWalletAddressesBase with _$ElectrumWalletAddresses;
const List<BitcoinAddressType> ADDRESS_TYPES = [ const List<BitcoinAddressType> BITCOIN_ADDRESS_TYPES = [
SegwitAddresType.p2wpkh, SegwitAddresType.p2wpkh,
P2pkhAddressType.p2pkh, P2pkhAddressType.p2pkh,
SegwitAddresType.p2tr, SegwitAddresType.p2tr,
@ -18,6 +21,15 @@ const List<BitcoinAddressType> ADDRESS_TYPES = [
P2shAddressType.p2wpkhInP2sh, P2shAddressType.p2wpkhInP2sh,
]; ];
const List<BitcoinAddressType> LITECOIN_ADDRESS_TYPES = [
SegwitAddresType.p2wpkh,
SegwitAddresType.mweb,
];
const List<BitcoinAddressType> BITCOIN_CASH_ADDRESS_TYPES = [
P2pkhAddressType.p2pkh,
];
abstract class ElectrumWalletAddressesBase extends WalletAddresses with Store { abstract class ElectrumWalletAddressesBase extends WalletAddresses with Store {
ElectrumWalletAddressesBase( ElectrumWalletAddressesBase(
WalletInfo walletInfo, { WalletInfo walletInfo, {
@ -29,6 +41,7 @@ abstract class ElectrumWalletAddressesBase extends WalletAddresses with Store {
Map<String, int>? initialChangeAddressIndex, Map<String, int>? initialChangeAddressIndex,
List<BitcoinSilentPaymentAddressRecord>? initialSilentAddresses, List<BitcoinSilentPaymentAddressRecord>? initialSilentAddresses,
int initialSilentAddressIndex = 0, int initialSilentAddressIndex = 0,
List<BitcoinAddressRecord>? initialMwebAddresses,
Bip32Slip10Secp256k1? masterHd, Bip32Slip10Secp256k1? masterHd,
BitcoinAddressType? initialAddressPageType, BitcoinAddressType? initialAddressPageType,
}) : _addresses = ObservableList<BitcoinAddressRecord>.of((initialAddresses ?? []).toSet()), }) : _addresses = ObservableList<BitcoinAddressRecord>.of((initialAddresses ?? []).toSet()),
@ -49,6 +62,8 @@ abstract class ElectrumWalletAddressesBase extends WalletAddresses with Store {
silentAddresses = ObservableList<BitcoinSilentPaymentAddressRecord>.of( silentAddresses = ObservableList<BitcoinSilentPaymentAddressRecord>.of(
(initialSilentAddresses ?? []).toSet()), (initialSilentAddresses ?? []).toSet()),
currentSilentAddressIndex = initialSilentAddressIndex, currentSilentAddressIndex = initialSilentAddressIndex,
mwebAddresses =
ObservableList<BitcoinAddressRecord>.of((initialMwebAddresses ?? []).toSet()),
super(walletInfo) { super(walletInfo) {
if (masterHd != null) { if (masterHd != null) {
silentAddress = SilentPaymentOwner.fromPrivateKeys( silentAddress = SilentPaymentOwner.fromPrivateKeys(
@ -87,10 +102,13 @@ abstract class ElectrumWalletAddressesBase extends WalletAddresses with Store {
static const gap = 20; static const gap = 20;
final ObservableList<BitcoinAddressRecord> _addresses; final ObservableList<BitcoinAddressRecord> _addresses;
late ObservableList<BaseBitcoinAddressRecord> addressesByReceiveType; final ObservableList<BaseBitcoinAddressRecord> addressesByReceiveType;
final ObservableList<BitcoinAddressRecord> receiveAddresses; final ObservableList<BitcoinAddressRecord> receiveAddresses;
final ObservableList<BitcoinAddressRecord> changeAddresses; final ObservableList<BitcoinAddressRecord> changeAddresses;
// TODO: add this variable in `bitcoin_wallet_addresses` and just add a cast in cw_bitcoin to use it
final ObservableList<BitcoinSilentPaymentAddressRecord> silentAddresses; final ObservableList<BitcoinSilentPaymentAddressRecord> silentAddresses;
// TODO: add this variable in `litecoin_wallet_addresses` and just add a cast in cw_bitcoin to use it
final ObservableList<BitcoinAddressRecord> mwebAddresses;
final BasedUtxoNetwork network; final BasedUtxoNetwork network;
final Bip32Slip10Secp256k1 mainHd; final Bip32Slip10Secp256k1 mainHd;
final Bip32Slip10Secp256k1 sideHd; final Bip32Slip10Secp256k1 sideHd;
@ -149,6 +167,9 @@ abstract class ElectrumWalletAddressesBase extends WalletAddresses with Store {
@override @override
set address(String addr) { set address(String addr) {
if (addr == "Silent Payments" && SilentPaymentsAddresType.p2sp != addressPageType) {
return;
}
if (addressPageType == SilentPaymentsAddresType.p2sp) { if (addressPageType == SilentPaymentsAddresType.p2sp) {
final selected = silentAddresses.firstWhere((addressRecord) => addressRecord.address == addr); final selected = silentAddresses.firstWhere((addressRecord) => addressRecord.address == addr);
@ -160,12 +181,17 @@ abstract class ElectrumWalletAddressesBase extends WalletAddresses with Store {
} }
return; return;
} }
try {
final addressRecord = _addresses.firstWhere((addressRecord) => addressRecord.address == addr); final addressRecord = _addresses.firstWhere(
(addressRecord) => addressRecord.address == addr,
);
previousAddressRecord = addressRecord; previousAddressRecord = addressRecord;
receiveAddresses.remove(addressRecord); receiveAddresses.remove(addressRecord);
receiveAddresses.insert(0, addressRecord); receiveAddresses.insert(0, addressRecord);
} catch (e) {
print("ElectrumWalletAddressBase: set address ($addr): $e");
}
} }
@override @override
@ -213,7 +239,10 @@ abstract class ElectrumWalletAddressesBase extends WalletAddresses with Store {
if (walletInfo.type == WalletType.bitcoinCash) { if (walletInfo.type == WalletType.bitcoinCash) {
await _generateInitialAddresses(type: P2pkhAddressType.p2pkh); await _generateInitialAddresses(type: P2pkhAddressType.p2pkh);
} else if (walletInfo.type == WalletType.litecoin) { } else if (walletInfo.type == WalletType.litecoin) {
await _generateInitialAddresses(); await _generateInitialAddresses(type: SegwitAddresType.p2wpkh);
if (Platform.isAndroid || Platform.isIOS) {
await _generateInitialAddresses(type: SegwitAddresType.mweb);
}
} else if (walletInfo.type == WalletType.bitcoin) { } else if (walletInfo.type == WalletType.bitcoin) {
await _generateInitialAddresses(); await _generateInitialAddresses();
await _generateInitialAddresses(type: P2pkhAddressType.p2pkh); await _generateInitialAddresses(type: P2pkhAddressType.p2pkh);
@ -221,6 +250,7 @@ abstract class ElectrumWalletAddressesBase extends WalletAddresses with Store {
await _generateInitialAddresses(type: SegwitAddresType.p2tr); await _generateInitialAddresses(type: SegwitAddresType.p2tr);
await _generateInitialAddresses(type: SegwitAddresType.p2wsh); await _generateInitialAddresses(type: SegwitAddresType.p2wsh);
} }
updateAddressesByMatch(); updateAddressesByMatch();
updateReceiveAddresses(); updateReceiveAddresses();
updateChangeAddresses(); updateChangeAddresses();
@ -237,7 +267,7 @@ abstract class ElectrumWalletAddressesBase extends WalletAddresses with Store {
} }
@action @action
Future<String> getChangeAddress() async { Future<String> getChangeAddress({List<BitcoinOutput>? outputs, UtxoDetails? utxoDetails}) async {
updateChangeAddresses(); updateChangeAddresses();
if (changeAddresses.isEmpty) { if (changeAddresses.isEmpty) {
@ -317,12 +347,110 @@ abstract class ElectrumWalletAddressesBase extends WalletAddresses with Store {
return address; return address;
} }
String getAddress( String getAddress({
{required int index, required int index,
required Bip32Slip10Secp256k1 hd, required Bip32Slip10Secp256k1 hd,
BitcoinAddressType? addressType}) => BitcoinAddressType? addressType,
}) =>
''; '';
Future<String> getAddressAsync({
required int index,
required Bip32Slip10Secp256k1 hd,
BitcoinAddressType? addressType,
}) async =>
getAddress(index: index, hd: hd, addressType: addressType);
void addBitcoinAddressTypes() {
final lastP2wpkh = _addresses
.where((addressRecord) =>
_isUnusedReceiveAddressByType(addressRecord, SegwitAddresType.p2wpkh))
.toList()
.last;
if (lastP2wpkh.address != address) {
addressesMap[lastP2wpkh.address] = 'P2WPKH';
} else {
addressesMap[address] = 'Active - P2WPKH';
}
final lastP2pkh = _addresses.firstWhere(
(addressRecord) => _isUnusedReceiveAddressByType(addressRecord, P2pkhAddressType.p2pkh));
if (lastP2pkh.address != address) {
addressesMap[lastP2pkh.address] = 'P2PKH';
} else {
addressesMap[address] = 'Active - P2PKH';
}
final lastP2sh = _addresses.firstWhere((addressRecord) =>
_isUnusedReceiveAddressByType(addressRecord, P2shAddressType.p2wpkhInP2sh));
if (lastP2sh.address != address) {
addressesMap[lastP2sh.address] = 'P2SH';
} else {
addressesMap[address] = 'Active - P2SH';
}
final lastP2tr = _addresses.firstWhere(
(addressRecord) => _isUnusedReceiveAddressByType(addressRecord, SegwitAddresType.p2tr));
if (lastP2tr.address != address) {
addressesMap[lastP2tr.address] = 'P2TR';
} else {
addressesMap[address] = 'Active - P2TR';
}
final lastP2wsh = _addresses.firstWhere(
(addressRecord) => _isUnusedReceiveAddressByType(addressRecord, SegwitAddresType.p2wsh));
if (lastP2wsh.address != address) {
addressesMap[lastP2wsh.address] = 'P2WSH';
} else {
addressesMap[address] = 'Active - P2WSH';
}
silentAddresses.forEach((addressRecord) {
if (addressRecord.type != SilentPaymentsAddresType.p2sp || addressRecord.isHidden) {
return;
}
if (addressRecord.address != address) {
addressesMap[addressRecord.address] = addressRecord.name.isEmpty
? "Silent Payments"
: "Silent Payments - " + addressRecord.name;
} else {
addressesMap[address] = 'Active - Silent Payments';
}
});
}
void addLitecoinAddressTypes() {
final lastP2wpkh = _addresses
.where((addressRecord) =>
_isUnusedReceiveAddressByType(addressRecord, SegwitAddresType.p2wpkh))
.toList()
.last;
if (lastP2wpkh.address != address) {
addressesMap[lastP2wpkh.address] = 'P2WPKH';
} else {
addressesMap[address] = 'Active - P2WPKH';
}
final lastMweb = _addresses.firstWhere(
(addressRecord) => _isUnusedReceiveAddressByType(addressRecord, SegwitAddresType.mweb));
if (lastMweb.address != address) {
addressesMap[lastMweb.address] = 'MWEB';
} else {
addressesMap[address] = 'Active - MWEB';
}
}
void addBitcoinCashAddressTypes() {
final lastP2pkh = _addresses.firstWhere(
(addressRecord) => _isUnusedReceiveAddressByType(addressRecord, P2pkhAddressType.p2pkh));
if (lastP2pkh.address != address) {
addressesMap[lastP2pkh.address] = 'P2PKH';
} else {
addressesMap[address] = 'Active - P2PKH';
}
}
@override @override
Future<void> updateAddressesInBox() async { Future<void> updateAddressesInBox() async {
try { try {
@ -334,63 +462,20 @@ abstract class ElectrumWalletAddressesBase extends WalletAddresses with Store {
allAddressesMap[addressRecord.address] = addressRecord.name; allAddressesMap[addressRecord.address] = addressRecord.name;
}); });
final lastP2wpkh = _addresses switch (walletInfo.type) {
.where((addressRecord) => case WalletType.bitcoin:
_isUnusedReceiveAddressByType(addressRecord, SegwitAddresType.p2wpkh)) addBitcoinAddressTypes();
.toList() break;
.last; case WalletType.litecoin:
if (lastP2wpkh.address != address) { addLitecoinAddressTypes();
addressesMap[lastP2wpkh.address] = 'P2WPKH'; break;
} else { case WalletType.bitcoinCash:
addressesMap[address] = 'Active - P2WPKH'; addBitcoinCashAddressTypes();
break;
default:
break;
} }
final lastP2pkh = _addresses.firstWhere(
(addressRecord) => _isUnusedReceiveAddressByType(addressRecord, P2pkhAddressType.p2pkh));
if (lastP2pkh.address != address) {
addressesMap[lastP2pkh.address] = 'P2PKH';
} else {
addressesMap[address] = 'Active - P2PKH';
}
final lastP2sh = _addresses.firstWhere((addressRecord) =>
_isUnusedReceiveAddressByType(addressRecord, P2shAddressType.p2wpkhInP2sh));
if (lastP2sh.address != address) {
addressesMap[lastP2sh.address] = 'P2SH';
} else {
addressesMap[address] = 'Active - P2SH';
}
final lastP2tr = _addresses.firstWhere(
(addressRecord) => _isUnusedReceiveAddressByType(addressRecord, SegwitAddresType.p2tr));
if (lastP2tr.address != address) {
addressesMap[lastP2tr.address] = 'P2TR';
} else {
addressesMap[address] = 'Active - P2TR';
}
final lastP2wsh = _addresses.firstWhere(
(addressRecord) => _isUnusedReceiveAddressByType(addressRecord, SegwitAddresType.p2wsh));
if (lastP2wsh.address != address) {
addressesMap[lastP2wsh.address] = 'P2WSH';
} else {
addressesMap[address] = 'Active - P2WSH';
}
silentAddresses.forEach((addressRecord) {
if (addressRecord.type != SilentPaymentsAddresType.p2sp || addressRecord.isHidden) {
return;
}
if (addressRecord.address != address) {
addressesMap[addressRecord.address] = addressRecord.name.isEmpty
? "Silent Payments"
: "Silent Payments - " + addressRecord.name;
} else {
addressesMap[address] = 'Active - Silent Payments';
}
});
await saveAddressesInBox(); await saveAddressesInBox();
} catch (e) { } catch (e) {
print(e.toString()); print(e.toString());
@ -410,6 +495,11 @@ abstract class ElectrumWalletAddressesBase extends WalletAddresses with Store {
foundAddress = addressRecord; foundAddress = addressRecord;
} }
}); });
mwebAddresses.forEach((addressRecord) {
if (addressRecord.address == address) {
foundAddress = addressRecord;
}
});
if (foundAddress != null) { if (foundAddress != null) {
foundAddress!.setNewName(label); foundAddress!.setNewName(label);
@ -510,7 +600,7 @@ abstract class ElectrumWalletAddressesBase extends WalletAddresses with Store {
for (var i = startIndex; i < count + startIndex; i++) { for (var i = startIndex; i < count + startIndex; i++) {
final address = BitcoinAddressRecord( final address = BitcoinAddressRecord(
getAddress(index: i, hd: _getHd(isHidden), addressType: type ?? addressPageType), await getAddressAsync(index: i, hd: _getHd(isHidden), addressType: type ?? addressPageType),
index: i, index: i,
isHidden: isHidden, isHidden: isHidden,
type: type ?? addressPageType, type: type ?? addressPageType,
@ -540,15 +630,28 @@ abstract class ElectrumWalletAddressesBase extends WalletAddresses with Store {
updateAddressesByMatch(); updateAddressesByMatch();
} }
@action
void addMwebAddresses(Iterable<BitcoinAddressRecord> addresses) {
final addressesSet = this.mwebAddresses.toSet();
addressesSet.addAll(addresses);
this.mwebAddresses.clear();
this.mwebAddresses.addAll(addressesSet);
updateAddressesByMatch();
}
void _validateAddresses() { void _validateAddresses() {
_addresses.forEach((element) { _addresses.forEach((element) async {
if (element.type == SegwitAddresType.mweb) {
// this would add a ton of startup lag for mweb addresses since we have 1000 of them
return;
}
if (!element.isHidden && if (!element.isHidden &&
element.address != element.address !=
getAddress(index: element.index, hd: mainHd, addressType: element.type)) { await getAddressAsync(index: element.index, hd: mainHd, addressType: element.type)) {
element.isHidden = true; element.isHidden = true;
} else if (element.isHidden && } else if (element.isHidden &&
element.address != element.address !=
getAddress(index: element.index, hd: sideHd, addressType: element.type)) { await getAddressAsync(index: element.index, hd: sideHd, addressType: element.type)) {
element.isHidden = false; element.isHidden = false;
} }
}); });

View file

@ -23,6 +23,8 @@ class ElectrumWalletSnapshot {
required this.addressPageType, required this.addressPageType,
required this.silentAddresses, required this.silentAddresses,
required this.silentAddressIndex, required this.silentAddressIndex,
required this.mwebAddresses,
required this.alwaysScan,
this.passphrase, this.passphrase,
this.derivationType, this.derivationType,
this.derivationPath, this.derivationPath,
@ -44,6 +46,9 @@ class ElectrumWalletSnapshot {
List<BitcoinAddressRecord> addresses; List<BitcoinAddressRecord> addresses;
List<BitcoinSilentPaymentAddressRecord> silentAddresses; List<BitcoinSilentPaymentAddressRecord> silentAddresses;
List<BitcoinAddressRecord> mwebAddresses;
bool alwaysScan;
ElectrumBalance balance; ElectrumBalance balance;
Map<String, int> regularAddressIndex; Map<String, int> regularAddressIndex;
Map<String, int> changeAddressIndex; Map<String, int> changeAddressIndex;
@ -51,15 +56,16 @@ class ElectrumWalletSnapshot {
DerivationType? derivationType; DerivationType? derivationType;
String? derivationPath; String? derivationPath;
static Future<ElectrumWalletSnapshot> load( static Future<ElectrumWalletSnapshot> load(EncryptionFileUtils encryptionFileUtils, String name,
EncryptionFileUtils encryptionFileUtils, String name, WalletType type, String password, BasedUtxoNetwork network) async { WalletType type, String password, BasedUtxoNetwork network) async {
final path = await pathForWallet(name: name, type: type); final path = await pathForWallet(name: name, type: type);
final jsonSource = await encryptionFileUtils.read(path: path, password: password); final jsonSource = await encryptionFileUtils.read(path: path, password: password);
final data = json.decode(jsonSource) as Map; final data = json.decode(jsonSource) as Map;
final addressesTmp = data['addresses'] as List? ?? <Object>[];
final mnemonic = data['mnemonic'] as String?; final mnemonic = data['mnemonic'] as String?;
final xpub = data['xpub'] as String?; final xpub = data['xpub'] as String?;
final passphrase = data['passphrase'] as String? ?? ''; final passphrase = data['passphrase'] as String? ?? '';
final addressesTmp = data['addresses'] as List? ?? <Object>[];
final addresses = addressesTmp final addresses = addressesTmp
.whereType<String>() .whereType<String>()
.map((addr) => BitcoinAddressRecord.fromJSON(addr, network: network)) .map((addr) => BitcoinAddressRecord.fromJSON(addr, network: network))
@ -71,6 +77,14 @@ class ElectrumWalletSnapshot {
.map((addr) => BitcoinSilentPaymentAddressRecord.fromJSON(addr, network: network)) .map((addr) => BitcoinSilentPaymentAddressRecord.fromJSON(addr, network: network))
.toList(); .toList();
final mwebAddressTmp = data['mweb_addresses'] as List? ?? <Object>[];
final mwebAddresses = mwebAddressTmp
.whereType<String>()
.map((addr) => BitcoinAddressRecord.fromJSON(addr, network: network))
.toList();
final alwaysScan = data['alwaysScan'] as bool? ?? false;
final balance = ElectrumBalance.fromJSON(data['balance'] as String?) ?? final balance = ElectrumBalance.fromJSON(data['balance'] as String?) ??
ElectrumBalance(confirmed: 0, unconfirmed: 0, frozen: 0); ElectrumBalance(confirmed: 0, unconfirmed: 0, frozen: 0);
var regularAddressIndexByType = {SegwitAddresType.p2wpkh.toString(): 0}; var regularAddressIndexByType = {SegwitAddresType.p2wpkh.toString(): 0};
@ -113,6 +127,8 @@ class ElectrumWalletSnapshot {
derivationPath: derivationPath, derivationPath: derivationPath,
silentAddresses: silentAddresses, silentAddresses: silentAddresses,
silentAddressIndex: silentAddressIndex, silentAddressIndex: silentAddressIndex,
mwebAddresses: mwebAddresses,
alwaysScan: alwaysScan,
); );
} }
} }

View file

@ -3,6 +3,11 @@ import 'package:cw_core/exceptions.dart';
class BitcoinTransactionWrongBalanceException extends TransactionWrongBalanceException { class BitcoinTransactionWrongBalanceException extends TransactionWrongBalanceException {
BitcoinTransactionWrongBalanceException({super.amount}) : super(CryptoCurrency.btc); BitcoinTransactionWrongBalanceException({super.amount}) : super(CryptoCurrency.btc);
@override
String toString() {
return "BitcoinTransactionWrongBalanceException: $amount, $currency";
}
} }
class BitcoinTransactionNoInputsException extends TransactionNoInputsException {} class BitcoinTransactionNoInputsException extends TransactionNoInputsException {}
@ -13,10 +18,20 @@ class BitcoinTransactionNoDustException extends TransactionNoDustException {}
class BitcoinTransactionNoDustOnChangeException extends TransactionNoDustOnChangeException { class BitcoinTransactionNoDustOnChangeException extends TransactionNoDustOnChangeException {
BitcoinTransactionNoDustOnChangeException(super.max, super.min); BitcoinTransactionNoDustOnChangeException(super.max, super.min);
@override
String toString() {
return "BitcoinTransactionNoDustOnChangeException: max: $max, min: $min";
}
} }
class BitcoinTransactionCommitFailed extends TransactionCommitFailed { class BitcoinTransactionCommitFailed extends TransactionCommitFailed {
BitcoinTransactionCommitFailed({super.errorMessage}); BitcoinTransactionCommitFailed({super.errorMessage});
@override
String toString() {
return errorMessage??"unknown error";
}
} }
class BitcoinTransactionCommitFailedDustChange extends TransactionCommitFailedDustChange {} class BitcoinTransactionCommitFailedDustChange extends TransactionCommitFailedDustChange {}
@ -30,4 +45,6 @@ class BitcoinTransactionCommitFailedVoutNegative extends TransactionCommitFailed
class BitcoinTransactionCommitFailedBIP68Final extends TransactionCommitFailedBIP68Final {} class BitcoinTransactionCommitFailedBIP68Final extends TransactionCommitFailedBIP68Final {}
class BitcoinTransactionCommitFailedLessThanMin extends TransactionCommitFailedLessThanMin {}
class BitcoinTransactionSilentPaymentsNotSupported extends TransactionInputNotSupported {} class BitcoinTransactionSilentPaymentsNotSupported extends TransactionInputNotSupported {}

View file

@ -1,15 +1,31 @@
import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'package:convert/convert.dart' as convert;
import 'dart:math';
import 'package:collection/collection.dart';
import 'package:crypto/crypto.dart';
import 'package:cw_bitcoin/bitcoin_transaction_credentials.dart';
import 'package:cw_core/cake_hive.dart';
import 'package:cw_core/mweb_utxo.dart';
import 'package:cw_mweb/mwebd.pbgrpc.dart';
import 'package:fixnum/fixnum.dart';
import 'package:bip39/bip39.dart' as bip39;
import 'package:bitcoin_base/bitcoin_base.dart'; import 'package:bitcoin_base/bitcoin_base.dart';
import 'package:blockchain_utils/blockchain_utils.dart'; import 'package:blockchain_utils/blockchain_utils.dart';
import 'package:blockchain_utils/signer/ecdsa_signing_key.dart'; import 'package:blockchain_utils/signer/ecdsa_signing_key.dart';
import 'package:bip39/bip39.dart' as bip39;
import 'package:cw_bitcoin/bitcoin_address_record.dart'; import 'package:cw_bitcoin/bitcoin_address_record.dart';
import 'package:cw_bitcoin/bitcoin_mnemonic.dart'; import 'package:cw_bitcoin/bitcoin_mnemonic.dart';
import 'package:cw_bitcoin/bitcoin_transaction_priority.dart'; import 'package:cw_bitcoin/bitcoin_transaction_priority.dart';
import 'package:cw_bitcoin/bitcoin_unspent.dart';
import 'package:cw_bitcoin/electrum_transaction_info.dart';
import 'package:cw_bitcoin/pending_bitcoin_transaction.dart';
import 'package:cw_bitcoin/utils.dart';
import 'package:cw_bitcoin/electrum_derivations.dart'; import 'package:cw_bitcoin/electrum_derivations.dart';
import 'package:cw_core/encryption_file_utils.dart'; import 'package:cw_core/encryption_file_utils.dart';
import 'package:cw_core/crypto_currency.dart'; import 'package:cw_core/crypto_currency.dart';
import 'package:cw_core/pending_transaction.dart';
import 'package:cw_core/sync_status.dart';
import 'package:cw_core/transaction_direction.dart';
import 'package:cw_core/unspent_coins_info.dart'; import 'package:cw_core/unspent_coins_info.dart';
import 'package:cw_bitcoin/electrum_balance.dart'; import 'package:cw_bitcoin/electrum_balance.dart';
import 'package:cw_bitcoin/electrum_wallet.dart'; import 'package:cw_bitcoin/electrum_wallet.dart';
@ -19,8 +35,11 @@ import 'package:cw_core/transaction_priority.dart';
import 'package:cw_core/wallet_info.dart'; import 'package:cw_core/wallet_info.dart';
import 'package:cw_core/wallet_keys_file.dart'; import 'package:cw_core/wallet_keys_file.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:grpc/grpc.dart';
import 'package:hive/hive.dart'; import 'package:hive/hive.dart';
import 'package:mobx/mobx.dart'; import 'package:mobx/mobx.dart';
import 'package:cw_core/wallet_type.dart';
import 'package:cw_mweb/cw_mweb.dart';
import 'package:bitcoin_base/src/crypto/keypair/sign_utils.dart'; import 'package:bitcoin_base/src/crypto/keypair/sign_utils.dart';
import 'package:pointycastle/ecc/api.dart'; import 'package:pointycastle/ecc/api.dart';
import 'package:pointycastle/ecc/curves/secp256k1.dart'; import 'package:pointycastle/ecc/curves/secp256k1.dart';
@ -40,34 +59,54 @@ abstract class LitecoinWalletBase extends ElectrumWallet with Store {
String? passphrase, String? passphrase,
String? addressPageType, String? addressPageType,
List<BitcoinAddressRecord>? initialAddresses, List<BitcoinAddressRecord>? initialAddresses,
List<BitcoinAddressRecord>? initialMwebAddresses,
ElectrumBalance? initialBalance, ElectrumBalance? initialBalance,
Map<String, int>? initialRegularAddressIndex, Map<String, int>? initialRegularAddressIndex,
Map<String, int>? initialChangeAddressIndex, Map<String, int>? initialChangeAddressIndex,
int? initialMwebHeight,
bool? alwaysScan,
}) : super( }) : super(
mnemonic: mnemonic, mnemonic: mnemonic,
password: password, password: password,
walletInfo: walletInfo, walletInfo: walletInfo,
unspentCoinsInfo: unspentCoinsInfo, unspentCoinsInfo: unspentCoinsInfo,
network: LitecoinNetwork.mainnet, network: LitecoinNetwork.mainnet,
initialAddresses: initialAddresses, initialAddresses: initialAddresses,
initialBalance: initialBalance, initialBalance: initialBalance,
seedBytes: seedBytes, seedBytes: seedBytes,
encryptionFileUtils: encryptionFileUtils, encryptionFileUtils: encryptionFileUtils,
passphrase: passphrase, currency: CryptoCurrency.ltc,
currency: CryptoCurrency.ltc) { alwaysScan: alwaysScan,
) {
mwebHd = Bip32Slip10Secp256k1.fromSeed(seedBytes).derivePath("m/1000'") as Bip32Slip10Secp256k1;
mwebEnabled = alwaysScan ?? false;
walletAddresses = LitecoinWalletAddresses( walletAddresses = LitecoinWalletAddresses(
walletInfo, walletInfo,
initialAddresses: initialAddresses, initialAddresses: initialAddresses,
initialRegularAddressIndex: initialRegularAddressIndex, initialRegularAddressIndex: initialRegularAddressIndex,
initialChangeAddressIndex: initialChangeAddressIndex, initialChangeAddressIndex: initialChangeAddressIndex,
initialMwebAddresses: initialMwebAddresses,
mainHd: hd, mainHd: hd,
sideHd: accountHD.childKey(Bip32KeyIndex(1)), sideHd: accountHD.childKey(Bip32KeyIndex(1)),
network: network, network: network,
mwebHd: mwebHd,
mwebEnabled: mwebEnabled,
); );
autorun((_) { autorun((_) {
this.walletAddresses.isEnabledAutoGenerateSubaddress = this.isEnabledAutoGenerateSubaddress; this.walletAddresses.isEnabledAutoGenerateSubaddress = this.isEnabledAutoGenerateSubaddress;
}); });
} }
late final Bip32Slip10Secp256k1 mwebHd;
late final Box<MwebUtxo> mwebUtxosBox;
Timer? _syncTimer;
Timer? _feeRatesTimer;
Timer? _processingTimer;
StreamSubscription<Utxo>? _utxoStream;
late bool mwebEnabled;
bool processingUtxos = false;
List<int> get scanSecret => mwebHd.childKey(Bip32KeyIndex(0x80000000)).privateKey.privKey.raw;
List<int> get spendSecret => mwebHd.childKey(Bip32KeyIndex(0x80000001)).privateKey.privKey.raw;
static Future<LitecoinWallet> create( static Future<LitecoinWallet> create(
{required String mnemonic, {required String mnemonic,
@ -78,6 +117,7 @@ abstract class LitecoinWalletBase extends ElectrumWallet with Store {
String? passphrase, String? passphrase,
String? addressPageType, String? addressPageType,
List<BitcoinAddressRecord>? initialAddresses, List<BitcoinAddressRecord>? initialAddresses,
List<BitcoinAddressRecord>? initialMwebAddresses,
ElectrumBalance? initialBalance, ElectrumBalance? initialBalance,
Map<String, int>? initialRegularAddressIndex, Map<String, int>? initialRegularAddressIndex,
Map<String, int>? initialChangeAddressIndex}) async { Map<String, int>? initialChangeAddressIndex}) async {
@ -101,6 +141,7 @@ abstract class LitecoinWalletBase extends ElectrumWallet with Store {
walletInfo: walletInfo, walletInfo: walletInfo,
unspentCoinsInfo: unspentCoinsInfo, unspentCoinsInfo: unspentCoinsInfo,
initialAddresses: initialAddresses, initialAddresses: initialAddresses,
initialMwebAddresses: initialMwebAddresses,
initialBalance: initialBalance, initialBalance: initialBalance,
encryptionFileUtils: encryptionFileUtils, encryptionFileUtils: encryptionFileUtils,
passphrase: passphrase, passphrase: passphrase,
@ -111,12 +152,14 @@ abstract class LitecoinWalletBase extends ElectrumWallet with Store {
); );
} }
static Future<LitecoinWallet> open( static Future<LitecoinWallet> open({
{required String name, required String name,
required WalletInfo walletInfo, required WalletInfo walletInfo,
required Box<UnspentCoinsInfo> unspentCoinsInfo, required Box<UnspentCoinsInfo> unspentCoinsInfo,
required String password, required String password,
required EncryptionFileUtils encryptionFileUtils}) async { required bool alwaysScan,
required EncryptionFileUtils encryptionFileUtils,
}) async {
final hasKeysFile = await WalletKeysFile.hasKeysFile(name, walletInfo.type); final hasKeysFile = await WalletKeysFile.hasKeysFile(name, walletInfo.type);
ElectrumWalletSnapshot? snp = null; ElectrumWalletSnapshot? snp = null;
@ -178,6 +221,7 @@ abstract class LitecoinWalletBase extends ElectrumWallet with Store {
walletInfo: walletInfo, walletInfo: walletInfo,
unspentCoinsInfo: unspentCoinsInfo, unspentCoinsInfo: unspentCoinsInfo,
initialAddresses: snp?.addresses, initialAddresses: snp?.addresses,
initialMwebAddresses: snp?.mwebAddresses,
initialBalance: snp?.balance, initialBalance: snp?.balance,
seedBytes: seedBytes!, seedBytes: seedBytes!,
passphrase: passphrase, passphrase: passphrase,
@ -185,6 +229,565 @@ abstract class LitecoinWalletBase extends ElectrumWallet with Store {
initialRegularAddressIndex: snp?.regularAddressIndex, initialRegularAddressIndex: snp?.regularAddressIndex,
initialChangeAddressIndex: snp?.changeAddressIndex, initialChangeAddressIndex: snp?.changeAddressIndex,
addressPageType: snp?.addressPageType, addressPageType: snp?.addressPageType,
alwaysScan: snp?.alwaysScan,
);
}
Future<void> waitForMwebAddresses() async {
print("waitForMwebAddresses() called!");
// ensure that we have the full 1000 mweb addresses generated before continuing:
// should no longer be needed, but leaving here just in case
await (walletAddresses as LitecoinWalletAddresses).ensureMwebAddressUpToIndexExists(1020);
}
@action
@override
Future<void> startSync() async {
print("startSync() called!");
if (syncStatus is SyncronizingSyncStatus) {
return;
}
print("STARTING SYNC - MWEB ENABLED: $mwebEnabled");
_syncTimer?.cancel();
try {
syncStatus = SyncronizingSyncStatus();
await subscribeForUpdates();
updateFeeRates();
_feeRatesTimer?.cancel();
_feeRatesTimer =
Timer.periodic(const Duration(minutes: 1), (timer) async => await updateFeeRates());
if (!mwebEnabled) {
try {
// in case we're switching from a litecoin wallet that had mweb enabled
CwMweb.stop();
} catch (_) {}
try {
await updateAllUnspents();
await updateTransactions();
await updateBalance();
syncStatus = SyncedSyncStatus();
} catch (e, s) {
print(e);
print(s);
syncStatus = FailedSyncStatus();
}
return;
}
await waitForMwebAddresses();
await processMwebUtxos();
await updateTransactions();
await updateUnspent();
await updateBalance();
} catch (e) {
print("failed to start mweb sync: $e");
syncStatus = FailedSyncStatus(error: "failed to start");
return;
}
_syncTimer = Timer.periodic(const Duration(milliseconds: 3000), (timer) async {
if (syncStatus is FailedSyncStatus) return;
final nodeHeight =
await electrumClient.getCurrentBlockChainTip() ?? 0; // current block height of our node
if (nodeHeight == 0) {
// we aren't connected to the ltc node yet
if (syncStatus is! NotConnectedSyncStatus) {
syncStatus = FailedSyncStatus(error: "Failed to connect to Litecoin node");
}
return;
}
final resp = await CwMweb.status(StatusRequest());
try {
if (resp.blockHeaderHeight < nodeHeight) {
int h = resp.blockHeaderHeight;
syncStatus = SyncingSyncStatus(nodeHeight - h, h / nodeHeight);
} else if (resp.mwebHeaderHeight < nodeHeight) {
int h = resp.mwebHeaderHeight;
syncStatus = SyncingSyncStatus(nodeHeight - h, h / nodeHeight);
} else if (resp.mwebUtxosHeight < nodeHeight) {
syncStatus = SyncingSyncStatus(1, 0.999);
} else {
if (resp.mwebUtxosHeight > walletInfo.restoreHeight) {
await walletInfo.updateRestoreHeight(resp.mwebUtxosHeight);
await checkMwebUtxosSpent();
// update the confirmations for each transaction:
for (final transaction in transactionHistory.transactions.values) {
if (transaction.isPending) continue;
int txHeight = transaction.height ?? resp.mwebUtxosHeight;
final confirmations = (resp.mwebUtxosHeight - txHeight) + 1;
if (transaction.confirmations == confirmations) continue;
transaction.confirmations = confirmations;
transactionHistory.addOne(transaction);
}
await transactionHistory.save();
}
// prevent unnecessary reaction triggers:
if (syncStatus is! SyncedSyncStatus) {
// mwebd is synced, but we could still be processing incoming utxos:
if (!processingUtxos) {
syncStatus = SyncedSyncStatus();
}
}
return;
}
} catch (e) {
print("error syncing: $e");
syncStatus = FailedSyncStatus(error: e.toString());
}
});
}
@action
@override
Future<void> stopSync() async {
print("stopSync() called!");
_syncTimer?.cancel();
_utxoStream?.cancel();
_feeRatesTimer?.cancel();
await CwMweb.stop();
print("stopped syncing!");
}
Future<void> initMwebUtxosBox() async {
final boxName = "${walletInfo.name.replaceAll(" ", "_")}_${MwebUtxo.boxName}";
mwebUtxosBox = await CakeHive.openBox<MwebUtxo>(boxName);
}
@override
Future<void> renameWalletFiles(String newWalletName) async {
// rename the hive box:
final oldBoxName = "${walletInfo.name.replaceAll(" ", "_")}_${MwebUtxo.boxName}";
final newBoxName = "${newWalletName.replaceAll(" ", "_")}_${MwebUtxo.boxName}";
final oldBox = await CakeHive.openBox<MwebUtxo>(oldBoxName);
mwebUtxosBox = await CakeHive.openBox<MwebUtxo>(newBoxName);
for (final key in oldBox.keys) {
await mwebUtxosBox.put(key, oldBox.get(key)!);
}
oldBox.deleteFromDisk();
await super.renameWalletFiles(newWalletName);
}
@action
@override
Future<void> rescan({
required int height,
int? chainTip,
ScanData? scanData,
bool? doSingleScan,
bool? usingElectrs,
}) async {
_syncTimer?.cancel();
int oldHeight = walletInfo.restoreHeight;
await walletInfo.updateRestoreHeight(height);
// go through mwebUtxos and clear any that are above the new restore height:
if (height == 0) {
await mwebUtxosBox.clear();
transactionHistory.clear();
} else {
for (final utxo in mwebUtxosBox.values) {
if (utxo.height > height) {
await mwebUtxosBox.delete(utxo.outputId);
}
}
// TODO: remove transactions that are above the new restore height!
}
// reset coin balances and txCount to 0:
unspentCoins.forEach((coin) {
if (coin.bitcoinAddressRecord is! BitcoinSilentPaymentAddressRecord)
coin.bitcoinAddressRecord.balance = 0;
coin.bitcoinAddressRecord.txCount = 0;
});
for (var addressRecord in walletAddresses.allAddresses) {
addressRecord.balance = 0;
addressRecord.txCount = 0;
}
await startSync();
}
@override
Future<void> init() async {
await super.init();
await initMwebUtxosBox();
}
Future<void> handleIncoming(MwebUtxo utxo) async {
print("handleIncoming() called!");
final status = await CwMweb.status(StatusRequest());
var date = DateTime.now();
var confirmations = 0;
if (utxo.height > 0) {
date = DateTime.fromMillisecondsSinceEpoch(utxo.blockTime * 1000);
confirmations = status.blockHeaderHeight - utxo.height + 1;
}
var tx = transactionHistory.transactions.values
.firstWhereOrNull((tx) => tx.outputAddresses?.contains(utxo.outputId) ?? false);
if (tx == null) {
tx = ElectrumTransactionInfo(
WalletType.litecoin,
id: utxo.outputId,
height: utxo.height,
amount: utxo.value.toInt(),
fee: 0,
direction: TransactionDirection.incoming,
isPending: utxo.height == 0,
date: date,
confirmations: confirmations,
inputAddresses: [],
outputAddresses: [utxo.outputId],
isReplaced: false,
);
}
// don't update the confirmations if the tx is updated by electrum:
if (tx.confirmations == 0 || utxo.height != 0) {
tx.height = utxo.height;
tx.isPending = utxo.height == 0;
tx.confirmations = confirmations;
}
bool isNew = transactionHistory.transactions[tx.id] == null;
if (!(tx.outputAddresses?.contains(utxo.address) ?? false)) {
tx.outputAddresses?.add(utxo.address);
isNew = true;
}
if (isNew) {
final addressRecord = walletAddresses.allAddresses
.firstWhereOrNull((addressRecord) => addressRecord.address == utxo.address);
if (addressRecord == null) {
print("we don't have this address in the wallet! ${utxo.address}");
return;
}
// update the txCount:
addressRecord.txCount++;
addressRecord.balance += utxo.value.toInt();
addressRecord.setAsUsed();
}
transactionHistory.addOne(tx);
if (isNew) {
// update the unconfirmed balance when a new tx is added:
// we do this after adding the tx to the history so that sub address balances are updated correctly
// (since that calculation is based on the tx history)
await updateBalance();
}
}
Future<void> processMwebUtxos() async {
print("processMwebUtxos() called!");
if (!mwebEnabled) {
return;
}
int restoreHeight = walletInfo.restoreHeight;
print("SCANNING FROM HEIGHT: $restoreHeight");
final req = UtxosRequest(scanSecret: scanSecret, fromHeight: restoreHeight);
// process new utxos as they come in:
await _utxoStream?.cancel();
ResponseStream<Utxo>? responseStream = await CwMweb.utxos(req);
if (responseStream == null) {
throw Exception("failed to get utxos stream!");
}
_utxoStream = responseStream.listen((Utxo sUtxo) async {
// we're processing utxos, so our balance could still be innacurate:
if (syncStatus is! SyncronizingSyncStatus && syncStatus is! SyncingSyncStatus) {
syncStatus = SyncronizingSyncStatus();
processingUtxos = true;
_processingTimer?.cancel();
_processingTimer = Timer.periodic(const Duration(seconds: 2), (timer) async {
processingUtxos = false;
timer.cancel();
});
}
final utxo = MwebUtxo(
address: sUtxo.address,
blockTime: sUtxo.blockTime,
height: sUtxo.height,
outputId: sUtxo.outputId,
value: sUtxo.value.toInt(),
);
// if (mwebUtxosBox.containsKey(utxo.outputId)) {
// // we've already stored this utxo, skip it:
// return;
// }
await updateUnspent();
await updateBalance();
final mwebAddrs = (walletAddresses as LitecoinWalletAddresses).mwebAddrs;
// don't process utxos with addresses that are not in the mwebAddrs list:
if (utxo.address.isNotEmpty && !mwebAddrs.contains(utxo.address)) {
return;
}
await mwebUtxosBox.put(utxo.outputId, utxo);
await handleIncoming(utxo);
});
}
Future<void> checkMwebUtxosSpent() async {
if (!mwebEnabled) {
return;
}
final pendingOutgoingTransactions = transactionHistory.transactions.values
.where((tx) => tx.direction == TransactionDirection.outgoing && tx.isPending);
// check if any of the pending outgoing transactions are now confirmed:
bool updatedAny = false;
for (final tx in pendingOutgoingTransactions) {
updatedAny = await isConfirmed(tx) || updatedAny;
}
// get output ids of all the mweb utxos that have > 0 height:
final outputIds =
mwebUtxosBox.values.where((utxo) => utxo.height > 0).map((utxo) => utxo.outputId).toList();
final resp = await CwMweb.spent(SpentRequest(outputId: outputIds));
final spent = resp.outputId;
if (spent.isEmpty) {
return;
}
final status = await CwMweb.status(StatusRequest());
final height = await electrumClient.getCurrentBlockChainTip();
if (height == null || status.blockHeaderHeight != height) return;
if (status.mwebUtxosHeight != height) return; // we aren't synced
int amount = 0;
Set<String> inputAddresses = {};
var output = convert.AccumulatorSink<Digest>();
var input = sha256.startChunkedConversion(output);
for (final outputId in spent) {
final utxo = mwebUtxosBox.get(outputId);
await mwebUtxosBox.delete(outputId);
if (utxo == null) continue;
final addressRecord = walletAddresses.allAddresses
.firstWhere((addressRecord) => addressRecord.address == utxo.address);
if (!inputAddresses.contains(utxo.address)) {
addressRecord.txCount++;
}
addressRecord.balance -= utxo.value.toInt();
amount += utxo.value.toInt();
inputAddresses.add(utxo.address);
input.add(hex.decode(outputId));
}
if (inputAddresses.isEmpty) return;
input.close();
var digest = output.events.single;
final tx = ElectrumTransactionInfo(
WalletType.litecoin,
id: digest.toString(),
height: height,
amount: amount,
fee: 0,
direction: TransactionDirection.outgoing,
isPending: false,
date: DateTime.fromMillisecondsSinceEpoch(status.blockTime * 1000),
confirmations: 1,
inputAddresses: inputAddresses.toList(),
outputAddresses: [],
isReplaced: false,
);
transactionHistory.addOne(tx);
await transactionHistory.save();
if (updatedAny) {
await updateBalance();
}
}
// checks if a pending transaction is now confirmed, and updates the tx info accordingly:
Future<bool> isConfirmed(ElectrumTransactionInfo tx) async {
if (!mwebEnabled) return false;
if (!tx.isPending) return false;
final outputId = <String>[], target = <String>{};
final isHash = RegExp(r'^[a-f0-9]{64}$').hasMatch;
final spendingOutputIds = tx.inputAddresses?.where(isHash) ?? [];
final payingToOutputIds = tx.outputAddresses?.where(isHash) ?? [];
outputId.addAll(spendingOutputIds);
outputId.addAll(payingToOutputIds);
target.addAll(spendingOutputIds);
for (final outputId in payingToOutputIds) {
final spendingTx = transactionHistory.transactions.values
.firstWhereOrNull((tx) => tx.inputAddresses?.contains(outputId) ?? false);
if (spendingTx != null && !spendingTx.isPending) {
target.add(outputId);
}
}
if (outputId.isEmpty) {
return false;
}
final resp = await CwMweb.spent(SpentRequest(outputId: outputId));
if (!setEquals(resp.outputId.toSet(), target)) {
return false;
}
final status = await CwMweb.status(StatusRequest());
tx.height = status.mwebUtxosHeight;
tx.confirmations = 1;
tx.isPending = false;
await transactionHistory.save();
return true;
}
Future<void> updateUnspent() async {
print("updateUnspent() called!");
await checkMwebUtxosSpent();
await updateAllUnspents();
}
@override
@action
Future<void> updateAllUnspents() async {
// get ltc unspents:
await super.updateAllUnspents();
if (!mwebEnabled) {
return;
}
// add the mweb unspents to the list:
List<BitcoinUnspent> mwebUnspentCoins = [];
// update mweb unspents:
final mwebAddrs = (walletAddresses as LitecoinWalletAddresses).mwebAddrs;
mwebUtxosBox.keys.forEach((dynamic oId) {
final String outputId = oId as String;
final utxo = mwebUtxosBox.get(outputId);
if (utxo == null) {
return;
}
if (utxo.address.isEmpty) {
// not sure if a bug or a special case but we definitely ignore these
return;
}
final addressRecord = walletAddresses.allAddresses
.firstWhereOrNull((addressRecord) => addressRecord.address == utxo.address);
if (addressRecord == null) {
print("utxo contains an address that is not in the wallet: ${utxo.address}");
return;
}
final unspent = BitcoinUnspent(
addressRecord,
outputId,
utxo.value.toInt(),
mwebAddrs.indexOf(utxo.address),
);
if (unspent.vout == 0) {
unspent.isChange = true;
}
mwebUnspentCoins.add(unspent);
});
unspentCoins.addAll(mwebUnspentCoins);
}
@override
Future<ElectrumBalance> fetchBalances() async {
final balance = await super.fetchBalances();
if (!mwebEnabled) {
return balance;
}
// update unspent balances:
await updateUnspent();
int confirmed = balance.confirmed;
int unconfirmed = balance.unconfirmed;
int confirmedMweb = 0;
int unconfirmedMweb = 0;
try {
mwebUtxosBox.values.forEach((utxo) {
if (utxo.height > 0) {
confirmedMweb += utxo.value.toInt();
} else {
unconfirmedMweb += utxo.value.toInt();
}
});
if (unconfirmedMweb > 0) {
unconfirmedMweb = -1 * (confirmedMweb - unconfirmedMweb);
}
} catch (_) {}
for (var addressRecord in walletAddresses.allAddresses) {
addressRecord.balance = 0;
addressRecord.txCount = 0;
}
unspentCoins.forEach((coin) {
final coinInfoList = unspentCoinsInfo.values.where(
(element) =>
element.walletId.contains(id) &&
element.hash.contains(coin.hash) &&
element.vout == coin.vout,
);
if (coinInfoList.isNotEmpty) {
final coinInfo = coinInfoList.first;
coin.isFrozen = coinInfo.isFrozen;
coin.isSending = coinInfo.isSending;
coin.note = coinInfo.note;
if (coin.bitcoinAddressRecord is! BitcoinSilentPaymentAddressRecord)
coin.bitcoinAddressRecord.balance += coinInfo.value;
} else {
super.addCoinInfo(coin);
}
});
// update the txCount for each address using the tx history, since we can't rely on mwebd
// to have an accurate count, we should just keep it in sync with what we know from the tx history:
for (final tx in transactionHistory.transactions.values) {
// if (tx.isPending) continue;
if (tx.inputAddresses == null || tx.outputAddresses == null) {
continue;
}
final txAddresses = tx.inputAddresses! + tx.outputAddresses!;
for (final address in txAddresses) {
final addressRecord = walletAddresses.allAddresses
.firstWhereOrNull((addressRecord) => addressRecord.address == address);
if (addressRecord == null) {
continue;
}
addressRecord.txCount++;
}
}
return ElectrumBalance(
confirmed: confirmed,
unconfirmed: unconfirmed,
frozen: balance.frozen,
secondConfirmed: confirmedMweb,
secondUnconfirmed: unconfirmedMweb,
); );
} }
@ -204,6 +807,229 @@ abstract class LitecoinWalletBase extends ElectrumWallet with Store {
return 0; return 0;
} }
@override
Future<int> calcFee({
required List<UtxoWithAddress> utxos,
required List<BitcoinBaseOutput> outputs,
required BasedUtxoNetwork network,
String? memo,
required int feeRate,
List<ECPrivateInfo>? inputPrivKeyInfos,
List<Outpoint>? vinOutpoints,
}) async {
final spendsMweb = utxos.any((utxo) => utxo.utxo.scriptType == SegwitAddresType.mweb);
final paysToMweb = outputs
.any((output) => output.toOutput.scriptPubKey.getAddressType() == SegwitAddresType.mweb);
if (!spendsMweb && !paysToMweb) {
return await super.calcFee(
utxos: utxos,
outputs: outputs,
network: network,
memo: memo,
feeRate: feeRate,
inputPrivKeyInfos: inputPrivKeyInfos,
vinOutpoints: vinOutpoints,
);
}
if (!mwebEnabled) {
throw Exception("MWEB is not enabled! can't calculate fee without starting the mweb server!");
}
if (outputs.length == 1 && outputs[0].toOutput.amount == BigInt.zero) {
outputs = [
BitcoinScriptOutput(
script: outputs[0].toOutput.scriptPubKey, value: utxos.sumOfUtxosValue())
];
}
// https://github.com/ltcmweb/mwebd?tab=readme-ov-file#fee-estimation
final preOutputSum =
outputs.fold<BigInt>(BigInt.zero, (acc, output) => acc + output.toOutput.amount);
final fee = utxos.sumOfUtxosValue() - preOutputSum;
final txb =
BitcoinTransactionBuilder(utxos: utxos, outputs: outputs, fee: fee, network: network);
final resp = await CwMweb.create(CreateRequest(
rawTx: txb.buildTransaction((a, b, c, d) => '').toBytes(),
scanSecret: scanSecret,
spendSecret: spendSecret,
feeRatePerKb: Int64(feeRate * 1000),
dryRun: true));
final tx = BtcTransaction.fromRaw(hex.encode(resp.rawTx));
final posUtxos = utxos
.where((utxo) => tx.inputs
.any((input) => input.txId == utxo.utxo.txHash && input.txIndex == utxo.utxo.vout))
.toList();
final posOutputSum = tx.outputs.fold<int>(0, (acc, output) => acc + output.amount.toInt());
final mwebInputSum = utxos.sumOfUtxosValue() - posUtxos.sumOfUtxosValue();
final expectedPegin = max(0, (preOutputSum - mwebInputSum).toInt());
var feeIncrease = posOutputSum - expectedPegin;
if (expectedPegin > 0 && fee == BigInt.zero) {
feeIncrease += await super.calcFee(
utxos: posUtxos,
outputs: tx.outputs
.map((output) =>
BitcoinScriptOutput(script: output.scriptPubKey, value: output.amount))
.toList(),
network: network,
memo: memo,
feeRate: feeRate) +
feeRate * 41;
}
return fee.toInt() + feeIncrease;
}
@override
Future<PendingTransaction> createTransaction(Object credentials) async {
try {
var tx = await super.createTransaction(credentials) as PendingBitcoinTransaction;
tx.isMweb = mwebEnabled;
if (!mwebEnabled) {
return tx;
}
await waitForMwebAddresses();
final resp = await CwMweb.create(CreateRequest(
rawTx: hex.decode(tx.hex),
scanSecret: scanSecret,
spendSecret: spendSecret,
feeRatePerKb: Int64.parseInt(tx.feeRate) * 1000,
));
final tx2 = BtcTransaction.fromRaw(hex.encode(resp.rawTx));
// check if the transaction doesn't contain any mweb inputs or outputs:
final transactionCredentials = credentials as BitcoinTransactionCredentials;
bool hasMwebInput = false;
bool hasMwebOutput = false;
for (final output in transactionCredentials.outputs) {
if (output.extractedAddress?.toLowerCase().contains("mweb") ?? false) {
hasMwebOutput = true;
break;
}
}
if (tx2.mwebBytes != null && tx2.mwebBytes!.isNotEmpty) {
hasMwebInput = true;
}
if (!hasMwebInput && !hasMwebOutput) {
return tx;
}
// check if any of the inputs of this transaction are hog-ex:
// this list is only non-mweb inputs:
tx2.inputs.forEach((txInput) {
bool isHogEx = true;
final utxo = unspentCoins
.firstWhere((utxo) => utxo.hash == txInput.txId && utxo.vout == txInput.txIndex);
// TODO: detect actual hog-ex inputs
if (!isHogEx) {
return;
}
int confirmations = utxo.confirmations ?? 0;
if (confirmations < 6) {
throw Exception(
"A transaction input has less than 6 confirmations, please try again later.");
}
});
tx.hexOverride = tx2
.copyWith(
witnesses: tx2.inputs.asMap().entries.map((e) {
final utxo = unspentCoins
.firstWhere((utxo) => utxo.hash == e.value.txId && utxo.vout == e.value.txIndex);
final key = generateECPrivate(
hd: utxo.bitcoinAddressRecord.isHidden
? walletAddresses.sideHd
: walletAddresses.mainHd,
index: utxo.bitcoinAddressRecord.index,
network: network);
final digest = tx2.getTransactionSegwitDigit(
txInIndex: e.key,
script: key.getPublic().toP2pkhAddress().toScriptPubKey(),
amount: BigInt.from(utxo.value),
);
return TxWitnessInput(stack: [key.signInput(digest), key.getPublic().toHex()]);
}).toList())
.toHex();
tx.outputAddresses = resp.outputId;
return tx
..addListener((transaction) async {
final addresses = <String>{};
transaction.inputAddresses?.forEach((id) async {
final utxo = mwebUtxosBox.get(id);
// await mwebUtxosBox.delete(id);// gets deleted in checkMwebUtxosSpent
if (utxo == null) return;
final addressRecord = walletAddresses.allAddresses
.firstWhere((addressRecord) => addressRecord.address == utxo.address);
if (!addresses.contains(utxo.address)) {
addresses.add(utxo.address);
}
addressRecord.balance -= utxo.value.toInt();
});
transaction.inputAddresses?.addAll(addresses);
transactionHistory.addOne(transaction);
await updateUnspent();
await updateBalance();
});
} catch (e, s) {
print(e);
print(s);
if (e.toString().contains("commit failed")) {
throw Exception("Transaction commit failed (no peers responded), please try again.");
}
rethrow;
}
}
@override
Future<void> save() async {
await super.save();
}
@override
Future<void> close({required bool shouldCleanup}) async {
_utxoStream?.cancel();
_feeRatesTimer?.cancel();
_syncTimer?.cancel();
_processingTimer?.cancel();
if (shouldCleanup) {
try {
await stopSync();
} catch (_) {}
}
await super.close(shouldCleanup: shouldCleanup);
}
Future<void> setMwebEnabled(bool enabled) async {
if (mwebEnabled == enabled) {
return;
}
alwaysScan = enabled;
mwebEnabled = enabled;
(walletAddresses as LitecoinWalletAddresses).mwebEnabled = enabled;
await save();
try {
await stopSync();
} catch (_) {}
await startSync();
}
Future<StatusResponse> getStatusRequest() async {
final resp = await CwMweb.status(StatusRequest());
return resp;
}
@override @override
Future<String> signMessage(String message, {String? address = null}) async { Future<String> signMessage(String message, {String? address = null}) async {
final index = address != null final index = address != null
@ -301,7 +1127,7 @@ abstract class LitecoinWalletBase extends ElectrumWallet with Store {
List<int> possibleRecoverIds = [0, 1]; List<int> possibleRecoverIds = [0, 1];
final baseAddress = addressTypeFromStr(address, network); final baseAddress = RegexUtils.addressTypeFromStr(address, network);
for (int recoveryId in possibleRecoverIds) { for (int recoveryId in possibleRecoverIds) {
final pubKey = sig.recoverPublicKey(messageHash, Curves.generatorSecp256k1, recoveryId); final pubKey = sig.recoverPublicKey(messageHash, Curves.generatorSecp256k1, recoveryId);

View file

@ -1,8 +1,16 @@
import 'dart:async';
import 'dart:io' show Platform;
import 'dart:typed_data';
import 'package:bitcoin_base/bitcoin_base.dart'; import 'package:bitcoin_base/bitcoin_base.dart';
import 'package:blockchain_utils/blockchain_utils.dart'; import 'package:blockchain_utils/blockchain_utils.dart';
import 'package:cw_bitcoin/bitcoin_address_record.dart';
import 'package:cw_bitcoin/electrum_wallet.dart';
import 'package:cw_bitcoin/utils.dart'; import 'package:cw_bitcoin/utils.dart';
import 'package:cw_bitcoin/electrum_wallet_addresses.dart'; import 'package:cw_bitcoin/electrum_wallet_addresses.dart';
import 'package:cw_core/wallet_info.dart'; import 'package:cw_core/wallet_info.dart';
import 'package:cw_mweb/cw_mweb.dart';
import 'package:flutter/foundation.dart';
import 'package:mobx/mobx.dart'; import 'package:mobx/mobx.dart';
part 'litecoin_wallet_addresses.g.dart'; part 'litecoin_wallet_addresses.g.dart';
@ -15,15 +23,167 @@ abstract class LitecoinWalletAddressesBase extends ElectrumWalletAddresses with
required super.mainHd, required super.mainHd,
required super.sideHd, required super.sideHd,
required super.network, required super.network,
required this.mwebHd,
required this.mwebEnabled,
super.initialAddresses, super.initialAddresses,
super.initialMwebAddresses,
super.initialRegularAddressIndex, super.initialRegularAddressIndex,
super.initialChangeAddressIndex, super.initialChangeAddressIndex,
}) : super(walletInfo); }) : super(walletInfo) {
for (int i = 0; i < mwebAddresses.length; i++) {
mwebAddrs.add(mwebAddresses[i].address);
}
print("initialized with ${mwebAddrs.length} mweb addresses");
}
final Bip32Slip10Secp256k1 mwebHd;
bool mwebEnabled;
int mwebTopUpIndex = 1000;
List<String> mwebAddrs = [];
bool generating = false;
List<int> get scanSecret => mwebHd.childKey(Bip32KeyIndex(0x80000000)).privateKey.privKey.raw;
List<int> get spendPubkey =>
mwebHd.childKey(Bip32KeyIndex(0x80000001)).publicKey.pubKey.compressed;
@override @override
String getAddress( Future<void> init() async {
{required int index, await initMwebAddresses();
required Bip32Slip10Secp256k1 hd, await super.init();
BitcoinAddressType? addressType}) => }
generateP2WPKHAddress(hd: hd, index: index, network: network);
@computed
@override
List<BitcoinAddressRecord> get allAddresses {
return List.from(super.allAddresses)..addAll(mwebAddresses);
}
Future<void> ensureMwebAddressUpToIndexExists(int index) async {
if (Platform.isLinux || Platform.isMacOS || Platform.isWindows) {
return null;
}
Uint8List scan = Uint8List.fromList(scanSecret);
Uint8List spend = Uint8List.fromList(spendPubkey);
if (index < mwebAddresses.length && index < mwebAddrs.length) {
return;
}
while (generating) {
print("generating.....");
// this function was called multiple times in multiple places:
await Future.delayed(const Duration(milliseconds: 100));
}
print("Generating MWEB addresses up to index $index");
generating = true;
try {
while (mwebAddrs.length <= (index + 1)) {
final addresses =
await CwMweb.addresses(scan, spend, mwebAddrs.length, mwebAddrs.length + 50);
print("generated up to index ${mwebAddrs.length}");
// sleep for a bit to avoid making the main thread unresponsive:
await Future.delayed(Duration(milliseconds: 200));
mwebAddrs.addAll(addresses!);
}
} catch (_) {}
generating = false;
print("Done generating MWEB addresses len: ${mwebAddrs.length}");
// ensure mweb addresses are up to date:
if (mwebAddresses.length < mwebAddrs.length) {
List<BitcoinAddressRecord> addressRecords = mwebAddrs
.asMap()
.entries
.map((e) => BitcoinAddressRecord(
e.value,
index: e.key,
type: SegwitAddresType.mweb,
network: network,
))
.toList();
addMwebAddresses(addressRecords);
print("set ${addressRecords.length} mweb addresses");
}
}
Future<void> initMwebAddresses() async {
if (mwebAddrs.length < 1000) {
await ensureMwebAddressUpToIndexExists(20);
return;
}
}
@override
String getAddress({
required int index,
required Bip32Slip10Secp256k1 hd,
BitcoinAddressType? addressType,
}) {
if (addressType == SegwitAddresType.mweb) {
return hd == sideHd ? mwebAddrs[0] : mwebAddrs[index + 1];
}
return generateP2WPKHAddress(hd: hd, index: index, network: network);
}
@override
Future<String> getAddressAsync({
required int index,
required Bip32Slip10Secp256k1 hd,
BitcoinAddressType? addressType,
}) async {
if (addressType == SegwitAddresType.mweb) {
await ensureMwebAddressUpToIndexExists(index);
}
return getAddress(index: index, hd: hd, addressType: addressType);
}
@action
@override
Future<String> getChangeAddress({List<BitcoinOutput>? outputs, UtxoDetails? utxoDetails}) async {
// use regular change address on peg in, otherwise use mweb for change address:
if (!mwebEnabled) {
return super.getChangeAddress();
}
if (outputs != null && utxoDetails != null) {
// check if this is a PEGIN:
bool outputsToMweb = false;
bool comesFromMweb = false;
for (var i = 0; i < outputs.length; i++) {
// TODO: probably not the best way to tell if this is an mweb address
// (but it doesn't contain the "mweb" text at this stage)
if (outputs[i].address.toAddress(network).length > 110) {
outputsToMweb = true;
}
}
// TODO: this doesn't respect coin control because it doesn't know which available inputs are selected
utxoDetails.availableInputs.forEach((element) {
if (element.address.contains("mweb")) {
comesFromMweb = true;
}
});
bool isPegIn = !comesFromMweb && outputsToMweb;
if (isPegIn && mwebEnabled) {
return super.getChangeAddress();
}
// use regular change address if it's not an mweb tx:
if (!comesFromMweb && !outputsToMweb) {
return super.getChangeAddress();
}
}
if (mwebEnabled) {
await ensureMwebAddressUpToIndexExists(1);
return mwebAddrs[0];
}
return super.getChangeAddress();
}
} }

View file

@ -1,10 +1,10 @@
import 'dart:io'; import 'dart:io';
import 'package:cw_bitcoin/bitcoin_mnemonics_bip39.dart'; import 'package:cw_bitcoin/bitcoin_mnemonics_bip39.dart';
import 'package:cw_bitcoin/mnemonic_is_incorrect_exception.dart';
import 'package:cw_core/encryption_file_utils.dart'; import 'package:cw_core/encryption_file_utils.dart';
import 'package:cw_core/unspent_coins_info.dart'; import 'package:cw_core/unspent_coins_info.dart';
import 'package:hive/hive.dart'; import 'package:hive/hive.dart';
import 'package:cw_bitcoin/bitcoin_mnemonic.dart'; import 'package:cw_bitcoin/bitcoin_mnemonic.dart';
import 'package:cw_bitcoin/mnemonic_is_incorrect_exception.dart';
import 'package:cw_bitcoin/bitcoin_wallet_creation_credentials.dart'; import 'package:cw_bitcoin/bitcoin_wallet_creation_credentials.dart';
import 'package:cw_bitcoin/litecoin_wallet.dart'; import 'package:cw_bitcoin/litecoin_wallet.dart';
import 'package:cw_core/wallet_service.dart'; import 'package:cw_core/wallet_service.dart';
@ -14,16 +14,19 @@ import 'package:cw_core/wallet_info.dart';
import 'package:cw_core/wallet_base.dart'; import 'package:cw_core/wallet_base.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:bip39/bip39.dart' as bip39; import 'package:bip39/bip39.dart' as bip39;
import 'package:path_provider/path_provider.dart';
class LitecoinWalletService extends WalletService< class LitecoinWalletService extends WalletService<
BitcoinNewWalletCredentials, BitcoinNewWalletCredentials,
BitcoinRestoreWalletFromSeedCredentials, BitcoinRestoreWalletFromSeedCredentials,
BitcoinRestoreWalletFromWIFCredentials, BitcoinRestoreWalletFromWIFCredentials,
BitcoinNewWalletCredentials> { BitcoinNewWalletCredentials> {
LitecoinWalletService(this.walletInfoSource, this.unspentCoinsInfoSource, this.isDirect); LitecoinWalletService(
this.walletInfoSource, this.unspentCoinsInfoSource, this.alwaysScan, this.isDirect);
final Box<WalletInfo> walletInfoSource; final Box<WalletInfo> walletInfoSource;
final Box<UnspentCoinsInfo> unspentCoinsInfoSource; final Box<UnspentCoinsInfo> unspentCoinsInfoSource;
final bool alwaysScan;
final bool isDirect; final bool isDirect;
@override @override
@ -32,11 +35,11 @@ class LitecoinWalletService extends WalletService<
@override @override
Future<LitecoinWallet> create(BitcoinNewWalletCredentials credentials, {bool? isTestnet}) async { Future<LitecoinWallet> create(BitcoinNewWalletCredentials credentials, {bool? isTestnet}) async {
final String mnemonic; final String mnemonic;
switch ( credentials.walletInfo?.derivationInfo?.derivationType) { switch (credentials.walletInfo?.derivationInfo?.derivationType) {
case DerivationType.bip39: case DerivationType.bip39:
final strength = credentials.seedPhraseLength == 24 ? 256 : 128; final strength = credentials.seedPhraseLength == 24 ? 256 : 128;
mnemonic = await MnemonicBip39.generate(strength: strength); mnemonic = credentials.mnemonic ?? await MnemonicBip39.generate(strength: strength);
break; break;
case DerivationType.electrum: case DerivationType.electrum:
default: default:
@ -64,6 +67,7 @@ class LitecoinWalletService extends WalletService<
@override @override
Future<LitecoinWallet> openWallet(String name, String password) async { Future<LitecoinWallet> openWallet(String name, String password) async {
final walletInfo = walletInfoSource.values final walletInfo = walletInfoSource.values
.firstWhereOrNull((info) => info.id == WalletBase.idFor(name, getType()))!; .firstWhereOrNull((info) => info.id == WalletBase.idFor(name, getType()))!;
@ -73,6 +77,7 @@ class LitecoinWalletService extends WalletService<
name: name, name: name,
walletInfo: walletInfo, walletInfo: walletInfo,
unspentCoinsInfo: unspentCoinsInfoSource, unspentCoinsInfo: unspentCoinsInfoSource,
alwaysScan: alwaysScan,
encryptionFileUtils: encryptionFileUtilsFor(isDirect), encryptionFileUtils: encryptionFileUtilsFor(isDirect),
); );
await wallet.init(); await wallet.init();
@ -85,6 +90,7 @@ class LitecoinWalletService extends WalletService<
name: name, name: name,
walletInfo: walletInfo, walletInfo: walletInfo,
unspentCoinsInfo: unspentCoinsInfoSource, unspentCoinsInfo: unspentCoinsInfoSource,
alwaysScan: alwaysScan,
encryptionFileUtils: encryptionFileUtilsFor(isDirect), encryptionFileUtils: encryptionFileUtilsFor(isDirect),
); );
await wallet.init(); await wallet.init();
@ -98,6 +104,23 @@ class LitecoinWalletService extends WalletService<
final walletInfo = walletInfoSource.values final walletInfo = walletInfoSource.values
.firstWhereOrNull((info) => info.id == WalletBase.idFor(wallet, getType()))!; .firstWhereOrNull((info) => info.id == WalletBase.idFor(wallet, getType()))!;
await walletInfoSource.delete(walletInfo.key); await walletInfoSource.delete(walletInfo.key);
// if there are no more litecoin wallets left, cleanup the neutrino db and other files created by mwebd:
if (walletInfoSource.values.where((info) => info.type == WalletType.litecoin).isEmpty) {
final appDirPath = (await getApplicationSupportDirectory()).path;
File neturinoDb = File('$appDirPath/neutrino.db');
File blockHeaders = File('$appDirPath/block_headers.bin');
File regFilterHeaders = File('$appDirPath/reg_filter_headers.bin');
if (neturinoDb.existsSync()) {
neturinoDb.deleteSync();
}
if (blockHeaders.existsSync()) {
blockHeaders.deleteSync();
}
if (regFilterHeaders.existsSync()) {
regFilterHeaders.deleteSync();
}
}
} }
@override @override
@ -109,6 +132,7 @@ class LitecoinWalletService extends WalletService<
name: currentName, name: currentName,
walletInfo: currentWalletInfo, walletInfo: currentWalletInfo,
unspentCoinsInfo: unspentCoinsInfoSource, unspentCoinsInfo: unspentCoinsInfoSource,
alwaysScan: alwaysScan,
encryptionFileUtils: encryptionFileUtilsFor(isDirect), encryptionFileUtils: encryptionFileUtilsFor(isDirect),
); );

View file

@ -1,11 +1,15 @@
import 'package:grpc/grpc.dart';
import 'package:cw_bitcoin/exceptions.dart'; import 'package:cw_bitcoin/exceptions.dart';
import 'package:bitcoin_base/bitcoin_base.dart'; import 'package:bitcoin_base/bitcoin_base.dart';
import 'package:blockchain_utils/blockchain_utils.dart';
import 'package:cw_core/pending_transaction.dart'; import 'package:cw_core/pending_transaction.dart';
import 'package:cw_bitcoin/electrum.dart'; import 'package:cw_bitcoin/electrum.dart';
import 'package:cw_bitcoin/bitcoin_amount_format.dart'; import 'package:cw_bitcoin/bitcoin_amount_format.dart';
import 'package:cw_bitcoin/electrum_transaction_info.dart'; import 'package:cw_bitcoin/electrum_transaction_info.dart';
import 'package:cw_core/transaction_direction.dart'; import 'package:cw_core/transaction_direction.dart';
import 'package:cw_core/wallet_type.dart'; import 'package:cw_core/wallet_type.dart';
import 'package:cw_mweb/cw_mweb.dart';
import 'package:cw_mweb/mwebd.pb.dart';
class PendingBitcoinTransaction with PendingTransaction { class PendingBitcoinTransaction with PendingTransaction {
PendingBitcoinTransaction( PendingBitcoinTransaction(
@ -19,6 +23,7 @@ class PendingBitcoinTransaction with PendingTransaction {
required this.hasChange, required this.hasChange,
this.isSendAll = false, this.isSendAll = false,
this.hasTaprootInputs = false, this.hasTaprootInputs = false,
this.isMweb = false,
}) : _listeners = <void Function(ElectrumTransactionInfo transaction)>[]; }) : _listeners = <void Function(ElectrumTransactionInfo transaction)>[];
final WalletType type; final WalletType type;
@ -28,15 +33,19 @@ class PendingBitcoinTransaction with PendingTransaction {
final int fee; final int fee;
final String feeRate; final String feeRate;
final BasedUtxoNetwork? network; final BasedUtxoNetwork? network;
final bool hasChange;
final bool isSendAll; final bool isSendAll;
final bool hasChange;
final bool hasTaprootInputs; final bool hasTaprootInputs;
bool isMweb;
String? idOverride;
String? hexOverride;
List<String>? outputAddresses;
@override @override
String get id => _tx.txId(); String get id => idOverride ?? _tx.txId();
@override @override
String get hex => _tx.serialize(); String get hex => hexOverride ?? _tx.serialize();
@override @override
String get amountFormatted => bitcoinAmountToString(amount: amount); String get amountFormatted => bitcoinAmountToString(amount: amount);
@ -47,10 +56,22 @@ class PendingBitcoinTransaction with PendingTransaction {
@override @override
int? get outputCount => _tx.outputs.length; int? get outputCount => _tx.outputs.length;
List<TxOutput> get outputs => _tx.outputs;
bool get hasSilentPayment => _tx.hasSilentPayment;
PendingChange? get change {
try {
final change = _tx.outputs.firstWhere((out) => out.isChange);
return PendingChange(change.scriptPubKey.toAddress(), BtcUtils.fromSatoshi(change.amount));
} catch (_) {
return null;
}
}
final List<void Function(ElectrumTransactionInfo transaction)> _listeners; final List<void Function(ElectrumTransactionInfo transaction)> _listeners;
@override Future<void> _commit() async {
Future<void> commit() async {
int? callId; int? callId;
final result = await electrumClient.broadcastTransaction( final result = await electrumClient.broadcastTransaction(
@ -78,11 +99,36 @@ class PendingBitcoinTransaction with PendingTransaction {
throw BitcoinTransactionCommitFailedBIP68Final(); throw BitcoinTransactionCommitFailedBIP68Final();
} }
if (error.contains("min fee not met")) {
throw BitcoinTransactionCommitFailedLessThanMin();
}
throw BitcoinTransactionCommitFailed(errorMessage: error); throw BitcoinTransactionCommitFailed(errorMessage: error);
} }
throw BitcoinTransactionCommitFailed(); throw BitcoinTransactionCommitFailed();
} }
}
Future<void> _ltcCommit() async {
try {
final stub = await CwMweb.stub();
final resp = await stub.broadcast(BroadcastRequest(rawTx: BytesUtils.fromHexString(hex)));
idOverride = resp.txid;
} on GrpcError catch (e) {
throw BitcoinTransactionCommitFailed(errorMessage: e.message);
} catch (e) {
throw BitcoinTransactionCommitFailed(errorMessage: "Unknown error: ${e.toString()}");
}
}
@override
Future<void> commit() async {
if (isMweb) {
await _ltcCommit();
} else {
await _commit();
}
_listeners.forEach((listener) => listener(transactionInfo())); _listeners.forEach((listener) => listener(transactionInfo()));
} }
@ -97,6 +143,9 @@ class PendingBitcoinTransaction with PendingTransaction {
direction: TransactionDirection.outgoing, direction: TransactionDirection.outgoing,
date: DateTime.now(), date: DateTime.now(),
isPending: true, isPending: true,
isReplaced: false,
confirmations: 0, confirmations: 0,
inputAddresses: _tx.inputs.map((input) => input.txId).toList(),
outputAddresses: outputAddresses,
fee: fee); fee: fee);
} }

View file

@ -1,19 +0,0 @@
import 'package:crypto/crypto.dart';
import 'package:cw_bitcoin/address_to_output_script.dart';
import 'package:bitcoin_base/bitcoin_base.dart' as bitcoin;
String scriptHash(String address, {required bitcoin.BasedUtxoNetwork network}) {
final outputScript = addressToOutputScript(address, network);
final parts = sha256.convert(outputScript).toString().split('');
var res = '';
for (var i = parts.length - 1; i >= 0; i--) {
final char = parts[i];
i--;
final nextChar = parts[i];
res += nextChar;
res += char;
}
return res;
}

View file

@ -17,6 +17,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.7.0" version: "4.7.0"
archive:
dependency: transitive
description:
name: archive
sha256: "22600aa1e926be775fa5fe7e6894e7fb3df9efda8891c73f70fb3262399a432d"
url: "https://pub.dev"
source: hosted
version: "3.4.10"
args: args:
dependency: transitive dependency: transitive
description: description:
@ -29,10 +37,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: asn1lib name: asn1lib
sha256: "58082b3f0dca697204dbab0ef9ff208bfaea7767ea771076af9a343488428dda" sha256: "6b151826fcc95ff246cd219a0bf4c753ea14f4081ad71c61939becf3aba27f70"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.5.3" version: "1.5.5"
async: async:
dependency: transitive dependency: transitive
description: description:
@ -41,6 +49,15 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.11.0" version: "2.11.0"
bech32:
dependency: "direct main"
description:
path: "."
ref: HEAD
resolved-ref: "05755063b593aa6cca0a4820a318e0ce17de6192"
url: "https://github.com/cake-tech/bech32.git"
source: git
version: "0.2.2"
bip32: bip32:
dependency: transitive dependency: transitive
description: description:
@ -70,8 +87,8 @@ packages:
dependency: "direct overridden" dependency: "direct overridden"
description: description:
path: "." path: "."
ref: cake-update-v5 ref: cake-update-v8
resolved-ref: ff2b10eb27b0254ce4518d054332d97d77d9b380 resolved-ref: fc045a11db3d85d806ca67f75e8b916c706745a2
url: "https://github.com/cake-tech/bitcoin_base" url: "https://github.com/cake-tech/bitcoin_base"
source: git source: git
version: "4.7.0" version: "4.7.0"
@ -260,6 +277,13 @@ packages:
relative: true relative: true
source: path source: path
version: "0.0.1" version: "0.0.1"
cw_mweb:
dependency: "direct main"
description:
path: "../cw_mweb"
relative: true
source: path
version: "0.0.1"
dart_style: dart_style:
dependency: transitive dependency: transitive
description: description:
@ -293,13 +317,13 @@ packages:
source: hosted source: hosted
version: "1.3.1" version: "1.3.1"
ffi: ffi:
dependency: transitive dependency: "direct overridden"
description: description:
name: ffi name: ffi
sha256: "493f37e7df1804778ff3a53bd691d8692ddf69702cf4c1c1096a2e41b4779e21" sha256: "7bf0adc28a23d395f19f3f1eb21dd7cfd1dd9f8e1c50051c069122e6853bc878"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.2" version: "2.1.0"
ffigen: ffigen:
dependency: transitive dependency: transitive
description: description:
@ -379,6 +403,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.2" version: "2.1.2"
googleapis_auth:
dependency: transitive
description:
name: googleapis_auth
sha256: af7c3a3edf9d0de2e1e0a77e994fae0a581c525fa7012af4fa0d4a52ed9484da
url: "https://pub.dev"
source: hosted
version: "1.4.1"
graphs: graphs:
dependency: transitive dependency: transitive
description: description:
@ -387,6 +419,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.3.1" version: "2.3.1"
grpc:
dependency: "direct main"
description:
name: grpc
sha256: e93ee3bce45c134bf44e9728119102358c7cd69de7832d9a874e2e74eb8cab40
url: "https://pub.dev"
source: hosted
version: "3.2.4"
hex: hex:
dependency: transitive dependency: transitive
description: description:
@ -419,6 +459,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.2.2" version: "1.2.2"
http2:
dependency: transitive
description:
name: http2
sha256: "9ced024a160b77aba8fb8674e38f70875e321d319e6f303ec18e87bd5a4b0c1d"
url: "https://pub.dev"
source: hosted
version: "2.3.0"
http_multi_server: http_multi_server:
dependency: transitive dependency: transitive
description: description:
@ -553,10 +601,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: mime name: mime
sha256: "2e123074287cc9fd6c09de8336dae606d1ddb88d9ac47358826db698c176a1f2" sha256: "801fd0b26f14a4a58ccb09d5892c3fbdeff209594300a542492cf13fba9d247a"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.0.5" version: "1.0.6"
mobx: mobx:
dependency: "direct main" dependency: "direct main"
description: description:
@ -662,13 +710,13 @@ packages:
source: hosted source: hosted
version: "2.1.8" version: "2.1.8"
pointycastle: pointycastle:
dependency: transitive dependency: "direct overridden"
description: description:
name: pointycastle name: pointycastle
sha256: "4be0097fcf3fd3e8449e53730c631200ebc7b88016acecab2b0da2f0149222fe" sha256: "43ac87de6e10afabc85c445745a7b799e04de84cebaa4fd7bf55a5e1e9604d29"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.9.1" version: "3.7.4"
pool: pool:
dependency: transitive dependency: transitive
description: description:
@ -678,13 +726,13 @@ packages:
source: hosted source: hosted
version: "1.5.1" version: "1.5.1"
protobuf: protobuf:
dependency: transitive dependency: "direct overridden"
description: description:
name: protobuf name: protobuf
sha256: "01dd9bd0fa02548bf2ceee13545d4a0ec6046459d847b6b061d8a27237108a08" sha256: "68645b24e0716782e58948f8467fd42a880f255096a821f9e7d0ec625b00c84d"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.0" version: "3.1.0"
provider: provider:
dependency: transitive dependency: transitive
description: description:
@ -713,10 +761,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: quiver name: quiver
sha256: b1c1ac5ce6688d77f65f3375a9abb9319b3cb32486bdc7a1e0fdf004d7ba4e47 sha256: ea0b925899e64ecdfbf9c7becb60d5b50e706ade44a85b2363be2a22d88117d2
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.2.1" version: "3.2.2"
reactive_ble_mobile: reactive_ble_mobile:
dependency: transitive dependency: transitive
description: description:
@ -855,7 +903,7 @@ packages:
description: description:
path: "." path: "."
ref: "sp_v4.0.0" ref: "sp_v4.0.0"
resolved-ref: "9b04f4b0af80dd7dae9274b496a53c23dcc80ea5" resolved-ref: ca1add293bd1e06920aa049b655832da50d0dab2
url: "https://github.com/cake-tech/sp_scanner" url: "https://github.com/cake-tech/sp_scanner"
source: git source: git
version: "0.0.1" version: "0.0.1"

View file

@ -34,11 +34,16 @@ dependencies:
ledger_bitcoin: ledger_bitcoin:
git: git:
url: https://github.com/cake-tech/ledger-bitcoin url: https://github.com/cake-tech/ledger-bitcoin
cw_mweb:
path: ../cw_mweb
grpc: ^3.2.4
sp_scanner: sp_scanner:
git: git:
url: https://github.com/cake-tech/sp_scanner url: https://github.com/cake-tech/sp_scanner
ref: sp_v4.0.0 ref: sp_v4.0.0
bech32:
git:
url: https://github.com/cake-tech/bech32.git
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:
@ -54,10 +59,13 @@ dependency_overrides:
url: https://github.com/cake-tech/ledger-flutter.git url: https://github.com/cake-tech/ledger-flutter.git
ref: cake-v3 ref: cake-v3
watcher: ^1.1.0 watcher: ^1.1.0
protobuf: ^3.1.0
bitcoin_base: bitcoin_base:
git: git:
url: https://github.com/cake-tech/bitcoin_base url: https://github.com/cake-tech/bitcoin_base
ref: cake-update-v5 ref: cake-update-v8
pointycastle: 3.7.4
ffi: 2.1.0
# For information on the generic Dart part of this file, see the # For information on the generic Dart part of this file, see the
# following page: https://dart.dev/tools/pub/pubspec # following page: https://dart.dev/tools/pub/pubspec

View file

@ -83,7 +83,7 @@ abstract class BitcoinCashWalletBase extends ElectrumWallet with Store {
unspentCoinsInfo: unspentCoinsInfo, unspentCoinsInfo: unspentCoinsInfo,
initialAddresses: initialAddresses, initialAddresses: initialAddresses,
initialBalance: initialBalance, initialBalance: initialBalance,
seedBytes: await MnemonicBip39.toSeed(mnemonic, passphrase: passphrase), seedBytes: MnemonicBip39.toSeed(mnemonic, passphrase: passphrase),
encryptionFileUtils: encryptionFileUtils, encryptionFileUtils: encryptionFileUtils,
initialRegularAddressIndex: initialRegularAddressIndex, initialRegularAddressIndex: initialRegularAddressIndex,
initialChangeAddressIndex: initialChangeAddressIndex, initialChangeAddressIndex: initialChangeAddressIndex,

View file

@ -2,9 +2,21 @@ import 'package:cw_core/wallet_credentials.dart';
import 'package:cw_core/wallet_info.dart'; import 'package:cw_core/wallet_info.dart';
class BitcoinCashNewWalletCredentials extends WalletCredentials { class BitcoinCashNewWalletCredentials extends WalletCredentials {
BitcoinCashNewWalletCredentials( BitcoinCashNewWalletCredentials({
{required String name, WalletInfo? walletInfo, String? password, String? passphrase}) required String name,
: super(name: name, walletInfo: walletInfo, password: password, passphrase: passphrase); WalletInfo? walletInfo,
String? password,
String? passphrase,
this.mnemonic,
String? parentAddress,
}) : super(
name: name,
walletInfo: walletInfo,
password: password,
passphrase: passphrase,
parentAddress: parentAddress
);
final String? mnemonic;
} }
class BitcoinCashRestoreWalletFromSeedCredentials extends WalletCredentials { class BitcoinCashRestoreWalletFromSeedCredentials extends WalletCredentials {

View file

@ -36,7 +36,7 @@ class BitcoinCashWalletService extends WalletService<
final strength = credentials.seedPhraseLength == 24 ? 256 : 128; final strength = credentials.seedPhraseLength == 24 ? 256 : 128;
final wallet = await BitcoinCashWalletBase.create( final wallet = await BitcoinCashWalletBase.create(
mnemonic: await MnemonicBip39.generate(strength: strength), mnemonic: credentials.mnemonic ?? MnemonicBip39.generate(strength: strength),
password: credentials.password!, password: credentials.password!,
walletInfo: credentials.walletInfo!, walletInfo: credentials.walletInfo!,
unspentCoinsInfo: unspentCoinsInfoSource, unspentCoinsInfo: unspentCoinsInfoSource,

View file

@ -82,5 +82,7 @@ class PendingBitcoinCashTransaction with PendingTransaction {
date: DateTime.now(), date: DateTime.now(),
isPending: true, isPending: true,
confirmations: 0, confirmations: 0,
fee: fee); fee: fee,
isReplaced: false,
);
} }

View file

@ -42,7 +42,7 @@ dependency_overrides:
bitcoin_base: bitcoin_base:
git: git:
url: https://github.com/cake-tech/bitcoin_base url: https://github.com/cake-tech/bitcoin_base
ref: cake-update-v5 ref: cake-update-v8
# For information on the generic Dart part of this file, see the # For information on the generic Dart part of this file, see the
# following page: https://dart.dev/tools/pub/pubspec # following page: https://dart.dev/tools/pub/pubspec

View file

@ -1,13 +1,17 @@
abstract class Balance { abstract class Balance {
const Balance(this.available, this.additional); const Balance(this.available, this.additional, {this.secondAvailable, this.secondAdditional});
final int available; final int available;
final int additional; final int additional;
final int? secondAvailable;
final int? secondAdditional;
String get formattedAvailableBalance; String get formattedAvailableBalance;
String get formattedAdditionalBalance; String get formattedAdditionalBalance;
String get formattedUnAvailableBalance => ''; String get formattedUnAvailableBalance => '';
String get formattedSecondAvailableBalance => '';
String get formattedSecondAdditionalBalance => '';
String get formattedFullAvailableBalance => formattedAvailableBalance;
} }

View file

@ -106,6 +106,7 @@ class CryptoCurrency extends EnumerableItem<int> with Serializable<int> implemen
CryptoCurrency.usdcTrc20, CryptoCurrency.usdcTrc20,
CryptoCurrency.tbtc, CryptoCurrency.tbtc,
CryptoCurrency.wow, CryptoCurrency.wow,
CryptoCurrency.ton,
]; ];
static const havenCurrencies = [ static const havenCurrencies = [
@ -174,11 +175,11 @@ class CryptoCurrency extends EnumerableItem<int> with Serializable<int> implemen
static const zen = CryptoCurrency(title: 'ZEN', fullName: 'Horizen', raw: 44, name: 'zen', iconPath: 'assets/images/zen_icon.png', decimals: 8); static const zen = CryptoCurrency(title: 'ZEN', fullName: 'Horizen', raw: 44, name: 'zen', iconPath: 'assets/images/zen_icon.png', decimals: 8);
static const xvg = CryptoCurrency(title: 'XVG', fullName: 'Verge', raw: 45, name: 'xvg', iconPath: 'assets/images/xvg_icon.png', decimals: 8); static const xvg = CryptoCurrency(title: 'XVG', fullName: 'Verge', raw: 45, name: 'xvg', iconPath: 'assets/images/xvg_icon.png', decimals: 8);
static const usdcpoly = CryptoCurrency(title: 'USDC', tag: 'POLY', fullName: 'USD Coin', raw: 46, name: 'usdcpoly', iconPath: 'assets/images/usdc_icon.png', decimals: 6); static const usdcpoly = CryptoCurrency(title: 'USDC', tag: 'POL', fullName: 'USD Coin', raw: 46, name: 'usdcpoly', iconPath: 'assets/images/usdc_icon.png', decimals: 6);
static const dcr = CryptoCurrency(title: 'DCR', fullName: 'Decred', raw: 47, name: 'dcr', iconPath: 'assets/images/dcr_icon.png', decimals: 8); static const dcr = CryptoCurrency(title: 'DCR', fullName: 'Decred', raw: 47, name: 'dcr', iconPath: 'assets/images/dcr_icon.png', decimals: 8);
static const kmd = CryptoCurrency(title: 'KMD', fullName: 'Komodo', raw: 48, name: 'kmd', iconPath: 'assets/images/kmd_icon.png', decimals: 8); static const kmd = CryptoCurrency(title: 'KMD', fullName: 'Komodo', raw: 48, name: 'kmd', iconPath: 'assets/images/kmd_icon.png', decimals: 8);
static const mana = CryptoCurrency(title: 'MANA', tag: 'ETH', fullName: 'Decentraland', raw: 49, name: 'mana', iconPath: 'assets/images/mana_icon.png', decimals: 18); static const mana = CryptoCurrency(title: 'MANA', tag: 'ETH', fullName: 'Decentraland', raw: 49, name: 'mana', iconPath: 'assets/images/mana_icon.png', decimals: 18);
static const maticpoly = CryptoCurrency(title: 'MATIC', tag: 'POLY', fullName: 'Polygon', raw: 50, name: 'maticpoly', iconPath: 'assets/images/matic_icon.png', decimals: 18); static const maticpoly = CryptoCurrency(title: 'POL', tag: 'POL', fullName: 'Polygon', raw: 50, name: 'maticpoly', iconPath: 'assets/images/matic_icon.png', decimals: 18);
static const matic = CryptoCurrency(title: 'MATIC', tag: 'ETH', fullName: 'Polygon', raw: 51, name: 'matic', iconPath: 'assets/images/matic_icon.png', decimals: 18); static const matic = CryptoCurrency(title: 'MATIC', tag: 'ETH', fullName: 'Polygon', raw: 51, name: 'matic', iconPath: 'assets/images/matic_icon.png', decimals: 18);
static const mkr = CryptoCurrency(title: 'MKR', tag: 'ETH', fullName: 'Maker', raw: 52, name: 'mkr', iconPath: 'assets/images/mkr_icon.png', decimals: 18); static const mkr = CryptoCurrency(title: 'MKR', tag: 'ETH', fullName: 'Maker', raw: 52, name: 'mkr', iconPath: 'assets/images/mkr_icon.png', decimals: 18);
static const near = CryptoCurrency(title: 'NEAR', fullName: 'NEAR Protocol', raw: 53, name: 'near', iconPath: 'assets/images/near_icon.png', decimals: 24); static const near = CryptoCurrency(title: 'NEAR', fullName: 'NEAR Protocol', raw: 53, name: 'near', iconPath: 'assets/images/near_icon.png', decimals: 24);
@ -215,14 +216,15 @@ class CryptoCurrency extends EnumerableItem<int> with Serializable<int> implemen
static const dydx = CryptoCurrency(title: 'DYDX', tag: 'ETH', fullName: 'dYdX', raw: 84, name: 'dydx', iconPath: 'assets/images/dydx_icon.png', decimals: 18); static const dydx = CryptoCurrency(title: 'DYDX', tag: 'ETH', fullName: 'dYdX', raw: 84, name: 'dydx', iconPath: 'assets/images/dydx_icon.png', decimals: 18);
static const steth = CryptoCurrency(title: 'STETH', tag: 'ETH', fullName: 'Lido Staked Ethereum', raw: 85, name: 'steth', iconPath: 'assets/images/steth_icon.png', decimals: 18); static const steth = CryptoCurrency(title: 'STETH', tag: 'ETH', fullName: 'Lido Staked Ethereum', raw: 85, name: 'steth', iconPath: 'assets/images/steth_icon.png', decimals: 18);
static const banano = CryptoCurrency(title: 'BAN', fullName: 'Banano', raw: 86, name: 'banano', iconPath: 'assets/images/nano_icon.png', decimals: 29); static const banano = CryptoCurrency(title: 'BAN', fullName: 'Banano', raw: 86, name: 'banano', iconPath: 'assets/images/nano_icon.png', decimals: 29);
static const usdtPoly = CryptoCurrency(title: 'USDT', tag: 'POLY', fullName: 'Tether USD (PoS)', raw: 87, name: 'usdtpoly', iconPath: 'assets/images/usdt_icon.png', decimals: 6); static const usdtPoly = CryptoCurrency(title: 'USDT', tag: 'POL', fullName: 'Tether USD (PoS)', raw: 87, name: 'usdtpoly', iconPath: 'assets/images/usdt_icon.png', decimals: 6);
static const usdcEPoly = CryptoCurrency(title: 'USDC.E', tag: 'POLY', fullName: 'USD Coin (PoS)', raw: 88, name: 'usdcepoly', iconPath: 'assets/images/usdc_icon.png', decimals: 6); static const usdcEPoly = CryptoCurrency(title: 'USDC.E', tag: 'POL', fullName: 'USD Coin (PoS)', raw: 88, name: 'usdcepoly', iconPath: 'assets/images/usdc_icon.png', decimals: 6);
static const kaspa = CryptoCurrency(title: 'KAS', fullName: 'Kaspa', raw: 89, name: 'kas', iconPath: 'assets/images/kaspa_icon.png', decimals: 8); static const kaspa = CryptoCurrency(title: 'KAS', fullName: 'Kaspa', raw: 89, name: 'kas', iconPath: 'assets/images/kaspa_icon.png', decimals: 8);
static const digibyte = CryptoCurrency(title: 'DGB', fullName: 'DigiByte', raw: 90, name: 'dgb', iconPath: 'assets/images/digibyte.png', decimals: 8); static const digibyte = CryptoCurrency(title: 'DGB', fullName: 'DigiByte', raw: 90, name: 'dgb', iconPath: 'assets/images/digibyte.png', decimals: 8);
static const usdtSol = CryptoCurrency(title: 'USDT', tag: 'SOL', fullName: 'USDT Tether', raw: 91, name: 'usdtsol', iconPath: 'assets/images/usdt_icon.png', decimals: 6); static const usdtSol = CryptoCurrency(title: 'USDT', tag: 'SOL', fullName: 'USDT Tether', raw: 91, name: 'usdtsol', iconPath: 'assets/images/usdt_icon.png', decimals: 6);
static const usdcTrc20 = CryptoCurrency(title: 'USDC', tag: 'TRX', fullName: 'USDC Coin', raw: 92, name: 'usdctrc20', iconPath: 'assets/images/usdc_icon.png', decimals: 6); static const usdcTrc20 = CryptoCurrency(title: 'USDC', tag: 'TRX', fullName: 'USDC Coin', raw: 92, name: 'usdctrc20', iconPath: 'assets/images/usdc_icon.png', decimals: 6);
static const tbtc = CryptoCurrency(title: 'tBTC', fullName: 'Testnet Bitcoin', raw: 93, name: 'tbtc', iconPath: 'assets/images/tbtc.png', decimals: 8); static const tbtc = CryptoCurrency(title: 'tBTC', fullName: 'Testnet Bitcoin', raw: 93, name: 'tbtc', iconPath: 'assets/images/tbtc.png', decimals: 8);
static const wow = CryptoCurrency(title: 'WOW', fullName: 'Wownero', raw: 94, name: 'wow', iconPath: 'assets/images/wownero_icon.png', decimals: 11); static const wow = CryptoCurrency(title: 'WOW', fullName: 'Wownero', raw: 94, name: 'wow', iconPath: 'assets/images/wownero_icon.png', decimals: 11);
static const ton = CryptoCurrency(title: 'TON', fullName: 'Toncoin', raw: 95, name: 'ton', iconPath: 'assets/images/ton_icon.png', decimals: 8);
static final Map<int, CryptoCurrency> _rawCurrencyMap = static final Map<int, CryptoCurrency> _rawCurrencyMap =

View file

@ -24,6 +24,11 @@ class TransactionCommitFailed implements Exception {
final String? errorMessage; final String? errorMessage;
TransactionCommitFailed({this.errorMessage}); TransactionCommitFailed({this.errorMessage});
@override
String toString() {
return errorMessage??"unknown error";
}
} }
class TransactionCommitFailedDustChange implements Exception {} class TransactionCommitFailedDustChange implements Exception {}
@ -36,4 +41,6 @@ class TransactionCommitFailedVoutNegative implements Exception {}
class TransactionCommitFailedBIP68Final implements Exception {} class TransactionCommitFailedBIP68Final implements Exception {}
class TransactionCommitFailedLessThanMin implements Exception {}
class TransactionInputNotSupported implements Exception {} class TransactionInputNotSupported implements Exception {}

View file

@ -267,6 +267,16 @@ const bitcoinDates = {
"2023-01": 769810, "2023-01": 769810,
}; };
Future<int> getBitcoinHeightByDateAPI({required DateTime date}) async {
final response = await http.get(
Uri.parse(
"http://mempool.cakewallet.com:8999/api/v1/mining/blocks/timestamp/${(date.millisecondsSinceEpoch / 1000).round()}",
),
);
return jsonDecode(response.body)['height'] as int;
}
int getBitcoinHeightByDate({required DateTime date}) { int getBitcoinHeightByDate({required DateTime date}) {
String dateKey = '${date.year}-${date.month.toString().padLeft(2, '0')}'; String dateKey = '${date.year}-${date.month.toString().padLeft(2, '0')}';
final closestKey = bitcoinDates.keys final closestKey = bitcoinDates.keys
@ -300,6 +310,11 @@ DateTime getDateByBitcoinHeight(int height) {
return estimatedDate; return estimatedDate;
} }
int getLtcHeightByDate({required DateTime date}) {
// TODO: use the proxy layer to get the height with a binary search of blocked header heights
return 0;
}
// TODO: enhance all of this global const lists // TODO: enhance all of this global const lists
const wowDates = { const wowDates = {
"2023-12": 583048, "2023-12": 583048,
@ -377,4 +392,3 @@ int getWowneroHeightByDate({required DateTime date}) {
return wowDates[closestKey] ?? 0; return wowDates[closestKey] ?? 0;
} }

View file

@ -18,3 +18,4 @@ const SPL_TOKEN_TYPE_ID = 16;
const DERIVATION_INFO_TYPE_ID = 17; const DERIVATION_INFO_TYPE_ID = 17;
const TRON_TOKEN_TYPE_ID = 18; const TRON_TOKEN_TYPE_ID = 18;
const HARDWARE_WALLET_TYPE_TYPE_ID = 19; const HARDWARE_WALLET_TYPE_TYPE_ID = 19;
const MWEB_UTXO_TYPE_ID = 20;

View file

@ -0,0 +1,33 @@
import 'package:cw_core/hive_type_ids.dart';
import 'package:hive/hive.dart';
part 'mweb_utxo.g.dart';
@HiveType(typeId: MWEB_UTXO_TYPE_ID)
class MwebUtxo extends HiveObject {
MwebUtxo({
required this.height,
required this.value,
required this.address,
required this.outputId,
required this.blockTime,
});
static const typeId = MWEB_UTXO_TYPE_ID;
static const boxName = 'MwebUtxo';
@HiveField(0)
int height;
@HiveField(1)
int value;
@HiveField(2)
String address;
@HiveField(3)
String outputId;
@HiveField(4)
int blockTime;
}

View file

@ -239,12 +239,15 @@ class Node extends HiveObject with Keyable {
// you try to communicate with it // you try to communicate with it
Future<bool> requestElectrumServer() async { Future<bool> requestElectrumServer() async {
try { try {
final Socket socket;
if (useSSL == true) { if (useSSL == true) {
await SecureSocket.connect(uri.host, uri.port, socket = await SecureSocket.connect(uri.host, uri.port,
timeout: Duration(seconds: 5), onBadCertificate: (_) => true); timeout: Duration(seconds: 5), onBadCertificate: (_) => true);
} else { } else {
await Socket.connect(uri.host, uri.port, timeout: Duration(seconds: 5)); socket = await Socket.connect(uri.host, uri.port, timeout: Duration(seconds: 5));
} }
socket.destroy();
return true; return true;
} catch (_) { } catch (_) {
return false; return false;

View file

@ -1,3 +1,10 @@
class PendingChange {
final String address;
final String amount;
PendingChange(this.address, this.amount);
}
mixin PendingTransaction { mixin PendingTransaction {
String get id; String get id;
String get amountFormatted; String get amountFormatted;
@ -5,6 +12,7 @@ mixin PendingTransaction {
String? feeRate; String? feeRate;
String get hex; String get hex;
int? get outputCount => null; int? get outputCount => null;
PendingChange? change;
Future<void> commit(); Future<void> commit();
} }

View file

@ -1,12 +1,22 @@
class Subaddress { class Subaddress {
Subaddress({required this.id, required this.address, required this.label}); Subaddress({
required this.id,
required this.address,
required this.label,
this.balance = null,
this.txCount = null,
});
Subaddress.fromMap(Map<String, Object?> map) Subaddress.fromMap(Map<String, Object?> map)
: this.id = map['id'] == null ? 0 : int.parse(map['id'] as String), : this.id = map['id'] == null ? 0 : int.parse(map['id'] as String),
this.address = (map['address'] ?? '') as String, this.address = (map['address'] ?? '') as String,
this.label = (map['label'] ?? '') as String; this.label = (map['label'] ?? '') as String,
this.balance = (map['balance'] ?? '') as String?,
this.txCount = (map['txCount'] ?? '') as int?;
final int id; final int id;
final String address; final String address;
final String label; final String label;
final String? balance;
final int? txCount;
} }

View file

@ -4,6 +4,9 @@ abstract class SyncStatus {
} }
class StartingScanSyncStatus extends SyncStatus { class StartingScanSyncStatus extends SyncStatus {
StartingScanSyncStatus(this.beginHeight);
final int beginHeight;
@override @override
double progress() => 0.0; double progress() => 0.0;
} }
@ -59,7 +62,18 @@ class AttemptingSyncStatus extends SyncStatus {
double progress() => 0.0; double progress() => 0.0;
} }
class FailedSyncStatus extends NotConnectedSyncStatus {} class AttemptingScanSyncStatus extends SyncStatus {
@override
double progress() => 0.0;
}
class FailedSyncStatus extends NotConnectedSyncStatus {
String? error;
FailedSyncStatus({this.error});
@override
String toString() => error ?? super.toString();
}
class ConnectingSyncStatus extends SyncStatus { class ConnectingSyncStatus extends SyncStatus {
@override @override

View file

@ -18,6 +18,7 @@ abstract class TransactionInfo extends Object with Keyable {
String? to; String? to;
String? from; String? from;
String? evmSignatureName; String? evmSignatureName;
bool? isReplaced;
List<String>? inputAddresses; List<String>? inputAddresses;
List<String>? outputAddresses; List<String>? outputAddresses;

View file

@ -1,26 +1,58 @@
import 'package:cw_core/address_info.dart'; import 'package:cw_core/address_info.dart';
import 'package:cw_core/wallet_info.dart'; import 'package:cw_core/wallet_info.dart';
import 'package:cw_core/wallet_type.dart';
abstract class WalletAddresses { abstract class WalletAddresses {
WalletAddresses(this.walletInfo) WalletAddresses(this.walletInfo)
: addressesMap = {}, : addressesMap = {},
allAddressesMap = {}, allAddressesMap = {},
addressInfos = {}; addressInfos = {},
usedAddresses = {},
hiddenAddresses = walletInfo.hiddenAddresses?.toSet() ?? {},
manualAddresses = walletInfo.manualAddresses?.toSet() ?? {};
final WalletInfo walletInfo; final WalletInfo walletInfo;
String get address; String get address;
String get latestAddress {
if (walletInfo.type == WalletType.monero || walletInfo.type == WalletType.wownero) {
if (addressesMap.keys.length == 0) return address;
return addressesMap[addressesMap.keys.last] ?? address;
}
return _localAddress ?? address;
}
String? get primaryAddress => null; String? get primaryAddress => null;
set address(String address); String? _localAddress;
set address(String address) => _localAddress = address;
String get addressForExchange => address;
Map<String, String> addressesMap; Map<String, String> addressesMap;
Map<String, String> allAddressesMap; Map<String, String> allAddressesMap;
Map<String, String> get usableAddressesMap {
final tmp = addressesMap.map((key, value) => MapEntry(key, value)); // copy address map
tmp.removeWhere((key, value) => hiddenAddresses.contains(key) || manualAddresses.contains(key));
return tmp;
}
Map<String, String> get usableAllAddressesMap {
final tmp = allAddressesMap.map((key, value) => MapEntry(key, value)); // copy address map
tmp.removeWhere((key, value) => hiddenAddresses.contains(key) || manualAddresses.contains(key));
return tmp;
}
Map<int, List<AddressInfo>> addressInfos; Map<int, List<AddressInfo>> addressInfos;
Set<String> usedAddresses = {}; Set<String> usedAddresses;
Set<String> hiddenAddresses;
Set<String> manualAddresses;
Future<void> init(); Future<void> init();
@ -32,6 +64,8 @@ abstract class WalletAddresses {
walletInfo.addresses = addressesMap; walletInfo.addresses = addressesMap;
walletInfo.addressInfos = addressInfos; walletInfo.addressInfos = addressInfos;
walletInfo.usedAddresses = usedAddresses.toList(); walletInfo.usedAddresses = usedAddresses.toList();
walletInfo.hiddenAddresses = hiddenAddresses.toList();
walletInfo.manualAddresses = manualAddresses.toList();
if (walletInfo.isInBox) { if (walletInfo.isInBox) {
await walletInfo.save(); await walletInfo.save();

View file

@ -67,6 +67,8 @@ abstract class WalletBase<BalanceType extends Balance, HistoryType extends Trans
Future<void> startSync(); Future<void> startSync();
Future<void> stopSync() async {}
Future<PendingTransaction> createTransaction(Object credentials); Future<PendingTransaction> createTransaction(Object credentials);
int calculateEstimatedFee(TransactionPriority priority, int? amount); int calculateEstimatedFee(TransactionPriority priority, int? amount);
@ -81,7 +83,7 @@ abstract class WalletBase<BalanceType extends Balance, HistoryType extends Trans
Future<void> rescan({required int height}); Future<void> rescan({required int height});
void close(); Future<void> close({required bool shouldCleanup});
Future<void> changePassword(String password); Future<void> changePassword(String password);

View file

@ -10,6 +10,7 @@ abstract class WalletCredentials {
this.passphrase, this.passphrase,
this.derivationInfo, this.derivationInfo,
this.hardwareWalletType, this.hardwareWalletType,
this.parentAddress,
}) { }) {
if (this.walletInfo != null && derivationInfo != null) { if (this.walletInfo != null && derivationInfo != null) {
this.walletInfo!.derivationInfo = derivationInfo; this.walletInfo!.derivationInfo = derivationInfo;
@ -18,6 +19,7 @@ abstract class WalletCredentials {
final String name; final String name;
final int? height; final int? height;
String? parentAddress;
int? seedPhraseLength; int? seedPhraseLength;
String? password; String? password;
String? passphrase; String? passphrase;

View file

@ -80,6 +80,7 @@ class WalletInfo extends HiveObject {
this.showIntroCakePayCard, this.showIntroCakePayCard,
this.derivationInfo, this.derivationInfo,
this.hardwareWalletType, this.hardwareWalletType,
this.parentAddress,
) : _yatLastUsedAddressController = StreamController<String>.broadcast(); ) : _yatLastUsedAddressController = StreamController<String>.broadcast();
factory WalletInfo.external({ factory WalletInfo.external({
@ -97,6 +98,7 @@ class WalletInfo extends HiveObject {
String yatLastUsedAddressRaw = '', String yatLastUsedAddressRaw = '',
DerivationInfo? derivationInfo, DerivationInfo? derivationInfo,
HardwareWalletType? hardwareWalletType, HardwareWalletType? hardwareWalletType,
String? parentAddress,
}) { }) {
return WalletInfo( return WalletInfo(
id, id,
@ -113,6 +115,7 @@ class WalletInfo extends HiveObject {
showIntroCakePayCard, showIntroCakePayCard,
derivationInfo, derivationInfo,
hardwareWalletType, hardwareWalletType,
parentAddress,
); );
} }
@ -184,6 +187,18 @@ class WalletInfo extends HiveObject {
@HiveField(21) @HiveField(21)
HardwareWalletType? hardwareWalletType; HardwareWalletType? hardwareWalletType;
@HiveField(22)
String? parentAddress;
@HiveField(23)
List<String>? hiddenAddresses;
@HiveField(24)
List<String>? manualAddresses;
String get yatLastUsedAddress => yatLastUsedAddressRaw ?? ''; String get yatLastUsedAddress => yatLastUsedAddressRaw ?? '';
set yatLastUsedAddress(String address) { set yatLastUsedAddress(String address) {

View file

@ -27,6 +27,7 @@ class EthereumWallet extends EVMChainWallet {
super.initialBalance, super.initialBalance,
super.privateKey, super.privateKey,
required super.encryptionFileUtils, required super.encryptionFileUtils,
super.passphrase,
}) : super(nativeCurrency: CryptoCurrency.eth); }) : super(nativeCurrency: CryptoCurrency.eth);
@override @override
@ -142,7 +143,7 @@ class EthereumWallet extends EVMChainWallet {
if (!hasKeysFile) rethrow; if (!hasKeysFile) rethrow;
} }
final balance = EVMChainERC20Balance.fromJSON(data?['balance'] as String) ?? final balance = EVMChainERC20Balance.fromJSON(data?['balance'] as String?) ??
EVMChainERC20Balance(BigInt.zero); EVMChainERC20Balance(BigInt.zero);
final WalletKeysData keysData; final WalletKeysData keysData;
@ -150,8 +151,9 @@ class EthereumWallet extends EVMChainWallet {
if (!hasKeysFile) { if (!hasKeysFile) {
final mnemonic = data!['mnemonic'] as String?; final mnemonic = data!['mnemonic'] as String?;
final privateKey = data['private_key'] as String?; final privateKey = data['private_key'] as String?;
final passphrase = data['passphrase'] as String?;
keysData = WalletKeysData(mnemonic: mnemonic, privateKey: privateKey); keysData = WalletKeysData(mnemonic: mnemonic, privateKey: privateKey, passphrase: passphrase);
} else { } else {
keysData = await WalletKeysFile.readKeysFile( keysData = await WalletKeysFile.readKeysFile(
name, name,
@ -166,6 +168,7 @@ class EthereumWallet extends EVMChainWallet {
password: password, password: password,
mnemonic: keysData.mnemonic, mnemonic: keysData.mnemonic,
privateKey: keysData.privateKey, privateKey: keysData.privateKey,
passphrase: keysData.passphrase,
initialBalance: balance, initialBalance: balance,
client: EthereumClient(), client: EthereumClient(),
encryptionFileUtils: encryptionFileUtils, encryptionFileUtils: encryptionFileUtils,

View file

@ -21,12 +21,13 @@ class EthereumWalletService extends EVMChainWalletService<EthereumWallet> {
Future<EthereumWallet> create(EVMChainNewWalletCredentials credentials, {bool? isTestnet}) async { Future<EthereumWallet> create(EVMChainNewWalletCredentials credentials, {bool? isTestnet}) async {
final strength = credentials.seedPhraseLength == 24 ? 256 : 128; final strength = credentials.seedPhraseLength == 24 ? 256 : 128;
final mnemonic = bip39.generateMnemonic(strength: strength); final mnemonic = credentials.mnemonic ?? bip39.generateMnemonic(strength: strength);
final wallet = EthereumWallet( final wallet = EthereumWallet(
walletInfo: credentials.walletInfo!, walletInfo: credentials.walletInfo!,
mnemonic: mnemonic, mnemonic: mnemonic,
password: credentials.password!, password: credentials.password!,
passphrase: credentials.passphrase,
client: client, client: client,
encryptionFileUtils: encryptionFileUtilsFor(isDirect), encryptionFileUtils: encryptionFileUtilsFor(isDirect),
); );
@ -144,6 +145,7 @@ class EthereumWalletService extends EVMChainWalletService<EthereumWallet> {
password: credentials.password!, password: credentials.password!,
mnemonic: credentials.mnemonic, mnemonic: credentials.mnemonic,
walletInfo: credentials.walletInfo!, walletInfo: credentials.walletInfo!,
passphrase: credentials.passphrase,
client: client, client: client,
encryptionFileUtils: encryptionFileUtilsFor(isDirect), encryptionFileUtils: encryptionFileUtilsFor(isDirect),
); );

View file

@ -70,6 +70,7 @@ abstract class EVMChainWalletBase
required String password, required String password,
EVMChainERC20Balance? initialBalance, EVMChainERC20Balance? initialBalance,
required this.encryptionFileUtils, required this.encryptionFileUtils,
this.passphrase,
}) : syncStatus = const NotConnectedSyncStatus(), }) : syncStatus = const NotConnectedSyncStatus(),
_password = password, _password = password,
_mnemonic = mnemonic, _mnemonic = mnemonic,
@ -178,6 +179,7 @@ abstract class EVMChainWalletBase
mnemonic: _mnemonic, mnemonic: _mnemonic,
privateKey: _hexPrivateKey, privateKey: _hexPrivateKey,
password: _password, password: _password,
passphrase: passphrase,
); );
walletAddresses.address = _evmChainPrivateKey.address.hexEip55; walletAddresses.address = _evmChainPrivateKey.address.hexEip55;
} }
@ -262,7 +264,7 @@ abstract class EVMChainWalletBase
} }
@override @override
void close() { Future<void> close({required bool shouldCleanup}) async {
_client.stop(); _client.stop();
_transactionsUpdateTimer?.cancel(); _transactionsUpdateTimer?.cancel();
_updateFeesTimer?.cancel(); _updateFeesTimer?.cancel();
@ -545,6 +547,7 @@ abstract class EVMChainWalletBase
'mnemonic': _mnemonic, 'mnemonic': _mnemonic,
'private_key': privateKey, 'private_key': privateKey,
'balance': balance[currency]!.toJSON(), 'balance': balance[currency]!.toJSON(),
'passphrase': passphrase,
}); });
Future<void> _updateBalance() async { Future<void> _updateBalance() async {
@ -574,15 +577,19 @@ abstract class EVMChainWalletBase
} }
} }
Future<EthPrivateKey> getPrivateKey( Future<EthPrivateKey> getPrivateKey({
{String? mnemonic, String? privateKey, required String password}) async { String? mnemonic,
String? privateKey,
required String password,
String? passphrase,
}) async {
assert(mnemonic != null || privateKey != null); assert(mnemonic != null || privateKey != null);
if (privateKey != null) { if (privateKey != null) {
return EthPrivateKey.fromHex(privateKey); return EthPrivateKey.fromHex(privateKey);
} }
final seed = bip39.mnemonicToSeed(mnemonic!); final seed = bip39.mnemonicToSeed(mnemonic!, passphrase: passphrase ?? '');
final root = bip32.BIP32.fromSeed(seed); final root = bip32.BIP32.fromSeed(seed);
@ -716,4 +723,7 @@ abstract class EVMChainWalletBase
@override @override
String get password => _password; String get password => _password;
@override
final String? passphrase;
} }

View file

@ -3,17 +3,26 @@ import 'package:cw_core/wallet_credentials.dart';
import 'package:cw_core/wallet_info.dart'; import 'package:cw_core/wallet_info.dart';
class EVMChainNewWalletCredentials extends WalletCredentials { class EVMChainNewWalletCredentials extends WalletCredentials {
EVMChainNewWalletCredentials({required String name, WalletInfo? walletInfo, String? password}) EVMChainNewWalletCredentials({
: super(name: name, walletInfo: walletInfo, password: password); required super.name,
super.walletInfo,
super.password,
super.parentAddress,
this.mnemonic,
super.passphrase,
});
final String? mnemonic;
} }
class EVMChainRestoreWalletFromSeedCredentials extends WalletCredentials { class EVMChainRestoreWalletFromSeedCredentials extends WalletCredentials {
EVMChainRestoreWalletFromSeedCredentials({ EVMChainRestoreWalletFromSeedCredentials({
required String name, required super.name,
required String password, required super.password,
required this.mnemonic, required this.mnemonic,
WalletInfo? walletInfo, super.walletInfo,
}) : super(name: name, password: password, walletInfo: walletInfo); super.passphrase,
});
final String mnemonic; final String mnemonic;
} }

View file

@ -35,7 +35,6 @@ android {
} }
externalNativeBuild { externalNativeBuild {
cmake { cmake {
path "CMakeLists.txt"
} }
} }
} }

View file

@ -2,4 +2,9 @@ class SetupWalletException implements Exception {
SetupWalletException({required this.message}); SetupWalletException({required this.message});
final String message; final String message;
@override
String toString() {
return message;
}
} }

View file

@ -106,7 +106,7 @@ abstract class HavenWalletBase
Future<void>? updateBalance() => null; Future<void>? updateBalance() => null;
@override @override
void close() { Future<void> close({required bool shouldCleanup}) async {
_listener?.stop(); _listener?.stop();
_onAccountChangeReaction?.reaction.dispose(); _onAccountChangeReaction?.reaction.dispose();
_autoSaveTimer?.cancel(); _autoSaveTimer?.cancel();

View file

@ -1,6 +1,7 @@
import 'package:cw_core/wallet_addresses_with_account.dart'; import 'package:cw_core/wallet_addresses_with_account.dart';
import 'package:cw_core/wallet_info.dart'; import 'package:cw_core/wallet_info.dart';
import 'package:cw_core/account.dart'; import 'package:cw_core/account.dart';
import 'package:cw_haven/api/wallet.dart';
import 'package:cw_haven/haven_account_list.dart'; import 'package:cw_haven/haven_account_list.dart';
import 'package:cw_haven/haven_subaddress_list.dart'; import 'package:cw_haven/haven_subaddress_list.dart';
import 'package:cw_core/subaddress.dart'; import 'package:cw_core/subaddress.dart';
@ -36,7 +37,7 @@ abstract class HavenWalletAddressesBase extends WalletAddressesWithAccount<Accou
@override @override
Future<void> init() async { Future<void> init() async {
accountList.update(); accountList.update();
account = accountList.accounts.first; account = accountList.accounts.isEmpty ? Account(id: 0, label: "Primary address") : accountList.accounts.first;
updateSubaddressList(accountIndex: account?.id ?? 0); updateSubaddressList(accountIndex: account?.id ?? 0);
await updateAddressesInBox(); await updateAddressesInBox();
} }
@ -81,8 +82,9 @@ abstract class HavenWalletAddressesBase extends WalletAddressesWithAccount<Accou
void updateSubaddressList({required int accountIndex}) { void updateSubaddressList({required int accountIndex}) {
subaddressList.update(accountIndex: accountIndex); subaddressList.update(accountIndex: accountIndex);
subaddress = subaddressList.subaddresses.first; address = subaddressList.subaddresses.isNotEmpty
address = subaddress!.address; ? subaddressList.subaddresses.first.address
: getAddress();
} }
@override @override

View file

@ -116,7 +116,7 @@ class HavenWalletService extends WalletService<
if (!isValid) { if (!isValid) {
await restoreOrResetWalletFiles(name); await restoreOrResetWalletFiles(name);
wallet.close(); wallet.close(shouldCleanup: false);
return openWallet(name, password); return openWallet(name, password);
} }

View file

@ -2,4 +2,7 @@ class SetupWalletException implements Exception {
SetupWalletException({required this.message}); SetupWalletException({required this.message});
final String message; final String message;
@override
String toString() => message;
} }

View file

@ -2,4 +2,7 @@ class WalletRestoreFromKeysException implements Exception {
WalletRestoreFromKeysException({required this.message}); WalletRestoreFromKeysException({required this.message});
final String message; final String message;
@override
String toString() => message;
} }

View file

@ -2,4 +2,7 @@ class WalletRestoreFromSeedException implements Exception {
WalletRestoreFromSeedException({required this.message}); WalletRestoreFromSeedException({required this.message});
final String message; final String message;
@override
String toString() => message;
} }

View file

@ -1,5 +1,6 @@
import 'package:cw_monero/api/account_list.dart'; import 'package:cw_monero/api/account_list.dart';
import 'package:cw_monero/api/transaction_history.dart';
import 'package:cw_monero/api/wallet.dart'; import 'package:cw_monero/api/wallet.dart';
import 'package:monero/monero.dart' as monero; import 'package:monero/monero.dart' as monero;
@ -14,6 +15,10 @@ class SubaddressInfoMetadata {
SubaddressInfoMetadata? subaddress = null; SubaddressInfoMetadata? subaddress = null;
String getRawLabel({required int accountIndex, required int addressIndex}) {
return monero.Wallet_getSubaddressLabel(wptr!, accountIndex: accountIndex, addressIndex: addressIndex);
}
void refreshSubaddresses({required int accountIndex}) { void refreshSubaddresses({required int accountIndex}) {
try { try {
isUpdating = true; isUpdating = true;
@ -29,31 +34,94 @@ class Subaddress {
Subaddress({ Subaddress({
required this.addressIndex, required this.addressIndex,
required this.accountIndex, required this.accountIndex,
required this.received,
required this.txCount,
}); });
String get address => monero.Wallet_address( late String address = getAddress(
wptr!, accountIndex: accountIndex,
accountIndex: accountIndex, addressIndex: addressIndex,
addressIndex: addressIndex, );
);
final int addressIndex; final int addressIndex;
final int accountIndex; final int accountIndex;
String get label => monero.Wallet_getSubaddressLabel(wptr!, accountIndex: accountIndex, addressIndex: addressIndex); final int received;
final int txCount;
String get label {
final localLabel = monero.Wallet_getSubaddressLabel(wptr!, accountIndex: accountIndex, addressIndex: addressIndex);
if (localLabel.startsWith("#$addressIndex")) return localLabel; // don't duplicate the ID if it was user-providen
return "#$addressIndex ${localLabel}".trim();
}
} }
class TinyTransactionDetails {
TinyTransactionDetails({
required this.address,
required this.amount,
});
final List<String> address;
final int amount;
}
int lastWptr = 0;
int lastTxCount = 0;
List<TinyTransactionDetails> ttDetails = [];
List<Subaddress> getAllSubaddresses() { List<Subaddress> getAllSubaddresses() {
txhistory = monero.Wallet_history(wptr!);
final txCount = monero.TransactionHistory_count(txhistory!);
if (lastTxCount != txCount && lastWptr != wptr!.address) {
final List<TinyTransactionDetails> newttDetails = [];
lastTxCount = txCount;
lastWptr = wptr!.address;
for (var i = 0; i < txCount; i++) {
final tx = monero.TransactionHistory_transaction(txhistory!, index: i);
if (monero.TransactionInfo_direction(tx) == monero.TransactionInfo_Direction.Out) continue;
final subaddrs = monero.TransactionInfo_subaddrIndex(tx).split(",");
final account = monero.TransactionInfo_subaddrAccount(tx);
newttDetails.add(TinyTransactionDetails(
address: List.generate(subaddrs.length, (index) => getAddress(accountIndex: account, addressIndex: int.tryParse(subaddrs[index])??0)),
amount: monero.TransactionInfo_amount(tx),
));
}
ttDetails.clear();
ttDetails.addAll(newttDetails);
}
final size = monero.Wallet_numSubaddresses(wptr!, accountIndex: subaddress!.accountIndex); final size = monero.Wallet_numSubaddresses(wptr!, accountIndex: subaddress!.accountIndex);
final list = List.generate(size, (index) { final list = List.generate(size, (index) {
final ttDetailsLocal = ttDetails.where((element) {
final address = getAddress(
accountIndex: subaddress!.accountIndex,
addressIndex: index,
);
if (element.address.contains(address)) return true;
return false;
}).toList();
int received = 0;
for (var i = 0; i < ttDetailsLocal.length; i++) {
received += ttDetailsLocal[i].amount;
}
return Subaddress( return Subaddress(
accountIndex: subaddress!.accountIndex, accountIndex: subaddress!.accountIndex,
addressIndex: index, addressIndex: index,
received: received,
txCount: ttDetailsLocal.length,
); );
}).reversed.toList(); }).reversed.toList();
if (list.length == 0) { if (list.length == 0) {
list.add(Subaddress(addressIndex: subaddress!.accountIndex, accountIndex: 0)); list.add(
Subaddress(
addressIndex: subaddress!.accountIndex,
accountIndex: 0,
received: 0,
txCount: 0,
));
} }
return list; return list;
} }
int numSubaddresses(int subaccountIndex) {
return monero.Wallet_numSubaddresses(wptr!, accountIndex: subaccountIndex);
}
void addSubaddressSync({required int accountIndex, required String label}) { void addSubaddressSync({required int accountIndex, required String label}) {
monero.Wallet_addSubaddress(wptr!, accountIndex: accountIndex, label: label); monero.Wallet_addSubaddress(wptr!, accountIndex: accountIndex, label: label);
refreshSubaddresses(accountIndex: accountIndex); refreshSubaddresses(accountIndex: accountIndex);

View file

@ -5,32 +5,42 @@ import 'package:cw_monero/api/account_list.dart';
import 'package:cw_monero/api/exceptions/creation_transaction_exception.dart'; import 'package:cw_monero/api/exceptions/creation_transaction_exception.dart';
import 'package:cw_monero/api/monero_output.dart'; import 'package:cw_monero/api/monero_output.dart';
import 'package:cw_monero/api/structs/pending_transaction.dart'; import 'package:cw_monero/api/structs/pending_transaction.dart';
import 'package:cw_monero/api/wallet.dart';
import 'package:ffi/ffi.dart'; import 'package:ffi/ffi.dart';
import 'package:monero/monero.dart' as monero; import 'package:monero/monero.dart' as monero;
import 'package:monero/src/generated_bindings_monero.g.dart' as monero_gen; import 'package:monero/src/generated_bindings_monero.g.dart' as monero_gen;
import 'package:mutex/mutex.dart';
String getTxKey(String txId) { String getTxKey(String txId) {
return monero.Wallet_getTxKey(wptr!, txid: txId); return monero.Wallet_getTxKey(wptr!, txid: txId);
} }
final txHistoryMutex = Mutex();
monero.TransactionHistory? txhistory; monero.TransactionHistory? txhistory;
bool isRefreshingTx = false;
void refreshTransactions() { Future<void> refreshTransactions() async {
if (isRefreshingTx == true) return;
isRefreshingTx = true;
txhistory ??= monero.Wallet_history(wptr!); txhistory ??= monero.Wallet_history(wptr!);
monero.TransactionHistory_refresh(txhistory!); final ptr = txhistory!.address;
await txHistoryMutex.acquire();
await Isolate.run(() {
monero.TransactionHistory_refresh(Pointer.fromAddress(ptr));
});
txHistoryMutex.release();
isRefreshingTx = false;
} }
int countOfTransactions() => monero.TransactionHistory_count(txhistory!); int countOfTransactions() => monero.TransactionHistory_count(txhistory!);
List<Transaction> getAllTransactions() { Future<List<Transaction>> getAllTransactions() async {
List<Transaction> dummyTxs = []; List<Transaction> dummyTxs = [];
await txHistoryMutex.acquire();
txhistory ??= monero.Wallet_history(wptr!); txhistory ??= monero.Wallet_history(wptr!);
monero.TransactionHistory_refresh(txhistory!);
int size = countOfTransactions(); int size = countOfTransactions();
final list = List.generate(size, (index) => Transaction(txInfo: monero.TransactionHistory_transaction(txhistory!, index: index))); final list = List.generate(size, (index) => Transaction(txInfo: monero.TransactionHistory_transaction(txhistory!, index: index)));
txHistoryMutex.release();
final accts = monero.Wallet_numSubaddressAccounts(wptr!); final accts = monero.Wallet_numSubaddressAccounts(wptr!);
for (var i = 0; i < accts; i++) { for (var i = 0; i < accts; i++) {
final fullBalance = monero.Wallet_balance(wptr!, accountIndex: i); final fullBalance = monero.Wallet_balance(wptr!, accountIndex: i);
@ -45,6 +55,8 @@ List<Transaction> getAllTransactions() {
confirmations: 0, confirmations: 0,
blockheight: 0, blockheight: 0,
accountIndex: i, accountIndex: i,
addressIndex: 0,
addressIndexList: [0],
paymentId: "", paymentId: "",
amount: fullBalance - availBalance, amount: fullBalance - availBalance,
isSpend: false, isSpend: false,
@ -251,19 +263,28 @@ Future<PendingTransactionDescription> createTransactionMultDest(
class Transaction { class Transaction {
final String displayLabel; final String displayLabel;
String subaddressLabel = monero.Wallet_getSubaddressLabel(wptr!, accountIndex: 0, addressIndex: 0); late final String subaddressLabel = monero.Wallet_getSubaddressLabel(
late final String address = monero.Wallet_address(
wptr!, wptr!,
accountIndex: 0, accountIndex: accountIndex,
addressIndex: 0, addressIndex: addressIndex,
); );
late final String address = getAddress(
accountIndex: accountIndex,
addressIndex: addressIndex,
);
late final List<String> addressList = List.generate(addressIndexList.length, (index) =>
getAddress(
accountIndex: accountIndex,
addressIndex: addressIndexList[index],
));
final String description; final String description;
final int fee; final int fee;
final int confirmations; final int confirmations;
late final bool isPending = confirmations < 10; late final bool isPending = confirmations < 10;
final int blockheight; final int blockheight;
final int addressIndex = 0; final int addressIndex;
final int accountIndex; final int accountIndex;
final List<int> addressIndexList;
final String paymentId; final String paymentId;
final int amount; final int amount;
final bool isSpend; final bool isSpend;
@ -309,6 +330,8 @@ class Transaction {
amount = monero.TransactionInfo_amount(txInfo), amount = monero.TransactionInfo_amount(txInfo),
paymentId = monero.TransactionInfo_paymentId(txInfo), paymentId = monero.TransactionInfo_paymentId(txInfo),
accountIndex = monero.TransactionInfo_subaddrAccount(txInfo), accountIndex = monero.TransactionInfo_subaddrAccount(txInfo),
addressIndex = int.tryParse(monero.TransactionInfo_subaddrIndex(txInfo).split(", ")[0]) ?? 0,
addressIndexList = monero.TransactionInfo_subaddrIndex(txInfo).split(", ").map((e) => int.tryParse(e) ?? 0).toList(),
blockheight = monero.TransactionInfo_blockHeight(txInfo), blockheight = monero.TransactionInfo_blockHeight(txInfo),
confirmations = monero.TransactionInfo_confirmations(txInfo), confirmations = monero.TransactionInfo_confirmations(txInfo),
fee = monero.TransactionInfo_fee(txInfo), fee = monero.TransactionInfo_fee(txInfo),
@ -319,27 +342,9 @@ class Transaction {
final txKey = monero.Wallet_getTxKey(wptr!, txid: monero.TransactionInfo_hash(txInfo)); final txKey = monero.Wallet_getTxKey(wptr!, txid: monero.TransactionInfo_hash(txInfo));
final status = monero.Wallet_status(wptr!); final status = monero.Wallet_status(wptr!);
if (status != 0) { if (status != 0) {
return monero.Wallet_errorString(wptr!); return "";
} }
return breakTxKey(txKey); return txKey;
}
static String breakTxKey(String input) {
final x = 64;
StringBuffer buffer = StringBuffer();
for (int i = 0; i < input.length; i += x) {
int endIndex = i + x;
if (endIndex > input.length) {
endIndex = input.length;
}
buffer.write(input.substring(i, endIndex));
if (endIndex != input.length) {
buffer.write('\n\n');
}
}
return buffer.toString().trim();
} }
Transaction.dummy({ Transaction.dummy({
@ -349,6 +354,8 @@ class Transaction {
required this.confirmations, required this.confirmations,
required this.blockheight, required this.blockheight,
required this.accountIndex, required this.accountIndex,
required this.addressIndexList,
required this.addressIndex,
required this.paymentId, required this.paymentId,
required this.amount, required this.amount,
required this.isSpend, required this.isSpend,

View file

@ -82,9 +82,20 @@ String getPassphrase() {
return monero.Wallet_getCacheAttribute(wptr!, key: "cakewallet.passphrase"); return monero.Wallet_getCacheAttribute(wptr!, key: "cakewallet.passphrase");
} }
String getAddress({int accountIndex = 0, int addressIndex = 0}) => Map<int, Map<int, Map<int, String>>> addressCache = {};
monero.Wallet_address(wptr!,
String getAddress({int accountIndex = 0, int addressIndex = 0}) {
// print("getaddress: ${accountIndex}/${addressIndex}: ${monero.Wallet_numSubaddresses(wptr!, accountIndex: accountIndex)}: ${monero.Wallet_address(wptr!, accountIndex: accountIndex, addressIndex: addressIndex)}");
while (monero.Wallet_numSubaddresses(wptr!, accountIndex: accountIndex)-1 < addressIndex) {
print("adding subaddress");
monero.Wallet_addSubaddress(wptr!, accountIndex: accountIndex);
}
addressCache[wptr!.address] ??= {};
addressCache[wptr!.address]![accountIndex] ??= {};
addressCache[wptr!.address]![accountIndex]![addressIndex] ??= monero.Wallet_address(wptr!,
accountIndex: accountIndex, addressIndex: addressIndex); accountIndex: accountIndex, addressIndex: addressIndex);
return addressCache[wptr!.address]![accountIndex]![addressIndex]!;
}
int getFullBalance({int accountIndex = 0}) => int getFullBalance({int accountIndex = 0}) =>
monero.Wallet_balance(wptr!, accountIndex: accountIndex); monero.Wallet_balance(wptr!, accountIndex: accountIndex);

View file

@ -130,7 +130,7 @@ void restoreWalletFromKeysSync(
int nettype = 0, int nettype = 0,
int restoreHeight = 0}) { int restoreHeight = 0}) {
txhistory = null; txhistory = null;
final newWptr = spendKey != "" var newWptr = (spendKey != "")
? monero.WalletManager_createDeterministicWalletFromSpendKey( ? monero.WalletManager_createDeterministicWalletFromSpendKey(
wmPtr, wmPtr,
path: path, path: path,
@ -156,6 +156,32 @@ void restoreWalletFromKeysSync(
message: monero.Wallet_errorString(newWptr)); message: monero.Wallet_errorString(newWptr));
} }
// CW-712 - Try to restore deterministic wallet first, if the view key doesn't
// match the view key provided
if (spendKey != "") {
final viewKeyRestored = monero.Wallet_secretViewKey(newWptr);
if (viewKey != viewKeyRestored && viewKey != "") {
monero.WalletManager_closeWallet(wmPtr, newWptr, false);
File(path).deleteSync();
File(path+".keys").deleteSync();
newWptr = monero.WalletManager_createWalletFromKeys(
wmPtr,
path: path,
password: password,
restoreHeight: restoreHeight,
addressString: address,
viewKeyString: viewKey,
spendKeyString: spendKey,
nettype: 0,
);
final status = monero.Wallet_status(newWptr);
if (status != 0) {
throw WalletRestoreFromKeysException(
message: monero.Wallet_errorString(newWptr));
}
}
}
wptr = newWptr; wptr = newWptr;
openedWalletsByPath[path] = wptr!; openedWalletsByPath[path] = wptr!;

View file

@ -1,6 +1,7 @@
import 'package:cw_core/subaddress.dart'; import 'package:cw_core/subaddress.dart';
import 'package:cw_monero/api/coins_info.dart'; import 'package:cw_monero/api/coins_info.dart';
import 'package:cw_monero/api/subaddress_list.dart' as subaddress_list; import 'package:cw_monero/api/subaddress_list.dart' as subaddress_list;
import 'package:cw_monero/api/wallet.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:mobx/mobx.dart'; import 'package:mobx/mobx.dart';
@ -54,18 +55,12 @@ abstract class MoneroSubaddressListBase with Store {
final address = s.address; final address = s.address;
final label = s.label; final label = s.label;
final id = s.addressIndex; final id = s.addressIndex;
final hasDefaultAddressName =
label.toLowerCase() == 'Primary account'.toLowerCase() ||
label.toLowerCase() == 'Untitled account'.toLowerCase();
final isPrimaryAddress = id == 0 && hasDefaultAddressName;
return Subaddress( return Subaddress(
id: id, id: id,
address: address, address: address,
label: isPrimaryAddress balance: (s.received/1e12).toStringAsFixed(6),
? 'Primary address' txCount: s.txCount,
: hasDefaultAddressName label: label);
? ''
: label);
}).toList(); }).toList();
} }
@ -103,6 +98,9 @@ abstract class MoneroSubaddressListBase with Store {
required List<String> usedAddresses, required List<String> usedAddresses,
}) async { }) async {
_usedAddresses.addAll(usedAddresses); _usedAddresses.addAll(usedAddresses);
final _all = _usedAddresses.toSet().toList();
_usedAddresses.clear();
_usedAddresses.addAll(_all);
if (_isUpdating) { if (_isUpdating) {
return; return;
} }
@ -124,7 +122,8 @@ abstract class MoneroSubaddressListBase with Store {
Future<List<Subaddress>> _getAllUnusedAddresses( Future<List<Subaddress>> _getAllUnusedAddresses(
{required int accountIndex, required String label}) async { {required int accountIndex, required String label}) async {
final allAddresses = subaddress_list.getAllSubaddresses(); final allAddresses = subaddress_list.getAllSubaddresses();
if (allAddresses.isEmpty || _usedAddresses.contains(allAddresses.last)) { // first because addresses come in reversed order.
if (allAddresses.isEmpty || _usedAddresses.contains(allAddresses.first.address)) {
final isAddressUnused = await _newSubaddress(accountIndex: accountIndex, label: label); final isAddressUnused = await _newSubaddress(accountIndex: accountIndex, label: label);
if (!isAddressUnused) { if (!isAddressUnused) {
return await _getAllUnusedAddresses(accountIndex: accountIndex, label: label); return await _getAllUnusedAddresses(accountIndex: accountIndex, label: label);
@ -139,12 +138,13 @@ abstract class MoneroSubaddressListBase with Store {
return Subaddress( return Subaddress(
id: id, id: id,
address: address, address: address,
balance: (s.received/1e12).toStringAsFixed(6),
txCount: s.txCount,
label: id == 0 && label: id == 0 &&
label.toLowerCase() == 'Primary account'.toLowerCase() label.toLowerCase() == 'Primary account'.toLowerCase()
? 'Primary address' ? 'Primary address'
: label); : label);
}) }).toList().reversed.toList();
.toList();
} }
Future<bool> _newSubaddress({required int accountIndex, required String label}) async { Future<bool> _newSubaddress({required int accountIndex, required String label}) async {

View file

@ -59,7 +59,7 @@ abstract class MoneroWalletBase extends WalletBase<MoneroBalance,
}), }),
_isTransactionUpdating = false, _isTransactionUpdating = false,
_hasSyncAfterStartup = false, _hasSyncAfterStartup = false,
isEnabledAutoGenerateSubaddress = false, isEnabledAutoGenerateSubaddress = true,
_password = password, _password = password,
syncStatus = NotConnectedSyncStatus(), syncStatus = NotConnectedSyncStatus(),
unspentCoins = [], unspentCoins = [],
@ -86,6 +86,9 @@ abstract class MoneroWalletBase extends WalletBase<MoneroBalance,
reaction((_) => isEnabledAutoGenerateSubaddress, (bool enabled) { reaction((_) => isEnabledAutoGenerateSubaddress, (bool enabled) {
_updateSubAddress(enabled, account: walletAddresses.account); _updateSubAddress(enabled, account: walletAddresses.account);
}); });
_onTxHistoryChangeReaction = reaction((_) => transactionHistory, (__) {
_updateSubAddress(isEnabledAutoGenerateSubaddress, account: walletAddresses.account);
});
} }
static const int _autoSaveInterval = 30; static const int _autoSaveInterval = 30;
@ -131,6 +134,7 @@ abstract class MoneroWalletBase extends WalletBase<MoneroBalance,
monero_wallet.SyncListener? _listener; monero_wallet.SyncListener? _listener;
ReactionDisposer? _onAccountChangeReaction; ReactionDisposer? _onAccountChangeReaction;
ReactionDisposer? _onTxHistoryChangeReaction;
bool _isTransactionUpdating; bool _isTransactionUpdating;
bool _hasSyncAfterStartup; bool _hasSyncAfterStartup;
Timer? _autoSaveTimer; Timer? _autoSaveTimer;
@ -161,15 +165,18 @@ abstract class MoneroWalletBase extends WalletBase<MoneroBalance,
_autoSaveTimer = Timer.periodic( _autoSaveTimer = Timer.periodic(
Duration(seconds: _autoSaveInterval), (_) async => await save()); Duration(seconds: _autoSaveInterval), (_) async => await save());
// update transaction details after restore
walletAddresses.subaddressList.update(accountIndex: walletAddresses.account?.id??0);
} }
@override @override
Future<void>? updateBalance() => null; Future<void>? updateBalance() => null;
@override @override
void close() async { Future<void> close({required bool shouldCleanup}) async {
_listener?.stop(); _listener?.stop();
_onAccountChangeReaction?.reaction.dispose(); _onAccountChangeReaction?.reaction.dispose();
_onTxHistoryChangeReaction?.reaction.dispose();
_autoSaveTimer?.cancel(); _autoSaveTimer?.cancel();
} }
@ -581,7 +588,7 @@ abstract class MoneroWalletBase extends WalletBase<MoneroBalance,
@override @override
Future<Map<String, MoneroTransactionInfo>> fetchTransactions() async { Future<Map<String, MoneroTransactionInfo>> fetchTransactions() async {
transaction_history.refreshTransactions(); transaction_history.refreshTransactions();
return _getAllTransactionsOfAccount(walletAddresses.account?.id) return (await _getAllTransactionsOfAccount(walletAddresses.account?.id))
.fold<Map<String, MoneroTransactionInfo>>( .fold<Map<String, MoneroTransactionInfo>>(
<String, MoneroTransactionInfo>{}, <String, MoneroTransactionInfo>{},
(Map<String, MoneroTransactionInfo> acc, MoneroTransactionInfo tx) { (Map<String, MoneroTransactionInfo> acc, MoneroTransactionInfo tx) {
@ -597,8 +604,8 @@ abstract class MoneroWalletBase extends WalletBase<MoneroBalance,
} }
_isTransactionUpdating = true; _isTransactionUpdating = true;
transactionHistory.clear();
final transactions = await fetchTransactions(); final transactions = await fetchTransactions();
transactionHistory.clear();
transactionHistory.addMany(transactions); transactionHistory.addMany(transactions);
await transactionHistory.save(); await transactionHistory.save();
_isTransactionUpdating = false; _isTransactionUpdating = false;
@ -611,9 +618,9 @@ abstract class MoneroWalletBase extends WalletBase<MoneroBalance,
String getSubaddressLabel(int accountIndex, int addressIndex) => String getSubaddressLabel(int accountIndex, int addressIndex) =>
monero_wallet.getSubaddressLabel(accountIndex, addressIndex); monero_wallet.getSubaddressLabel(accountIndex, addressIndex);
List<MoneroTransactionInfo> _getAllTransactionsOfAccount(int? accountIndex) => Future<List<MoneroTransactionInfo>> _getAllTransactionsOfAccount(int? accountIndex) async =>
transaction_history (await transaction_history
.getAllTransactions() .getAllTransactions())
.map( .map(
(row) => MoneroTransactionInfo( (row) => MoneroTransactionInfo(
row.hash, row.hash,

View file

@ -3,6 +3,8 @@ import 'package:cw_core/address_info.dart';
import 'package:cw_core/subaddress.dart'; import 'package:cw_core/subaddress.dart';
import 'package:cw_core/wallet_addresses.dart'; import 'package:cw_core/wallet_addresses.dart';
import 'package:cw_core/wallet_info.dart'; import 'package:cw_core/wallet_info.dart';
import 'package:cw_monero/api/subaddress_list.dart' as subaddress_list;
import 'package:cw_monero/api/transaction_history.dart';
import 'package:cw_monero/api/wallet.dart'; import 'package:cw_monero/api/wallet.dart';
import 'package:cw_monero/monero_account_list.dart'; import 'package:cw_monero/monero_account_list.dart';
import 'package:cw_monero/monero_subaddress_list.dart'; import 'package:cw_monero/monero_subaddress_list.dart';
@ -27,6 +29,30 @@ abstract class MoneroWalletAddressesBase extends WalletAddresses with Store {
@observable @observable
String address; String address;
@override
String get latestAddress {
var addressIndex = subaddress_list.numSubaddresses(account?.id??0) - 1;
var address = getAddress(accountIndex: account?.id??0, addressIndex: addressIndex);
while (hiddenAddresses.contains(address)) {
addressIndex++;
address = getAddress(accountIndex: account?.id??0, addressIndex: addressIndex);
subaddressList.update(accountIndex: account?.id??0);
}
return address;
}
@override
String get addressForExchange {
var addressIndex = subaddress_list.numSubaddresses(account?.id??0) - 1;
var address = getAddress(accountIndex: account?.id??0, addressIndex: addressIndex);
while (hiddenAddresses.contains(address) || manualAddresses.contains(address) || subaddress_list.getRawLabel(accountIndex: account?.id??0, addressIndex: addressIndex).isNotEmpty) {
addressIndex++;
address = getAddress(accountIndex: account?.id??0, addressIndex: addressIndex);
subaddressList.update(accountIndex: account?.id??0);
}
return address;
}
@observable @observable
Account? account; Account? account;
@ -37,10 +63,12 @@ abstract class MoneroWalletAddressesBase extends WalletAddresses with Store {
MoneroAccountList accountList; MoneroAccountList accountList;
Set<String> usedAddresses = Set();
@override @override
Future<void> init() async { Future<void> init() async {
accountList.update(); accountList.update();
account = accountList.accounts.first; account = accountList.accounts.isEmpty ? Account(id: 0, label: "Primary address") : accountList.accounts.first;
updateSubaddressList(accountIndex: account?.id ?? 0); updateSubaddressList(accountIndex: account?.id ?? 0);
await updateAddressesInBox(); await updateAddressesInBox();
} }
@ -89,8 +117,9 @@ abstract class MoneroWalletAddressesBase extends WalletAddresses with Store {
void updateSubaddressList({required int accountIndex}) { void updateSubaddressList({required int accountIndex}) {
subaddressList.update(accountIndex: accountIndex); subaddressList.update(accountIndex: accountIndex);
subaddress = subaddressList.subaddresses.first; address = subaddressList.subaddresses.isNotEmpty
address = subaddress!.address; ? subaddressList.subaddresses.first.address
: getAddress();
} }
Future<void> updateUsedSubaddress() async { Future<void> updateUsedSubaddress() async {
@ -109,7 +138,10 @@ abstract class MoneroWalletAddressesBase extends WalletAddresses with Store {
accountIndex: accountIndex, accountIndex: accountIndex,
defaultLabel: defaultLabel, defaultLabel: defaultLabel,
usedAddresses: usedAddresses.toList()); usedAddresses: usedAddresses.toList());
subaddress = (subaddressList.subaddresses.isEmpty) ? Subaddress(id: 0, address: address, label: defaultLabel) : subaddressList.subaddresses.last; subaddress = (subaddressList.subaddresses.isEmpty) ? Subaddress(id: 0, address: address, label: defaultLabel, balance: '0', txCount: 0) : subaddressList.subaddresses.last;
if (num.tryParse(subaddress!.balance??'0') != 0) {
getAddress(accountIndex: accountIndex, addressIndex: (subaddress?.id??0)+1);
}
address = subaddress!.address; address = subaddress!.address;
} }

View file

@ -147,7 +147,7 @@ class MoneroWalletService extends WalletService<
if (!isValid) { if (!isValid) {
await restoreOrResetWalletFiles(name); await restoreOrResetWalletFiles(name);
wallet.close(); wallet.close(shouldCleanup: false);
return openWallet(name, password); return openWallet(name, password);
} }

View file

@ -463,8 +463,8 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
path: "impls/monero.dart" path: "impls/monero.dart"
ref: "3cb38bee9385faf46b03fd73aab85f3ac4115bf7" ref: "6eb571ea498ed7b854934785f00fabfd0dadf75b"
resolved-ref: "3cb38bee9385faf46b03fd73aab85f3ac4115bf7" resolved-ref: "6eb571ea498ed7b854934785f00fabfd0dadf75b"
url: "https://github.com/mrcyjanek/monero_c" url: "https://github.com/mrcyjanek/monero_c"
source: git source: git
version: "0.0.0" version: "0.0.0"

View file

@ -25,7 +25,7 @@ dependencies:
monero: monero:
git: git:
url: https://github.com/mrcyjanek/monero_c url: https://github.com/mrcyjanek/monero_c
ref: 3cb38bee9385faf46b03fd73aab85f3ac4115bf7 # monero_c hash ref: 6eb571ea498ed7b854934785f00fabfd0dadf75b # monero_c hash
path: impls/monero.dart path: impls/monero.dart
mutex: ^3.1.0 mutex: ^3.1.0

30
cw_mweb/.gitignore vendored Normal file
View file

@ -0,0 +1,30 @@
# Miscellaneous
*.class
*.log
*.pyc
*.swp
.DS_Store
.atom/
.buildlog/
.history
.svn/
migrate_working_dir/
# IntelliJ related
*.iml
*.ipr
*.iws
.idea/
# The .vscode folder contains launch configuration and tasks you configure in
# VS Code which you may wish to be included in version control, so this line
# is commented out by default.
#.vscode/
# Flutter/Dart/Pub related
# Libraries should not include pubspec.lock, per https://dart.dev/guides/libraries/private-files#pubspeclock.
/pubspec.lock
**/doc/api/
.dart_tool/
.packages
build/

36
cw_mweb/.metadata Normal file
View file

@ -0,0 +1,36 @@
# This file tracks properties of this Flutter project.
# Used by Flutter tool to assess capabilities and perform upgrades etc.
#
# This file should be version controlled.
version:
revision: f468f3366c26a5092eb964a230ce7892fda8f2f8
channel: stable
project_type: plugin
# Tracks metadata for the flutter migrate command
migration:
platforms:
- platform: root
create_revision: f468f3366c26a5092eb964a230ce7892fda8f2f8
base_revision: f468f3366c26a5092eb964a230ce7892fda8f2f8
- platform: android
create_revision: f468f3366c26a5092eb964a230ce7892fda8f2f8
base_revision: f468f3366c26a5092eb964a230ce7892fda8f2f8
- platform: ios
create_revision: f468f3366c26a5092eb964a230ce7892fda8f2f8
base_revision: f468f3366c26a5092eb964a230ce7892fda8f2f8
- platform: macos
create_revision: f468f3366c26a5092eb964a230ce7892fda8f2f8
base_revision: f468f3366c26a5092eb964a230ce7892fda8f2f8
# User provided section
# List of Local paths (relative to this file) that should be
# ignored by the migrate tool.
#
# Files that are not part of the templates will be ignored by default.
unmanaged_files:
- 'lib/main.dart'
- 'ios/Runner.xcodeproj/project.pbxproj'

3
cw_mweb/CHANGELOG.md Normal file
View file

@ -0,0 +1,3 @@
## 0.0.1
* TODO: Describe initial release.

1
cw_mweb/LICENSE Normal file
View file

@ -0,0 +1 @@
TODO: Add your license here.

15
cw_mweb/README.md Normal file
View file

@ -0,0 +1,15 @@
# cw_mweb
A new Flutter plugin project.
## Getting Started
This project is a starting point for a Flutter
[plug-in package](https://flutter.dev/developing-packages/),
a specialized package that includes platform-specific implementation code for
Android and/or iOS.
For help getting started with Flutter development, view the
[online documentation](https://flutter.dev/docs), which offers tutorials,
samples, guidance on mobile development, and a full API reference.

View file

@ -0,0 +1,4 @@
include: package:flutter_lints/flutter.yaml
# Additional information about this file can be found at
# https://dart.dev/guides/language/analysis-options

10
cw_mweb/android/.gitignore vendored Normal file
View file

@ -0,0 +1,10 @@
*.iml
.gradle
/local.properties
/.idea/workspace.xml
/.idea/libraries
.DS_Store
/build
/captures
/libs
.cxx

View file

@ -0,0 +1,76 @@
group 'com.cakewallet.mweb'
version '1.0-SNAPSHOT'
buildscript {
ext.kotlin_version = '1.7.10'
repositories {
google()
mavenCentral()
}
dependencies {
classpath 'com.android.tools.build:gradle:7.3.0'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
}
}
allprojects {
repositories {
google()
mavenCentral()
}
}
rootProject.allprojects {
repositories {
flatDir {
dirs project(':cw_mweb').file('libs')
}
}
}
apply plugin: 'com.android.library'
apply plugin: 'kotlin-android'
android {
compileSdkVersion 31
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = '1.8'
}
sourceSets {
main.java.srcDirs += 'src/main/kotlin'
test.java.srcDirs += 'src/test/kotlin'
}
defaultConfig {
minSdkVersion 16
}
dependencies {
testImplementation 'org.jetbrains.kotlin:kotlin-test'
testImplementation 'org.mockito:mockito-core:5.0.0'
}
testOptions {
unitTests.all {
useJUnitPlatform()
testLogging {
events "passed", "skipped", "failed", "standardOut", "standardError"
outputs.upToDateWhen {false}
showStandardStreams = true
}
}
}
}
dependencies {
implementation (name: 'mwebd', ext: 'aar')
}

View file

@ -0,0 +1 @@
rootProject.name = 'cw_mweb'

View file

@ -0,0 +1,3 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.cakewallet.mweb">
</manifest>

View file

@ -0,0 +1,66 @@
package com.cakewallet.mweb
import androidx.annotation.NonNull
import io.flutter.embedding.engine.plugins.FlutterPlugin
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import io.flutter.plugin.common.MethodChannel.MethodCallHandler
import io.flutter.plugin.common.MethodChannel.Result
import mwebd.Mwebd
import mwebd.Server
/** CwMwebPlugin */
class CwMwebPlugin: FlutterPlugin, MethodCallHandler {
/// The MethodChannel that will the communication between Flutter and native Android
///
/// This local reference serves to register the plugin with the Flutter Engine and unregister it
/// when the Flutter Engine is detached from the Activity
private lateinit var channel : MethodChannel
private var server: Server? = null
private var port: Long? = null
override fun onAttachedToEngine(@NonNull flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) {
channel = MethodChannel(flutterPluginBinding.binaryMessenger, "cw_mweb")
channel.setMethodCallHandler(this)
}
override fun onMethodCall(@NonNull call: MethodCall, @NonNull result: Result) {
if (call.method == "start") {
server?.stop()
val dataDir = call.argument("dataDir") ?: ""
val nodeUri = call.argument("nodeUri") ?: ""
server = server ?: Mwebd.newServer("", dataDir, nodeUri)
port = server?.start(0)
result.success(port)
} else if (call.method == "stop") {
server?.stop()
server = null
port = null
result.success(null)
} else if (call.method == "address") {
// val scanSecret: ByteArray = call.argument<ByteArray>("scanSecret") ?: ByteArray(0)
// val spendPub: ByteArray = call.argument<ByteArray>("spendPub") ?: ByteArray(0)
// val index: Int = call.argument<Int>("index") ?: 0
// val res = Mwebd.address(scanSecret, spendPub, index)
// result.success(res)
} else if (call.method == "addresses") {
val scanSecret: ByteArray = call.argument<ByteArray>("scanSecret") ?: ByteArray(0)
val spendPub: ByteArray = call.argument<ByteArray>("spendPub") ?: ByteArray(0)
val fromIndex: Int = call.argument<Int>("fromIndex") ?: 0
val toIndex: Int = call.argument<Int>("toIndex") ?: 0
val res = Mwebd.addresses(scanSecret, spendPub, fromIndex, toIndex)
result.success(res)
} else {
result.notImplemented()
}
}
override fun onDetachedFromEngine(@NonNull binding: FlutterPlugin.FlutterPluginBinding) {
channel.setMethodCallHandler(null)
server?.stop()
server = null
port = null
}
}

38
cw_mweb/ios/.gitignore vendored Normal file
View file

@ -0,0 +1,38 @@
.idea/
.vagrant/
.sconsign.dblite
.svn/
.DS_Store
*.swp
profile
DerivedData/
build/
GeneratedPluginRegistrant.h
GeneratedPluginRegistrant.m
.generated/
*.pbxuser
*.mode1v3
*.mode2v3
*.perspectivev3
!default.pbxuser
!default.mode1v3
!default.mode2v3
!default.perspectivev3
xcuserdata
*.moved-aside
*.pyc
*sync/
Icon?
.tags*
/Flutter/Generated.xcconfig
/Flutter/ephemeral/
/Flutter/flutter_export_environment.sh

View file

View file

@ -0,0 +1,100 @@
import Flutter
import UIKit
import Mwebd
public class CwMwebPlugin: NSObject, FlutterPlugin {
public static func register(with registrar: FlutterPluginRegistrar) {
let channel = FlutterMethodChannel(name: "cw_mweb", binaryMessenger: registrar.messenger())
let instance = CwMwebPlugin()
registrar.addMethodCallDelegate(instance, channel: channel)
}
private static var server: MwebdServer?
private static var port: Int = 0
private static var dataDir: String?
private static var nodeUri: String?
public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
switch call.method {
case "getPlatformVersion":
result("iOS " + UIDevice.current.systemVersion)
break
case "start":
stopServer()
let args = call.arguments as? [String: String]
let dataDir = args?["dataDir"]
let nodeUri = args?["nodeUri"]
CwMwebPlugin.dataDir = dataDir
CwMwebPlugin.nodeUri = nodeUri
startServer(result: result)
break
case "stop":
stopServer()
result(nil)
break
// case "address":
// let args = call.arguments as! [String: Any]
// let scanSecret = args["scanSecret"] as! FlutterStandardTypedData
// let spendPub = args["spendPub"] as! FlutterStandardTypedData
// let index = args["index"] as! Int32
// let scanSecretData = scanSecret.data
// let spendPubData = spendPub.data
// result(MwebdAddress(scanSecretData, spendPubData, index))
// break
case "addresses":
let args = call.arguments as! [String: Any]
let scanSecret = args["scanSecret"] as! FlutterStandardTypedData
let spendPub = args["spendPub"] as! FlutterStandardTypedData
let fromIndex = args["fromIndex"] as! Int32
let toIndex = args["toIndex"] as! Int32
let scanSecretData = scanSecret.data
let spendPubData = spendPub.data
result(MwebdAddresses(scanSecretData, spendPubData, fromIndex, toIndex))
break
default:
result(FlutterMethodNotImplemented)
break
}
}
private func startServer(result: @escaping FlutterResult) {
if CwMwebPlugin.server == nil {
var error: NSError?
CwMwebPlugin.server = MwebdNewServer("", CwMwebPlugin.dataDir, CwMwebPlugin.nodeUri, &error)
if let server = CwMwebPlugin.server {
do {
print("Starting server...")
try server.start(0, ret0_: &CwMwebPlugin.port)
print("Server started successfully on port: \(CwMwebPlugin.port)")
result(CwMwebPlugin.port)
} catch let startError as NSError {
print("Server Start Error: \(startError.localizedDescription)")
result(FlutterError(code: "Server Start Error", message: startError.localizedDescription, details: nil))
}
} else if let error = error {
print("Server Creation Error: \(error.localizedDescription)")
result(FlutterError(code: "Server Creation Error", message: error.localizedDescription, details: nil))
} else {
print("Unknown Error: Failed to create server")
result(FlutterError(code: "Unknown Error", message: "Failed to create server", details: nil))
}
} else {
print("Server already running on port: \(CwMwebPlugin.port)")
result(CwMwebPlugin.port)
}
}
private func stopServer() {
print("Stopping server")
CwMwebPlugin.server?.stop()
CwMwebPlugin.server = nil
CwMwebPlugin.port = 0
}
deinit {
stopServer()
}
}

Some files were not shown because too many files have changed in this diff Show more