diff --git a/android/app/build.gradle b/android/app/build.gradle index b65c541087..cb23c389ba 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -1,3 +1,9 @@ +plugins { + id "com.android.application" + id "kotlin-android" + id "dev.flutter.flutter-gradle-plugin" +} + def localProperties = new Properties() def localPropertiesFile = rootProject.file('local.properties') if (localPropertiesFile.exists()) { @@ -6,11 +12,6 @@ if (localPropertiesFile.exists()) { } } -def flutterRoot = localProperties.getProperty('flutter.sdk') -if (flutterRoot == null) { - throw new GradleException("Flutter SDK not found. Define location with flutter.sdk in the local.properties file.") -} - def flutterVersionCode = localProperties.getProperty('flutter.versionCode') if (flutterVersionCode == null) { flutterVersionCode = '1' @@ -21,9 +22,6 @@ if (flutterVersionName == null) { flutterVersionName = '1.0' } -apply plugin: 'com.android.application' -apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle" - def keystoreProperties = new Properties() def keystorePropertiesFile = rootProject.file('key.properties') if (keystorePropertiesFile.exists()) { diff --git a/android/build.gradle b/android/build.gradle index 66de0bdca1..9197d81d5e 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -23,6 +23,31 @@ rootProject.buildDir = '../build' subprojects { project.buildDir = "${rootProject.buildDir}/${project.name}" } +subprojects { + afterEvaluate { project -> + if (project.extensions.findByName("android") != null) { + Integer pluginCompileSdk = project.android.compileSdk + if (pluginCompileSdk != null && pluginCompileSdk < 31) { + project.logger.error( + "Warning: Overriding compileSdk version in Flutter plugin: " + + project.name + + " from " + + pluginCompileSdk + + " to 31 (to work around https://issuetracker.google.com/issues/199180389)." + + "\nIf there is not a new version of " + project.name + ", consider filing an issue against " + + project.name + + " to increase their compileSdk to the latest (otherwise try updating to the latest version)." + ) + project.android { + compileSdk 31 + } + } + } + } + + project.buildDir = "${rootProject.buildDir}/${project.name}" + project.evaluationDependsOn(":app") +} subprojects { project.evaluationDependsOn(':app') } diff --git a/android/settings.gradle b/android/settings.gradle index 5a2f14fb18..48ecc74e66 100644 --- a/android/settings.gradle +++ b/android/settings.gradle @@ -1,15 +1,25 @@ -include ':app' +pluginManagement { + def flutterSdkPath = { + def properties = new Properties() + file("local.properties").withInputStream { properties.load(it) } + def flutterSdkPath = properties.getProperty("flutter.sdk") + assert flutterSdkPath != null, "flutter.sdk not set in local.properties" + return flutterSdkPath + }() -def flutterProjectRoot = rootProject.projectDir.parentFile.toPath() + includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") -def plugins = new Properties() -def pluginsFile = new File(flutterProjectRoot.toFile(), '.flutter-plugins') -if (pluginsFile.exists()) { - pluginsFile.withReader('UTF-8') { reader -> plugins.load(reader) } + repositories { + google() + mavenCentral() + gradlePluginPortal() + } } -plugins.each { name, path -> - def pluginDirectory = flutterProjectRoot.resolve(path).resolve('android').toFile() - include ":$name" - project(":$name").projectDir = pluginDirectory +plugins { + id "dev.flutter.flutter-plugin-loader" version "1.0.0" + id "com.android.application" version "7.3.0" apply false + id "org.jetbrains.kotlin.android" version "1.8.21" apply false } + +include ":app" \ No newline at end of file diff --git a/cw_bitcoin/lib/bitcoin_address_record.dart b/cw_bitcoin/lib/bitcoin_address_record.dart index 7e4b5f58f0..30e3b981b4 100644 --- a/cw_bitcoin/lib/bitcoin_address_record.dart +++ b/cw_bitcoin/lib/bitcoin_address_record.dart @@ -19,7 +19,8 @@ abstract class BaseBitcoinAddressRecord { _isUsed = isUsed; @override - bool operator ==(Object o) => o is BaseBitcoinAddressRecord && address == o.address; + bool operator ==(Object o) => + o is BaseBitcoinAddressRecord && address == o.address; final String address; bool isHidden; @@ -65,9 +66,12 @@ class BitcoinAddressRecord extends BaseBitcoinAddressRecord { String? scriptHash, required super.network, }) : scriptHash = scriptHash ?? - (network != null ? BitcoinAddressUtils.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; return BitcoinAddressRecord( @@ -79,8 +83,8 @@ class BitcoinAddressRecord extends BaseBitcoinAddressRecord { name: decoded['name'] as String? ?? '', balance: decoded['balance'] as int? ?? 0, type: decoded['type'] != null && decoded['type'] != '' - ? BitcoinAddressType.values - .firstWhere((type) => type.toString() == decoded['type'] as String) + ? BitcoinAddressType.values.firstWhere( + (type) => type.toString() == decoded['type'] as String) : SegwitAddresType.p2wpkh, scriptHash: decoded['scriptHash'] as String?, network: network, @@ -140,8 +144,8 @@ class BitcoinSilentPaymentAddressRecord extends BaseBitcoinAddressRecord { : BasedUtxoNetwork.fromName(decoded['network'] as String), silentPaymentTweak: decoded['silent_payment_tweak'] as String?, type: decoded['type'] != null && decoded['type'] != '' - ? BitcoinAddressType.values - .firstWhere((type) => type.toString() == decoded['type'] as String) + ? BitcoinAddressType.values.firstWhere( + (type) => type.toString() == decoded['type'] as String) : SilentPaymentsAddresType.p2sp, ); } diff --git a/cw_bitcoin/lib/bitcoin_payjoin.dart b/cw_bitcoin/lib/bitcoin_payjoin.dart new file mode 100644 index 0000000000..114872da83 --- /dev/null +++ b/cw_bitcoin/lib/bitcoin_payjoin.dart @@ -0,0 +1,397 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:bitcoin_base/bitcoin_base.dart'; +import 'package:blockchain_utils/blockchain_utils.dart'; +import 'package:cw_bitcoin/bitcoin_unspent.dart'; +import 'package:cw_bitcoin/electrum_wallet.dart'; +import 'package:flutter/foundation.dart'; +import 'package:ledger_bitcoin/psbt.dart'; +import 'package:payjoin_flutter/common.dart'; +import 'package:payjoin_flutter/receive.dart'; +import 'package:payjoin_flutter/uri.dart' as pjuri; +import 'package:payjoin_flutter/uri.dart' as pj_uri; +import 'package:http/http.dart' as http; + +import 'package:payjoin_flutter/send.dart' as send; + +import 'pending_bitcoin_transaction.dart'; +import 'package:payjoin_flutter/bitcoin_ffi.dart' as script; + +export 'package:payjoin_flutter/receive.dart' + show Receiver, UncheckedProposal, PayjoinProposal; + +export 'package:payjoin_flutter/uri.dart' show Uri; + +export 'package:payjoin_flutter/send.dart' show Sender; + +export 'package:payjoin_flutter/src/exceptions.dart' show PayjoinException; + +class BitcoinPayjoin { + // Private constructor + BitcoinPayjoin._internal(); + + // Singleton instance + static final BitcoinPayjoin _instance = BitcoinPayjoin._internal(); + + // Factory constructor to return the singleton instance + factory BitcoinPayjoin() { + return _instance; + } + + static const pjUrl = "https://payjo.in"; + static const relayUrl = "https://pj.bobspacebkk.com"; + static const v2ContentType = "message/ohttp-req"; + + Network get testnet => Network.testnet; + Network get mainnet => Network.bitcoin; + +/* ++-------------------------+ +| Receiver starts from here | ++-------------------------+ +*/ + + Future> buildV2PjStr({ + int? amount, + required String address, + required Network network, + required BigInt expireAfter, + }) async { + debugPrint( + '[+] BITCOINPAYJOIN => buildV2PjStr - address: $address \n amount: $amount \n network: $network'); + + final _payjoinDirectory = await pjuri.Url.fromStr(pjUrl); + final _ohttpRelay = await pjuri.Url.fromStr(relayUrl); + + final _ohttpKeys = await pjuri.fetchOhttpKeys( + ohttpRelay: _ohttpRelay, + payjoinDirectory: _payjoinDirectory, + ); + + debugPrint( + '[+] BITCOINPAYJOIN => buildV2PjStr - OHTTP KEYS FETCHED ${_ohttpKeys.toString()}'); + + final receiver = await Receiver.create( + address: address, + network: network, + directory: _payjoinDirectory, + ohttpKeys: _ohttpKeys, + ohttpRelay: _ohttpRelay, + expireAfter: expireAfter, // 5 minutes + ); + + String pjUriStr; + + final pjUriBuilder = receiver.pjUriBuilder(); + + if (amount != null) { + // ignore + final pjUriBuilderWithAmount = + pjUriBuilder.amountSats(amount: BigInt.from(amount)); + final pjUri = pjUriBuilderWithAmount.build(); + pjUriStr = pjUri.asString(); + } else { + final pjUri = pjUriBuilder.build(); + pjUriStr = pjUri.asString(); + } + + return {'pjUri': pjUriStr, 'session': receiver}; + } + + Future handleReceiverSession(Receiver session) async { + debugPrint("BITCOINPAYJOIN => pollV2Request"); + try { + final httpClient = HttpClient(); + UncheckedProposal? proposal; + + while (proposal == null) { + final extractReq = await session.extractReq(); + final request = extractReq.$1; + final clientResponse = extractReq.$2; + + final url = Uri.parse(request.url.asString()); + final httpRequest = await httpClient.postUrl(url); + httpRequest.headers.set('Content-Type', request.contentType); + httpRequest.add(request.body); + + final response = await httpRequest.close(); + final responseBody = await response.fold>( + [], (previous, element) => previous..addAll(element)); + final uint8Response = Uint8List.fromList(responseBody); + proposal = + await session.processRes(body: uint8Response, ctx: clientResponse); + } + + return proposal; + } catch (e, st) { + debugPrint( + '[!] BITCOINPAYJOINERROR => buildV2PjStr - Error: ${e.toString()}, Stacktrace: $st'); + rethrow; + } + } + + Future extractOriginalTransaction(UncheckedProposal proposal) async { + final originalTxBytes = await proposal.extractTxToScheduleBroadcast(); + final originalTx = getTxIdFromTxBytes(originalTxBytes); + return originalTx; + } + + Future processProposal({ + required UncheckedProposal proposal, + required Object receiverWallet, + }) async { + final bitcoinWallet = receiverWallet as ElectrumWallet; + + final maybeInputsOwned = await proposal.assumeInteractiveReceiver(); + + final maybeInputsSeen = await maybeInputsOwned.checkInputsNotOwned( + isOwned: (outpoint) async => + false // TODO Implement actual ownership check + ); + + final outputsUnknown = await maybeInputsSeen.checkNoInputsSeenBefore( + isKnown: (outpoint) async => false // TODO Implement actual seen check + ); + + final wantsOutputs = await outputsUnknown.identifyReceiverOutputs( + isReceiverOutput: (script) async { + return receiverWallet.isMine(Script(script: script)); + }); + + var wantsInputs = await wantsOutputs.commitOutputs(); + + // final unspent = receiverWallet.listUnspent(); + final List unspent = bitcoinWallet.unspentCoins; + + List candidateInputs = []; + + for (BitcoinUnspent input in unspent) { + final scriptBytes = BitcoinBaseAddress.fromString(input.address) + .toScriptPubKey() + .toBytes(); + final txout = TxOut( + value: BigInt.from(input.value), + scriptPubkey: Uint8List.fromList(scriptBytes), + ); + + final psbtin = PsbtInput( + witnessUtxo: txout, redeemScript: null, witnessScript: null); + + final previousOutput = OutPoint(txid: input.hash, vout: input.vout); + + final txin = TxIn( + previousOutput: previousOutput, + scriptSig: await script.Script.newInstance(rawOutputScript: []), + witness: [], + sequence: 0, + ); + + final ip = await InputPair.newInstance(txin, psbtin); + + candidateInputs.add(ip); + } + + final inputPair = await wantsInputs.tryPreservingPrivacy( + candidateInputs: candidateInputs); + + wantsInputs = + await wantsInputs.contributeInputs(replacementInputs: [inputPair]); + + final provisionalProposal = await wantsInputs.commitInputs(); + + final finalProposal = await provisionalProposal.finalizeProposal( + processPsbt: (i) => _processPsbt(i, receiverWallet), + maxFeeRateSatPerVb: BigInt.from(25)); + + return finalProposal; + } + + Future sendFinalProposal(PayjoinProposal finalProposal) async { + final req = await finalProposal.extractV2Req(); + final proposalReq = req.$1; + final proposalCtx = req.$2; + + final httpClient = HttpClient(); + final httpRequest = await httpClient.postUrl( + Uri.parse(proposalReq.url.asString()), + ); + httpRequest.headers.set('content-type', 'message/ohttp-req'); + httpRequest.add(proposalReq.body); + + final response = await httpRequest.close(); + final responseBody = await response.fold>( + [], + (previous, element) => previous..addAll(element), + ); + await finalProposal.processRes( + res: responseBody, ohttpContext: proposalCtx); + + final proposalPsbt = await finalProposal.psbt(); + + return await getTxIdFromPsbt(proposalPsbt); + } + + String getTxIdFromTxBytes(Uint8List txBytes) { + final originalTx = BtcTransaction.fromRaw(BytesUtils.toHexString(txBytes)); + return originalTx.txId(); + } + + Future _processPsbt( + String preProcessed, + ElectrumWallet wallet, + ) async { + final signedPsbt = wallet.signPsbt(preProcessed); + return signedPsbt; + } + + Future getTxIdFromPsbt(String psbtBase64) async { + final psbt = PsbtV2()..deserialize(base64.decode(psbtBase64)); + final doubleSha256 = QuickCrypto.sha256DoubleHash(psbt.extract()); + final revert = Uint8List.fromList(doubleSha256); + final txId = hex.encode(revert.reversed.toList()); + return txId; + } +/* ++-------------------------+ +| Sender starts from here | ++-------------------------+ +*/ + + Future stringToPjUri(String pj) async { + try { + return await pjuri.Uri.fromStr(pj); + } catch (e, st) { + debugPrint( + '[!] BITCOINPAYJOINERROR => stringToPjUri - Error: ${e.toString()}, Stacktrace: $st'); + return null; + } + } + + Future buildOriginalPsbt( + Object wallet, + dynamic pjUri, + int fee, + double amount, + bool isTestnet, + Object credentials, + ) async { + final uri = pjUri as pj_uri.Uri; + final bitcoinWallet = wallet as ElectrumWallet; + + final psbtv2 = await bitcoinWallet.createPayjoinTransaction( + credentials, + pjBtcAddress: uri.address(), + ); + debugPrint( + '[+] BITCOINPAYJOIN => buildOriginalPsbt - psbtv2: ${base64Encode(psbtv2.serialize())}'); + + final psbtv0 = base64Encode(psbtv2.asPsbtV0()); + debugPrint('[+] BITCOINPAYJOIN => buildOriginalPsbt - psbtv0: $psbtv0'); + + return psbtv0; + } + + Future buildPayjoinRequest( + String originalPsbt, + dynamic pjUri, + int fee, + ) async { + final uri = pjUri as pj_uri.Uri; + + final senderBuilder = await send.SenderBuilder.fromPsbtAndUri( + psbtBase64: originalPsbt, + pjUri: uri.checkPjSupported(), + ); + + final sender = await senderBuilder.buildRecommended( + minFeeRate: BigInt.from(250), + ); + + return sender; + } + + Future requestAndPollV2Proposal( + send.Sender sender, + ) async { + debugPrint( + '[+] BITCOINPAYJOIN => requestAndPollV2Proposal - Sending V2 Proposal Request...'); + + try { + final extractV2 = await sender.extractV2( + ohttpProxyUrl: await pj_uri.Url.fromStr(relayUrl), + ); + final request = extractV2.$1; + final postCtx = extractV2.$2; + + final response = await http.post( + Uri.parse(request.url.asString()), + headers: { + 'Content-Type': v2ContentType, + }, + body: request.body, + ); + + final getCtx = + await postCtx.processResponse(response: response.bodyBytes); + + while (true) { + debugPrint( + '[+] BITCOINPAYJOIN => requestAndPollV2Proposal - Polling V2 Proposal Request...'); + + try { + final extractReq = await getCtx.extractReq( + ohttpRelay: await pj_uri.Url.fromStr(relayUrl), + ); + final getReq = extractReq.$1; + final ohttpCtx = extractReq.$2; + + final loopResponse = await http.post( + Uri.parse(getReq.url.asString()), + headers: { + 'Content-Type': v2ContentType, + }, + body: getReq.body, + ); + + final proposal = await getCtx.processResponse( + response: loopResponse.bodyBytes, ohttpCtx: ohttpCtx); + + if (proposal != null) { + debugPrint( + '[+] BITCOINPAYJOIN => requestAndPollV2Proposal - Received V2 proposal: $proposal'); + return proposal; + } + + debugPrint( + '[+] BITCOINPAYJOIN => requestAndPollV2Proposal - No valid proposal received, retrying after 2 seconds...'); + + await Future.delayed(const Duration(seconds: 2)); + } catch (e, st) { + // If the session times out or another error occurs, rethrow the error + debugPrint( + '[!] BITCOINPAYJOINERROR => stringToPjUri - Error: ${e.toString()}, Stacktrace: $st'); + rethrow; + } + } + } catch (e, st) { + // If the initial request fails, rethrow the error + debugPrint( + '[!] BITCOINPAYJOINERROR => stringToPjUri - Error: ${e.toString()}, Stacktrace: $st'); + rethrow; + } + } + + Future extractPjTx( + Object wallet, + String psbtString, + Object credentials, + ) async { + final bitcoinWallet = wallet as ElectrumWallet; + + final pendingTx = + await bitcoinWallet.psbtToPendingTx(psbtString, credentials); + + return pendingTx; + } +} diff --git a/cw_bitcoin/lib/bitcoin_receive_page_option.dart b/cw_bitcoin/lib/bitcoin_receive_page_option.dart index 07083e111a..f73e29ee8c 100644 --- a/cw_bitcoin/lib/bitcoin_receive_page_option.dart +++ b/cw_bitcoin/lib/bitcoin_receive_page_option.dart @@ -11,6 +11,8 @@ class BitcoinReceivePageOption implements ReceivePageOption { static const silent_payments = BitcoinReceivePageOption._('Silent Payments'); + static const payjoin_payments = BitcoinReceivePageOption._('Payjoin'); + const BitcoinReceivePageOption._(this.value); final String value; @@ -20,6 +22,7 @@ class BitcoinReceivePageOption implements ReceivePageOption { } static const all = [ + BitcoinReceivePageOption.payjoin_payments, BitcoinReceivePageOption.silent_payments, BitcoinReceivePageOption.p2wpkh, BitcoinReceivePageOption.p2tr, @@ -32,7 +35,7 @@ class BitcoinReceivePageOption implements ReceivePageOption { BitcoinReceivePageOption.p2wpkh, BitcoinReceivePageOption.mweb, ]; - + BitcoinAddressType toType() { switch (this) { case BitcoinReceivePageOption.p2tr: @@ -45,6 +48,8 @@ class BitcoinReceivePageOption implements ReceivePageOption { return P2shAddressType.p2wpkhInP2sh; case BitcoinReceivePageOption.silent_payments: return SilentPaymentsAddresType.p2sp; + case BitcoinReceivePageOption.payjoin_payments: + return SegwitAddresType.p2tr; case BitcoinReceivePageOption.mweb: return SegwitAddresType.mweb; case BitcoinReceivePageOption.p2wpkh: diff --git a/cw_bitcoin/lib/bitcoin_wallet.dart b/cw_bitcoin/lib/bitcoin_wallet.dart index 9088978459..81e1e93384 100644 --- a/cw_bitcoin/lib/bitcoin_wallet.dart +++ b/cw_bitcoin/lib/bitcoin_wallet.dart @@ -119,7 +119,8 @@ abstract class BitcoinWalletBase extends ElectrumWallet with Store { break; case DerivationType.electrum: default: - seedBytes = await mnemonicToSeedBytes(mnemonic, passphrase: passphrase ?? ""); + seedBytes = + await mnemonicToSeedBytes(mnemonic, passphrase: passphrase ?? ""); break; } return BitcoinWallet( @@ -201,7 +202,8 @@ abstract class BitcoinWalletBase extends ElectrumWallet with Store { if (mnemonic != null) { switch (walletInfo.derivationInfo!.derivationType) { case DerivationType.electrum: - seedBytes = await mnemonicToSeedBytes(mnemonic, passphrase: passphrase ?? ""); + seedBytes = + await mnemonicToSeedBytes(mnemonic, passphrase: passphrase ?? ""); break; case DerivationType.bip39: default: @@ -244,6 +246,47 @@ abstract class BitcoinWalletBase extends ElectrumWallet with Store { derivationPath: walletInfo.derivationInfo!.derivationPath!); } + @override + Future buildPayjoinTransaction({ + required List outputs, + required BigInt fee, + required BasedUtxoNetwork network, + required List utxos, + required Map publicKeys, + String? memo, + bool enableRBF = false, + BitcoinOrdering inputOrdering = BitcoinOrdering.bip69, + BitcoinOrdering outputOrdering = BitcoinOrdering.bip69, + }) async { + final psbtReadyInputs = []; + for (final UtxoWithAddress utxo in utxos) { + debugPrint( + '[+] BITCOINWALLET => buildPayjoinTransaction - utxo: ${utxo.utxo.toString()}'); + + final rawTx = + await electrumClient.getTransactionHex(hash: utxo.utxo.txHash); + final publicKeyAndDerivationPath = + publicKeys[utxo.ownerDetails.address.pubKeyHash()]!; + + psbtReadyInputs.add(PSBTReadyUtxoWithAddress( + utxo: utxo.utxo, + rawTx: rawTx, + ownerDetails: utxo.ownerDetails, + ownerDerivationPath: publicKeyAndDerivationPath.derivationPath, + ownerMasterFingerprint: Uint8List(0), + ownerPublicKey: publicKeyAndDerivationPath.publicKey, + )); + } + + final psbt = PSBTTransactionBuild( + inputs: psbtReadyInputs, + outputs: outputs, + enableRBF: enableRBF, + ); + + return psbt; + } + @override Future buildHardwareWalletTransaction({ required List outputs, diff --git a/cw_bitcoin/lib/electrum_transaction_info.dart b/cw_bitcoin/lib/electrum_transaction_info.dart index 7a8b3b951f..8f664f7796 100644 --- a/cw_bitcoin/lib/electrum_transaction_info.dart +++ b/cw_bitcoin/lib/electrum_transaction_info.dart @@ -58,7 +58,8 @@ class ElectrumTransactionInfo extends TransactionInfo { this.additionalInfo = additionalInfo ?? {}; } - factory ElectrumTransactionInfo.fromElectrumVerbose(Map obj, WalletType type, + factory ElectrumTransactionInfo.fromElectrumVerbose( + Map obj, WalletType type, {required List addresses, required int height}) { final addressesSet = addresses.map((addr) => addr.address).toSet(); final id = obj['txid'] as String; @@ -76,8 +77,10 @@ class ElectrumTransactionInfo extends TransactionInfo { for (dynamic vin in vins) { final vout = vin['vout'] as int; final out = vin['tx']['vout'][vout] as Map; - final outAddresses = (out['scriptPubKey']['addresses'] as List?)?.toSet(); - inputsAmount += stringDoubleToBitcoinAmount((out['value'] as double? ?? 0).toString()); + final outAddresses = + (out['scriptPubKey']['addresses'] as List?)?.toSet(); + inputsAmount += stringDoubleToBitcoinAmount( + (out['value'] as double? ?? 0).toString()); if (outAddresses?.intersection(addressesSet).isNotEmpty ?? false) { direction = TransactionDirection.outgoing; @@ -85,9 +88,11 @@ class ElectrumTransactionInfo extends TransactionInfo { } for (dynamic out in vout) { - final outAddresses = out['scriptPubKey']['addresses'] as List? ?? []; + final outAddresses = + out['scriptPubKey']['addresses'] as List? ?? []; final ntrs = outAddresses.toSet().intersection(addressesSet); - final value = stringDoubleToBitcoinAmount((out['value'] as double? ?? 0.0).toString()); + final value = stringDoubleToBitcoinAmount( + (out['value'] as double? ?? 0.0).toString()); totalOutAmount += value; if ((direction == TransactionDirection.incoming && ntrs.isNotEmpty) || @@ -110,9 +115,21 @@ class ElectrumTransactionInfo extends TransactionInfo { confirmations: confirmations); } + static bool isMine( + Script script, + BasedUtxoNetwork network, { + required Set addresses, + }) { + final derivedAddress = addressFromOutputScript(script, network); + return addresses.contains(derivedAddress); + } + factory ElectrumTransactionInfo.fromElectrumBundle( - ElectrumTransactionBundle bundle, WalletType type, BasedUtxoNetwork network, - {required Set addresses, int? height}) { + ElectrumTransactionBundle bundle, + WalletType type, + BasedUtxoNetwork network, + {required Set addresses, + int? height}) { final date = bundle.time != null ? DateTime.fromMillisecondsSinceEpoch(bundle.time! * 1000) : DateTime.now(); @@ -128,16 +145,19 @@ class ElectrumTransactionInfo extends TransactionInfo { final inputTransaction = bundle.ins[i]; final outTransaction = inputTransaction.outputs[input.txIndex]; inputAmount += outTransaction.amount.toInt(); - if (addresses.contains(addressFromOutputScript(outTransaction.scriptPubKey, network))) { + if (addresses.contains( + addressFromOutputScript(outTransaction.scriptPubKey, network))) { direction = TransactionDirection.outgoing; - inputAddresses.add(addressFromOutputScript(outTransaction.scriptPubKey, network)); + inputAddresses + .add(addressFromOutputScript(outTransaction.scriptPubKey, network)); } } final receivedAmounts = []; for (final out in bundle.originalTransaction.outputs) { totalOutAmount += out.amount.toInt(); - final addressExists = addresses.contains(addressFromOutputScript(out.scriptPubKey, network)); + final addressExists = addresses + .contains(addressFromOutputScript(out.scriptPubKey, network)); final address = addressFromOutputScript(out.scriptPubKey, network); if (address.isNotEmpty) outputAddresses.add(address); @@ -188,7 +208,8 @@ class ElectrumTransactionInfo extends TransactionInfo { confirmations: bundle.confirmations); } - factory ElectrumTransactionInfo.fromJson(Map data, WalletType type) { + factory ElectrumTransactionInfo.fromJson( + Map data, WalletType type) { final inputAddresses = data['inputAddresses'] as List? ?? []; final outputAddresses = data['outputAddresses'] as List? ?? []; final unspents = data['unspents'] as List? ?? []; @@ -204,16 +225,19 @@ class ElectrumTransactionInfo extends TransactionInfo { isPending: data['isPending'] as bool, isReplaced: data['isReplaced'] as bool? ?? false, confirmations: data['confirmations'] as int, - inputAddresses: - inputAddresses.isEmpty ? [] : inputAddresses.map((e) => e.toString()).toList(), - outputAddresses: - outputAddresses.isEmpty ? [] : outputAddresses.map((e) => e.toString()).toList(), + inputAddresses: inputAddresses.isEmpty + ? [] + : inputAddresses.map((e) => e.toString()).toList(), + outputAddresses: outputAddresses.isEmpty + ? [] + : outputAddresses.map((e) => e.toString()).toList(), to: data['to'] as String?, unspents: unspents - .map((unspent) => - BitcoinSilentPaymentsUnspent.fromJSON(null, unspent as Map)) + .map((unspent) => BitcoinSilentPaymentsUnspent.fromJSON( + null, unspent as Map)) .toList(), - isReceivedSilentPayment: data['isReceivedSilentPayment'] as bool? ?? false, + isReceivedSilentPayment: + data['isReceivedSilentPayment'] as bool? ?? false, additionalInfo: data['additionalInfo'] as Map?, ); } diff --git a/cw_bitcoin/lib/electrum_wallet.dart b/cw_bitcoin/lib/electrum_wallet.dart index d9041cba4f..5b38dfac6d 100644 --- a/cw_bitcoin/lib/electrum_wallet.dart +++ b/cw_bitcoin/lib/electrum_wallet.dart @@ -9,6 +9,9 @@ import 'package:cw_core/utils/print_verbose.dart'; import 'package:cw_bitcoin/bitcoin_wallet.dart'; import 'package:cw_bitcoin/litecoin_wallet.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import 'package:cw_bitcoin/psbt_converter.dart'; +import 'package:cw_bitcoin/psbt_signer.dart'; +import 'package:cw_core/encryption_file_utils.dart'; import 'package:blockchain_utils/blockchain_utils.dart'; import 'package:collection/collection.dart'; import 'package:cw_bitcoin/address_from_output.dart'; @@ -44,19 +47,23 @@ import 'package:cw_core/unspent_coin_type.dart'; import 'package:flutter/foundation.dart'; import 'package:hive/hive.dart'; import 'package:ledger_flutter_plus/ledger_flutter_plus.dart' as ledger; +import 'package:ledger_bitcoin/psbt.dart'; import 'package:mobx/mobx.dart'; import 'package:rxdart/subjects.dart'; import 'package:sp_scanner/sp_scanner.dart'; import 'package:hex/hex.dart'; import 'package:http/http.dart' as http; +import 'psbt_transaction_builder.dart'; + part 'electrum_wallet.g.dart'; class ElectrumWallet = ElectrumWalletBase with _$ElectrumWallet; -abstract class ElectrumWalletBase - extends WalletBase - with Store, WalletKeysFile { +abstract class ElectrumWalletBase extends WalletBase< + ElectrumBalance, + ElectrumTransactionHistory, + ElectrumTransactionInfo> with Store, WalletKeysFile { ElectrumWalletBase({ required String password, required WalletInfo walletInfo, @@ -72,8 +79,8 @@ abstract class ElectrumWalletBase ElectrumBalance? initialBalance, CryptoCurrency? currency, this.alwaysScan, - }) : accountHD = - getAccountHDWallet(currency, network, seedBytes, xpub, walletInfo.derivationInfo), + }) : accountHD = getAccountHDWallet( + currency, network, seedBytes, xpub, walletInfo.derivationInfo), syncStatus = NotConnectedSyncStatus(), _password = password, _feeRates = [], @@ -81,16 +88,17 @@ abstract class ElectrumWalletBase isEnabledAutoGenerateSubaddress = true, unspentCoins = [], _scripthashesUpdateSubject = {}, - balance = ObservableMap.of(currency != null - ? { - currency: initialBalance ?? - ElectrumBalance( - confirmed: 0, - unconfirmed: 0, - frozen: 0, - ) - } - : {}), + balance = + ObservableMap.of(currency != null + ? { + currency: initialBalance ?? + ElectrumBalance( + confirmed: 0, + unconfirmed: 0, + frozen: 0, + ) + } + : {}), this.unspentCoinsInfo = unspentCoinsInfo, this.isTestnet = !network.isMainnet, this._mnemonic = mnemonic, @@ -108,8 +116,12 @@ abstract class ElectrumWalletBase sharedPrefs.complete(SharedPreferences.getInstance()); } - static Bip32Slip10Secp256k1 getAccountHDWallet(CryptoCurrency? currency, BasedUtxoNetwork network, - Uint8List? seedBytes, String? xpub, DerivationInfo? derivationInfo) { + static Bip32Slip10Secp256k1 getAccountHDWallet( + CryptoCurrency? currency, + BasedUtxoNetwork network, + Uint8List? seedBytes, + String? xpub, + DerivationInfo? derivationInfo) { if (seedBytes == null && xpub == null) { throw Exception( "To create a Wallet you need either a seed or an xpub. This should not happen"); @@ -120,8 +132,10 @@ abstract class ElectrumWalletBase case CryptoCurrency.btc: case CryptoCurrency.ltc: case CryptoCurrency.tbtc: - return Bip32Slip10Secp256k1.fromSeed(seedBytes, getKeyNetVersion(network)).derivePath( - _hardenedDerivationPath(derivationInfo?.derivationPath ?? electrum_path)) + return Bip32Slip10Secp256k1.fromSeed( + seedBytes, getKeyNetVersion(network)) + .derivePath(_hardenedDerivationPath( + derivationInfo?.derivationPath ?? electrum_path)) as Bip32Slip10Secp256k1; case CryptoCurrency.bch: return bitcoinCashHDWallet(seedBytes); @@ -130,11 +144,13 @@ abstract class ElectrumWalletBase } } - return Bip32Slip10Secp256k1.fromExtendedKey(xpub!, getKeyNetVersion(network)); + return Bip32Slip10Secp256k1.fromExtendedKey( + xpub!, getKeyNetVersion(network)); } static Bip32Slip10Secp256k1 bitcoinCashHDWallet(Uint8List seedBytes) => - Bip32Slip10Secp256k1.fromSeed(seedBytes).derivePath("m/44'/145'/0'") as Bip32Slip10Secp256k1; + Bip32Slip10Secp256k1.fromSeed(seedBytes).derivePath("m/44'/145'/0'") + as Bip32Slip10Secp256k1; static int estimatedTransactionSize(int inputsCount, int outputsCounts) => inputsCount * 68 + outputsCounts * 34 + 10; @@ -186,13 +202,15 @@ abstract class ElectrumWalletBase .toSet(); List get scriptHashes => walletAddresses.addressesByReceiveType - .where((addr) => RegexUtils.addressTypeFromStr(addr.address, network) is! MwebAddress) + .where((addr) => + RegexUtils.addressTypeFromStr(addr.address, network) is! MwebAddress) .map((addr) => (addr as BitcoinAddressRecord).getScriptHash(network)) .toList(); List get publicScriptHashes => walletAddresses.allAddresses .where((addr) => !addr.isHidden) - .where((addr) => RegexUtils.addressTypeFromStr(addr.address, network) is! MwebAddress) + .where((addr) => + RegexUtils.addressTypeFromStr(addr.address, network) is! MwebAddress) .map((addr) => addr.getScriptHash(network)) .toList(); @@ -225,7 +243,8 @@ abstract class ElectrumWalletBase Completer sharedPrefs = Completer(); Future checkIfMempoolAPIIsEnabled() async { - bool isMempoolAPIEnabled = (await sharedPrefs.future).getBool("use_mempool_fee_api") ?? true; + bool isMempoolAPIEnabled = + (await sharedPrefs.future).getBool("use_mempool_fee_api") ?? true; return isMempoolAPIEnabled; } @@ -309,12 +328,13 @@ abstract class ElectrumWalletBase await cleanUpDuplicateUnspentCoins(); await save(); - _autoSaveTimer = - Timer.periodic(Duration(minutes: _autoSaveInterval), (_) async => await save()); + _autoSaveTimer = Timer.periodic( + Duration(minutes: _autoSaveInterval), (_) async => await save()); } @action - Future _setListeners(int height, {int? chainTipParam, bool? doSingleScan}) async { + Future _setListeners(int height, + {int? chainTipParam, bool? doSingleScan}) async { if (this is! BitcoinWallet) return; final chainTip = chainTipParam ?? await getUpdatedChainTip(); @@ -346,7 +366,8 @@ abstract class ElectrumWalletBase : null, labels: walletAddresses.labels, labelIndexes: walletAddresses.silentAddresses - .where((addr) => addr.type == SilentPaymentsAddresType.p2sp && addr.index >= 1) + .where((addr) => + addr.type == SilentPaymentsAddresType.p2sp && addr.index >= 1) .map((addr) => addr.index) .toList(), isSingleScan: doSingleScan ?? false, @@ -370,10 +391,11 @@ abstract class ElectrumWalletBase 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) ?? + .where((unspent) => !(existingTxInfo.unspents?.any( + (element) => + element.hash.contains(unspent.hash) && + element.vout == unspent.vout && + element.value == unspent.value) ?? false)) .toList(); @@ -384,7 +406,9 @@ abstract class ElectrumWalletBase existingTxInfo.unspents!.addAll(newUnspents); final newAmount = newUnspents.length > 1 - ? newUnspents.map((e) => e.value).reduce((value, unspent) => value + unspent) + ? newUnspents + .map((e) => e.value) + .reduce((value, unspent) => value + unspent) : newUnspents[0].value; if (existingTxInfo.direction == TransactionDirection.incoming) { @@ -435,14 +459,15 @@ abstract class ElectrumWalletBase B_scan: silentAddress.B_scan, B_spend: unspent.silentPaymentLabel != null ? silentAddress.B_spend.tweakAdd( - BigintUtils.fromBytes(BytesUtils.fromHexString(unspent.silentPaymentLabel!)), + BigintUtils.fromBytes( + BytesUtils.fromHexString(unspent.silentPaymentLabel!)), ) : silentAddress.B_spend, network: network, ); - final addressRecord = walletAddresses.silentAddresses - .firstWhereOrNull((address) => address.address == silentPaymentAddress.toString()); + final addressRecord = walletAddresses.silentAddresses.firstWhereOrNull( + (address) => address.address == silentPaymentAddress.toString()); addressRecord?.txCount += 1; addressRecord?.balance += unspent.value; @@ -472,8 +497,8 @@ abstract class ElectrumWalletBase await updateBalance(); await updateFeeRates(); - _updateFeeRateTimer ??= - Timer.periodic(const Duration(minutes: 1), (timer) async => await updateFeeRates()); + _updateFeeRateTimer ??= Timer.periodic( + const Duration(minutes: 1), (timer) async => await updateFeeRates()); if (alwaysScan == true) { _setListeners(walletInfo.restoreHeight); @@ -492,7 +517,8 @@ abstract class ElectrumWalletBase if (await checkIfMempoolAPIIsEnabled() && type == WalletType.bitcoin) { try { final response = await http - .get(Uri.parse("https://mempool.cakewallet.com/api/v1/fees/recommended")) + .get(Uri.parse( + "https://mempool.cakewallet.com/api/v1/fees/recommended")) .timeout(Duration(seconds: 5)); final result = json.decode(response.body) as Map; @@ -598,7 +624,8 @@ abstract class ElectrumWalletBase int get _dustAmount => 546; - bool _isBelowDust(int amount) => amount <= _dustAmount && network != BitcoinNetwork.testnet; + bool _isBelowDust(int amount) => + amount <= _dustAmount && network != BitcoinNetwork.testnet; UtxoDetails _createUTXOS({ required bool sendAll, @@ -630,10 +657,12 @@ abstract class ElectrumWalletBase return true; } }).toList(); - final unconfirmedCoins = availableInputs.where((utx) => utx.confirmations == 0).toList(); + final unconfirmedCoins = + availableInputs.where((utx) => utx.confirmations == 0).toList(); // sort the unconfirmed coins so that mweb coins are first: - availableInputs.sort((a, b) => a.bitcoinAddressRecord.type == SegwitAddresType.mweb ? -1 : 1); + availableInputs.sort((a, b) => + a.bitcoinAddressRecord.type == SegwitAddresType.mweb ? -1 : 1); for (int i = 0; i < availableInputs.length; i++) { final utx = availableInputs[i]; @@ -653,11 +682,13 @@ abstract class ElectrumWalletBase ECPrivate? privkey; bool? isSilentPayment = false; - final hd = - utx.bitcoinAddressRecord.isHidden ? walletAddresses.sideHd : walletAddresses.mainHd; + final hd = utx.bitcoinAddressRecord.isHidden + ? walletAddresses.sideHd + : walletAddresses.mainHd; if (utx.bitcoinAddressRecord is BitcoinSilentPaymentAddressRecord) { - final unspentAddress = utx.bitcoinAddressRecord as BitcoinSilentPaymentAddressRecord; + final unspentAddress = + utx.bitcoinAddressRecord as BitcoinSilentPaymentAddressRecord; privkey = walletAddresses.silentAddress!.b_spend.tweakAdd( BigintUtils.fromBytes( BytesUtils.fromHexString(unspentAddress.silentPaymentTweak!), @@ -666,8 +697,8 @@ abstract class ElectrumWalletBase spendsSilentPayment = true; isSilentPayment = true; } else if (!isHardwareWallet) { - privkey = - generateECPrivate(hd: hd, index: utx.bitcoinAddressRecord.index, network: network); + privkey = generateECPrivate( + hd: hd, index: utx.bitcoinAddressRecord.index, network: network); } vinOutpoints.add(Outpoint(txid: utx.hash, index: utx.vout)); @@ -682,14 +713,18 @@ abstract class ElectrumWalletBase pubKeyHex = privkey.getPublic().toHex(); } else { - pubKeyHex = hd.childKey(Bip32KeyIndex(utx.bitcoinAddressRecord.index)).publicKey.toHex(); + pubKeyHex = hd + .childKey(Bip32KeyIndex(utx.bitcoinAddressRecord.index)) + .publicKey + .toHex(); } final derivationPath = "${_hardenedDerivationPath(walletInfo.derivationInfo?.derivationPath ?? electrum_path)}" "/${utx.bitcoinAddressRecord.isHidden ? "1" : "0"}" "/${utx.bitcoinAddressRecord.index}"; - publicKeys[address.pubKeyHash()] = PublicKeyWithDerivationPath(pubKeyHex, derivationPath); + publicKeys[address.pubKeyHash()] = + PublicKeyWithDerivationPath(pubKeyHex, derivationPath); utxos.add( UtxoWithAddress( @@ -740,6 +775,8 @@ abstract class ElectrumWalletBase bool hasSilentPayment = false, UnspentCoinType coinTypeToSpendFrom = UnspentCoinType.any, }) async { + print('[+] ElectrumWallet => estimateSendAllTx()'); + final utxoDetails = _createUTXOS( sendAll: true, paysToSilentPayment: hasSilentPayment, @@ -764,7 +801,10 @@ abstract class ElectrumWalletBase int amount = utxoDetails.allInputsAmount - fee; if (amount <= 0) { - throw BitcoinTransactionWrongBalanceException(amount: utxoDetails.allInputsAmount + fee); + print('[+] ElectrumWallet => estimateSendAllTx() | Running herre.'); + + throw BitcoinTransactionWrongBalanceException( + amount: utxoDetails.allInputsAmount + fee); } // Attempting to send less than the dust limit @@ -773,7 +813,8 @@ abstract class ElectrumWalletBase } if (outputs.length == 1) { - outputs[0] = BitcoinOutput(address: outputs.last.address, value: BigInt.from(amount)); + outputs[0] = BitcoinOutput( + address: outputs.last.address, value: BigInt.from(amount)); } return EstimatedTxResult( @@ -814,16 +855,20 @@ abstract class ElectrumWalletBase coinTypeToSpendFrom: coinTypeToSpendFrom, ); - final spendingAllCoins = utxoDetails.availableInputs.length == utxoDetails.utxos.length; + final spendingAllCoins = + utxoDetails.availableInputs.length == utxoDetails.utxos.length; final spendingAllConfirmedCoins = !utxoDetails.spendsUnconfirmedTX && utxoDetails.utxos.length == - utxoDetails.availableInputs.length - utxoDetails.unconfirmedCoins.length; + utxoDetails.availableInputs.length - + utxoDetails.unconfirmedCoins.length; // How much is being spent - how much is being sent - int amountLeftForChangeAndFee = utxoDetails.allInputsAmount - credentialsAmount; + int amountLeftForChangeAndFee = + utxoDetails.allInputsAmount - credentialsAmount; if (amountLeftForChangeAndFee <= 0) { if (!spendingAllCoins) { + print('[+] ElectrumWallet || Recursive Call to estimateTx'); return estimateTxForAmount( credentialsAmount, outputs, @@ -843,7 +888,8 @@ abstract class ElectrumWalletBase inputs: utxoDetails.availableInputs, outputs: updatedOutputs, ); - final address = RegexUtils.addressTypeFromStr(changeAddress.address, network); + final address = + RegexUtils.addressTypeFromStr(changeAddress.address, network); updatedOutputs.add(BitcoinOutput( address: address, value: BigInt.from(amountLeftForChangeAndFee), @@ -987,13 +1033,125 @@ abstract class ElectrumWalletBase return feeAmountWithFeeRate(feeRate, 0, 0, size: estimatedSize); } + Future createPayjoinTransaction( + Object credentials, { + String? pjBtcAddress, + }) async { + try { + final outputs = []; + 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; + + 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 addressStr = (pjBtcAddress != null) + ? pjBtcAddress + : (out.isParsedAddress ? out.extractedAddress! : out.address); + + print( + '[+] ElectrumWallet => createPayjoinTx - addressStr: $addressStr'); + + final address = RegexUtils.addressTypeFromStr(addressStr, network); + + 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), + )); + } else { + outputs.add(BitcoinOutput( + address: address, + value: BigInt.from(outputAmount), + )); + } + } + + 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, + coinTypeToSpendFrom: coinTypeToSpendFrom, + ); + } else { + estimatedTx = await estimateTxForAmount( + credentialsAmount, + outputs, + updatedOutputs, + feeRateInt, + memo: memo, + coinTypeToSpendFrom: coinTypeToSpendFrom, + ); + } + + final transaction = await buildPayjoinTransaction( + utxos: estimatedTx.utxos, + outputs: outputs, + fee: BigInt.from(estimatedTx.fee), + network: network, + memo: estimatedTx.memo, + outputOrdering: BitcoinOrdering.none, + enableRBF: true, + publicKeys: estimatedTx.publicKeys, + ); + + transaction.psbt.sign(estimatedTx.utxos, + (txDigest, utxo, publicKey, sighash) { + return ''; + }); + + // return transaction.psbt.asPsbtV0(); + return transaction.psbt; + } catch (e, st) { + debugPrint( + '[!] ElectrumWallet => createPayjoinTransaction - e: $e and st: $st'); + throw e; + } + } + @override Future createTransaction(Object credentials) async { try { final outputs = []; - final transactionCredentials = credentials as BitcoinTransactionCredentials; + final transactionCredentials = + credentials as BitcoinTransactionCredentials; final hasMultiDestination = transactionCredentials.outputs.length > 1; - final sendAll = !hasMultiDestination && transactionCredentials.outputs.first.sendAll; + final sendAll = + !hasMultiDestination && transactionCredentials.outputs.first.sendAll; final memo = transactionCredentials.outputs.first.memo; final coinTypeToSpendFrom = transactionCredentials.coinTypeToSpendFrom; @@ -1127,7 +1285,8 @@ abstract class ElectrumWalletBase bool hasTaprootInputs = false; - final transaction = txb.buildTransaction((txDigest, utxo, publicKey, sighash) { + final transaction = + txb.buildTransaction((txDigest, utxo, publicKey, sighash) { String error = "Cannot find private key."; ECPrivateInfo? key; @@ -1181,14 +1340,14 @@ abstract class ElectrumWalletBase transactionHistory.addOne(transaction); if (estimatedTx.spendsSilentPayment) { transactionHistory.transactions.values.forEach((tx) { - tx.unspents?.removeWhere( - (unspent) => estimatedTx.utxos.any((e) => e.utxo.txHash == unspent.hash)); + 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)); + unspentCoins.removeWhere((utxo) => + estimatedTx.utxos.any((e) => e.utxo.txHash == utxo.hash)); await updateBalance(); }); @@ -1197,7 +1356,119 @@ abstract class ElectrumWalletBase } } - void setLedgerConnection(ledger.LedgerConnection connection) => throw UnimplementedError(); + void setLedgerConnection(ledger.LedgerConnection connection) => + throw UnimplementedError(); + + Future psbtToPendingTx( + String preProcessedPsbt, + Object credentials, + ) async { + final outputs = []; + 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; + + 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); + + 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), + )); + } else { + outputs.add(BitcoinOutput( + address: address, + value: BigInt.from(outputAmount), + )); + } + } + + 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, + coinTypeToSpendFrom: coinTypeToSpendFrom, + ); + } else { + estimatedTx = await estimateTxForAmount( + credentialsAmount, + outputs, + updatedOutputs, + feeRateInt, + memo: memo, + coinTypeToSpendFrom: coinTypeToSpendFrom, + ); + } + + final btcTx = await getBtcTransactionFromPsbt(preProcessedPsbt); + + return PendingBitcoinTransaction( + btcTx, + type, + electrumClient: electrumClient, + amount: estimatedTx.amount, + fee: estimatedTx.fee, + feeRate: feeRateInt.toString(), + network: network, + hasChange: estimatedTx.hasChange, + isSendAll: estimatedTx.isSendAll, + hasTaprootInputs: false, // ToDo: (Konsti) Support Taproot + )..addListener( + (transaction) async { + transactionHistory.addOne(transaction); + await updateBalance(); + }, + ); + } + + Future buildPayjoinTransaction({ + required List outputs, + required BigInt fee, + required BasedUtxoNetwork network, + required List utxos, + required Map publicKeys, + String? memo, + bool enableRBF = false, + BitcoinOrdering inputOrdering = BitcoinOrdering.bip69, + BitcoinOrdering outputOrdering = BitcoinOrdering.bip69, + }) async => + throw UnimplementedError(); Future buildHardwareWalletTransaction({ required List outputs, @@ -1212,22 +1483,34 @@ abstract class ElectrumWalletBase }) async => throw UnimplementedError(); + Future signPsbt(String preProcessedPsbt) async => + throw UnimplementedError(); + + Future getBtcTransactionFromPsbt( + String preProcessedPsbt) async => + throw UnimplementedError(); + String toJSON() => json.encode({ 'mnemonic': _mnemonic, 'xpub': xpub, 'passphrase': passphrase ?? '', 'account_index': walletAddresses.currentReceiveAddressIndexByType, 'change_address_index': walletAddresses.currentChangeAddressIndexByType, - 'addresses': walletAddresses.allAddresses.map((addr) => addr.toJSON()).toList(), + 'addresses': + walletAddresses.allAddresses.map((addr) => addr.toJSON()).toList(), 'address_page_type': walletInfo.addressPageType == null ? SegwitAddresType.p2wpkh.toString() : walletInfo.addressPageType.toString(), 'balance': balance[currency]?.toJSON(), 'derivationTypeIndex': walletInfo.derivationInfo?.derivationType?.index, 'derivationPath': walletInfo.derivationInfo?.derivationPath, - 'silent_addresses': walletAddresses.silentAddresses.map((addr) => addr.toJSON()).toList(), - 'silent_address_index': walletAddresses.currentSilentAddressIndex.toString(), - 'mweb_addresses': walletAddresses.mwebAddresses.map((addr) => addr.toJSON()).toList(), + 'silent_addresses': walletAddresses.silentAddresses + .map((addr) => addr.toJSON()) + .toList(), + 'silent_address_index': + walletAddresses.currentSilentAddressIndex.toString(), + 'mweb_addresses': + walletAddresses.mwebAddresses.map((addr) => addr.toJSON()).toList(), 'alwaysScan': alwaysScan, }); @@ -1243,11 +1526,14 @@ abstract class ElectrumWalletBase } } - int feeAmountForPriority(TransactionPriority priority, int inputsCount, int outputsCount, + int feeAmountForPriority( + TransactionPriority priority, int inputsCount, int outputsCount, {int? size}) => - feeRate(priority) * (size ?? estimatedTransactionSize(inputsCount, outputsCount)); + feeRate(priority) * + (size ?? estimatedTransactionSize(inputsCount, outputsCount)); - int feeAmountWithFeeRate(int feeRate, int inputsCount, int outputsCount, {int? size}) => + int feeAmountWithFeeRate(int feeRate, int inputsCount, int outputsCount, + {int? size}) => feeRate * (size ?? estimatedTransactionSize(inputsCount, outputsCount)); @override @@ -1261,7 +1547,8 @@ abstract class ElectrumWalletBase return 0; } - int calculateEstimatedFeeWithFeeRate(int feeRate, int? amount, {int? outputsCount, int? size}) { + int calculateEstimatedFeeWithFeeRate(int feeRate, int? amount, + {int? outputsCount, int? size}) { if (size != null) { return feeAmountWithFeeRate(feeRate, 0, 0, size: size); } @@ -1305,26 +1592,33 @@ abstract class ElectrumWalletBase } final path = await makePath(); - await encryptionFileUtils.write(path: path, password: _password, data: toJSON()); + await encryptionFileUtils.write( + path: path, password: _password, data: toJSON()); await transactionHistory.save(); } @override Future renameWalletFiles(String newWalletName) async { - final currentWalletPath = await pathForWallet(name: walletInfo.name, type: type); + final currentWalletPath = + await pathForWallet(name: walletInfo.name, type: type); final currentWalletFile = File(currentWalletPath); - final currentDirPath = await pathForWalletDir(name: walletInfo.name, type: type); - final currentTransactionsFile = File('$currentDirPath/$transactionsHistoryFileName'); + final currentDirPath = + await pathForWalletDir(name: walletInfo.name, type: type); + final currentTransactionsFile = + File('$currentDirPath/$transactionsHistoryFileName'); // Copies current wallet files into new wallet name's dir and files if (currentWalletFile.existsSync()) { - final newWalletPath = await pathForWallet(name: newWalletName, type: type); + final newWalletPath = + await pathForWallet(name: newWalletName, type: type); await currentWalletFile.copy(newWalletPath); } if (currentTransactionsFile.existsSync()) { - final newDirPath = await pathForWalletDir(name: newWalletName, type: type); - await currentTransactionsFile.copy('$newDirPath/$transactionsHistoryFileName'); + final newDirPath = + await pathForWalletDir(name: newWalletName, type: type); + await currentTransactionsFile + .copy('$newDirPath/$transactionsHistoryFileName'); } // Delete old name's dir and files @@ -1429,11 +1723,13 @@ abstract class ElectrumWalletBase } @action - Future> fetchUnspent(BitcoinAddressRecord address) async { + Future> fetchUnspent( + BitcoinAddressRecord address) async { List> unspents = []; List updatedUnspentCoins = []; - unspents = await electrumClient.getListUnspent(address.getScriptHash(network)); + unspents = + await electrumClient.getListUnspent(address.getScriptHash(network)); await Future.wait(unspents.map((unspent) async { try { @@ -1452,8 +1748,8 @@ abstract class ElectrumWalletBase @action Future addCoinInfo(BitcoinUnspent coin) async { // Check if the coin is already in the unspentCoinsInfo for the wallet - final existingCoinInfo = unspentCoinsInfo.values - .firstWhereOrNull((element) => element.walletId == walletInfo.id && element == coin); + final existingCoinInfo = unspentCoinsInfo.values.firstWhereOrNull( + (element) => element.walletId == walletInfo.id && element == coin); if (existingCoinInfo == null) { final newInfo = UnspentCoinsInfo( @@ -1480,7 +1776,8 @@ abstract class ElectrumWalletBase unspentCoinsInfo.values.where((record) => record.walletId == id); for (final element in currentWalletUnspentCoins) { - if (RegexUtils.addressTypeFromStr(element.address, network) is MwebAddress) continue; + if (RegexUtils.addressTypeFromStr(element.address, network) + is MwebAddress) continue; final existUnspentCoins = unspentCoins.where((coin) => element == coin); @@ -1512,17 +1809,21 @@ abstract class ElectrumWalletBase } } - if (duplicateKeys.isNotEmpty) await unspentCoinsInfo.deleteAll(duplicateKeys); + if (duplicateKeys.isNotEmpty) + await unspentCoinsInfo.deleteAll(duplicateKeys); } - int transactionVSize(String transactionHex) => BtcTransaction.fromRaw(transactionHex).getVSize(); + int transactionVSize(String transactionHex) => + BtcTransaction.fromRaw(transactionHex).getVSize(); Future canReplaceByFee(ElectrumTransactionInfo tx) async { try { final bundle = await getTransactionExpanded(hash: tx.txHash); _updateInputsAndOutputs(tx, bundle); if (bundle.confirmations > 0) return null; - return bundle.originalTransaction.canReplaceByFee ? bundle.originalTransaction.toHex() : null; + return bundle.originalTransaction.canReplaceByFee + ? bundle.originalTransaction.toHex() + : null; } catch (e) { return null; } @@ -1532,18 +1833,20 @@ abstract class ElectrumWalletBase final bundle = await getTransactionExpanded(hash: txId); final outputs = bundle.originalTransaction.outputs; - final ownAddresses = walletAddresses.allAddresses.map((addr) => addr.address).toSet(); + final ownAddresses = + walletAddresses.allAddresses.map((addr) => addr.address).toSet(); final receiverAmount = outputs - .where((output) => - !ownAddresses.contains(addressFromOutputScript(output.scriptPubKey, network))) + .where((output) => !ownAddresses + .contains(addressFromOutputScript(output.scriptPubKey, network))) .fold(0, (sum, output) => sum + output.amount.toInt()); if (receiverAmount == 0) { throw Exception("Receiver output not found."); } - final availableInputs = unspentCoins.where((utxo) => utxo.isSending && !utxo.isFrozen).toList(); + final availableInputs = + unspentCoins.where((utxo) => utxo.isSending && !utxo.isFrozen).toList(); int totalBalance = availableInputs.fold( 0, (previousValue, element) => previousValue + element.value.toInt()); @@ -1556,15 +1859,16 @@ abstract class ElectrumWalletBase allInputsAmount += outTransaction.amount.toInt(); } - int totalOutAmount = bundle.originalTransaction.outputs - .fold(0, (previousValue, element) => previousValue + element.amount.toInt()); + int totalOutAmount = bundle.originalTransaction.outputs.fold( + 0, (previousValue, element) => previousValue + element.amount.toInt()); var currentFee = allInputsAmount - totalOutAmount; int remainingFee = (newFee - currentFee > 0) ? newFee - currentFee : newFee; return totalBalance - receiverAmount - remainingFee >= _dustAmount; } - Future replaceByFee(String hash, int newFee) async { + Future replaceByFee( + String hash, int newFee) async { try { final bundle = await getTransactionExpanded(hash: hash); @@ -1581,14 +1885,18 @@ abstract class ElectrumWalletBase final inputTransaction = bundle.ins[i]; final vout = input.txIndex; final outTransaction = inputTransaction.outputs[vout]; - final address = addressFromOutputScript(outTransaction.scriptPubKey, network); + final address = + addressFromOutputScript(outTransaction.scriptPubKey, network); allInputsAmount += outTransaction.amount.toInt(); - final addressRecord = - walletAddresses.allAddresses.firstWhere((element) => element.address == address); - final btcAddress = RegexUtils.addressTypeFromStr(addressRecord.address, network); + final addressRecord = walletAddresses.allAddresses + .firstWhere((element) => element.address == address); + final btcAddress = + RegexUtils.addressTypeFromStr(addressRecord.address, network); final privkey = generateECPrivate( - hd: addressRecord.isHidden ? walletAddresses.sideHd : walletAddresses.mainHd, + hd: addressRecord.isHidden + ? walletAddresses.sideHd + : walletAddresses.mainHd, index: addressRecord.index, network: network); @@ -1602,8 +1910,8 @@ abstract class ElectrumWalletBase vout: vout, scriptType: _getScriptType(btcAddress), ), - ownerDetails: - UtxoAddressDetails(publicKey: privkey.getPublic().toHex(), address: btcAddress), + ownerDetails: UtxoAddressDetails( + publicKey: privkey.getPublic().toHex(), address: btcAddress), ), ); } @@ -1626,12 +1934,13 @@ abstract class ElectrumWalletBase final address = addressFromOutputScript(out.scriptPubKey, 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()))); } // Calculate the total amount and fees - int totalOutAmount = - outputs.fold(0, (previousValue, output) => previousValue + output.value.toInt()); + int totalOutAmount = outputs.fold( + 0, (previousValue, output) => previousValue + output.value.toInt()); int currentFee = allInputsAmount - totalOutAmount; int remainingFee = newFee - currentFee; @@ -1641,11 +1950,12 @@ abstract class ElectrumWalletBase // Deduct fee from change outputs first, if possible if (remainingFee > 0) { - final changeAddresses = walletAddresses.allAddresses.where((element) => element.isHidden); + final changeAddresses = + walletAddresses.allAddresses.where((element) => element.isHidden); for (int i = outputs.length - 1; i >= 0; i--) { final output = outputs[i]; - final isChange = changeAddresses - .any((element) => element.address == output.address.toAddress(network)); + final isChange = changeAddresses.any((element) => + element.address == output.address.toAddress(network)); if (isChange) { int outputAmount = output.value.toInt(); @@ -1654,7 +1964,8 @@ abstract class ElectrumWalletBase ? remainingFee : outputAmount - _dustAmount; outputs[i] = BitcoinOutput( - address: output.address, value: BigInt.from(outputAmount - deduction)); + address: output.address, + value: BigInt.from(outputAmount - deduction)); remainingFee -= deduction; if (remainingFee <= 0) break; @@ -1666,7 +1977,8 @@ abstract class ElectrumWalletBase // If still not enough, add UTXOs until the fee is covered if (remainingFee > 0) { final unusedUtxos = unspentCoins - .where((utxo) => utxo.isSending && !utxo.isFrozen && utxo.confirmations! > 0) + .where((utxo) => + utxo.isSending && !utxo.isFrozen && utxo.confirmations! > 0) .toList(); for (final utxo in unusedUtxos) { @@ -1686,24 +1998,26 @@ abstract class ElectrumWalletBase value: BigInt.from(utxo.value), vout: utxo.vout, scriptType: _getScriptType(address)), - ownerDetails: - UtxoAddressDetails(publicKey: privkey.getPublic().toHex(), address: address), + ownerDetails: UtxoAddressDetails( + publicKey: privkey.getPublic().toHex(), address: address), )); allInputsAmount += utxo.value; remainingFee -= utxo.value; if (remainingFee < 0) { - final changeOutput = outputs.firstWhereOrNull((output) => walletAddresses.allAddresses - .any((addr) => addr.address == output.address.toAddress(network))); + final changeOutput = outputs.firstWhereOrNull((output) => + walletAddresses.allAddresses.any((addr) => + addr.address == output.address.toAddress(network))); if (changeOutput != null) { final newValue = changeOutput.value.toInt() + (-remainingFee); - outputs[outputs.indexOf(changeOutput)] = - BitcoinOutput(address: changeOutput.address, value: BigInt.from(newValue)); + outputs[outputs.indexOf(changeOutput)] = BitcoinOutput( + address: changeOutput.address, value: BigInt.from(newValue)); } else { final changeAddress = await walletAddresses.getChangeAddress(); outputs.add(BitcoinOutput( - address: RegexUtils.addressTypeFromStr(changeAddress.address, network), + address: RegexUtils.addressTypeFromStr( + changeAddress.address, network), value: BigInt.from(-remainingFee))); } @@ -1727,7 +2041,8 @@ abstract class ElectrumWalletBase : outputAmount - _dustAmount; outputs[i] = BitcoinOutput( - address: output.address, value: BigInt.from(outputAmount - deduction)); + address: output.address, + value: BigInt.from(outputAmount - deduction)); remainingFee -= deduction; if (remainingFee <= 0) break; @@ -1741,14 +2056,15 @@ abstract class ElectrumWalletBase } // Identify all change outputs - final changeAddresses = walletAddresses.allAddresses.where((element) => element.isHidden); + final changeAddresses = + walletAddresses.allAddresses.where((element) => element.isHidden); final List changeOutputs = outputs - .where((output) => changeAddresses - .any((element) => element.address == output.address.toAddress(network))) + .where((output) => changeAddresses.any((element) => + element.address == output.address.toAddress(network))) .toList(); - int totalChangeAmount = - changeOutputs.fold(0, (sum, output) => sum + output.value.toInt()); + int totalChangeAmount = changeOutputs.fold( + 0, (sum, output) => sum + output.value.toInt()); // The final amount that the receiver will receive int sendingAmount = allInputsAmount - newFee - totalChangeAmount; @@ -1763,9 +2079,10 @@ abstract class ElectrumWalletBase enableRBF: true, ); - final transaction = txb.buildTransaction((txDigest, utxo, publicKey, sighash) { - final key = - privateKeys.firstWhereOrNull((element) => element.getPublic().toHex() == publicKey); + final transaction = + txb.buildTransaction((txDigest, utxo, publicKey, sighash) { + final key = privateKeys.firstWhereOrNull( + (element) => element.getPublic().toHex() == publicKey); if (key == null) { throw Exception("Cannot find private key"); } @@ -1808,7 +2125,8 @@ abstract class ElectrumWalletBase int? time; int? confirmations; - final verboseTransaction = await electrumClient.getTransactionVerbose(hash: hash); + final verboseTransaction = + await electrumClient.getTransactionVerbose(hash: hash); if (verboseTransaction.isEmpty) { transactionHex = await electrumClient.getTransactionHex(hash: hash); @@ -1832,7 +2150,8 @@ abstract class ElectrumWalletBase if (blockResponse.statusCode == 200 && blockResponse.body.isNotEmpty && jsonDecode(blockResponse.body)['timestamp'] != null) { - time = int.parse(jsonDecode(blockResponse.body)['timestamp'].toString()); + time = int.parse( + jsonDecode(blockResponse.body)['timestamp'].toString()); } } } catch (_) {} @@ -1845,7 +2164,8 @@ abstract class ElectrumWalletBase if (height != null) { if (time == null && height > 0) { - time = (getDateByBitcoinHeight(height).millisecondsSinceEpoch / 1000).round(); + time = (getDateByBitcoinHeight(height).millisecondsSinceEpoch / 1000) + .round(); } if (confirmations == null) { @@ -1861,12 +2181,14 @@ abstract class ElectrumWalletBase final ins = []; for (final vin in original.inputs) { - final verboseTransaction = await electrumClient.getTransactionVerbose(hash: vin.txId); + final verboseTransaction = + await electrumClient.getTransactionVerbose(hash: vin.txId); final String inputTransactionHex; if (verboseTransaction.isEmpty) { - inputTransactionHex = await electrumClient.getTransactionHex(hash: hash); + inputTransactionHex = + await electrumClient.getTransactionHex(hash: hash); } else { inputTransactionHex = verboseTransaction['hex'] as String; } @@ -1901,30 +2223,41 @@ abstract class ElectrumWalletBase } } + bool isMine(Script script) { + final res = ElectrumTransactionInfo.isMine( + script, + network, + addresses: addressesSet, + ); + return res; + } + @override Future> fetchTransactions() async { try { final Map historiesWithDetails = {}; if (type == WalletType.bitcoin) { - await Future.wait(BITCOIN_ADDRESS_TYPES - .map((type) => fetchTransactionsForAddressType(historiesWithDetails, type))); + await Future.wait(BITCOIN_ADDRESS_TYPES.map((type) => + fetchTransactionsForAddressType(historiesWithDetails, type))); } else if (type == WalletType.bitcoinCash) { - await Future.wait(BITCOIN_CASH_ADDRESS_TYPES - .map((type) => fetchTransactionsForAddressType(historiesWithDetails, type))); + await Future.wait(BITCOIN_CASH_ADDRESS_TYPES.map((type) => + fetchTransactionsForAddressType(historiesWithDetails, type))); } else if (type == WalletType.litecoin) { await Future.wait(LITECOIN_ADDRESS_TYPES .where((type) => type != SegwitAddresType.mweb) - .map((type) => fetchTransactionsForAddressType(historiesWithDetails, type))); + .map((type) => + fetchTransactionsForAddressType(historiesWithDetails, type))); } transactionHistory.transactions.values.forEach((tx) async { final isPendingSilentPaymentUtxo = - (tx.isPending || tx.confirmations == 0) && historiesWithDetails[tx.id] == null; + (tx.isPending || tx.confirmations == 0) && + historiesWithDetails[tx.id] == null; if (isPendingSilentPaymentUtxo) { - final info = - await fetchTransactionInfo(hash: tx.id, height: tx.height, retryOnFailure: true); + final info = await fetchTransactionInfo( + hash: tx.id, height: tx.height, retryOnFailure: true); if (info != null) { tx.confirmations = info.confirmations; @@ -1946,20 +2279,28 @@ abstract class ElectrumWalletBase Map historiesWithDetails, BitcoinAddressType type, ) async { - final addressesByType = walletAddresses.allAddresses.where((addr) => addr.type == type); - final hiddenAddresses = addressesByType.where((addr) => addr.isHidden == true); - final receiveAddresses = addressesByType.where((addr) => addr.isHidden == false); - walletAddresses.hiddenAddresses.addAll(hiddenAddresses.map((e) => e.address)); + final addressesByType = + walletAddresses.allAddresses.where((addr) => addr.type == type); + final hiddenAddresses = + addressesByType.where((addr) => addr.isHidden == true); + 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 { - final history = await _fetchAddressHistory(addressRecord, await getCurrentChainTip()); + final history = + await _fetchAddressHistory(addressRecord, await getCurrentChainTip()); if (history.isNotEmpty) { addressRecord.txCount = history.length; historiesWithDetails.addAll(history); - final matchedAddresses = addressRecord.isHidden ? hiddenAddresses : receiveAddresses; - final isUsedAddressUnderGap = matchedAddresses.toList().indexOf(addressRecord) >= + final matchedAddresses = + addressRecord.isHidden ? hiddenAddresses : receiveAddresses; + final isUsedAddressUnderGap = matchedAddresses + .toList() + .indexOf(addressRecord) >= matchedAddresses.length - (addressRecord.isHidden ? ElectrumWalletAddressesBase.defaultChangeAddressesCount @@ -1975,7 +2316,8 @@ abstract class ElectrumWalletBase (address) async { await subscribeForUpdates(); return _fetchAddressHistory(address, await getCurrentChainTip()) - .then((history) => history.isNotEmpty ? address.address : null); + .then( + (history) => history.isNotEmpty ? address.address : null); }, type: type, ); @@ -1997,7 +2339,8 @@ abstract class ElectrumWalletBase try { final Map historiesWithDetails = {}; - final history = await electrumClient.getHistory(addressRecord.getScriptHash(network)); + final history = + await electrumClient.getHistory(addressRecord.getScriptHash(network)); if (history.isNotEmpty) { addressRecord.setAsUsed(); @@ -2019,7 +2362,8 @@ abstract class ElectrumWalletBase historiesWithDetails[txid] = storedTx; } else { - final tx = await fetchTransactionInfo(hash: txid, height: height, retryOnFailure: true); + final tx = await fetchTransactionInfo( + hash: txid, height: height, retryOnFailure: true); if (tx != null) { historiesWithDetails[txid] = tx; @@ -2031,7 +2375,8 @@ abstract class ElectrumWalletBase // if we have a peg out transaction with the same value // that matches this received transaction, mark it as being from a peg out: for (final tx2 in transactionHistory.transactions.values) { - final heightDiff = ((tx2.height ?? 0) - (tx.height ?? 0)).abs(); + final heightDiff = + ((tx2.height ?? 0) - (tx.height ?? 0)).abs(); // this isn't a perfect matching algorithm since we don't have the right input/output information from these transaction models (the addresses are in different formats), but this should be more than good enough for now as it's extremely unlikely a user receives the EXACT same amount from 2 different sources and one of them is a peg out and the other isn't WITHIN 5 blocks of each other if (tx2.additionalInfo["isPegOut"] == true && tx2.amount == tx.amount && @@ -2102,7 +2447,8 @@ abstract class ElectrumWalletBase Future subscribeForUpdates() async { final unsubscribedScriptHashes = walletAddresses.allAddresses.where( (address) => - !_scripthashesUpdateSubject.containsKey(address.getScriptHash(network)) && + !_scripthashesUpdateSubject + .containsKey(address.getScriptHash(network)) && address.type != SegwitAddresType.mweb, ); @@ -2116,7 +2462,8 @@ abstract class ElectrumWalletBase } } try { - _scripthashesUpdateSubject[sh] = await electrumClient.scripthashUpdate(sh); + _scripthashesUpdateSubject[sh] = + await electrumClient.scripthashUpdate(sh); } catch (e) { printV("failed scripthashUpdate: $e"); } @@ -2143,7 +2490,9 @@ abstract class ElectrumWalletBase Future fetchBalances() async { final addresses = walletAddresses.allAddresses - .where((address) => RegexUtils.addressTypeFromStr(address.address, network) is! MwebAddress) + .where((address) => + RegexUtils.addressTypeFromStr(address.address, network) + is! MwebAddress) .toList(); final balanceFutures = >>[]; for (var i = 0; i < addresses.length; i++) { @@ -2175,7 +2524,8 @@ abstract class ElectrumWalletBase transactionHistory.transactions.values.forEach((tx) { if (tx.unspents != null) { tx.unspents!.forEach((unspent) { - if (unspent.bitcoinAddressRecord is BitcoinSilentPaymentAddressRecord) { + if (unspent.bitcoinAddressRecord + is BitcoinSilentPaymentAddressRecord) { if (unspent.isFrozen) totalFrozen += unspent.value; totalConfirmed += unspent.value; } @@ -2188,9 +2538,11 @@ abstract class ElectrumWalletBase if (balances.isNotEmpty && balances.first['confirmed'] == null) { // if we got null balance responses from the server, set our connection status to lost and return our last known balance: - printV("got null balance responses from the server, setting connection status to lost"); + printV( + "got null balance responses from the server, setting connection status to lost"); syncStatus = LostConnectionSyncStatus(); - return balance[currency] ?? ElectrumBalance(confirmed: 0, unconfirmed: 0, frozen: 0); + return balance[currency] ?? + ElectrumBalance(confirmed: 0, unconfirmed: 0, frozen: 0); } for (var i = 0; i < balances.length; i++) { @@ -2221,24 +2573,29 @@ abstract class ElectrumWalletBase } @override - void setExceptionHandler(void Function(FlutterErrorDetails) onError) => _onError = onError; + void setExceptionHandler(void Function(FlutterErrorDetails) onError) => + _onError = onError; @override Future signMessage(String message, {String? address = null}) async { final index = address != null - ? walletAddresses.allAddresses.firstWhere((element) => element.address == address).index + ? walletAddresses.allAddresses + .firstWhere((element) => element.address == address) + .index : null; final HD = index == null ? hd : hd.childKey(Bip32KeyIndex(index)); final priv = ECPrivate.fromHex(HD.privateKey.privKey.toHex()); String messagePrefix = '\x18Bitcoin Signed Message:\n'; - final hexEncoded = priv.signMessage(utf8.encode(message), messagePrefix: messagePrefix); + final hexEncoded = + priv.signMessage(utf8.encode(message), messagePrefix: messagePrefix); final decodedSig = hex.decode(hexEncoded); return base64Encode(decodedSig); } @override - Future verifyMessage(String message, String signature, {String? address = null}) async { + Future verifyMessage(String message, String signature, + {String? address = null}) async { if (address == null) { return false; } @@ -2260,18 +2617,21 @@ abstract class ElectrumWalletBase final messageHash = QuickCrypto.sha256Hash( BitcoinSignerUtils.magicMessage(utf8.encode(message), messagePrefix)); - List correctSignature = - sigDecodedBytes.length == 65 ? sigDecodedBytes.sublist(1) : List.from(sigDecodedBytes); + List correctSignature = sigDecodedBytes.length == 65 + ? sigDecodedBytes.sublist(1) + : List.from(sigDecodedBytes); List rBytes = correctSignature.sublist(0, 32); List sBytes = correctSignature.sublist(32); - final sig = ECDSASignature(BigintUtils.fromBytes(rBytes), BigintUtils.fromBytes(sBytes)); + final sig = ECDSASignature( + BigintUtils.fromBytes(rBytes), BigintUtils.fromBytes(sBytes)); List possibleRecoverIds = [0, 1]; final baseAddress = RegexUtils.addressTypeFromStr(address, network); for (int recoveryId in possibleRecoverIds) { - final pubKey = sig.recoverPublicKey(messageHash, Curves.generatorSecp256k1, recoveryId); + final pubKey = sig.recoverPublicKey( + messageHash, Curves.generatorSecp256k1, recoveryId); final recoveredPub = ECPublic.fromBytes(pubKey!.toBytes()); @@ -2300,7 +2660,8 @@ abstract class ElectrumWalletBase currentChainTip = await getUpdatedChainTip(); - if ((currentChainTip == null || currentChainTip! == 0) && walletInfo.restoreHeight == 0) { + if ((currentChainTip == null || currentChainTip! == 0) && + walletInfo.restoreHeight == 0) { await walletInfo.updateRestoreHeight(currentChainTip!); } @@ -2360,7 +2721,8 @@ abstract class ElectrumWalletBase return; } - if (syncStatus is NotConnectedSyncStatus || syncStatus is LostConnectionSyncStatus) { + if (syncStatus is NotConnectedSyncStatus || + syncStatus is LostConnectionSyncStatus) { // Needs to re-subscribe to all scripthashes when reconnected _scripthashesUpdateSubject = {}; @@ -2383,13 +2745,16 @@ abstract class ElectrumWalletBase // Message is shown on the UI for 3 seconds, revert to synced if (syncStatus is SyncedTipSyncStatus) { Timer(Duration(seconds: 3), () { - if (this.syncStatus is SyncedTipSyncStatus) this.syncStatus = SyncedSyncStatus(); + if (this.syncStatus is SyncedTipSyncStatus) + this.syncStatus = SyncedSyncStatus(); }); } } - void _updateInputsAndOutputs(ElectrumTransactionInfo tx, ElectrumTransactionBundle bundle) { - tx.inputAddresses = tx.inputAddresses?.where((address) => address.isNotEmpty).toList(); + void _updateInputsAndOutputs( + ElectrumTransactionInfo tx, ElectrumTransactionBundle bundle) { + tx.inputAddresses = + tx.inputAddresses?.where((address) => address.isNotEmpty).toList(); if (tx.inputAddresses == null || tx.inputAddresses!.isEmpty || @@ -2403,7 +2768,8 @@ abstract class ElectrumWalletBase final inputTransaction = bundle.ins[i]; final vout = input.txIndex; final outTransaction = inputTransaction.outputs[vout]; - final address = addressFromOutputScript(outTransaction.scriptPubKey, network); + final address = + addressFromOutputScript(outTransaction.scriptPubKey, network); if (address.isNotEmpty) inputAddresses.add(address); } @@ -2527,7 +2893,8 @@ Future startRefresh(ScanData scanData) async { // Initial status UI update, send how many blocks in total to scan final initialCount = getCountPerRequest(syncHeight); - scanData.sendPort.send(SyncResponse(syncHeight, StartingScanSyncStatus(syncHeight))); + scanData.sendPort + .send(SyncResponse(syncHeight, StartingScanSyncStatus(syncHeight))); tweaksSubscription = await electrumClient.tweaksSubscribe( height: syncHeight, @@ -2561,7 +2928,8 @@ Future startRefresh(ScanData scanData) async { // Continuous status UI update, send how many blocks left to scan final syncingStatus = scanData.isSingleScan ? SyncingSyncStatus(1, 0) - : SyncingSyncStatus.fromHeightValues(scanData.chainTip, initialSyncHeight, syncHeight); + : SyncingSyncStatus.fromHeightValues( + scanData.chainTip, initialSyncHeight, syncHeight); scanData.sendPort.send(SyncResponse(syncHeight, syncingStatus)); final blockHeight = tweaks.keys.first; @@ -2573,7 +2941,8 @@ Future startRefresh(ScanData scanData) async { for (var j = 0; j < blockTweaks.keys.length; j++) { final txid = blockTweaks.keys.elementAt(j); final details = blockTweaks[txid] as Map; - final outputPubkeys = (details["output_pubkeys"] as Map); + final outputPubkeys = + (details["output_pubkeys"] as Map); final tweak = details["tweak"].toString(); try { @@ -2702,7 +3071,8 @@ class EstimatedTxResult { final List utxos; final List inputPrivKeyInfos; - final Map publicKeys; // PubKey to derivationPath + final Map + publicKeys; // PubKey to derivationPath final int fee; final int amount; final bool spendsSilentPayment; @@ -2745,7 +3115,8 @@ class UtxoDetails { final List utxos; final List vinOutpoints; final List inputPrivKeyInfos; - final Map publicKeys; // PubKey to derivationPath + final Map + publicKeys; // PubKey to derivationPath final int allInputsAmount; final bool spendsSilentPayment; final bool spendsUnconfirmedTX; diff --git a/cw_bitcoin/lib/map_extension.dart b/cw_bitcoin/lib/map_extension.dart new file mode 100644 index 0000000000..940ad3f9cb --- /dev/null +++ b/cw_bitcoin/lib/map_extension.dart @@ -0,0 +1,38 @@ +import 'dart:typed_data'; + +import 'package:convert/convert.dart'; +import 'package:ledger_bitcoin/src/psbt/keypair.dart'; +import 'package:ledger_bitcoin/src/utils/buffer_writer.dart'; + +extension PsbtGetAndSet on Map { + Uint8List? get( + int keyType, + Uint8List keyData, [ + bool acceptUndefined = false, + ]) { + final key = Key(keyType, keyData); + final value = this[key.toString()]; + if (value == null && !acceptUndefined) { + throw Exception(key.toString()); + } + // Make sure to return a copy, to protect the underlying data. + return value as Uint8List?; + } + + void set(int keyType, Uint8List keyData, Uint8List value) { + final key = Key(keyType, keyData); + this[key.toString()] = value; + } + + void serializeMap(BufferWriter buf) { + for (final k in keys) { + final value = this[k] as Uint8List; + final keyPair = + KeyPair(_createKey(hex.decode(k.toString()) as Uint8List), value); + keyPair.serialize(buf); + } + buf.writeUInt8(0); + } + + Key _createKey(Uint8List buf) => Key(buf[0], buf.sublist(1)); +} diff --git a/cw_bitcoin/lib/psbt_converter.dart b/cw_bitcoin/lib/psbt_converter.dart new file mode 100644 index 0000000000..045293d9b1 --- /dev/null +++ b/cw_bitcoin/lib/psbt_converter.dart @@ -0,0 +1,79 @@ +import "dart:typed_data"; + +import "package:ledger_bitcoin/src/psbt/constants.dart"; +import "package:ledger_bitcoin/src/psbt/psbtv2.dart"; +import "package:ledger_bitcoin/src/utils/buffer_writer.dart"; +import "package:cw_bitcoin/map_extension.dart"; + +extension V0Serializer on PsbtV2 { + Uint8List asPsbtV0() { + final excludedGlobalKeyTypes = [ + PSBTGlobal.txVersion, + PSBTGlobal.fallbackLocktime, + PSBTGlobal.inputCount, + PSBTGlobal.outputCount, + PSBTGlobal.txModifiable, + ].map((e) => e.value.toString()); + + final excludedInputKeyTypes = [ + PSBTIn.previousTXID, + PSBTIn.outputIndex, + PSBTIn.sequence, + ].map((e) => e.value.toString()); + + final excludedOutputKeyTypes = [ + PSBTOut.amount, + PSBTOut.script, + ].map((e) => e.value.toString()); + + final buf = BufferWriter()..writeSlice(psbtMagicBytes); + + setGlobalPsbtVersion(0); + final sGlobalMap = Map.from(globalMap) + ..removeWhere((k, v) => excludedGlobalKeyTypes.contains(k)); + + sGlobalMap["00"] = extractUnsignedTX(); + + sGlobalMap.serializeMap(buf); + for (final map in inputMaps) { + final sMap = Map.from(map) + ..removeWhere((k, v) => excludedInputKeyTypes.contains(k)); + sMap.serializeMap(buf); + } + for (final map in outputMaps) { + final sMap = Map.from(map) + ..removeWhere((k, v) => excludedOutputKeyTypes.contains(k)); + sMap.serializeMap(buf); + } + return buf.buffer(); + } + + Uint8List extractUnsignedTX() { + final tx = BufferWriter()..writeUInt32(getGlobalTxVersion()); + + final isSegwit = getInputWitnessUtxo(0) != null; + if (isSegwit) { + tx.writeSlice(Uint8List.fromList([0, 1])); + } + + final inputCount = getGlobalInputCount(); + tx.writeVarInt(inputCount); + + for (var i = 0; i < inputCount; i++) { + tx + ..writeSlice(getInputPreviousTxid(i)) + ..writeUInt32(getInputOutputIndex(i)) + ..writeVarSlice(Uint8List(0)) + ..writeUInt32(getInputSequence(i)); + } + + final outputCount = getGlobalOutputCount(); + tx.writeVarInt(outputCount); + for (var i = 0; i < outputCount; i++) { + tx.writeUInt64(getOutputAmount(i)); + tx.writeVarSlice(getOutputScript(i)); + } + tx.writeUInt32(getGlobalFallbackLocktime() ?? 0); + return tx.buffer(); + } +} diff --git a/cw_bitcoin/lib/psbt_signer.dart b/cw_bitcoin/lib/psbt_signer.dart new file mode 100644 index 0000000000..80dd890629 --- /dev/null +++ b/cw_bitcoin/lib/psbt_signer.dart @@ -0,0 +1,120 @@ +import 'dart:typed_data'; + +import 'package:bitcoin_base/bitcoin_base.dart'; +import 'package:blockchain_utils/blockchain_utils.dart'; +import 'package:ledger_bitcoin/psbt.dart'; +import 'package:ledger_bitcoin/src/psbt/constants.dart'; + +extension PsbtSigner on PsbtV2 { + void sign(List utxos, BitcoinSignerCallBack signer) { + final int inputsSize = getGlobalInputCount(); + final raw = hex.encode(extractUnsignedTX()); + print('[+] PsbtSigner | sign => raw: $raw'); + final tx = BtcTransaction.fromRaw(raw); + + /// when the transaction is taproot and we must use getTaproot tansaction digest + /// we need all of inputs amounts and owner script pub keys + List taprootAmounts = []; + List