dev
dart 289 lines 10.9 KB
Raw
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 }