Line data Source code
1 : import 'dart:convert';
2 : import 'dart:typed_data';
3 :
4 : import 'package:bip32/bip32.dart';
5 : import 'package:convert/convert.dart';
6 : import 'package:walletkit_dart/src/common/logger.dart';
7 : import 'package:walletkit_dart/src/crypto/utxo/entities/payments/input_selection.dart';
8 : import 'package:walletkit_dart/src/crypto/utxo/entities/payments/p2h.dart';
9 : import 'package:walletkit_dart/src/crypto/utxo/entities/raw_transaction/input.dart';
10 : import 'package:walletkit_dart/src/crypto/utxo/entities/raw_transaction/output.dart';
11 : import 'package:walletkit_dart/src/crypto/utxo/repositories/electrum_json_rpc_client.dart';
12 : import 'package:walletkit_dart/src/crypto/utxo/utils/endpoint_utils.dart';
13 : import 'package:walletkit_dart/src/utils/der.dart';
14 : import 'package:walletkit_dart/src/utils/int.dart';
15 : import 'package:walletkit_dart/walletkit_dart.dart';
16 :
17 : ///
18 : /// Useful: https://btcinformation.org/en/developer-reference#raw-transaction-format
19 : /// https://github.com/bitcoin/bips/blob/master/bip-0144.mediawiki
20 : ///
21 :
22 0 : RawTransaction buildUnsignedTransaction({
23 : required TransferIntent<UtxoFeeInformation> intent,
24 : required UTXONetworkType networkType,
25 : required HDWalletPath walletPath,
26 : required Iterable<UTXOTransaction> txList,
27 : required Amount feePerByte,
28 : required Iterable<String> changeAddresses,
29 :
30 : /// Pre chosen UTXOs to deterministly choose the UTXOs
31 : /// if null, the UTXOs will be chosen randomly
32 : List<ElectrumOutput>? preChosenUTXOs,
33 : }) {
34 0 : if (txList.isEmpty) {
35 0 : throw SendFailure("No transactions");
36 : }
37 :
38 0 : var targetValue = intent.amount.value;
39 :
40 0 : if (targetValue < BigInt.zero) {
41 0 : throw SendFailure("targetValue < 0");
42 : }
43 :
44 0 : if (targetValue < networkType.dustTreshhold.legacy.toBI &&
45 0 : walletPath.purpose != HDWalletPurpose.BIP84) {
46 0 : throw SendFailure(
47 0 : "targetValue < DUST_THRESHOLD: ${networkType.dustTreshhold.legacy}",
48 : );
49 : }
50 0 : if (walletPath.purpose == HDWalletPurpose.BIP84 &&
51 0 : targetValue < networkType.dustTreshhold.segwit.toBI) {
52 0 : throw SendFailure(
53 0 : "targetValue < DUST_THRESHOLD_BIP84: ${networkType.dustTreshhold.segwit}",
54 : );
55 : }
56 :
57 0 : final allUTXOs = extractUTXOs(txList: txList);
58 :
59 0 : if (allUTXOs.isEmpty) {
60 : throw const SendFailure("no UTXOs"); // should be never reached
61 : }
62 :
63 : const lockTime = 0;
64 : const validFrom = 0; // EC8 specific
65 : const validUntil = 0; // EC8 specific
66 0 : final version = networkType.txVersion;
67 :
68 : final chosenUTXOs = preChosenUTXOs ??
69 0 : singleRandomDrawUTXOSelection(
70 0 : allUTXOs.keys.toList(),
71 : targetValue,
72 : );
73 :
74 0 : Logger.log("Chosen UTXOs: ${chosenUTXOs}");
75 :
76 0 : var chosenUTXOsMap = {
77 0 : for (final utxo in chosenUTXOs) utxo: allUTXOs[utxo]!,
78 : };
79 :
80 0 : var (totalInputValue, inputMap) = buildInputs(chosenUTXOsMap, networkType);
81 :
82 0 : if (totalInputValue < targetValue) {
83 0 : throw SendFailure("Not enough funds to pay targetValue $targetValue");
84 : }
85 0 : if (inputMap.keys.isEmpty) {
86 0 : throw SendFailure("No inputs");
87 : }
88 :
89 0 : final targetAddress = intent.recipient;
90 :
91 0 : final changeAddress = findUnusedAddress(
92 : addresses: changeAddresses,
93 : txs: txList,
94 : );
95 :
96 : ///
97 : /// Build Dummy TX
98 : ///
99 :
100 0 : final dummyOutputs = buildOutputs(
101 0 : recipient: intent.recipient,
102 : value: targetValue,
103 : changeAddress: changeAddress,
104 0 : changeValue: BigInt.one,
105 : networkType: networkType,
106 : );
107 :
108 0 : var dummyTx = buildDummyTx(
109 : networkType: networkType,
110 : walletPath: walletPath,
111 : inputMap: inputMap,
112 : dummyOutputs: dummyOutputs,
113 : );
114 :
115 : ///
116 : /// Build Outputs again with the estimated size
117 : ///
118 :
119 0 : var estimatedFee = calculateFee(tx: dummyTx, feePerByte: feePerByte);
120 :
121 0 : var changeValue = totalInputValue - targetValue - estimatedFee;
122 :
123 0 : if (changeValue < BigInt.zero) {
124 0 : targetValue -= changeValue.abs();
125 0 : if (targetValue < networkType.dustTreshhold.legacy.toBI) {
126 : /// Ad addidional UTXO to cover the fee
127 0 : targetValue = intent.amount.value;
128 0 : final additionalUTXO = fillUpToTargetAmount(
129 : chosenUTXOs,
130 0 : allUTXOs.keys.toList(),
131 0 : targetValue + estimatedFee * BigInt.two,
132 : );
133 :
134 0 : chosenUTXOsMap = {
135 0 : for (final utxo in additionalUTXO) utxo: allUTXOs[utxo]!,
136 : };
137 :
138 0 : (totalInputValue, inputMap) = buildInputs(chosenUTXOsMap, networkType);
139 :
140 0 : dummyTx = buildDummyTx(
141 : networkType: networkType,
142 : walletPath: walletPath,
143 : inputMap: inputMap,
144 : dummyOutputs: dummyOutputs,
145 : );
146 :
147 0 : estimatedFee = calculateFee(tx: dummyTx, feePerByte: feePerByte);
148 : }
149 :
150 0 : changeValue = totalInputValue - targetValue - estimatedFee;
151 0 : if (changeValue < BigInt.zero)
152 0 : throw SendFailure("Not enough funds to pay targetValue $targetValue");
153 : }
154 :
155 : assert(
156 0 : totalInputValue == targetValue + changeValue + estimatedFee,
157 : "Total Input Value does not match Total Output Value",
158 : );
159 :
160 0 : Logger.log("Estimated Fee: $estimatedFee");
161 :
162 0 : final outputs = buildOutputs(
163 : recipient: targetAddress,
164 : value: targetValue,
165 : changeAddress: changeAddress,
166 : changeValue: changeValue,
167 : networkType: networkType,
168 : );
169 :
170 : ///
171 : /// Build final transaction
172 : ///
173 :
174 0 : var tx = RawTransaction.build(
175 : version: version,
176 : lockTime: lockTime,
177 : validFrom: validFrom,
178 : validUntil: validUntil,
179 : inputMap: inputMap,
180 : outputs: outputs,
181 : );
182 :
183 0 : if (tx.totalOutputValue + estimatedFee != totalInputValue) {
184 0 : throw SendFailure(
185 : "Total Output Value does not match Total Input Value",
186 : );
187 : }
188 :
189 : return tx;
190 : }
191 :
192 : typedef DummyTxInfo = ({RawTransaction dummyRawTx, List<ElectrumOutput> chosenUTXOs});
193 :
194 : ///
195 : /// Creates a dummy transaction to estimate the size of the transaction and hence the fee
196 : /// Also returns the chosen UTXOs so that they can be used to create the real transaction with the same UTXOs
197 : /// Includes a safety margin so that changes in the Amount dont lead to a different fee
198 : ///
199 0 : DummyTxInfo buildDummyTxFromScratch({
200 : required TransferIntent intent,
201 : required UTXONetworkType networkType,
202 : required HDWalletPath walletPath,
203 : required Iterable<UTXOTransaction> txList,
204 : required List<String> changeAddresses,
205 : }) {
206 0 : final allUTXOs = extractUTXOs(txList: txList);
207 :
208 0 : final chosenUTXOs = singleRandomDrawUTXOSelection(
209 0 : allUTXOs.keys.toList(),
210 0 : intent.amount.value,
211 : );
212 :
213 0 : final chosenUTXOsMap = {
214 0 : for (final utxo in chosenUTXOs) utxo: allUTXOs[utxo]!,
215 : };
216 :
217 0 : final (_, inputMap) = buildInputs(chosenUTXOsMap, networkType);
218 :
219 0 : final changeAddress = findUnusedAddress(
220 : addresses: changeAddresses,
221 : txs: txList,
222 : );
223 :
224 0 : final dummyOutputs = buildOutputs(
225 0 : recipient: intent.recipient,
226 0 : value: intent.amount.value,
227 : changeAddress: changeAddress,
228 0 : changeValue: BigInt.one,
229 : networkType: networkType,
230 : );
231 :
232 0 : final dummyTx = buildDummyTx(
233 : networkType: networkType,
234 : walletPath: walletPath,
235 : inputMap: inputMap,
236 : dummyOutputs: dummyOutputs,
237 : );
238 :
239 : return (dummyRawTx: dummyTx, chosenUTXOs: chosenUTXOs);
240 : }
241 :
242 0 : RawTransaction buildDummyTx({
243 : required UTXONetworkType networkType,
244 : required HDWalletPath walletPath,
245 : required Map<ElectrumOutput, Input> inputMap,
246 : required List<Output> dummyOutputs,
247 : }) {
248 0 : final dummySeed = helloSeed;
249 :
250 0 : var dummyTx = RawTransaction.build(
251 : version: 0,
252 : lockTime: 0,
253 : validFrom: 0,
254 : validUntil: 0,
255 : inputMap: inputMap,
256 : outputs: dummyOutputs,
257 0 : ).sign(
258 : seed: dummySeed,
259 : networkType: networkType,
260 : walletPath: walletPath,
261 : );
262 :
263 : return dummyTx;
264 : }
265 :
266 4 : List<Input> signInputs({
267 : required Map<ElectrumOutput, Input> inputs,
268 : required HDWalletPath walletPath,
269 : required UTXONetworkType networkType,
270 : required RawTransaction tx,
271 : required Uint8List seed,
272 : }) {
273 4 : final signedInputs = <Input>[];
274 :
275 12 : for (var i = 0; i < inputs.length; i++) {
276 8 : final entry = inputs.entries.elementAt(i);
277 4 : final input = entry.value;
278 4 : final output = entry.key;
279 8 : var bip32Node = output.node.bip32Node;
280 :
281 4 : if (bip32Node == null || bip32Node.isNeutered()) {
282 0 : if (output.belongsToUs) {
283 0 : bip32Node = deriveChildNodeFromPath(
284 : seed: seed,
285 0 : childDerivationPath: output.node.derivationPath,
286 : networkType: networkType,
287 : walletPath: walletPath,
288 : );
289 : } else
290 0 : throw SendFailure("Can't sign input without node: $output $input");
291 : }
292 :
293 10 : if (tx is BTCRawTransaction && output.scriptPubKey.isSegwit) {
294 1 : final witnessSript = createScriptWitness(
295 : tx: tx,
296 : i: i,
297 : output: output,
298 : networkType: networkType,
299 : node: bip32Node,
300 : );
301 :
302 2 : signedInputs.add(input.addScript(wittnessScript: witnessSript));
303 : continue;
304 : }
305 :
306 4 : final scriptSig = createScriptSignature(
307 : tx: tx,
308 : i: i,
309 : output: output,
310 4 : walletPurpose: walletPath.purpose,
311 : networkType: networkType,
312 : node: bip32Node,
313 : );
314 :
315 8 : signedInputs.add(input.addScript(scriptSig: scriptSig));
316 : }
317 :
318 : return signedInputs;
319 : }
320 :
321 4 : Uint8List createScriptSignature({
322 : required RawTransaction tx,
323 : required int i,
324 : required ElectrumOutput output,
325 : required HDWalletPurpose walletPurpose,
326 : required UTXONetworkType networkType,
327 : required BIP32 node,
328 : }) {
329 8 : final hashType = networkType.sighash.all;
330 8 : final prevScriptPubKey = output.scriptPubKey.lockingScript;
331 :
332 : final sigHash = switch (networkType) {
333 12 : BITCOINCASH_NETWORK() || ZENIQ_NETWORK() when tx is BTCRawTransaction => tx.bip143sigHash(
334 : index: i,
335 : prevScriptPubKey: prevScriptPubKey,
336 : output: output,
337 : hashType: hashType,
338 : ),
339 10 : LITECOIN_NETWORK() || BITCOIN_NETWORK() || EUROCOIN_NETWORK() => tx.legacySigHash(
340 : index: i,
341 : prevScriptPubKey: prevScriptPubKey,
342 : hashType: hashType,
343 : ),
344 0 : _ => throw SendFailure("Could not find sigHash for networkType $networkType"),
345 : };
346 :
347 4 : final sig = signInput(bip32: node, sigHash: sigHash);
348 :
349 4 : final scriptSig = encodeSignature(sig, hashType);
350 :
351 4 : final unlockingScript = constructScriptSig(
352 : walletPurpose: walletPurpose,
353 : signature: scriptSig,
354 4 : publicKey: node.publicKey,
355 : );
356 :
357 : return unlockingScript;
358 : }
359 :
360 1 : Uint8List createScriptWitness({
361 : required BTCRawTransaction tx,
362 : required int i,
363 : required ElectrumOutput output,
364 : required UTXONetworkType networkType,
365 : required BIP32 node,
366 : }) {
367 2 : final hashType = networkType.sighash.all;
368 2 : final prevScriptPubKey = output.scriptPubKey.lockingScript;
369 :
370 3 : assert(output.scriptPubKey.isSegwit);
371 :
372 1 : final sigHash = tx.bip143sigHash(
373 : index: i,
374 : prevScriptPubKey: prevScriptPubKey,
375 : output: output,
376 : hashType: hashType,
377 : );
378 :
379 1 : final sig = signInput(bip32: node, sigHash: sigHash);
380 :
381 1 : final scriptSig = encodeSignature(sig, hashType);
382 :
383 1 : final pubkey = node.publicKey;
384 :
385 1 : return [
386 : 0x02,
387 1 : scriptSig.length,
388 1 : ...scriptSig,
389 1 : pubkey.length,
390 1 : ...pubkey,
391 1 : ].toUint8List;
392 : }
393 :
394 4 : (BigInt, Map<ElectrumOutput, Input>) buildInputs(
395 : Map<ElectrumOutput, UTXOTransaction> utxos,
396 : UTXONetworkType networkType,
397 : ) {
398 : final usedUTXO = <String>{};
399 4 : final inputs = <ElectrumOutput, Input>{};
400 4 : var totalInputValue = BigInt.zero;
401 :
402 8 : for (final uTXOEntry in utxos.entries) {
403 4 : final uTXO = uTXOEntry.key;
404 4 : final uTXOTx = uTXOEntry.value;
405 :
406 4 : final hash = uTXOTx.id;
407 :
408 8 : inputs[uTXO] = buildInput(
409 : txidHex: hash,
410 : usedUTXO: usedUTXO,
411 : utxo: uTXO,
412 : networkType: networkType,
413 : );
414 :
415 8 : totalInputValue += uTXO.value;
416 : }
417 :
418 : return (totalInputValue, inputs);
419 : }
420 :
421 1 : List<Output> buildOutputs({
422 : required String recipient,
423 : required BigInt value,
424 : required String? changeAddress,
425 : required BigInt changeValue,
426 : required UTXONetworkType networkType,
427 : }) {
428 1 : return [
429 1 : buildOutput(recipient, value, networkType),
430 2 : if (changeAddress != null && changeValue != BigInt.zero)
431 1 : buildOutput(changeAddress, changeValue, networkType),
432 : ];
433 : }
434 :
435 4 : Input buildInput({
436 : required String txidHex,
437 : required Set<String> usedUTXO,
438 : required ElectrumOutput utxo,
439 : required UTXONetworkType networkType,
440 : }) {
441 4 : final vout = utxo.n;
442 4 : final txid = Uint8List.fromList(
443 12 : hex.decode(txidHex).reversed.toList(),
444 : ); // Use 'txid' instead of 'hash'
445 :
446 4 : final prevTxOut = '$txidHex:$vout';
447 :
448 4 : if (usedUTXO.contains(prevTxOut)) {
449 : throw const SendFailure("double spend");
450 : }
451 :
452 : /// Check if utxo has a ScriptSig => Input should also have a ScriptSig
453 : /// Check if utxo has a WitnessScript => Input should also have a WitnessScript
454 : ///
455 :
456 : return switch (networkType) {
457 14 : BITCOIN_NETWORK() || BITCOINCASH_NETWORK() || ZENIQ_NETWORK() || LITECOIN_NETWORK() => BTCInput(
458 : txid: txid,
459 : vout: vout,
460 3 : value: utxo.value,
461 6 : prevScriptPubKey: utxo.scriptPubKey.lockingScript,
462 : ),
463 2 : EUROCOIN_NETWORK() => EC8Input(
464 : txid: txid,
465 : vout: vout,
466 1 : value: utxo.value,
467 2 : prevScriptPubKey: utxo.scriptPubKey.lockingScript,
468 : ),
469 : };
470 : }
471 :
472 1 : Output buildOutput(String address, BigInt value, UTXONetworkType networkType) {
473 2 : final lockingScript = P2Hash(address).publicKeyScript;
474 :
475 : return switch (networkType) {
476 1 : BITCOIN_NETWORK() ||
477 1 : BITCOINCASH_NETWORK() ||
478 1 : ZENIQ_NETWORK() ||
479 0 : LITECOIN_NETWORK() =>
480 1 : BTCOutput(
481 : value: value,
482 : scriptPubKey: lockingScript,
483 : ),
484 0 : EUROCOIN_NETWORK() => EC8Output(
485 : value: value,
486 : scriptPubKey: lockingScript,
487 : ),
488 : };
489 : }
490 :
491 0 : Future<String> broadcastTransaction({
492 : required String rawTxHex,
493 : required UTXONetworkType type,
494 : }) async {
495 0 : final (result, client, error) = await fetchFromRandomElectrumXNode(
496 0 : (client) async {
497 0 : final broadcastResult = await client.broadcastTransaction(rawTxHex: rawTxHex);
498 : return broadcastResult;
499 : },
500 : client: null,
501 0 : token: type.coin,
502 0 : endpoints: type.endpoints,
503 : );
504 :
505 0 : final host = "${client?.host}:${client?.port}";
506 :
507 : if (result == null) {
508 0 : throw SendFailure("Broadcasting failed for $host: ${error?.message}");
509 : }
510 :
511 0 : final json = jsonDecode(result);
512 :
513 0 : if (result.contains('error')) {
514 0 : if (json case {"error": {"error": {"code": int code, "message": String message}}}) {
515 0 : throw SendFailure("$host $code $message");
516 : }
517 0 : if (json case {"error": {"code": int code, "message": String message}}) {
518 0 : throw SendFailure("$host $code $message");
519 : }
520 0 : throw SendFailure("Unknown error for $host: $result");
521 : }
522 :
523 0 : if (result.contains('result') == false) {
524 0 : throw SendFailure("Unknown error for $host: $result");
525 : }
526 :
527 0 : final hash = json['result'];
528 :
529 : return hash;
530 : }
531 :
532 : ///
533 : /// For a given [hash] and [serializedTx] we check if the transaction is already in the mempool
534 : /// If not we rebroadcast the transaction until at least half of the nodes have the transaction
535 : ///
536 0 : Future<bool> rebroadcastTransaction({
537 : required String hash,
538 : required String serializedTx,
539 : required UTXONetworkType type,
540 : Duration delay = const Duration(seconds: 5),
541 : }) async {
542 0 : await Future.delayed(delay);
543 :
544 0 : final clients = await Future.wait(
545 0 : [
546 0 : for (final endpoint in type.endpoints)
547 0 : createElectrumXClient(
548 : endpoint: endpoint.$1,
549 : port: endpoint.$2,
550 0 : token: type.coin,
551 : ),
552 : ],
553 0 : ).then(
554 0 : (clients) => clients.whereType<ElectrumXClient>(),
555 : );
556 :
557 : while (true) {
558 : int rebroadcastCount = 0;
559 : Set<ElectrumXClient> clientsForRebroadcast = {};
560 :
561 0 : Future<void> testEndpoint(ElectrumXClient client) async {
562 0 : final (rawTx, error) = await fetchFromNode(
563 0 : (client) => client.getRaw(hash),
564 : client: client,
565 : );
566 :
567 : if (error != null) {
568 0 : clientsForRebroadcast.add(client);
569 : return;
570 : }
571 :
572 0 : if (rawTx == serializedTx) {
573 0 : rebroadcastCount++;
574 : }
575 : }
576 :
577 0 : await Future.wait(
578 0 : [
579 0 : for (final client in clients) testEndpoint(client),
580 : ],
581 : );
582 :
583 0 : if (rebroadcastCount > type.endpoints.length / 2) {
584 : break;
585 : }
586 :
587 0 : Logger.log(
588 0 : "Rebroadcasting: $hash for ${clientsForRebroadcast.length} endpoints",
589 : );
590 :
591 0 : for (final client in clientsForRebroadcast) {
592 0 : final (result, _) = await fetchFromNode(
593 0 : (client) => client.broadcastTransaction(rawTxHex: serializedTx),
594 : client: client,
595 : );
596 : if (result == null) continue;
597 0 : final json = jsonDecode(result);
598 0 : final hasResult = json.containsKey('result');
599 0 : final hasError = json.containsKey('error');
600 : if (hasResult) {
601 0 : final _hash = json['result'];
602 0 : Logger.log("Rebroadcasted: $_hash");
603 0 : assert(_hash == hash);
604 : }
605 : if (hasError) {
606 0 : final error = json['error'];
607 0 : Logger.logWarning("Error rebroadcasting: $error");
608 : }
609 : }
610 :
611 0 : await Future.delayed(delay);
612 : }
613 :
614 0 : await Future.wait([for (final client in clients) client.disconnect()]);
615 :
616 : return true;
617 : }
618 :
619 4 : Uint8List signInput({
620 : required BIP32 bip32,
621 : required Uint8List sigHash,
622 : }) {
623 : try {
624 4 : return bip32.sign(sigHash);
625 : } catch (e) {
626 0 : throw SendFailure("signing failed $e");
627 : }
628 : }
629 :
630 4 : Uint8List constructScriptSig({
631 : required HDWalletPurpose walletPurpose,
632 : required Uint8List signature,
633 : required Uint8List publicKey,
634 : Uint8List? redeemScript, // Required for BIP49 (P2SH-P2WPKH)
635 : }) =>
636 : switch (walletPurpose) {
637 12 : HDWalletPurpose.NO_STRUCTURE || HDWalletPurpose.BIP44 => Uint8List.fromList([
638 4 : signature.length,
639 4 : ...signature,
640 4 : publicKey.length,
641 4 : ...publicKey,
642 : ]),
643 0 : HDWalletPurpose.BIP49 => Uint8List.fromList([
644 : 0x00,
645 0 : signature.length,
646 0 : ...signature,
647 0 : redeemScript!.length,
648 0 : ...redeemScript,
649 : ]),
650 :
651 : /// Should never be called as it is handled in constructWitnessScript
652 0 : HDWalletPurpose.BIP84 => Uint8List.fromList([
653 : 0x00,
654 0 : signature.length,
655 0 : ...signature,
656 0 : publicKey.length,
657 0 : ...publicKey,
658 : ]),
659 : };
660 :
661 0 : BigInt calculateFee({
662 : required RawTransaction tx,
663 : required Amount feePerByte,
664 : }) {
665 : return switch (tx) {
666 0 : EC8RawTransaction _ => calculateFeeEC8(tx: tx),
667 0 : _ => tx.size.toBI * feePerByte.value,
668 : };
669 : }
670 :
671 : const int max_cheap_tx_weight = 15000;
672 :
673 0 : BigInt calculateFeeEC8({
674 : required RawTransaction tx,
675 : }) {
676 0 : var fee = 1000.toBI; // Base fee
677 :
678 0 : final outputLength = tx.outputs.length;
679 :
680 0 : if (outputLength > 2) {
681 0 : fee += 1000.toBI * (outputLength - 2).toBI;
682 : }
683 :
684 0 : if (tx.weight > max_cheap_tx_weight.toBI) {
685 0 : fee += 1000.toBI * ((tx.weight + 999.toBI) / 1000.toBI).toBI;
686 : }
687 :
688 0 : assert(fee % 1000.toBI == 0.toBI);
689 :
690 : return fee;
691 : }
|