dev
dart 226 lines 7.6 KB
Raw
1 import 'dart:async';
2 import 'dart:io';
3 import 'dart:isolate';
4 import 'dart:typed_data';
5
6 import 'package:blockchain_utils/blockchain_utils.dart';
7 import 'package:cw_bitcoin/payjoin/manager.dart';
8 import 'package:cw_bitcoin/payjoin/payjoin_session_errors.dart';
9 import 'package:cw_bitcoin/psbt/signer.dart';
10 import 'package:cw_core/utils/print_verbose.dart';
11 import 'package:cw_core/utils/proxy_wrapper.dart';
12 import 'package:cw_core/utils/tor/abstract.dart';
13 import 'package:payjoin_flutter/bitcoin_ffi.dart';
14 import 'package:payjoin_flutter/common.dart';
15 import 'package:payjoin_flutter/receive.dart';
16 import 'package:payjoin_flutter/src/generated/frb_generated.dart' as pj;
17 import 'package:http/http.dart' as very_insecure_http_do_not_use; // for errors
18
19 enum PayjoinReceiverRequestTypes {
20 processOriginalTx,
21 proposalSent,
22 getCandidateInputs,
23 checkIsOwned,
24 checkIsReceiverOutput,
25 processPsbt;
26 }
27
28 class PayjoinReceiverWorker {
29 final SendPort sendPort;
30 final pendingRequests = <String, Completer<dynamic>>{};
31
32 PayjoinReceiverWorker._(this.sendPort);
33 static final client = ProxyWrapper().getHttpIOClient();
34 static Future<void> run(List<Object> args) async {
35 await pj.core.init();
36 CakeTor.instance = await CakeTorInstance.getInstance();
37
38 final sendPort = args[0] as SendPort;
39 final receiverJson = args[1] as String;
40
41 final worker = PayjoinReceiverWorker._(sendPort);
42 final receivePort = ReceivePort();
43
44 sendPort.send(receivePort.sendPort);
45 receivePort.listen(worker.handleMessage);
46
47 try {
48 final receiver = Receiver.fromJson(json: receiverJson);
49
50 final uncheckedProposal = await worker.receiveUncheckedProposal(receiver);
51
52 final originalTx = await uncheckedProposal.extractTxToScheduleBroadcast();
53 sendPort.send({
54 'type': PayjoinReceiverRequestTypes.processOriginalTx,
55 'tx': BytesUtils.toHexString(originalTx),
56 });
57
58 final payjoinProposal = await worker.processPayjoinProposal(
59 uncheckedProposal,
60 );
61 final psbt = await worker.sendFinalProposal(payjoinProposal);
62 sendPort.send({
63 'type': PayjoinReceiverRequestTypes.proposalSent,
64 'psbt': psbt,
65 });
66 } catch (e) {
67 if (e is HttpException ||
68 (e is very_insecure_http_do_not_use.ClientException &&
69 e.message.contains("Software caused connection abort"))) {
70 sendPort.send(PayjoinSessionError.recoverable(e.toString()));
71 } else {
72 sendPort.send(PayjoinSessionError.unrecoverable(e.toString()));
73 }
74 }
75 }
76
77 void handleMessage(dynamic message) async {
78 if (message is Map<String, dynamic>) {
79 final requestId = message['requestId'] as String?;
80 if (requestId != null && pendingRequests.containsKey(requestId)) {
81 pendingRequests[requestId]!.complete(message['result']);
82 pendingRequests.remove(requestId);
83 }
84 }
85 }
86
87 Future<dynamic> _sendRequest(PayjoinReceiverRequestTypes type,
88 [Map<String, dynamic> data = const {}]) async {
89 final completer = Completer<dynamic>();
90 final requestId = DateTime.now().millisecondsSinceEpoch.toString();
91 pendingRequests[requestId] = completer;
92
93 sendPort.send({
94 ...data,
95 'type': type,
96 'requestId': requestId,
97 });
98
99 return completer.future;
100 }
101
102 Future<UncheckedProposal> receiveUncheckedProposal(Receiver session) async {
103 while (true) {
104 printV("Polling for Proposal (${session.id()})");
105 final extractReq = await session.extractReq(
106 ohttpRelay: await PayjoinManager.randomOhttpRelayUrl(),
107 );
108 final request = extractReq.$1;
109
110 final url = Uri.parse(request.url.asString());
111 final httpRequest = await client.post(url,
112 headers: {'Content-Type': request.contentType}, body: request.body);
113
114 final proposal = await session.processRes(body: httpRequest.bodyBytes, ctx: extractReq.$2);
115 if (proposal != null) return proposal;
116 sleep(Duration(seconds: 2));
117 }
118 }
119
120 Future<String> sendFinalProposal(PayjoinProposal finalProposal) async {
121 final req = await finalProposal.extractReq(
122 ohttpRelay: await PayjoinManager.randomOhttpRelayUrl(),
123 );
124 final proposalReq = req.$1;
125 final proposalCtx = req.$2;
126
127 final request = await client.post(
128 Uri.parse(proposalReq.url.asString()),
129 headers: {"Content-Type": proposalReq.contentType},
130 body: proposalReq.body,
131 );
132
133 await finalProposal.processRes(
134 res: request.bodyBytes,
135 ohttpContext: proposalCtx,
136 );
137
138 return await finalProposal.psbt();
139 }
140
141 Future<PayjoinProposal> processPayjoinProposal(UncheckedProposal proposal) async {
142 await proposal.extractTxToScheduleBroadcast();
143 // TODO Handle this. send to the main port on a timer?
144
145 try {
146 // Receive Check 1: can broadcast
147 final pj1 = await proposal.assumeInteractiveReceiver();
148
149 // Receive Check 2: original PSBT has no receiver-owned inputs
150 final pj2 = await pj1.checkInputsNotOwned(
151 isOwned: (inputScript) async {
152 final result = await _sendRequest(
153 PayjoinReceiverRequestTypes.checkIsOwned,
154 {'input_script': inputScript},
155 );
156 return result as bool;
157 },
158 );
159 // Receive Check 3: sender inputs have not been seen before (prevent probing attacks)
160 final pj3 = await pj2.checkNoInputsSeenBefore(isKnown: (input) => false);
161
162 // Identify receiver outputs
163 final pj4 = await pj3.identifyReceiverOutputs(
164 isReceiverOutput: (outputScript) async {
165 final result = await _sendRequest(
166 PayjoinReceiverRequestTypes.checkIsReceiverOutput,
167 {'output_script': outputScript},
168 );
169 return result as bool;
170 },
171 );
172 final pj5 = await pj4.commitOutputs();
173
174 final listUnspent = await _sendRequest(PayjoinReceiverRequestTypes.getCandidateInputs);
175 final unspent = listUnspent as List<UtxoWithPrivateKey>;
176 if (unspent.isEmpty) throw RecoverableError('No unspent outputs available');
177
178 final candidateInputs = await Future.wait(unspent.map(_inputPairFromUtxo));
179
180 // Prefer a UTXO that avoids the Unnecessary Input Heuristic (UIH2);
181 // fall back to the first candidate if none preserves privacy.
182 InputPair selectedUtxo = candidateInputs.first;
183 try {
184 selectedUtxo = await pj5.tryPreservingPrivacy(candidateInputs: candidateInputs);
185 } catch (_) {}
186
187 final pj6 = await pj5.contributeInputs(replacementInputs: [selectedUtxo]);
188 final pj7 = await pj6.commitInputs();
189
190 // Finalize proposal
191 final payjoinProposal = await pj7.finalizeProposal(
192 processPsbt: (String psbt) async {
193 final result =
194 await _sendRequest(PayjoinReceiverRequestTypes.processPsbt, {'psbt': psbt});
195 return result as String;
196 },
197 // TODO set maxFeeRateSatPerVb
198 maxFeeRateSatPerVb: BigInt.from(10000),
199 );
200 return payjoinProposal;
201 } catch (e) {
202 printV('Error occurred while finalizing proposal: $e');
203 rethrow;
204 }
205 }
206
207 Future<InputPair> _inputPairFromUtxo(UtxoWithPrivateKey utxo) async {
208 final txout = TxOut(
209 value: utxo.utxo.value,
210 scriptPubkey: Uint8List.fromList(utxo.ownerDetails.address.toScriptPubKey().toBytes()),
211 );
212
213 final psbtin = PsbtInput(witnessUtxo: txout, redeemScript: null, witnessScript: null);
214
215 final previousOutput = OutPoint(txid: utxo.utxo.txHash, vout: utxo.utxo.vout);
216
217 final txin = TxIn(
218 previousOutput: previousOutput,
219 scriptSig: await Script.newInstance(rawOutputScript: []),
220 witness: [],
221 sequence: 0,
222 );
223
224 return InputPair.newInstance(txin: txin, psbtin: psbtin);
225 }
226 }