cake_wallet/cw_bitcoin/lib/bitcoin_wallet.dart

1484 lines
48 KiB
Dart

import 'dart:async';
import 'dart:convert';
import 'package:bitcoin_base/bitcoin_base.dart';
import 'package:blockchain_utils/blockchain_utils.dart';
import 'package:cw_bitcoin/bitcoin_address_record.dart';
import 'package:cw_bitcoin/bitcoin_transaction_credentials.dart';
import 'package:cw_bitcoin/electrum_worker/methods/methods.dart';
import 'package:cw_bitcoin/exceptions.dart';
import 'package:cw_bitcoin/pending_bitcoin_transaction.dart';
import 'package:cw_bitcoin/psbt_transaction_builder.dart';
import 'package:cw_bitcoin/bitcoin_unspent.dart';
import 'package:cw_bitcoin/electrum_transaction_info.dart';
import 'package:cw_bitcoin/electrum_wallet_addresses.dart';
import 'package:cw_core/encryption_file_utils.dart';
import 'package:cw_bitcoin/electrum_derivations.dart';
import 'package:cw_bitcoin/bitcoin_wallet_addresses.dart';
import 'package:cw_bitcoin/electrum_balance.dart';
import 'package:cw_bitcoin/electrum_wallet.dart';
import 'package:cw_bitcoin/electrum_wallet_snapshot.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_coin_type.dart';
import 'package:cw_core/unspent_coins_info.dart';
import 'package:cw_core/utils/print_verbose.dart';
import 'package:cw_core/wallet_info.dart';
import 'package:cw_core/wallet_keys_file.dart';
import 'package:hive/hive.dart';
import 'package:ledger_bitcoin/ledger_bitcoin.dart';
import 'package:ledger_flutter_plus/ledger_flutter_plus.dart';
import 'package:mobx/mobx.dart';
part 'bitcoin_wallet.g.dart';
class BitcoinWallet = BitcoinWalletBase with _$BitcoinWallet;
abstract class BitcoinWalletBase extends ElectrumWallet with Store {
@observable
bool nodeSupportsSilentPayments = true;
@observable
bool silentPaymentsScanningActive = false;
@observable
bool allowedToSwitchNodesForScanning = false;
BitcoinWalletBase({
required String password,
required WalletInfo walletInfo,
required Box<UnspentCoinsInfo> unspentCoinsInfo,
required EncryptionFileUtils encryptionFileUtils,
List<int>? seedBytes,
String? mnemonic,
String? xpub,
String? addressPageType,
BasedUtxoNetwork? networkParam,
List<BitcoinAddressRecord>? initialAddresses,
ElectrumBalance? initialBalance,
Map<String, int>? initialRegularAddressIndex,
Map<String, int>? initialChangeAddressIndex,
String? passphrase,
List<BitcoinSilentPaymentAddressRecord>? initialSilentAddresses,
int initialSilentAddressIndex = 0,
bool? alwaysScan,
required bool mempoolAPIEnabled,
super.hdWallets,
super.initialUnspentCoins,
}) : super(
mnemonic: mnemonic,
passphrase: passphrase,
xpub: xpub,
password: password,
walletInfo: walletInfo,
unspentCoinsInfo: unspentCoinsInfo,
network: networkParam == null
? BitcoinNetwork.mainnet
: networkParam == BitcoinNetwork.mainnet
? BitcoinNetwork.mainnet
: BitcoinNetwork.testnet,
initialAddresses: initialAddresses,
initialBalance: initialBalance,
seedBytes: seedBytes,
encryptionFileUtils: encryptionFileUtils,
currency:
networkParam == BitcoinNetwork.testnet ? CryptoCurrency.tbtc : CryptoCurrency.btc,
alwaysScan: alwaysScan,
mempoolAPIEnabled: mempoolAPIEnabled,
) {
walletAddresses = BitcoinWalletAddresses(
walletInfo,
initialAddresses: initialAddresses,
initialSilentAddresses: initialSilentAddresses,
network: networkParam ?? network,
isHardwareWallet: walletInfo.isHardwareWallet,
hdWallets: hdWallets,
);
autorun((_) {
this.walletAddresses.isEnabledAutoGenerateSubaddress = this.isEnabledAutoGenerateSubaddress;
});
}
static Future<BitcoinWallet> create({
required String mnemonic,
required String password,
required WalletInfo walletInfo,
required Box<UnspentCoinsInfo> unspentCoinsInfo,
required EncryptionFileUtils encryptionFileUtils,
String? passphrase,
String? addressPageType,
BasedUtxoNetwork? network,
List<BitcoinAddressRecord>? initialAddresses,
List<BitcoinSilentPaymentAddressRecord>? initialSilentAddresses,
ElectrumBalance? initialBalance,
Map<String, int>? initialRegularAddressIndex,
Map<String, int>? initialChangeAddressIndex,
int initialSilentAddressIndex = 0,
required bool mempoolAPIEnabled,
}) async {
List<int>? seedBytes = null;
final Map<CWBitcoinDerivationType, Bip32Slip10Secp256k1> hdWallets = {};
if (walletInfo.isRecovery) {
for (final derivation in walletInfo.derivations ?? <DerivationInfo>[]) {
if (derivation.description?.contains("SP") ?? false) {
continue;
}
if (derivation.derivationType == DerivationType.bip39) {
seedBytes = Bip39SeedGenerator.generateFromString(mnemonic, passphrase);
hdWallets[CWBitcoinDerivationType.bip39] = Bip32Slip10Secp256k1.fromSeed(seedBytes);
break;
} else {
try {
seedBytes = ElectrumV2SeedGenerator.generateFromString(mnemonic, passphrase);
hdWallets[CWBitcoinDerivationType.electrum] = Bip32Slip10Secp256k1.fromSeed(seedBytes);
} catch (e) {
printV("electrum_v2 seed error: $e");
try {
seedBytes = ElectrumV1SeedGenerator(mnemonic).generate();
hdWallets[CWBitcoinDerivationType.electrum] =
Bip32Slip10Secp256k1.fromSeed(seedBytes);
} catch (e) {
printV("electrum_v1 seed error: $e");
}
}
break;
}
}
if (hdWallets[CWBitcoinDerivationType.bip39] != null) {
hdWallets[CWBitcoinDerivationType.old_bip39] = hdWallets[CWBitcoinDerivationType.bip39]!;
}
if (hdWallets[CWBitcoinDerivationType.electrum] != null) {
hdWallets[CWBitcoinDerivationType.old_electrum] =
hdWallets[CWBitcoinDerivationType.electrum]!;
}
} else {
switch (walletInfo.derivationInfo?.derivationType) {
case DerivationType.bip39:
seedBytes = await Bip39SeedGenerator.generateFromString(mnemonic, passphrase);
hdWallets[CWBitcoinDerivationType.bip39] = Bip32Slip10Secp256k1.fromSeed(seedBytes);
break;
case DerivationType.electrum:
default:
seedBytes = await ElectrumV2SeedGenerator.generateFromString(mnemonic, passphrase);
hdWallets[CWBitcoinDerivationType.electrum] = Bip32Slip10Secp256k1.fromSeed(seedBytes);
break;
}
}
return BitcoinWallet(
mnemonic: mnemonic,
passphrase: passphrase ?? "",
password: password,
walletInfo: walletInfo,
unspentCoinsInfo: unspentCoinsInfo,
initialAddresses: initialAddresses,
initialSilentAddresses: initialSilentAddresses,
initialSilentAddressIndex: initialSilentAddressIndex,
initialBalance: initialBalance,
encryptionFileUtils: encryptionFileUtils,
seedBytes: seedBytes,
hdWallets: hdWallets,
initialRegularAddressIndex: initialRegularAddressIndex,
initialChangeAddressIndex: initialChangeAddressIndex,
addressPageType: addressPageType,
networkParam: network,
mempoolAPIEnabled: mempoolAPIEnabled,
initialUnspentCoins: [],
);
}
static Future<BitcoinWallet> open({
required String name,
required WalletInfo walletInfo,
required Box<UnspentCoinsInfo> unspentCoinsInfo,
required String password,
required EncryptionFileUtils encryptionFileUtils,
required bool alwaysScan,
required bool mempoolAPIEnabled,
}) async {
final network = walletInfo.network != null
? BasedUtxoNetwork.fromName(walletInfo.network!)
: BitcoinNetwork.mainnet;
final hasKeysFile = await WalletKeysFile.hasKeysFile(name, walletInfo.type);
ElectrumWalletSnapshot? snp = null;
try {
snp = await ElectrumWalletSnapshot.load(
encryptionFileUtils,
name,
walletInfo.type,
password,
network,
);
} catch (e) {
if (!hasKeysFile) rethrow;
}
final WalletKeysData keysData;
// Migrate wallet from the old scheme to then new .keys file scheme
if (!hasKeysFile) {
keysData = WalletKeysData(
mnemonic: snp!.mnemonic,
xPub: snp.xpub,
passphrase: snp.passphrase,
);
} else {
keysData = await WalletKeysFile.readKeysFile(
name,
walletInfo.type,
password,
encryptionFileUtils,
);
}
walletInfo.derivationInfo ??= DerivationInfo();
// set the default if not present:
walletInfo.derivationInfo!.derivationPath ??= snp?.derivationPath ?? electrum_path;
walletInfo.derivationInfo!.derivationType ??= snp?.derivationType ?? DerivationType.electrum;
List<int>? seedBytes = null;
final Map<CWBitcoinDerivationType, Bip32Slip10Secp256k1> hdWallets = {};
final mnemonic = keysData.mnemonic;
final passphrase = keysData.passphrase;
if (mnemonic != null) {
final derivations = walletInfo.derivations ?? <DerivationInfo>[];
for (final derivation in derivations) {
if (derivation.description?.contains("SP") ?? false) {
continue;
}
if (derivation.derivationType == DerivationType.bip39) {
seedBytes = Bip39SeedGenerator.generateFromString(mnemonic, passphrase);
hdWallets[CWBitcoinDerivationType.bip39] = Bip32Slip10Secp256k1.fromSeed(seedBytes);
break;
} else {
try {
seedBytes = ElectrumV2SeedGenerator.generateFromString(mnemonic, passphrase);
hdWallets[CWBitcoinDerivationType.electrum] = Bip32Slip10Secp256k1.fromSeed(seedBytes);
} catch (e) {
printV("electrum_v2 seed error: $e");
try {
seedBytes = ElectrumV1SeedGenerator(mnemonic).generate();
hdWallets[CWBitcoinDerivationType.electrum] =
Bip32Slip10Secp256k1.fromSeed(seedBytes);
} catch (e) {
printV("electrum_v1 seed error: $e");
}
}
break;
}
}
if (hdWallets[CWBitcoinDerivationType.bip39] != null) {
hdWallets[CWBitcoinDerivationType.old_bip39] = hdWallets[CWBitcoinDerivationType.bip39]!;
}
if (hdWallets[CWBitcoinDerivationType.electrum] != null) {
hdWallets[CWBitcoinDerivationType.old_electrum] =
hdWallets[CWBitcoinDerivationType.electrum]!;
}
if (derivations.isEmpty) {
switch (walletInfo.derivationInfo?.derivationType) {
case DerivationType.bip39:
seedBytes = await Bip39SeedGenerator.generateFromString(mnemonic, passphrase);
hdWallets[CWBitcoinDerivationType.bip39] = Bip32Slip10Secp256k1.fromSeed(seedBytes);
break;
case DerivationType.electrum:
default:
seedBytes = await ElectrumV2SeedGenerator.generateFromString(mnemonic, passphrase);
hdWallets[CWBitcoinDerivationType.electrum] = Bip32Slip10Secp256k1.fromSeed(seedBytes);
break;
}
}
}
return BitcoinWallet(
mnemonic: mnemonic,
xpub: keysData.xPub,
password: password,
passphrase: passphrase,
walletInfo: walletInfo,
unspentCoinsInfo: unspentCoinsInfo,
initialAddresses: snp?.addresses,
initialSilentAddresses: snp?.silentAddresses,
initialSilentAddressIndex: snp?.silentAddressIndex ?? 0,
initialBalance: snp?.balance,
encryptionFileUtils: encryptionFileUtils,
seedBytes: seedBytes,
initialRegularAddressIndex: snp?.regularAddressIndex,
initialChangeAddressIndex: snp?.changeAddressIndex,
addressPageType: snp?.addressPageType,
networkParam: network,
alwaysScan: alwaysScan,
mempoolAPIEnabled: mempoolAPIEnabled,
hdWallets: hdWallets,
initialUnspentCoins: snp?.unspentCoins ?? [],
);
}
Future<bool> getNodeIsElectrs() async {
if (node?.isElectrs != null) {
return node!.isElectrs!;
}
final isNamedElectrs = node?.uri.host.contains("electrs") ?? false;
if (isNamedElectrs) {
node!.isElectrs = true;
}
final isNamedFulcrum = node!.uri.host.contains("fulcrum");
if (isNamedFulcrum) {
node!.isElectrs = false;
}
if (node!.isElectrs == null) {
final version = await sendWorker(ElectrumWorkerGetVersionRequest());
if (version is List<String> && version.isNotEmpty) {
final server = version[0];
if (server.toLowerCase().contains('electrs')) {
node!.isElectrs = true;
}
} else if (version is String && version.toLowerCase().contains('electrs')) {
node!.isElectrs = true;
} else {
node!.isElectrs = false;
}
}
node!.save();
return node!.isElectrs!;
}
Future<bool> getNodeSupportsSilentPayments() async {
if (node?.supportsSilentPayments != null) {
return node!.supportsSilentPayments!;
}
// As of today (august 2024), only ElectrumRS supports silent payments
final isElectrs = await getNodeIsElectrs();
if (!isElectrs) {
node!.supportsSilentPayments = false;
}
if (node!.supportsSilentPayments == null) {
try {
final workerResponse = (await sendWorker(ElectrumWorkerCheckTweaksRequest())) as String;
final tweaksResponse = ElectrumWorkerCheckTweaksResponse.fromJson(
json.decode(workerResponse) as Map<String, dynamic>,
);
final supportsScanning = tweaksResponse.result == true;
if (supportsScanning) {
node!.supportsSilentPayments = true;
} else {
node!.supportsSilentPayments = false;
}
} catch (_) {
node!.supportsSilentPayments = false;
}
}
node!.save();
return node!.supportsSilentPayments!;
}
LedgerConnection? _ledgerConnection;
BitcoinLedgerApp? _bitcoinLedgerApp;
@override
void setLedgerConnection(LedgerConnection connection) {
_ledgerConnection = connection;
_bitcoinLedgerApp = BitcoinLedgerApp(_ledgerConnection!,
derivationPath: walletInfo.derivationInfo!.derivationPath!);
}
@override
Future<BtcTransaction> buildHardwareWalletTransaction({
required List<BitcoinBaseOutput> outputs,
required BigInt fee,
required BasedUtxoNetwork network,
required List<UtxoWithAddress> utxos,
required Map<String, PublicKeyWithDerivationPath> publicKeys,
String? memo,
bool enableRBF = false,
BitcoinOrdering inputOrdering = BitcoinOrdering.bip69,
BitcoinOrdering outputOrdering = BitcoinOrdering.bip69,
}) async {
final masterFingerprint = await _bitcoinLedgerApp!.getMasterFingerprint();
final psbtReadyInputs = <PSBTReadyUtxoWithAddress>[];
for (final utxo in utxos) {
final rawTx =
(await getTransactionExpanded(hash: utxo.utxo.txHash)).originalTransaction.toHex();
final publicKeyAndDerivationPath = publicKeys[utxo.ownerDetails.address.pubKeyHash()]!;
psbtReadyInputs.add(PSBTReadyUtxoWithAddress(
utxo: utxo.utxo,
rawTx: rawTx,
ownerDetails: utxo.ownerDetails,
ownerDerivationPath: publicKeyAndDerivationPath.derivationPath,
ownerMasterFingerprint: masterFingerprint,
ownerPublicKey: publicKeyAndDerivationPath.publicKey,
));
}
final psbt =
PSBTTransactionBuild(inputs: psbtReadyInputs, outputs: outputs, enableRBF: enableRBF);
final rawHex = await _bitcoinLedgerApp!.signPsbt(psbt: psbt.psbt);
return BtcTransaction.fromRaw(BytesUtils.toHexString(rawHex));
}
@override
Future<String> signMessage(String message, {String? address = null}) async {
if (walletInfo.isHardwareWallet) {
final addressEntry = address != null
? walletAddresses.allAddresses.firstWhere((element) => element.address == address)
: null;
final index = addressEntry?.index ?? 0;
final isChange = addressEntry?.isChange == true ? 1 : 0;
final accountPath = walletInfo.derivationInfo?.derivationPath;
final derivationPath = accountPath != null ? "$accountPath/$isChange/$index" : null;
final signature = await _bitcoinLedgerApp!
.signMessage(message: ascii.encode(message), signDerivationPath: derivationPath);
return base64Encode(signature);
}
return super.signMessage(message, address: address);
}
@action
Future<void> setSilentPaymentsScanning(bool active) async {
silentPaymentsScanningActive = active;
final nodeSupportsSilentPayments = await getNodeSupportsSilentPayments();
final isAllowedToScan = nodeSupportsSilentPayments || allowedToSwitchNodesForScanning;
if (active && isAllowedToScan) {
syncStatus = AttemptingScanSyncStatus();
final tip = currentChainTip!;
if (tip == walletInfo.restoreHeight) {
syncStatus = SyncedTipSyncStatus(tip);
return;
}
if (tip > walletInfo.restoreHeight) {
_setListeners(walletInfo.restoreHeight);
}
} else if (syncStatus is! SyncedSyncStatus) {
await sendWorker(ElectrumWorkerStopScanningRequest());
await startSync();
}
}
@override
@action
Future<void> updateAllUnspents() async {
List<BitcoinUnspent> updatedUnspentCoins = [];
// Update unspents stored from scanned silent payment transactions
transactionHistory.transactions.values.forEach((tx) {
if (tx.unspents != null) {
updatedUnspentCoins.addAll(tx.unspents!);
}
});
unspentCoins.addAll(updatedUnspentCoins);
await super.updateAllUnspents();
final walletAddresses = this.walletAddresses as BitcoinWalletAddresses;
walletAddresses.silentPaymentAddresses.forEach((addressRecord) {
addressRecord.txCount = 0;
addressRecord.balance = 0;
});
walletAddresses.receivedSPAddresses.forEach((addressRecord) {
addressRecord.txCount = 0;
addressRecord.balance = 0;
});
final silentPaymentWallet = walletAddresses.silentPaymentWallet;
unspentCoins.forEach((unspent) {
if (unspent.bitcoinAddressRecord is BitcoinReceivedSPAddressRecord) {
_updateSilentAddressRecord(unspent);
final receiveAddressRecord = unspent.bitcoinAddressRecord as BitcoinReceivedSPAddressRecord;
final silentPaymentAddress = SilentPaymentAddress(
version: silentPaymentWallet!.version,
B_scan: silentPaymentWallet.B_scan,
B_spend: receiveAddressRecord.labelHex != null
? silentPaymentWallet.B_spend.tweakAdd(
BigintUtils.fromBytes(
BytesUtils.fromHexString(receiveAddressRecord.labelHex!),
),
)
: silentPaymentWallet.B_spend,
);
walletAddresses.silentPaymentAddresses.forEach((addressRecord) {
if (addressRecord.address == silentPaymentAddress.toAddress(network)) {
addressRecord.txCount += 1;
addressRecord.balance += unspent.value;
}
});
walletAddresses.receivedSPAddresses.forEach((addressRecord) {
if (addressRecord.address == receiveAddressRecord.address) {
addressRecord.txCount += 1;
addressRecord.balance += unspent.value;
}
});
}
});
await walletAddresses.updateAddressesInBox();
}
@override
void updateCoin(BitcoinUnspent 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
@override
Future<void> startSync() async {
await _setInitialScanHeight();
await super.startSync();
if (alwaysScan == true) {
_setListeners(walletInfo.restoreHeight);
}
}
@action
@override
Future<void> rescan({required int height, bool? doSingleScan}) async {
silentPaymentsScanningActive = true;
_setListeners(height, doSingleScan: doSingleScan);
}
// @action
// Future<void> registerSilentPaymentsKey(bool register) async {
// silentPaymentsScanningActive = active;
// if (active) {
// syncStatus = AttemptingScanSyncStatus();
// final tip = await getUpdatedChainTip();
// if (tip == walletInfo.restoreHeight) {
// syncStatus = SyncedTipSyncStatus(tip);
// return;
// }
// if (tip > walletInfo.restoreHeight) {
// _setListeners(walletInfo.restoreHeight, chainTipParam: _currentChainTip);
// }
// } else {
// alwaysScan = false;
// _isolate?.then((value) => value.kill(priority: Isolate.immediate));
// if (electrumClient.isConnected) {
// syncStatus = SyncedSyncStatus();
// } else {
// syncStatus = NotConnectedSyncStatus();
// }
// }
// }
@action
Future<void> registerSilentPaymentsKey() async {
// final registered = await electrumClient.tweaksRegister(
// secViewKey: walletAddresses.silentAddress!.b_scan.toHex(),
// pubSpendKey: walletAddresses.silentAddress!.B_spend.toHex(),
// labels: walletAddresses.silentAddresses
// .where((addr) => addr.type == SilentPaymentsAddresType.p2sp && addr.labelIndex >= 1)
// .map((addr) => addr.labelIndex)
// .toList(),
// );
// printV("registered: $registered");
}
@action
void _updateSilentAddressRecord(BitcoinUnspent unspent) {
final walletAddresses = this.walletAddresses as BitcoinWalletAddresses;
walletAddresses.addReceivedSPAddresses(
[unspent.bitcoinAddressRecord as BitcoinReceivedSPAddressRecord],
);
}
@override
@action
Future<void> handleWorkerResponse(dynamic message) async {
super.handleWorkerResponse(message);
Map<String, dynamic> messageJson;
if (message is String) {
messageJson = jsonDecode(message) as Map<String, dynamic>;
} else {
messageJson = message as Map<String, dynamic>;
}
final workerMethod = messageJson['method'] as String;
final workerError = messageJson['error'] as String?;
switch (workerMethod) {
case ElectrumRequestMethods.tweaksSubscribeMethod:
if (workerError != null) {
printV(messageJson);
// _onConnectionStatusChange(ConnectionStatus.failed);
break;
}
final response = ElectrumWorkerTweaksSubscribeResponse.fromJson(messageJson);
onTweaksSyncResponse(response.result);
break;
}
}
@action
Future<void> onTweaksSyncResponse(TweaksSyncResponse result) async {
if (result.transactions?.isNotEmpty == true) {
(walletAddresses as BitcoinWalletAddresses).silentPaymentAddresses.forEach((addressRecord) {
addressRecord.txCount = 0;
addressRecord.balance = 0;
});
(walletAddresses as BitcoinWalletAddresses).receivedSPAddresses.forEach((addressRecord) {
addressRecord.txCount = 0;
addressRecord.balance = 0;
});
for (final map in result.transactions!.entries) {
final txid = map.key;
final tx = map.value;
if (tx.unspents != null) {
final existingTxInfo = transactionHistory.transactions[txid];
final txAlreadyExisted = existingTxInfo != null;
// Updating tx after re-scanned
if (txAlreadyExisted) {
existingTxInfo.amount = tx.amount;
existingTxInfo.confirmations = tx.confirmations;
existingTxInfo.height = tx.height;
final newUnspents = tx.unspents!
.where((unspent) => !(existingTxInfo.unspents?.any((element) =>
element.hash.contains(unspent.hash) &&
element.vout == unspent.vout &&
element.value == unspent.value) ??
false))
.toList();
if (newUnspents.isNotEmpty) {
newUnspents.forEach(_updateSilentAddressRecord);
existingTxInfo.unspents ??= [];
existingTxInfo.unspents!.addAll(newUnspents);
final newAmount = newUnspents.length > 1
? newUnspents.map((e) => e.value).reduce((value, unspent) => value + unspent)
: newUnspents[0].value;
if (existingTxInfo.direction == TransactionDirection.incoming) {
existingTxInfo.amount += newAmount;
}
// Updates existing TX
transactionHistory.addOne(existingTxInfo);
// Update balance record
balance[currency]!.confirmed += newAmount;
}
} else {
// else: First time seeing this TX after scanning
tx.unspents!.forEach(_updateSilentAddressRecord);
transactionHistory.addOne(tx);
balance[currency]!.confirmed += tx.amount;
}
await updateAllUnspents();
}
}
}
final newSyncStatus = result.syncStatus;
if (newSyncStatus != null) {
if (newSyncStatus is UnsupportedSyncStatus) {
nodeSupportsSilentPayments = false;
}
if (newSyncStatus is SyncingSyncStatus) {
syncStatus = SyncingSyncStatus(newSyncStatus.blocksLeft, newSyncStatus.ptc);
} else {
syncStatus = newSyncStatus;
if (newSyncStatus is SyncedSyncStatus) {
silentPaymentsScanningActive = false;
}
}
final height = result.height;
if (height != null) {
await walletInfo.updateRestoreHeight(height);
}
}
}
@action
Future<void> _setListeners(int height, {bool? doSingleScan}) async {
if (currentChainTip == null) {
throw Exception("currentChainTip is null");
}
final chainTip = currentChainTip!;
if (chainTip == height) {
syncStatus = SyncedSyncStatus();
return;
}
syncStatus = AttemptingScanSyncStatus();
final walletAddresses = this.walletAddresses as BitcoinWalletAddresses;
workerSendPort!.send(
ElectrumWorkerTweaksSubscribeRequest(
scanData: ScanData(
silentPaymentsWallets: walletAddresses.silentPaymentWallets,
network: network,
height: height,
chainTip: chainTip,
transactionHistoryIds: transactionHistory.transactions.keys.toList(),
labels: walletAddresses.labels,
labelIndexes: walletAddresses.silentPaymentAddresses
.where((addr) =>
addr.addressType == SilentPaymentsAddresType.p2sp && addr.labelIndex >= 1)
.map((addr) => addr.labelIndex)
.toList(),
isSingleScan: doSingleScan ?? false,
shouldSwitchNodes:
!(await getNodeSupportsSilentPayments()) && allowedToSwitchNodesForScanning,
),
).toJson(),
);
}
@override
@action
Future<Map<String, ElectrumTransactionInfo>> fetchTransactions() async {
throw UnimplementedError();
// try {
// final Map<String, ElectrumTransactionInfo> historiesWithDetails = {};
// await Future.wait(
// BITCOIN_ADDRESS_TYPES.map(
// (type) => fetchTransactionsForAddressType(historiesWithDetails, type),
// ),
// );
// transactionHistory.transactions.values.forEach((tx) async {
// final isPendingSilentPaymentUtxo =
// (tx.isPending || tx.confirmations == 0) && historiesWithDetails[tx.id] == null;
// if (isPendingSilentPaymentUtxo) {
// final info = await fetchTransactionInfo(hash: tx.id, height: tx.height);
// if (info != null) {
// tx.confirmations = info.confirmations;
// tx.isPending = tx.confirmations == 0;
// transactionHistory.addOne(tx);
// await transactionHistory.save();
// }
// }
// });
// return historiesWithDetails;
// } catch (e) {
// printV("fetchTransactions $e");
// return {};
// }
}
@override
@action
Future<void> updateTransactions([List<BitcoinAddressRecord>? addresses]) async {
super.updateTransactions();
transactionHistory.transactions.values.forEach((tx) {
if (tx.unspents != null &&
tx.unspents!.isNotEmpty &&
tx.height != null &&
tx.height! > 0 &&
(currentChainTip ?? 0) > 0) {
tx.confirmations = currentChainTip! - tx.height! + 1;
}
});
}
// @action
// Future<ElectrumBalance> fetchBalances() async {
// final balance = await super.fetchBalances();
// int totalFrozen = balance.frozen;
// int totalConfirmed = balance.confirmed;
// // Add values from unspent coins that are not fetched by the address list
// // i.e. scanned silent payments
// transactionHistory.transactions.values.forEach((tx) {
// if (tx.unspents != null) {
// tx.unspents!.forEach((unspent) {
// if (unspent.bitcoinAddressRecord is BitcoinSilentPaymentAddressRecord) {
// if (unspent.isFrozen) totalFrozen += unspent.value;
// totalConfirmed += unspent.value;
// }
// });
// }
// });
// return ElectrumBalance(
// confirmed: totalConfirmed,
// unconfirmed: balance.unconfirmed,
// frozen: totalFrozen,
// );
// }
@override
@action
Future<void> onHeadersResponse(ElectrumHeaderResponse response) async {
super.onHeadersResponse(response);
_setInitialScanHeight();
// New headers received, start scanning
if (alwaysScan == true && syncStatus is SyncedSyncStatus) {
_setListeners(walletInfo.restoreHeight);
}
}
Future<void> _setInitialScanHeight() async {
final validChainTip = currentChainTip != null && currentChainTip != 0;
if (validChainTip && walletInfo.restoreHeight == 0) {
await walletInfo.updateRestoreHeight(currentChainTip!);
}
}
@override
@action
void syncStatusReaction(SyncStatus syncStatus) {
switch (syncStatus.runtimeType) {
case SyncingSyncStatus:
return;
case SyncedTipSyncStatus:
silentPaymentsScanningActive = false;
// Message is shown on the UI for 3 seconds, then reverted to synced
Timer(Duration(seconds: 3), () {
if (this.syncStatus is SyncedTipSyncStatus) this.syncStatus = SyncedSyncStatus();
});
break;
default:
super.syncStatusReaction(syncStatus);
}
}
@override
Future<int> calcFee({
required List<UtxoWithAddress> utxos,
required List<BitcoinBaseOutput> outputs,
String? memo,
required int feeRate,
List<ECPrivateInfo>? inputPrivKeyInfos,
List<Outpoint>? vinOutpoints,
}) async =>
feeRate *
BitcoinTransactionBuilder.estimateTransactionSize(
utxos: utxos,
outputs: outputs,
network: network,
memo: memo,
inputPrivKeyInfos: inputPrivKeyInfos,
vinOutpoints: vinOutpoints,
);
@override
TxCreateUtxoDetails createUTXOS({
required bool sendAll,
bool paysToSilentPayment = false,
int credentialsAmount = 0,
int? inputsCount,
UnspentCoinType coinTypeToSpendFrom = UnspentCoinType.any,
}) {
List<UtxoWithAddress> utxos = [];
List<Outpoint> vinOutpoints = [];
List<ECPrivateInfo> inputPrivKeyInfos = [];
final publicKeys = <String, PublicKeyWithDerivationPath>{};
int allInputsAmount = 0;
bool spendsSilentPayment = false;
bool spendsUnconfirmedTX = false;
int leftAmount = credentialsAmount;
var availableInputs = unspentCoins.where((utx) {
if (!utx.isSending || utx.isFrozen) {
return false;
}
return true;
}).toList();
final unconfirmedCoins = availableInputs.where((utx) => utx.confirmations == 0).toList();
for (int i = 0; i < availableInputs.length; i++) {
final utx = availableInputs[i];
if (!spendsUnconfirmedTX) spendsUnconfirmedTX = utx.confirmations == 0;
if (paysToSilentPayment) {
// Check inputs for shared secret derivation
if (utx.bitcoinAddressRecord.addressType == SegwitAddresType.p2wsh) {
throw BitcoinTransactionSilentPaymentsNotSupported();
}
}
allInputsAmount += utx.value;
leftAmount = leftAmount - utx.value;
final address = RegexUtils.addressTypeFromStr(utx.address, network);
ECPrivate? privkey;
bool? isSilentPayment = false;
if (utx.bitcoinAddressRecord is BitcoinSilentPaymentAddressRecord) {
privkey = (utx.bitcoinAddressRecord as BitcoinReceivedSPAddressRecord).getSpendKey(
(walletAddresses as BitcoinWalletAddresses).silentPaymentWallets,
network,
);
spendsSilentPayment = true;
isSilentPayment = true;
} else if (!isHardwareWallet) {
final addressRecord = (utx.bitcoinAddressRecord as BitcoinAddressRecord);
final path = addressRecord.derivationInfo.derivationPath
.addElem(Bip32KeyIndex(
BitcoinAddressUtils.getAccountFromChange(addressRecord.isChange),
))
.addElem(Bip32KeyIndex(addressRecord.index));
privkey = ECPrivate.fromBip32(bip32: bip32.derive(path));
}
vinOutpoints.add(Outpoint(txid: utx.hash, index: utx.vout));
String pubKeyHex;
if (privkey != null) {
inputPrivKeyInfos.add(ECPrivateInfo(
privkey,
address.type == SegwitAddresType.p2tr,
tweak: !isSilentPayment,
));
pubKeyHex = privkey.getPublic().toHex();
} else {
pubKeyHex = walletAddresses.hdWallet
.childKey(Bip32KeyIndex(utx.bitcoinAddressRecord.index))
.publicKey
.toHex();
}
if (utx.bitcoinAddressRecord is BitcoinAddressRecord) {
final derivationPath = (utx.bitcoinAddressRecord as BitcoinAddressRecord)
.derivationInfo
.derivationPath
.toString();
publicKeys[address.pubKeyHash()] = PublicKeyWithDerivationPath(pubKeyHex, derivationPath);
}
utxos.add(
UtxoWithAddress(
utxo: BitcoinUtxo(
txHash: utx.hash,
value: BigInt.from(utx.value),
vout: utx.vout,
scriptType: BitcoinAddressUtils.getScriptType(address),
isSilentPayment: isSilentPayment,
),
ownerDetails: UtxoAddressDetails(
publicKey: pubKeyHex,
address: address,
),
),
);
// sendAll continues for all inputs
if (!sendAll) {
bool amountIsAcquired = leftAmount <= 0;
if ((inputsCount == null && amountIsAcquired) || inputsCount == i + 1) {
break;
}
}
}
if (utxos.isEmpty) {
throw BitcoinTransactionNoInputsException();
}
return TxCreateUtxoDetails(
availableInputs: availableInputs,
unconfirmedCoins: unconfirmedCoins,
utxos: utxos,
vinOutpoints: vinOutpoints,
inputPrivKeyInfos: inputPrivKeyInfos,
publicKeys: publicKeys,
allInputsAmount: allInputsAmount,
spendsSilentPayment: spendsSilentPayment,
spendsUnconfirmedTX: spendsUnconfirmedTX,
);
}
@override
Future<EstimatedTxResult> estimateSendAllTx(
List<BitcoinOutput> outputs,
int feeRate, {
String? memo,
bool hasSilentPayment = false,
UnspentCoinType coinTypeToSpendFrom = UnspentCoinType.any,
}) async {
final utxoDetails = createUTXOS(sendAll: true, paysToSilentPayment: hasSilentPayment);
int fee = await calcFee(
utxos: utxoDetails.utxos,
outputs: outputs,
memo: memo,
feeRate: feeRate,
inputPrivKeyInfos: utxoDetails.inputPrivKeyInfos,
vinOutpoints: utxoDetails.vinOutpoints,
);
if (fee == 0) {
throw BitcoinTransactionNoFeeException();
}
// Here, when sending all, the output amount equals to the input value - fee to fully spend every input on the transaction and have no amount left for change
int amount = utxoDetails.allInputsAmount - fee;
if (amount <= 0) {
throw BitcoinTransactionWrongBalanceException(amount: utxoDetails.allInputsAmount + fee);
}
// Attempting to send less than the dust limit
if (isBelowDust(amount)) {
throw BitcoinTransactionNoDustException();
}
if (outputs.length == 1) {
outputs[0] = BitcoinOutput(address: outputs.last.address, value: BigInt.from(amount));
}
return EstimatedTxResult(
utxos: utxoDetails.utxos,
inputPrivKeyInfos: utxoDetails.inputPrivKeyInfos,
publicKeys: utxoDetails.publicKeys,
fee: fee,
amount: amount,
isSendAll: true,
hasChange: false,
memo: memo,
spendsUnconfirmedTX: utxoDetails.spendsUnconfirmedTX,
spendsSilentPayment: utxoDetails.spendsSilentPayment,
);
}
@override
Future<EstimatedTxResult> estimateTxForAmount(
int credentialsAmount,
List<BitcoinOutput> outputs,
int feeRate, {
List<BitcoinOutput> updatedOutputs = const [],
int? inputsCount,
String? memo,
bool? useUnconfirmed,
bool hasSilentPayment = false,
UnspentCoinType coinTypeToSpendFrom = UnspentCoinType.any,
}) async {
// Attempting to send less than the dust limit
if (isBelowDust(credentialsAmount)) {
throw BitcoinTransactionNoDustException();
}
final utxoDetails = createUTXOS(
sendAll: false,
credentialsAmount: credentialsAmount,
inputsCount: inputsCount,
paysToSilentPayment: hasSilentPayment,
);
final spendingAllCoins = utxoDetails.availableInputs.length == utxoDetails.utxos.length;
final spendingAllConfirmedCoins = !utxoDetails.spendsUnconfirmedTX &&
utxoDetails.utxos.length ==
utxoDetails.availableInputs.length - utxoDetails.unconfirmedCoins.length;
// How much is being spent - how much is being sent
int amountLeftForChangeAndFee = utxoDetails.allInputsAmount - credentialsAmount;
if (amountLeftForChangeAndFee <= 0) {
if (!spendingAllCoins) {
return estimateTxForAmount(
credentialsAmount,
outputs,
feeRate,
updatedOutputs: updatedOutputs,
inputsCount: utxoDetails.utxos.length + 1,
memo: memo,
hasSilentPayment: hasSilentPayment,
);
}
throw BitcoinTransactionWrongBalanceException();
}
final changeAddress = await walletAddresses.getChangeAddress(
inputs: utxoDetails.availableInputs,
outputs: updatedOutputs,
);
final address = RegexUtils.addressTypeFromStr(changeAddress.address, network);
updatedOutputs.add(BitcoinOutput(
address: address,
value: BigInt.from(amountLeftForChangeAndFee),
isChange: true,
));
outputs.add(BitcoinOutput(
address: address,
value: BigInt.from(amountLeftForChangeAndFee),
isChange: true,
));
// Get Derivation path for change Address since it is needed in Litecoin and BitcoinCash hardware Wallets
final changeDerivationPath = changeAddress.derivationInfo.derivationPath.toString();
utxoDetails.publicKeys[address.pubKeyHash()] =
PublicKeyWithDerivationPath('', changeDerivationPath);
// calcFee updates the silent payment outputs to calculate the tx size accounting
// for taproot addresses, but if more inputs are needed to make up for fees,
// the silent payment outputs need to be recalculated for the new inputs
var temp = outputs.map((output) => output).toList();
int fee = await calcFee(
utxos: utxoDetails.utxos,
// Always take only not updated bitcoin outputs here so for every estimation
// the SP outputs are re-generated to the proper taproot addresses
outputs: temp,
memo: memo,
feeRate: feeRate,
inputPrivKeyInfos: utxoDetails.inputPrivKeyInfos,
vinOutpoints: utxoDetails.vinOutpoints,
);
updatedOutputs.clear();
updatedOutputs.addAll(temp);
if (fee == 0) {
throw BitcoinTransactionNoFeeException();
}
int amount = credentialsAmount;
final lastOutput = updatedOutputs.last;
final amountLeftForChange = amountLeftForChangeAndFee - fee;
if (isBelowDust(amountLeftForChange)) {
// If has change that is lower than dust, will end up with tx rejected by network rules
// so remove the change amount
updatedOutputs.removeLast();
outputs.removeLast();
if (amountLeftForChange < 0) {
if (!spendingAllCoins) {
return estimateTxForAmount(
credentialsAmount,
outputs,
feeRate,
updatedOutputs: updatedOutputs,
inputsCount: utxoDetails.utxos.length + 1,
memo: memo,
useUnconfirmed: useUnconfirmed ?? spendingAllConfirmedCoins,
hasSilentPayment: hasSilentPayment,
);
} else {
throw BitcoinTransactionWrongBalanceException();
}
}
return EstimatedTxResult(
utxos: utxoDetails.utxos,
inputPrivKeyInfos: utxoDetails.inputPrivKeyInfos,
publicKeys: utxoDetails.publicKeys,
fee: fee,
amount: amount,
hasChange: false,
isSendAll: spendingAllCoins,
memo: memo,
spendsUnconfirmedTX: utxoDetails.spendsUnconfirmedTX,
spendsSilentPayment: utxoDetails.spendsSilentPayment,
);
} else {
// Here, lastOutput already is change, return the amount left without the fee to the user's address.
updatedOutputs[updatedOutputs.length - 1] = BitcoinOutput(
address: lastOutput.address,
value: BigInt.from(amountLeftForChange),
isSilentPayment: lastOutput.isSilentPayment,
isChange: true,
);
outputs[outputs.length - 1] = BitcoinOutput(
address: lastOutput.address,
value: BigInt.from(amountLeftForChange),
isSilentPayment: lastOutput.isSilentPayment,
isChange: true,
);
return EstimatedTxResult(
utxos: utxoDetails.utxos,
inputPrivKeyInfos: utxoDetails.inputPrivKeyInfos,
publicKeys: utxoDetails.publicKeys,
fee: fee,
amount: amount,
hasChange: true,
isSendAll: spendingAllCoins,
memo: memo,
spendsUnconfirmedTX: utxoDetails.spendsUnconfirmedTX,
spendsSilentPayment: utxoDetails.spendsSilentPayment,
);
}
}
@override
Future<PendingTransaction> createTransaction(Object credentials) async {
try {
final outputs = <BitcoinOutput>[];
final transactionCredentials = credentials as BitcoinTransactionCredentials;
final hasMultiDestination = transactionCredentials.outputs.length > 1;
final sendAll = !hasMultiDestination && transactionCredentials.outputs.first.sendAll;
final memo = transactionCredentials.outputs.first.memo;
final coinTypeToSpendFrom = transactionCredentials.coinTypeToSpendFrom;
int credentialsAmount = 0;
bool hasSilentPayment = false;
for (final out in transactionCredentials.outputs) {
final outputAmount = out.formattedCryptoAmount!;
if (!sendAll && isBelowDust(outputAmount)) {
throw BitcoinTransactionNoDustException();
}
if (hasMultiDestination) {
if (out.sendAll) {
throw BitcoinTransactionWrongBalanceException();
}
}
credentialsAmount += outputAmount;
final address = RegexUtils.addressTypeFromStr(
out.isParsedAddress ? out.extractedAddress! : out.address, network);
final isSilentPayment = address is SilentPaymentAddress;
if (isSilentPayment) {
hasSilentPayment = true;
}
if (sendAll) {
// 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),
isSilentPayment: isSilentPayment,
));
} else {
outputs.add(BitcoinOutput(
address: address,
value: BigInt.from(outputAmount),
isSilentPayment: isSilentPayment,
));
}
}
final feeRateInt = transactionCredentials.feeRate != null
? transactionCredentials.feeRate!
: feeRate(transactionCredentials.priority!);
EstimatedTxResult estimatedTx;
final updatedOutputs = outputs
.map((e) => BitcoinOutput(
address: e.address,
value: e.value,
isSilentPayment: e.isSilentPayment,
isChange: e.isChange,
))
.toList();
if (sendAll) {
estimatedTx = await estimateSendAllTx(
updatedOutputs,
feeRateInt,
memo: memo,
hasSilentPayment: hasSilentPayment,
coinTypeToSpendFrom: coinTypeToSpendFrom,
);
} else {
estimatedTx = await estimateTxForAmount(
credentialsAmount,
outputs,
feeRateInt,
updatedOutputs: updatedOutputs,
memo: memo,
hasSilentPayment: hasSilentPayment,
coinTypeToSpendFrom: coinTypeToSpendFrom,
);
}
if (walletInfo.isHardwareWallet) {
final transaction = await buildHardwareWalletTransaction(
utxos: estimatedTx.utxos,
outputs: updatedOutputs,
publicKeys: estimatedTx.publicKeys,
fee: BigInt.from(estimatedTx.fee),
network: network,
memo: estimatedTx.memo,
outputOrdering: BitcoinOrdering.none,
enableRBF: true,
);
return PendingBitcoinTransaction(
transaction,
type,
sendWorker: sendWorker,
amount: estimatedTx.amount,
fee: estimatedTx.fee,
feeRate: feeRateInt.toString(),
hasChange: estimatedTx.hasChange,
isSendAll: estimatedTx.isSendAll,
hasTaprootInputs: false, // ToDo: (Konsti) Support Taproot
)..addListener((transaction) async {
transactionHistory.addOne(transaction);
await updateBalance();
});
}
final txb = BitcoinTransactionBuilder(
utxos: estimatedTx.utxos,
outputs: updatedOutputs,
fee: BigInt.from(estimatedTx.fee),
network: network,
memo: estimatedTx.memo,
outputOrdering: BitcoinOrdering.none,
enableRBF: !estimatedTx.spendsUnconfirmedTX,
);
bool hasTaprootInputs = false;
final transaction = txb.buildTransaction((txDigest, utxo, publicKey, sighash) {
String error = "Cannot find private key.";
ECPrivateInfo? key;
if (estimatedTx.inputPrivKeyInfos.isEmpty) {
error += "\nNo private keys generated.";
} else {
error += "\nAddress: ${utxo.ownerDetails.address.toAddress(network)}";
try {
key = estimatedTx.inputPrivKeyInfos.firstWhere((element) {
final elemPubkey = element.privkey.getPublic().toHex();
if (elemPubkey == publicKey) {
return true;
} else {
error += "\nExpected: $publicKey";
error += "\nPubkey: $elemPubkey";
return false;
}
});
} catch (_) {
throw Exception(error);
}
}
if (key == null) {
throw Exception(error);
}
if (utxo.utxo.isP2tr()) {
hasTaprootInputs = true;
return key.privkey.signTapRoot(
txDigest,
sighash: sighash,
tweak: utxo.utxo.isSilentPayment != true,
);
} else {
return key.privkey.signInput(txDigest, sigHash: sighash);
}
});
return PendingBitcoinTransaction(
transaction,
type,
sendWorker: sendWorker,
amount: estimatedTx.amount,
fee: estimatedTx.fee,
feeRate: feeRateInt.toString(),
hasChange: estimatedTx.hasChange,
isSendAll: estimatedTx.isSendAll,
hasTaprootInputs: hasTaprootInputs,
utxos: estimatedTx.utxos,
hasSilentPayment: hasSilentPayment,
)..addListener((transaction) async {
transactionHistory.addOne(transaction);
if (estimatedTx.spendsSilentPayment) {
transactionHistory.transactions.values.forEach((tx) {
tx.unspents?.removeWhere(
(unspent) => estimatedTx.utxos.any((e) => e.utxo.txHash == unspent.hash));
transactionHistory.addOne(tx);
});
}
unspentCoins
.removeWhere((utxo) => estimatedTx.utxos.any((e) => e.utxo.txHash == utxo.hash));
await updateBalance();
});
} catch (e, s) {
print([e, s]);
throw e;
}
}
}