| 1 | import 'dart:convert'; |
| 2 | |
| 3 | import 'package:bitcoin_base/bitcoin_base.dart'; |
| 4 | import 'package:cw_bitcoin/address_from_output.dart'; |
| 5 | import 'package:cw_bitcoin/bitcoin_address_record.dart'; |
| 6 | import 'package:cw_bitcoin/bitcoin_amount_format.dart'; |
| 7 | import 'package:cw_bitcoin/bitcoin_unspent.dart'; |
| 8 | import 'package:cw_core/amount/money.dart'; |
| 9 | import 'package:cw_core/crypto_currency.dart'; |
| 10 | import 'package:cw_core/currency_for_wallet_type.dart'; |
| 11 | import 'package:cw_core/transaction_direction.dart'; |
| 12 | import 'package:cw_core/transaction_info.dart'; |
| 13 | import 'package:cw_core/wallet_type.dart'; |
| 14 | import 'package:hex/hex.dart'; |
| 15 | |
| 16 | class ElectrumTransactionBundle { |
| 17 | ElectrumTransactionBundle(this.originalTransaction, |
| 18 | {required this.ins, required this.confirmations, this.time}); |
| 19 | |
| 20 | final BtcTransaction originalTransaction; |
| 21 | final List<BtcTransaction?> ins; |
| 22 | final int? time; |
| 23 | final int confirmations; |
| 24 | } |
| 25 | |
| 26 | class ElectrumTransactionInfo extends TransactionInfo { |
| 27 | List<BitcoinSilentPaymentsUnspent>? unspents; |
| 28 | bool isReceivedSilentPayment; |
| 29 | bool isHogEx; |
| 30 | |
| 31 | ElectrumTransactionInfo( |
| 32 | this.type, { |
| 33 | required String id, |
| 34 | int? height, |
| 35 | required Money amount, |
| 36 | Money? fee, |
| 37 | List<String>? inputAddresses, |
| 38 | List<String>? outputAddresses, |
| 39 | required TransactionDirection direction, |
| 40 | required bool isPending, |
| 41 | bool isReplaced = false, |
| 42 | required DateTime date, |
| 43 | required int confirmations, |
| 44 | String? to, |
| 45 | this.unspents, |
| 46 | this.isReceivedSilentPayment = false, |
| 47 | this.isHogEx = false, |
| 48 | Map<String, dynamic>? additionalInfo, |
| 49 | }) { |
| 50 | this.id = id; |
| 51 | this.height = height; |
| 52 | this.amount = amount; |
| 53 | this.inputAddresses = inputAddresses; |
| 54 | this.outputAddresses = outputAddresses; |
| 55 | this.fee = fee; |
| 56 | this.direction = direction; |
| 57 | this.date = date; |
| 58 | this.isPending = isPending; |
| 59 | this.isReplaced = isReplaced; |
| 60 | this.confirmations = confirmations; |
| 61 | this.to = to; |
| 62 | this.additionalInfo = additionalInfo ?? {}; |
| 63 | } |
| 64 | |
| 65 | factory ElectrumTransactionInfo.fromElectrumVerbose(Map<String, Object> obj, WalletType type, |
| 66 | {required List<BitcoinAddressRecord> addresses, required int height}) { |
| 67 | final addressesSet = addresses.map((addr) => addr.address).toSet(); |
| 68 | final id = obj['txid'] as String; |
| 69 | final vins = obj['vin'] as List<Object>? ?? []; |
| 70 | final vout = (obj['vout'] as List<Object>? ?? []); |
| 71 | final date = obj['time'] is int |
| 72 | ? DateTime.fromMillisecondsSinceEpoch((obj['time'] as int) * 1000) |
| 73 | : DateTime.now(); |
| 74 | final confirmations = obj['confirmations'] as int? ?? 0; |
| 75 | var direction = TransactionDirection.incoming; |
| 76 | var inputsAmount = 0; |
| 77 | var amount = 0; |
| 78 | var totalOutAmount = 0; |
| 79 | |
| 80 | for (dynamic vin in vins) { |
| 81 | final vout = vin['vout'] as int; |
| 82 | final out = vin['tx']['vout'][vout] as Map; |
| 83 | final outAddresses = (out['scriptPubKey']['addresses'] as List<Object>?)?.toSet(); |
| 84 | inputsAmount += stringDoubleToBitcoinAmount((out['value'] as double? ?? 0).toString()); |
| 85 | |
| 86 | if (outAddresses?.intersection(addressesSet).isNotEmpty ?? false) { |
| 87 | direction = TransactionDirection.outgoing; |
| 88 | } |
| 89 | } |
| 90 | |
| 91 | for (dynamic out in vout) { |
| 92 | final outAddresses = out['scriptPubKey']['addresses'] as List<Object>? ?? []; |
| 93 | final ntrs = outAddresses.toSet().intersection(addressesSet); |
| 94 | final value = stringDoubleToBitcoinAmount((out['value'] as double? ?? 0.0).toString()); |
| 95 | totalOutAmount += value; |
| 96 | |
| 97 | if ((direction == TransactionDirection.incoming && ntrs.isNotEmpty) || |
| 98 | (direction == TransactionDirection.outgoing && ntrs.isEmpty)) { |
| 99 | amount += value; |
| 100 | } |
| 101 | } |
| 102 | |
| 103 | final fee = inputsAmount - totalOutAmount; |
| 104 | |
| 105 | return ElectrumTransactionInfo(type, |
| 106 | id: id, |
| 107 | height: height, |
| 108 | isPending: false, |
| 109 | isReplaced: false, |
| 110 | fee: Money.fromInt(fee, walletTypeToCryptoCurrency(type)), |
| 111 | direction: direction, |
| 112 | amount: Money.fromInt(amount, walletTypeToCryptoCurrency(type)), |
| 113 | date: date, |
| 114 | confirmations: confirmations); |
| 115 | } |
| 116 | |
| 117 | factory ElectrumTransactionInfo.fromElectrumBundle( |
| 118 | ElectrumTransactionBundle bundle, WalletType type, BasedUtxoNetwork network, |
| 119 | {required Set<String> addresses, int? height}) { |
| 120 | final date = bundle.time != null |
| 121 | ? DateTime.fromMillisecondsSinceEpoch(bundle.time! * 1000) |
| 122 | : DateTime.now(); |
| 123 | var direction = TransactionDirection.incoming; |
| 124 | var amount = 0; |
| 125 | var inputAmount = 0; |
| 126 | var totalOutAmount = 0; |
| 127 | List<String> inputAddresses = []; |
| 128 | List<String> outputAddresses = []; |
| 129 | |
| 130 | var hasMissingInputTx = false; |
| 131 | for (var i = 0; i < bundle.originalTransaction.inputs.length; i++) { |
| 132 | final input = bundle.originalTransaction.inputs[i]; |
| 133 | final inputTransaction = i < bundle.ins.length ? bundle.ins[i] : null; |
| 134 | if (inputTransaction == null || input.txIndex >= inputTransaction.outputs.length) { |
| 135 | hasMissingInputTx = true; |
| 136 | continue; |
| 137 | } |
| 138 | final outTransaction = inputTransaction.outputs[input.txIndex]; |
| 139 | inputAmount += outTransaction.amount.toInt(); |
| 140 | if (addresses.contains(addressFromOutputScript(outTransaction.scriptPubKey, network))) { |
| 141 | direction = TransactionDirection.outgoing; |
| 142 | inputAddresses.add(addressFromOutputScript(outTransaction.scriptPubKey, network)); |
| 143 | } |
| 144 | } |
| 145 | |
| 146 | final receivedAmounts = <int>[]; |
| 147 | for (final out in bundle.originalTransaction.outputs) { |
| 148 | totalOutAmount += out.amount.toInt(); |
| 149 | final addressExists = addresses.contains(addressFromOutputScript(out.scriptPubKey, network)); |
| 150 | final address = addressFromOutputScript(out.scriptPubKey, network); |
| 151 | |
| 152 | if (address.isNotEmpty) outputAddresses.add(address); |
| 153 | |
| 154 | // Check if the script contains OP_RETURN |
| 155 | final script = out.scriptPubKey.script; |
| 156 | if (script.contains('OP_RETURN')) { |
| 157 | final index = script.indexOf('OP_RETURN'); |
| 158 | if (index + 1 <= script.length) { |
| 159 | try { |
| 160 | final opReturnData = script[index + 1].toString(); |
| 161 | final decodedString = utf8.decode(HEX.decode(opReturnData)); |
| 162 | outputAddresses.add('OP_RETURN:$decodedString'); |
| 163 | } catch (_) { |
| 164 | outputAddresses.add('OP_RETURN:'); |
| 165 | } |
| 166 | } |
| 167 | } |
| 168 | |
| 169 | if (addressExists) { |
| 170 | receivedAmounts.add(out.amount.toInt()); |
| 171 | } |
| 172 | |
| 173 | if ((direction == TransactionDirection.incoming && addressExists) || |
| 174 | (direction == TransactionDirection.outgoing && !addressExists)) { |
| 175 | amount += out.amount.toInt(); |
| 176 | } |
| 177 | } |
| 178 | |
| 179 | if (receivedAmounts.length == bundle.originalTransaction.outputs.length) { |
| 180 | // Self-send |
| 181 | direction = TransactionDirection.incoming; |
| 182 | amount = receivedAmounts.reduce((a, b) => a + b); |
| 183 | } |
| 184 | |
| 185 | // MWEB HogEx |
| 186 | final isHogExTx = (BtcTransaction tx) { |
| 187 | if (tx.inputs.isEmpty || tx.inputs.first.txIndex > 0 || tx.outputs.isEmpty) return false; |
| 188 | final b = tx.outputs.first.scriptPubKey.toBytes(); |
| 189 | return b.length == 34 && b[0] == 88 && b[1] == 32; |
| 190 | }; |
| 191 | final firstInput = bundle.ins.isNotEmpty ? bundle.ins.first : null; |
| 192 | final isHogEx = |
| 193 | firstInput != null && isHogExTx(bundle.originalTransaction) && isHogExTx(firstInput); |
| 194 | |
| 195 | final fee = hasMissingInputTx ? null : inputAmount - totalOutAmount; |
| 196 | final walletCurrency = walletTypeToCryptoCurrency(type); |
| 197 | final feeMoney = fee != null ? Money.fromInt(fee, walletCurrency) : null; |
| 198 | return ElectrumTransactionInfo(type, |
| 199 | id: bundle.originalTransaction.txId(), |
| 200 | height: height, |
| 201 | isPending: bundle.confirmations == 0, |
| 202 | isReplaced: false, |
| 203 | inputAddresses: inputAddresses, |
| 204 | outputAddresses: outputAddresses, |
| 205 | fee: feeMoney, |
| 206 | direction: direction, |
| 207 | amount: Money.fromInt(amount, walletCurrency), |
| 208 | date: date, |
| 209 | isHogEx: isHogEx, |
| 210 | additionalInfo: {'hasMissingInputTx': hasMissingInputTx}, |
| 211 | confirmations: bundle.confirmations); |
| 212 | } |
| 213 | |
| 214 | factory ElectrumTransactionInfo.fromJson(Map<String, dynamic> data, WalletType type) { |
| 215 | final inputAddresses = data['inputAddresses'] as List<dynamic>? ?? []; |
| 216 | final outputAddresses = data['outputAddresses'] as List<dynamic>? ?? []; |
| 217 | final unspents = data['unspents'] as List<dynamic>? ?? []; |
| 218 | final additionalInfo = data['additionalInfo'] as Map<String, dynamic>?; |
| 219 | final isLightning = (additionalInfo?['isLightning'] as bool?) == true; |
| 220 | |
| 221 | final currency = isLightning ? CryptoCurrency.btcln : walletTypeToCryptoCurrency(type); |
| 222 | |
| 223 | return ElectrumTransactionInfo( |
| 224 | type, |
| 225 | id: data['id'] as String, |
| 226 | height: data['height'] as int?, |
| 227 | amount: Money.fromInt(data['amount'] as int, currency), |
| 228 | fee: data['fee'] != null ? Money.fromInt(data['fee'] as int, currency) : null, |
| 229 | direction: parseTransactionDirectionFromInt(data['direction'] as int), |
| 230 | date: DateTime.fromMillisecondsSinceEpoch(data['date'] as int), |
| 231 | isPending: data['isPending'] as bool, |
| 232 | isReplaced: data['isReplaced'] as bool? ?? false, |
| 233 | confirmations: data['confirmations'] as int, |
| 234 | inputAddresses: |
| 235 | inputAddresses.isEmpty ? [] : inputAddresses.map((e) => e.toString()).toList(), |
| 236 | outputAddresses: |
| 237 | outputAddresses.isEmpty ? [] : outputAddresses.map((e) => e.toString()).toList(), |
| 238 | to: data['to'] as String?, |
| 239 | unspents: unspents |
| 240 | .map((unspent) => |
| 241 | BitcoinSilentPaymentsUnspent.fromJSON(null, unspent as Map<String, dynamic>)) |
| 242 | .toList(), |
| 243 | isReceivedSilentPayment: data['isReceivedSilentPayment'] as bool? ?? false, |
| 244 | additionalInfo: additionalInfo, |
| 245 | ); |
| 246 | } |
| 247 | |
| 248 | final WalletType type; |
| 249 | |
| 250 | ElectrumTransactionInfo updated(ElectrumTransactionInfo info) { |
| 251 | return ElectrumTransactionInfo(info.type, |
| 252 | id: id, |
| 253 | height: info.height, |
| 254 | amount: info.amount, |
| 255 | fee: info.fee, |
| 256 | direction: direction, |
| 257 | date: date, |
| 258 | isPending: isPending, |
| 259 | isReplaced: isReplaced ?? false, |
| 260 | inputAddresses: inputAddresses, |
| 261 | outputAddresses: outputAddresses, |
| 262 | confirmations: info.confirmations, |
| 263 | additionalInfo: additionalInfo); |
| 264 | } |
| 265 | |
| 266 | Map<String, dynamic> toJson() { |
| 267 | final m = <String, dynamic>{}; |
| 268 | m['id'] = id; |
| 269 | m['height'] = height; |
| 270 | m['amount'] = amount.amount.toInt(); |
| 271 | m['direction'] = direction.index; |
| 272 | m['date'] = date.millisecondsSinceEpoch; |
| 273 | m['isPending'] = isPending; |
| 274 | m['isReplaced'] = isReplaced; |
| 275 | m['confirmations'] = confirmations; |
| 276 | m['fee'] = fee?.amount.toInt(); |
| 277 | m['to'] = to; |
| 278 | m['unspents'] = unspents?.map((e) => e.toJson()).toList() ?? []; |
| 279 | m['inputAddresses'] = inputAddresses; |
| 280 | m['outputAddresses'] = outputAddresses; |
| 281 | m['isReceivedSilentPayment'] = isReceivedSilentPayment; |
| 282 | m['additionalInfo'] = additionalInfo; |
| 283 | return m; |
| 284 | } |
| 285 | |
| 286 | String toString() { |
| 287 | return 'ElectrumTransactionInfo(id: $id, height: $height, amount: $amount, fee: $fee, direction: $direction, date: $date, isPending: $isPending, isReplaced: $isReplaced, confirmations: $confirmations, to: $to, unspent: $unspents, inputAddresses: $inputAddresses, outputAddresses: $outputAddresses, additionalInfo: $additionalInfo)'; |
| 288 | } |
| 289 | } |