From 7af2fea977e41af1c4905a7e49a6069874cd323e Mon Sep 17 00:00:00 2001 From: julian Date: Sun, 14 Jan 2024 13:03:07 -0600 Subject: [PATCH] paynymn fixes and clean up --- lib/db/migrate_wallets_to_isar.dart | 6 + .../models/blockchain_data/v2/input_v2.dart | 34 ++ lib/utilities/paynym_is_api.dart | 11 +- lib/wallets/wallet/impl/bitcoin_wallet.dart | 267 --------------- lib/wallets/wallet/impl/firo_wallet.dart | 1 + .../electrumx_interface.dart | 45 ++- .../paynym_interface.dart | 312 +++++++++++++++++- 7 files changed, 387 insertions(+), 289 deletions(-) diff --git a/lib/db/migrate_wallets_to_isar.dart b/lib/db/migrate_wallets_to_isar.dart index 5d2856f51..8d6af3523 100644 --- a/lib/db/migrate_wallets_to_isar.dart +++ b/lib/db/migrate_wallets_to_isar.dart @@ -4,6 +4,7 @@ import 'package:hive_flutter/hive_flutter.dart'; import 'package:isar/isar.dart'; import 'package:stackwallet/db/hive/db.dart'; import 'package:stackwallet/db/isar/main_db.dart'; +import 'package:stackwallet/models/isar/models/blockchain_data/v2/transaction_v2.dart'; import 'package:stackwallet/models/isar/models/isar_models.dart'; import 'package:stackwallet/utilities/enums/coin_enum.dart'; import 'package:stackwallet/utilities/flutter_secure_storage_interface.dart'; @@ -16,6 +17,11 @@ Future migrateWalletsToIsar({ required SecureStorageInterface secureStore, }) async { await MainDB.instance.initMainDB(); + + // ensure fresh + await MainDB.instance.isar + .writeTxn(() async => await MainDB.instance.isar.transactionV2s.clear()); + final allWalletsBox = await Hive.openBox(DB.boxNameAllWalletsData); final names = DB.instance diff --git a/lib/models/isar/models/blockchain_data/v2/input_v2.dart b/lib/models/isar/models/blockchain_data/v2/input_v2.dart index 9e0d715f6..7442bd2d4 100644 --- a/lib/models/isar/models/blockchain_data/v2/input_v2.dart +++ b/lib/models/isar/models/blockchain_data/v2/input_v2.dart @@ -1,3 +1,5 @@ +import 'dart:convert'; + import 'package:isar/isar.dart'; part 'input_v2.g.dart'; @@ -86,6 +88,38 @@ class InputV2 { ..coinbase = coinbase ..walletOwns = walletOwns; + static InputV2 fromElectrumxJson({ + required Map json, + required OutpointV2? outpoint, + required List addresses, + required String valueStringSats, + required String? coinbase, + required bool walletOwns, + }) { + final dynamicWitness = json["witness"] ?? json["txinwitness"]; + + final String? witness; + if (dynamicWitness is Map || dynamicWitness is List) { + witness = jsonEncode(dynamicWitness); + } else if (dynamicWitness is String) { + witness = dynamicWitness; + } else { + witness = null; + } + + return InputV2() + ..scriptSigHex = json["scriptSig"]?["hex"] as String? + ..scriptSigAsm = json["scriptSig"]?["asm"] as String? + ..sequence = json["sequence"] as int? + ..outpoint = outpoint + ..addresses = List.unmodifiable(addresses) + ..valueStringSats = valueStringSats + ..witness = witness + ..innerRedeemScriptAsm = json["innerRedeemscriptAsm"] as String? + ..coinbase = coinbase + ..walletOwns = walletOwns; + } + InputV2 copyWith({ String? scriptSigHex, String? scriptSigAsm, diff --git a/lib/utilities/paynym_is_api.dart b/lib/utilities/paynym_is_api.dart index 1393af4f1..3d02e67d1 100644 --- a/lib/utilities/paynym_is_api.dart +++ b/lib/utilities/paynym_is_api.dart @@ -10,7 +10,6 @@ import 'dart:convert'; -import 'package:flutter/cupertino.dart'; import 'package:stackwallet/models/paynym/created_paynym.dart'; import 'package:stackwallet/models/paynym/paynym_account.dart'; import 'package:stackwallet/models/paynym/paynym_claim.dart'; @@ -57,11 +56,11 @@ class PaynymIsApi { : null, ); - debugPrint("Paynym request uri: $uri"); - debugPrint("Paynym request body: $body"); - debugPrint("Paynym request headers: $headers"); - debugPrint("Paynym response code: ${response.code}"); - debugPrint("Paynym response body: ${response.body}"); + // debugPrint("Paynym request uri: $uri"); + // debugPrint("Paynym request body: $body"); + // debugPrint("Paynym request headers: $headers"); + // debugPrint("Paynym response code: ${response.code}"); + // debugPrint("Paynym response body: ${response.body}"); return Tuple2( jsonDecode(response.body) as Map, diff --git a/lib/wallets/wallet/impl/bitcoin_wallet.dart b/lib/wallets/wallet/impl/bitcoin_wallet.dart index 7a7e352e6..15fd58fcc 100644 --- a/lib/wallets/wallet/impl/bitcoin_wallet.dart +++ b/lib/wallets/wallet/impl/bitcoin_wallet.dart @@ -1,13 +1,6 @@ -import 'package:bip47/src/util.dart'; import 'package:isar/isar.dart'; import 'package:stackwallet/models/isar/models/blockchain_data/address.dart'; -import 'package:stackwallet/models/isar/models/blockchain_data/transaction.dart'; -import 'package:stackwallet/models/isar/models/blockchain_data/v2/input_v2.dart'; -import 'package:stackwallet/models/isar/models/blockchain_data/v2/output_v2.dart'; -import 'package:stackwallet/models/isar/models/blockchain_data/v2/transaction_v2.dart'; import 'package:stackwallet/utilities/amount/amount.dart'; -import 'package:stackwallet/utilities/extensions/extensions.dart'; -import 'package:stackwallet/utilities/logger.dart'; import 'package:stackwallet/wallets/crypto_currency/coins/bitcoin.dart'; import 'package:stackwallet/wallets/crypto_currency/crypto_currency.dart'; import 'package:stackwallet/wallets/crypto_currency/interfaces/paynym_currency_interface.dart'; @@ -51,266 +44,6 @@ class BitcoinWallet extends Bip39HDWallet // =========================================================================== - @override - Future updateTransactions() async { - // Get all addresses. - List
allAddressesOld = await fetchAddressesForElectrumXScan(); - - // Separate receiving and change addresses. - Set receivingAddresses = allAddressesOld - .where((e) => e.subType == AddressSubType.receiving) - .map((e) => e.value) - .toSet(); - Set changeAddresses = allAddressesOld - .where((e) => e.subType == AddressSubType.change) - .map((e) => e.value) - .toSet(); - - // Remove duplicates. - final allAddressesSet = {...receivingAddresses, ...changeAddresses}; - - // Fetch history from ElectrumX. - final List> allTxHashes = - await fetchHistory(allAddressesSet); - - // Only parse new txs (not in db yet). - List> allTransactions = []; - for (final txHash in allTxHashes) { - // Check for duplicates by searching for tx by tx_hash in db. - final storedTx = await mainDB.isar.transactionV2s - .where() - .txidWalletIdEqualTo(txHash["tx_hash"] as String, walletId) - .findFirst(); - - if (storedTx == null || - storedTx.height == null || - (storedTx.height != null && storedTx.height! <= 0)) { - // Tx not in db yet. - final tx = await electrumXCachedClient.getTransaction( - txHash: txHash["tx_hash"] as String, - verbose: true, - coin: cryptoCurrency.coin, - ); - - // Only tx to list once. - if (allTransactions - .indexWhere((e) => e["txid"] == tx["txid"] as String) == - -1) { - tx["height"] = txHash["height"]; - allTransactions.add(tx); - } - } - } - - // Parse all new txs. - final List txns = []; - for (final txData in allTransactions) { - bool wasSentFromThisWallet = false; - // Set to true if any inputs were detected as owned by this wallet. - - bool wasReceivedInThisWallet = false; - // Set to true if any outputs were detected as owned by this wallet. - - // Parse inputs. - BigInt amountReceivedInThisWallet = BigInt.zero; - BigInt changeAmountReceivedInThisWallet = BigInt.zero; - final List inputs = []; - for (final jsonInput in txData["vin"] as List) { - final map = Map.from(jsonInput as Map); - - final List addresses = []; - String valueStringSats = "0"; - OutpointV2? outpoint; - - final coinbase = map["coinbase"] as String?; - - if (coinbase == null) { - // Not a coinbase (ie a typical input). - final txid = map["txid"] as String; - final vout = map["vout"] as int; - - final inputTx = await electrumXCachedClient.getTransaction( - txHash: txid, - coin: cryptoCurrency.coin, - ); - - final prevOutJson = Map.from( - (inputTx["vout"] as List).firstWhere((e) => e["n"] == vout) - as Map); - - final prevOut = OutputV2.fromElectrumXJson( - prevOutJson, - decimalPlaces: cryptoCurrency.fractionDigits, - isFullAmountNotSats: true, - walletOwns: false, // Doesn't matter here as this is not saved. - ); - - outpoint = OutpointV2.isarCantDoRequiredInDefaultConstructor( - txid: txid, - vout: vout, - ); - valueStringSats = prevOut.valueStringSats; - addresses.addAll(prevOut.addresses); - } - - InputV2 input = InputV2.isarCantDoRequiredInDefaultConstructor( - scriptSigHex: map["scriptSig"]?["hex"] as String?, - scriptSigAsm: map["scriptSig"]?["asm"] as String?, - sequence: map["sequence"] as int?, - outpoint: outpoint, - valueStringSats: valueStringSats, - addresses: addresses, - witness: map["witness"] as String?, - coinbase: coinbase, - innerRedeemScriptAsm: map["innerRedeemscriptAsm"] as String?, - // Need addresses before we can know if the wallet owns this input. - walletOwns: false, - ); - - // Check if input was from this wallet. - if (allAddressesSet.intersection(input.addresses.toSet()).isNotEmpty) { - wasSentFromThisWallet = true; - input = input.copyWith(walletOwns: true); - } - - inputs.add(input); - } - - // Parse outputs. - final List outputs = []; - for (final outputJson in txData["vout"] as List) { - OutputV2 output = OutputV2.fromElectrumXJson( - Map.from(outputJson as Map), - decimalPlaces: cryptoCurrency.fractionDigits, - isFullAmountNotSats: true, - // Need addresses before we can know if the wallet owns this input. - walletOwns: false, - ); - - // If output was to my wallet, add value to amount received. - if (receivingAddresses - .intersection(output.addresses.toSet()) - .isNotEmpty) { - wasReceivedInThisWallet = true; - amountReceivedInThisWallet += output.value; - output = output.copyWith(walletOwns: true); - } else if (changeAddresses - .intersection(output.addresses.toSet()) - .isNotEmpty) { - wasReceivedInThisWallet = true; - changeAmountReceivedInThisWallet += output.value; - output = output.copyWith(walletOwns: true); - } - - outputs.add(output); - } - - final totalOut = outputs - .map((e) => e.value) - .fold(BigInt.zero, (value, element) => value + element); - - TransactionType type; - TransactionSubType subType = TransactionSubType.none; - if (outputs.length > 1 && inputs.isNotEmpty) { - for (int i = 0; i < outputs.length; i++) { - List? scriptChunks = outputs[i].scriptPubKeyAsm?.split(" "); - if (scriptChunks?.length == 2 && scriptChunks?[0] == "OP_RETURN") { - final blindedPaymentCode = scriptChunks![1]; - final bytes = blindedPaymentCode.fromHex; - - // https://en.bitcoin.it/wiki/BIP_0047#Sending - if (bytes.length == 80 && bytes.first == 1) { - subType = TransactionSubType.bip47Notification; - break; - } - } - } - } - - // At least one input was owned by this wallet. - if (wasSentFromThisWallet) { - type = TransactionType.outgoing; - - if (wasReceivedInThisWallet) { - if (changeAmountReceivedInThisWallet + amountReceivedInThisWallet == - totalOut) { - // Definitely sent all to self. - type = TransactionType.sentToSelf; - } else if (amountReceivedInThisWallet == BigInt.zero) { - // Most likely just a typical send, do nothing here yet. - } - } - } else if (wasReceivedInThisWallet) { - // Only found outputs owned by this wallet. - type = TransactionType.incoming; - - // TODO: [prio=none] Check for special Bitcoin outputs like ordinals. - } else { - Logging.instance.log( - "Unexpected tx found (ignoring it): $txData", - level: LogLevel.Error, - ); - continue; - } - - final tx = TransactionV2( - walletId: walletId, - blockHash: txData["blockhash"] as String?, - hash: txData["hash"] as String, - txid: txData["txid"] as String, - height: txData["height"] as int?, - version: txData["version"] as int, - timestamp: txData["blocktime"] as int? ?? - DateTime.timestamp().millisecondsSinceEpoch ~/ 1000, - inputs: List.unmodifiable(inputs), - outputs: List.unmodifiable(outputs), - type: type, - subType: subType, - otherData: null, - ); - - txns.add(tx); - } - - await mainDB.updateOrPutTransactionV2s(txns); - } - - @override - Future<({String? blockedReason, bool blocked, String? utxoLabel})> - checkBlockUTXO( - Map jsonUTXO, - String? scriptPubKeyHex, - Map? jsonTX, - String? utxoOwnerAddress, - ) async { - bool blocked = false; - String? blockedReason; - - if (jsonTX != null) { - // check for bip47 notification - final outputs = jsonTX["vout"] as List; - for (final output in outputs) { - List? scriptChunks = - (output['scriptPubKey']?['asm'] as String?)?.split(" "); - if (scriptChunks?.length == 2 && scriptChunks?[0] == "OP_RETURN") { - final blindedPaymentCode = scriptChunks![1]; - final bytes = blindedPaymentCode.toUint8ListFromHex; - - // https://en.bitcoin.it/wiki/BIP_0047#Sending - if (bytes.length == 80 && bytes.first == 1) { - blocked = true; - blockedReason = "Paynym notification output. Incautious " - "handling of outputs from notification transactions " - "may cause unintended loss of privacy."; - break; - } - } - } - } - - return (blockedReason: blockedReason, blocked: blocked, utxoLabel: null); - } - @override Amount roughFeeEstimate(int inputCount, int outputCount, int feeRatePerKB) { return Amount( diff --git a/lib/wallets/wallet/impl/firo_wallet.dart b/lib/wallets/wallet/impl/firo_wallet.dart index 778e458ae..ec1ce13ae 100644 --- a/lib/wallets/wallet/impl/firo_wallet.dart +++ b/lib/wallets/wallet/impl/firo_wallet.dart @@ -782,6 +782,7 @@ class FiroWallet extends Bip39HDWallet static const String _lelantusCoinIsarRescanRequired = "lelantusCoinIsarRescanRequired"; + // TODO: [prio=high] Future setLelantusCoinIsarRescanRequiredDone() async { await DB.instance.put( boxName: walletId, diff --git a/lib/wallets/wallet/wallet_mixin_interfaces/electrumx_interface.dart b/lib/wallets/wallet/wallet_mixin_interfaces/electrumx_interface.dart index 4834f71f1..0b63b6349 100644 --- a/lib/wallets/wallet/wallet_mixin_interfaces/electrumx_interface.dart +++ b/lib/wallets/wallet/wallet_mixin_interfaces/electrumx_interface.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:convert'; import 'dart:math'; @@ -19,9 +20,11 @@ import 'package:stackwallet/utilities/enums/coin_enum.dart'; import 'package:stackwallet/utilities/enums/derive_path_type_enum.dart'; import 'package:stackwallet/utilities/enums/fee_rate_type_enum.dart'; import 'package:stackwallet/utilities/logger.dart'; +import 'package:stackwallet/utilities/paynym_is_api.dart'; import 'package:stackwallet/wallets/crypto_currency/coins/firo.dart'; import 'package:stackwallet/wallets/crypto_currency/intermediate/bip39_hd_currency.dart'; import 'package:stackwallet/wallets/models/tx_data.dart'; +import 'package:stackwallet/wallets/wallet/impl/bitcoin_wallet.dart'; import 'package:stackwallet/wallets/wallet/intermediate/bip39_hd_wallet.dart'; import 'package:stackwallet/wallets/wallet/wallet_mixin_interfaces/paynym_interface.dart'; import 'package:uuid/uuid.dart'; @@ -1748,9 +1751,49 @@ mixin ElectrumXInterface on Bip39HDWallet { e.derivationIndex > highestReceivingIndexWithHistory); await mainDB.updateOrPutAddresses(addressesToStore); + + if (this is PaynymInterface) { + final notificationAddress = + await (this as PaynymInterface).getMyNotificationAddress(); + + await (this as BitcoinWallet) + .updateTransactions(overrideAddresses: [notificationAddress]); + + // get own payment code + // isSegwit does not matter here at all + final myCode = + await (this as PaynymInterface).getPaymentCode(isSegwit: false); + + try { + final Set codesToCheck = {}; + final nym = await PaynymIsApi().nym(myCode.toString()); + if (nym.value != null) { + for (final follower in nym.value!.followers) { + codesToCheck.add(follower.code); + } + for (final following in nym.value!.following) { + codesToCheck.add(following.code); + } + } + + // restore paynym transactions + await (this as PaynymInterface).restoreAllHistory( + maxUnusedAddressGap: 20, + maxNumberOfIndexesToCheck: 10000, + paymentCodeStrings: codesToCheck, + ); + } catch (e, s) { + Logging.instance.log( + "Failed to check paynym.is followers/following for history during " + "bitcoin wallet ($walletId ${info.name}) " + "_recoverWalletFromBIP32SeedPhrase: $e/n$s", + level: LogLevel.Error, + ); + } + } }); - await refresh(); + unawaited(refresh()); } catch (e, s) { Logging.instance.log( "Exception rethrown from electrumx_mixin recover(): $e\n$s", diff --git a/lib/wallets/wallet/wallet_mixin_interfaces/paynym_interface.dart b/lib/wallets/wallet/wallet_mixin_interfaces/paynym_interface.dart index 6efb4812b..b336b82f7 100644 --- a/lib/wallets/wallet/wallet_mixin_interfaces/paynym_interface.dart +++ b/lib/wallets/wallet/wallet_mixin_interfaces/paynym_interface.dart @@ -4,7 +4,6 @@ import 'dart:typed_data'; import 'package:bip32/bip32.dart' as bip32; import 'package:bip47/bip47.dart'; -import 'package:bip47/src/util.dart'; import 'package:bitcoindart/bitcoindart.dart' as btc_dart; import 'package:bitcoindart/src/utils/constants/op.dart' as op; import 'package:bitcoindart/src/utils/script.dart' as bscript; @@ -13,6 +12,7 @@ import 'package:pointycastle/digests/sha256.dart'; import 'package:stackwallet/exceptions/wallet/insufficient_balance_exception.dart'; import 'package:stackwallet/exceptions/wallet/paynym_send_exception.dart'; import 'package:stackwallet/models/isar/models/blockchain_data/v2/input_v2.dart'; +import 'package:stackwallet/models/isar/models/blockchain_data/v2/output_v2.dart'; import 'package:stackwallet/models/isar/models/blockchain_data/v2/transaction_v2.dart'; import 'package:stackwallet/models/isar/models/isar_models.dart'; import 'package:stackwallet/models/signing_data.dart'; @@ -20,6 +20,7 @@ import 'package:stackwallet/utilities/amount/amount.dart'; import 'package:stackwallet/utilities/bip32_utils.dart'; import 'package:stackwallet/utilities/bip47_utils.dart'; import 'package:stackwallet/utilities/enums/coin_enum.dart'; +import 'package:stackwallet/utilities/extensions/extensions.dart'; import 'package:stackwallet/utilities/format.dart'; import 'package:stackwallet/utilities/logger.dart'; import 'package:stackwallet/wallets/crypto_currency/interfaces/paynym_currency_interface.dart'; @@ -689,11 +690,11 @@ mixin PaynymInterface final myCode = await getPaymentCode(isSegwit: false); final utxo = utxoSigningData.first.utxo; - final txPoint = utxo.txid.fromHex.reversed.toList(); + final txPoint = utxo.txid.toUint8ListFromHex.reversed.toList(); final txPointIndex = utxo.vout; final rev = Uint8List(txPoint.length + 4); - Util.copyBytes(Uint8List.fromList(txPoint), 0, rev, 0, txPoint.length); + _copyBytes(Uint8List.fromList(txPoint), 0, rev, 0, txPoint.length); final buffer = rev.buffer.asByteData(); buffer.setUint32(txPoint.length, txPointIndex, Endian.little); @@ -923,16 +924,16 @@ mixin PaynymInterface Uint8List? _pubKeyFromInput(InputV2 input) { final scriptSigComponents = input.scriptSigAsm?.split(" ") ?? []; if (scriptSigComponents.length > 1) { - return scriptSigComponents[1].fromHex; + return scriptSigComponents[1].toUint8ListFromHex; } if (input.witness != null) { try { final witnessComponents = jsonDecode(input.witness!) as List; if (witnessComponents.length == 2) { - return (witnessComponents[1] as String).fromHex; + return (witnessComponents[1] as String).toUint8ListFromHex; } - } catch (_) { - // + } catch (e, s) { + Logging.instance.log("_pubKeyFromInput: $e\n$s", level: LogLevel.Info); } } return null; @@ -952,11 +953,12 @@ mixin PaynymInterface final designatedInput = transaction.inputs.first; - final txPoint = designatedInput.outpoint!.txid.fromHex.reversed.toList(); + final txPoint = + designatedInput.outpoint!.txid.toUint8ListFromHex.reversed.toList(); final txPointIndex = designatedInput.outpoint!.vout; final rev = Uint8List(txPoint.length + 4); - Util.copyBytes(Uint8List.fromList(txPoint), 0, rev, 0, txPoint.length); + _copyBytes(Uint8List.fromList(txPoint), 0, rev, 0, txPoint.length); final buffer = rev.buffer.asByteData(); buffer.setUint32(txPoint.length, txPointIndex, Endian.little); @@ -980,9 +982,9 @@ mixin PaynymInterface ); return unBlindedPaymentCode; - } catch (e) { + } catch (e, s) { Logging.instance.log( - "unBlindedPaymentCodeFromTransaction() failed: $e\nFor tx: $transaction", + "unBlindedPaymentCodeFromTransaction() failed: $e\n$s\nFor tx: $transaction", level: LogLevel.Warning, ); return null; @@ -1003,11 +1005,12 @@ mixin PaynymInterface final designatedInput = transaction.inputs.first; - final txPoint = designatedInput.outpoint!.txid.fromHex.toList(); + final txPoint = + designatedInput.outpoint!.txid.toUint8ListFromHex.toList(); final txPointIndex = designatedInput.outpoint!.vout; final rev = Uint8List(txPoint.length + 4); - Util.copyBytes(Uint8List.fromList(txPoint), 0, rev, 0, txPoint.length); + _copyBytes(Uint8List.fromList(txPoint), 0, rev, 0, txPoint.length); final buffer = rev.buffer.asByteData(); buffer.setUint32(txPoint.length, txPointIndex, Endian.little); @@ -1185,7 +1188,7 @@ mixin PaynymInterface final List> futures = []; for (final code in codes) { futures.add( - restoreHistoryWith( + _restoreHistoryWith( other: code, maxUnusedAddressGap: maxUnusedAddressGap, maxNumberOfIndexesToCheck: maxNumberOfIndexesToCheck, @@ -1197,7 +1200,7 @@ mixin PaynymInterface await Future.wait(futures); } - Future restoreHistoryWith({ + Future _restoreHistoryWith({ required PaymentCode other, required bool checkSegwitAsWell, required int maxUnusedAddressGap, @@ -1441,6 +1444,19 @@ mixin PaynymInterface return key; } + void _copyBytes( + Uint8List source, + int sourceStartingIndex, + Uint8List destination, + int destinationStartingIndex, + int numberOfBytes, + ) { + for (int i = 0; i < numberOfBytes; i++) { + destination[i + destinationStartingIndex] = + source[i + sourceStartingIndex]; + } + } + /// generate a new payment code string storage key String _generateKey() { final bytes = _randomBytes(24); @@ -1454,4 +1470,270 @@ mixin PaynymInterface return Uint8List.fromList( List.generate(n, (_) => rng.nextInt(0xFF + 1))); } + + // ================== Overrides ============================================== + + @override + Future updateTransactions({List
? overrideAddresses}) async { + // Get all addresses. + List
allAddressesOld = + overrideAddresses ?? await fetchAddressesForElectrumXScan(); + + // Separate receiving and change addresses. + Set receivingAddresses = allAddressesOld + .where((e) => + e.subType == AddressSubType.receiving || + e.subType == AddressSubType.paynymNotification || + e.subType == AddressSubType.paynymReceive) + .map((e) => e.value) + .toSet(); + Set changeAddresses = allAddressesOld + .where((e) => e.subType == AddressSubType.change) + .map((e) => e.value) + .toSet(); + + // Remove duplicates. + final allAddressesSet = {...receivingAddresses, ...changeAddresses}; + + // Fetch history from ElectrumX. + final List> allTxHashes = + await fetchHistory(allAddressesSet); + + // Only parse new txs (not in db yet). + List> allTransactions = []; + for (final txHash in allTxHashes) { + // Check for duplicates by searching for tx by tx_hash in db. + // final storedTx = await mainDB.isar.transactionV2s + // .where() + // .txidWalletIdEqualTo(txHash["tx_hash"] as String, walletId) + // .findFirst(); + // + // if (storedTx == null || + // storedTx.height == null || + // (storedTx.height != null && storedTx.height! <= 0)) { + // Tx not in db yet. + final tx = await electrumXCachedClient.getTransaction( + txHash: txHash["tx_hash"] as String, + verbose: true, + coin: cryptoCurrency.coin, + ); + + // Only tx to list once. + if (allTransactions + .indexWhere((e) => e["txid"] == tx["txid"] as String) == + -1) { + tx["height"] = txHash["height"]; + allTransactions.add(tx); + } + // } + } + + // Parse all new txs. + final List txns = []; + for (final txData in allTransactions) { + bool wasSentFromThisWallet = false; + // Set to true if any inputs were detected as owned by this wallet. + + bool wasReceivedInThisWallet = false; + // Set to true if any outputs were detected as owned by this wallet. + + // Parse inputs. + BigInt amountReceivedInThisWallet = BigInt.zero; + BigInt changeAmountReceivedInThisWallet = BigInt.zero; + final List inputs = []; + for (final jsonInput in txData["vin"] as List) { + final map = Map.from(jsonInput as Map); + + final List addresses = []; + String valueStringSats = "0"; + OutpointV2? outpoint; + + final coinbase = map["coinbase"] as String?; + + if (coinbase == null) { + // Not a coinbase (ie a typical input). + final txid = map["txid"] as String; + final vout = map["vout"] as int; + + final inputTx = await electrumXCachedClient.getTransaction( + txHash: txid, + coin: cryptoCurrency.coin, + ); + + final prevOutJson = Map.from( + (inputTx["vout"] as List).firstWhere((e) => e["n"] == vout) + as Map); + + final prevOut = OutputV2.fromElectrumXJson( + prevOutJson, + decimalPlaces: cryptoCurrency.fractionDigits, + isFullAmountNotSats: true, + walletOwns: false, // Doesn't matter here as this is not saved. + ); + + outpoint = OutpointV2.isarCantDoRequiredInDefaultConstructor( + txid: txid, + vout: vout, + ); + valueStringSats = prevOut.valueStringSats; + addresses.addAll(prevOut.addresses); + } + + InputV2 input = InputV2.fromElectrumxJson( + json: map, + outpoint: outpoint, + valueStringSats: valueStringSats, + addresses: addresses, + coinbase: coinbase, + // Need addresses before we can know if the wallet owns this input. + walletOwns: false, + ); + + // Check if input was from this wallet. + if (allAddressesSet.intersection(input.addresses.toSet()).isNotEmpty) { + wasSentFromThisWallet = true; + input = input.copyWith(walletOwns: true); + } + + inputs.add(input); + } + + // Parse outputs. + final List outputs = []; + for (final outputJson in txData["vout"] as List) { + OutputV2 output = OutputV2.fromElectrumXJson( + Map.from(outputJson as Map), + decimalPlaces: cryptoCurrency.fractionDigits, + isFullAmountNotSats: true, + // Need addresses before we can know if the wallet owns this input. + walletOwns: false, + ); + + // If output was to my wallet, add value to amount received. + if (receivingAddresses + .intersection(output.addresses.toSet()) + .isNotEmpty) { + wasReceivedInThisWallet = true; + amountReceivedInThisWallet += output.value; + output = output.copyWith(walletOwns: true); + } else if (changeAddresses + .intersection(output.addresses.toSet()) + .isNotEmpty) { + wasReceivedInThisWallet = true; + changeAmountReceivedInThisWallet += output.value; + output = output.copyWith(walletOwns: true); + } + + outputs.add(output); + } + + final totalOut = outputs + .map((e) => e.value) + .fold(BigInt.zero, (value, element) => value + element); + + TransactionType type; + TransactionSubType subType = TransactionSubType.none; + if (outputs.length > 1 && inputs.isNotEmpty) { + for (int i = 0; i < outputs.length; i++) { + List? scriptChunks = outputs[i].scriptPubKeyAsm?.split(" "); + if (scriptChunks?.length == 2 && scriptChunks?[0] == "OP_RETURN") { + final blindedPaymentCode = scriptChunks![1]; + final bytes = blindedPaymentCode.toUint8ListFromHex; + + // https://en.bitcoin.it/wiki/BIP_0047#Sending + if (bytes.length == 80 && bytes.first == 1) { + subType = TransactionSubType.bip47Notification; + break; + } + } + } + } + + // At least one input was owned by this wallet. + if (wasSentFromThisWallet) { + type = TransactionType.outgoing; + + if (wasReceivedInThisWallet) { + if (changeAmountReceivedInThisWallet + amountReceivedInThisWallet == + totalOut) { + // Definitely sent all to self. + type = TransactionType.sentToSelf; + } else if (amountReceivedInThisWallet == BigInt.zero) { + // Most likely just a typical send, do nothing here yet. + } + } + } else if (wasReceivedInThisWallet) { + // Only found outputs owned by this wallet. + type = TransactionType.incoming; + + // TODO: [prio=none] Check for special Bitcoin outputs like ordinals. + } else { + Logging.instance.log( + "Unexpected tx found (ignoring it): $txData", + level: LogLevel.Error, + ); + continue; + } + + final tx = TransactionV2( + walletId: walletId, + blockHash: txData["blockhash"] as String?, + hash: txData["hash"] as String, + txid: txData["txid"] as String, + height: txData["height"] as int?, + version: txData["version"] as int, + timestamp: txData["blocktime"] as int? ?? + DateTime.timestamp().millisecondsSinceEpoch ~/ 1000, + inputs: List.unmodifiable(inputs), + outputs: List.unmodifiable(outputs), + type: type, + subType: subType, + otherData: null, + ); + + txns.add(tx); + } + + await mainDB.updateOrPutTransactionV2s(txns); + } + + @override + Future< + ({ + String? blockedReason, + bool blocked, + String? utxoLabel, + })> checkBlockUTXO( + Map jsonUTXO, + String? scriptPubKeyHex, + Map? jsonTX, + String? utxoOwnerAddress, + ) async { + bool blocked = false; + String? blockedReason; + + if (jsonTX != null) { + // check for bip47 notification + final outputs = jsonTX["vout"] as List; + for (final output in outputs) { + List? scriptChunks = + (output['scriptPubKey']?['asm'] as String?)?.split(" "); + if (scriptChunks?.length == 2 && scriptChunks?[0] == "OP_RETURN") { + final blindedPaymentCode = scriptChunks![1]; + final bytes = blindedPaymentCode.toUint8ListFromHex; + + // https://en.bitcoin.it/wiki/BIP_0047#Sending + if (bytes.length == 80 && bytes.first == 1) { + blocked = true; + blockedReason = "Paynym notification output. Incautious " + "handling of outputs from notification transactions " + "may cause unintended loss of privacy."; + break; + } + } + } + } + + return (blockedReason: blockedReason, blocked: blocked, utxoLabel: null); + } }