Skip to content

Commit 8b7db6a

Browse files
authored
Merge pull request #1 from kumulynja/payjoin-21
Change internal ffi parameter types for exported types + update example
2 parents 5d7cc99 + 4b59d0e commit 8b7db6a

File tree

2 files changed

+119
-71
lines changed

2 files changed

+119
-71
lines changed

example/lib/screens/home.dart

Lines changed: 54 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -557,6 +557,12 @@ class _HomeState extends State<Home> {
557557
uncheckedProposal = proposal;
558558
});
559559

560+
// Extract the original transaction from the proposal in case you want
561+
// to broadcast it if the sender doesn't finalize the payjoin
562+
final originalTxBytes = await proposal.extractTxToScheduleBroadcast();
563+
final originalTx =
564+
await bdk.Transaction.fromBytes(transactionBytes: originalTxBytes);
565+
560566
// Process the proposal through the various checks
561567
final maybeInputsOwned = await proposal.assumeInteractiveReceiver();
562568

@@ -611,7 +617,6 @@ class _HomeState extends State<Home> {
611617
payjoinProposal = finalProposal;
612618
});
613619

614-
// Wait for transaction broadcast
615620
final proposalPsbt = await finalProposal.psbt();
616621
final proposalTxId = await payjoinManager.getTxIdFromPsbt(proposalPsbt);
617622
debugPrint('Receiver proposal tx: $proposalTxId');
@@ -628,8 +633,27 @@ class _HomeState extends State<Home> {
628633
[],
629634
(previous, element) => previous..addAll(element),
630635
);
631-
finalProposal.processRes(res: responseBody, ohttpContext: proposalCtx);
632-
// Await sender broadcast...
636+
await finalProposal.processRes(
637+
res: responseBody, ohttpContext: proposalCtx);
638+
639+
// Wait for the payjoin transaction to be broadcasted by the sender
640+
// Still possible the payjoin wasn't finalized and the original tx was
641+
// broadcasted instead by the sender, so also check for that
642+
// You could also put a timeout on waiting for the transaction and then
643+
// broadcast the original tx yourself if no transaction is received
644+
final receivedTxId = await waitForTransaction(
645+
originalTxId: await originalTx.txid(),
646+
proposalTxId: proposalTxId,
647+
);
648+
resetPayjoinSession();
649+
650+
if (receivedTxId.isNotEmpty) {
651+
showBottomSheet(
652+
'${receivedTxId == proposalTxId ? 'Payjoin' : 'Original'} tx received!',
653+
toCopy: receivedTxId,
654+
toUrl: 'https://mutinynet.com/tx/$receivedTxId',
655+
);
656+
}
633657
} catch (e) {
634658
debugPrint(e.toString());
635659
if (e is PayjoinException) {
@@ -673,6 +697,33 @@ class _HomeState extends State<Home> {
673697
});
674698
}
675699

700+
Future<String> waitForTransaction({
701+
required String originalTxId,
702+
required String proposalTxId,
703+
int timeout = 1,
704+
}) async {
705+
debugPrint('Waiting for payjoin tx to be sent...');
706+
await syncWallet();
707+
final txs = wallet.listTransactions(includeRaw: false);
708+
try {
709+
final tx = txs.firstWhere(
710+
(tx) => tx.txid == originalTxId || tx.txid == proposalTxId);
711+
debugPrint('Tx found: ${tx.txid}');
712+
return tx.txid;
713+
} catch (e) {
714+
debugPrint('Tx not found, retrying after $timeout second(s)...');
715+
if (v2Session == null) {
716+
// The session was canceled, stop polling
717+
return '';
718+
}
719+
await Future.delayed(Duration(seconds: timeout));
720+
return waitForTransaction(
721+
originalTxId: originalTxId,
722+
proposalTxId: proposalTxId,
723+
);
724+
}
725+
}
726+
676727
void resetPayjoinSession() {
677728
setState(() {
678729
pjUri = '';

lib/receive.dart

Lines changed: 65 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,13 @@ import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated.dart';
55
import 'common.dart';
66
import 'src/exceptions.dart';
77
import 'src/generated/api/receive.dart';
8-
import 'src/generated/api/bitcoin_ffi.dart';
98
import 'src/generated/utils/error.dart' as error;
109
import 'uri.dart';
10+
import 'bitcoin_ffi.dart';
1111

12-
class Receiver extends FfiReceiver {
13-
Receiver._({required super.field0});
12+
class Receiver {
13+
final FfiReceiver _ffiReceiver;
14+
Receiver._({required ffiReceiver}) : _ffiReceiver = ffiReceiver;
1415

1516
static Future<Receiver> create(
1617
{required String address,
@@ -27,16 +28,15 @@ class Receiver extends FfiReceiver {
2728
address: address,
2829
expireAfter: expireAfter,
2930
network: network);
30-
return Receiver._(field0: res.field0);
31+
return Receiver._(ffiReceiver: res);
3132
} on error.PayjoinError catch (e) {
3233
throw mapPayjoinError(e);
3334
}
3435
}
3536

36-
@override
3737
Future<(Request, ClientResponse)> extractReq() async {
3838
try {
39-
final res = await super.extractReq();
39+
final res = await _ffiReceiver.extractReq();
4040
final request = Request(
4141
url: await Url.fromStr(res.$1.url.asString()),
4242
contentType: res.$1.contentType,
@@ -48,13 +48,12 @@ class Receiver extends FfiReceiver {
4848
}
4949
}
5050

51-
@override
5251
Future<UncheckedProposal?> processRes(
5352
{required List<int> body, required ClientResponse ctx}) async {
5453
try {
55-
final res = await super.processRes(body: body, ctx: ctx);
54+
final res = await _ffiReceiver.processRes(body: body, ctx: ctx);
5655
if (res != null) {
57-
return UncheckedProposal._(field0: res.field0);
56+
return UncheckedProposal._(ffiUncheckedProposal: res);
5857
} else {
5958
return null;
6059
}
@@ -65,27 +64,26 @@ class Receiver extends FfiReceiver {
6564

6665
/// The contents of the `&pj=` query parameter including the base64url-encoded public key receiver subdirectory.
6766
/// This identifies a session at the payjoin directory server.
68-
@override
6967
Future<Url> pjUrl() async {
70-
final res = await super.pjUrl();
68+
final res = await _ffiReceiver.pjUrl();
7169
return Url.fromStr(res.asString());
7270
}
7371

74-
@override
7572
PjUriBuilder pjUriBuilder() {
76-
final res = super.pjUriBuilder();
73+
final res = _ffiReceiver.pjUriBuilder();
7774
return PjUriBuilder(internal: res.internal);
7875
}
7976
}
8077

81-
class UncheckedProposal extends FfiUncheckedProposal {
82-
UncheckedProposal._({required super.field0});
78+
class UncheckedProposal {
79+
final FfiUncheckedProposal _ffiUncheckedProposal;
80+
UncheckedProposal._({required ffiUncheckedProposal})
81+
: _ffiUncheckedProposal = ffiUncheckedProposal;
8382

8483
///The Sender’s Original PSBT
85-
@override
8684
Future<Uint8List> extractTxToScheduleBroadcast({hint}) async {
8785
try {
88-
return super.extractTxToScheduleBroadcast();
86+
return _ffiUncheckedProposal.extractTxToScheduleBroadcast();
8987
} on error.PayjoinError catch (e) {
9088
throw mapPayjoinError(e);
9189
}
@@ -95,15 +93,14 @@ class UncheckedProposal extends FfiUncheckedProposal {
9593
/// Receiver MUST check that the Original PSBT from the sender can be broadcast, i.e. testmempoolaccept bitcoind rpc returns { “allowed”: true,.. } for gettransactiontocheckbroadcast() before calling this method.
9694
/// Do this check if you generate bitcoin uri to receive Payjoin on sender request without manual human approval, like a payment processor. Such so called “non-interactive” receivers are otherwise vulnerable to probing attacks. If a sender can make requests at will, they can learn which bitcoin the receiver owns at no cost. Broadcasting the Original PSBT after some time in the failure case makes incurs sender cost and prevents probing.
9795
/// Call this after checking downstream.
98-
@override
9996
Future<MaybeInputsOwned> checkBroadcastSuitability(
10097
{BigInt? minFeeRate,
10198
required FutureOr<bool> Function(Uint8List p1) canBroadcast,
10299
hint}) async {
103100
try {
104-
final res = await super.checkBroadcastSuitability(
101+
final res = await _ffiUncheckedProposal.checkBroadcastSuitability(
105102
minFeeRate: minFeeRate, canBroadcast: canBroadcast);
106-
return MaybeInputsOwned._(field0: res.field0);
103+
return MaybeInputsOwned._(ffiMaybeInputsOwned: res);
107104
} on error.PayjoinError catch (e) {
108105
throw mapPayjoinError(e);
109106
}
@@ -112,120 +109,123 @@ class UncheckedProposal extends FfiUncheckedProposal {
112109
///Call this method if the only way to initiate a Payjoin with this receiver requires manual intervention, as in most consumer wallets.
113110
/// So-called “non-interactive” receivers, like payment processors,
114111
/// that allow arbitrary requests are otherwise vulnerable to probing attacks. Those receivers call gettransactiontocheckbroadcast() and attesttestedandscheduledbroadcast() after making those checks downstream
115-
@override
116112
Future<MaybeInputsOwned> assumeInteractiveReceiver({hint}) async {
117113
try {
118-
final res = await super.assumeInteractiveReceiver();
119-
return MaybeInputsOwned._(field0: res.field0);
114+
final res = await _ffiUncheckedProposal.assumeInteractiveReceiver();
115+
return MaybeInputsOwned._(ffiMaybeInputsOwned: res);
120116
} on error.PayjoinError catch (e) {
121117
throw mapPayjoinError(e);
122118
}
123119
}
124120
}
125121

126-
class MaybeInputsOwned extends FfiMaybeInputsOwned {
127-
MaybeInputsOwned._({required super.field0});
122+
class MaybeInputsOwned {
123+
final FfiMaybeInputsOwned _ffiMaybeInputsOwned;
124+
MaybeInputsOwned._({required ffiMaybeInputsOwned})
125+
: _ffiMaybeInputsOwned = ffiMaybeInputsOwned;
128126

129127
///Check that the Original PSBT has no receiver-owned inputs. Return original-psbt-rejected error or otherwise refuse to sign undesirable inputs.
130128
/// An attacker could try to spend receiver's own inputs. This check prevents that.
131-
@override
132129
Future<MaybeInputsSeen> checkInputsNotOwned(
133130
{required FutureOr<bool> Function(Uint8List p1) isOwned, hint}) async {
134131
try {
135-
final res = await super.checkInputsNotOwned(isOwned: isOwned);
136-
return MaybeInputsSeen._(field0: res.field0);
132+
final res =
133+
await _ffiMaybeInputsOwned.checkInputsNotOwned(isOwned: isOwned);
134+
return MaybeInputsSeen._(ffiMaybeInputsSeen: res);
137135
} on error.PayjoinError catch (e) {
138136
throw mapPayjoinError(e);
139137
}
140138
}
141139
}
142140

143-
class MaybeInputsSeen extends FfiMaybeInputsSeen {
144-
MaybeInputsSeen._({required super.field0});
141+
class MaybeInputsSeen {
142+
final FfiMaybeInputsSeen _ffiMaybeInputsSeen;
143+
MaybeInputsSeen._({required ffiMaybeInputsSeen})
144+
: _ffiMaybeInputsSeen = ffiMaybeInputsSeen;
145145

146146
/// Make sure that the original transaction inputs have never been seen before.
147147
/// This prevents probing attacks. This prevents reentrant Payjoin, where a sender
148148
/// proposes a Payjoin PSBT as a new Original PSBT for a new Payjoin.
149-
@override
150149
Future<OutputsUnknown> checkNoInputsSeenBefore(
151150
{required FutureOr<bool> Function(OutPoint p1) isKnown, hint}) async {
152151
try {
153-
final res = await super.checkNoInputsSeenBefore(isKnown: isKnown);
154-
return OutputsUnknown._(field0: res.field0);
152+
final res =
153+
await _ffiMaybeInputsSeen.checkNoInputsSeenBefore(isKnown: isKnown);
154+
return OutputsUnknown._(ffiOutputsUnknown: res);
155155
} on error.PayjoinError catch (e) {
156156
throw mapPayjoinError(e);
157157
}
158158
}
159159
}
160160

161-
class OutputsUnknown extends FfiOutputsUnknown {
162-
OutputsUnknown._({required super.field0});
161+
class OutputsUnknown {
162+
final FfiOutputsUnknown _ffiOutputsUnknown;
163+
OutputsUnknown._({required ffiOutputsUnknown})
164+
: _ffiOutputsUnknown = ffiOutputsUnknown;
163165

164166
/// Find which outputs belong to the receiver
165-
@override
166167
Future<WantsOutputs> identifyReceiverOutputs(
167168
{required FutureOr<bool> Function(Uint8List p1) isReceiverOutput,
168169
hint}) async {
169170
try {
170-
final res = await super
171-
.identifyReceiverOutputs(isReceiverOutput: isReceiverOutput);
172-
return WantsOutputs._(field0: res.field0);
171+
final res = await _ffiOutputsUnknown.identifyReceiverOutputs(
172+
isReceiverOutput: isReceiverOutput);
173+
return WantsOutputs._(ffiWantsOutputs: res);
173174
} on error.PayjoinError catch (e) {
174175
throw mapPayjoinError(e);
175176
}
176177
}
177178
}
178179

179-
class WantsOutputs extends FfiWantsOutputs {
180-
WantsOutputs._({required super.field0});
180+
class WantsOutputs {
181+
final FfiWantsOutputs _ffiWantsOutputs;
182+
WantsOutputs._({required ffiWantsOutputs})
183+
: _ffiWantsOutputs = ffiWantsOutputs;
181184

182-
@override
183185
Future<bool> isOutputSubstitutionDisabled({hint}) {
184186
try {
185-
return super.isOutputSubstitutionDisabled();
187+
return _ffiWantsOutputs.isOutputSubstitutionDisabled();
186188
} on error.PayjoinError catch (e) {
187189
throw mapPayjoinError(e);
188190
}
189191
}
190192

191-
@override
192193
Future<WantsOutputs> replaceReceiverOutputs(
193194
{required List<TxOut> replacementOutputs,
194-
required FfiScript drainScript}) async {
195+
required Script drainScript}) async {
195196
try {
196-
final res = await super.replaceReceiverOutputs(
197+
final res = await _ffiWantsOutputs.replaceReceiverOutputs(
197198
replacementOutputs: replacementOutputs, drainScript: drainScript);
198-
return WantsOutputs._(field0: res.field0);
199+
return WantsOutputs._(ffiWantsOutputs: res);
199200
} on error.PayjoinError catch (e) {
200201
throw mapPayjoinError(e);
201202
}
202203
}
203204

204-
@override
205205
Future<WantsOutputs> substituteReceiverScript(
206-
{required FfiScript outputScript}) async {
206+
{required Script outputScript}) async {
207207
try {
208-
final res =
209-
await super.substituteReceiverScript(outputScript: outputScript);
210-
return WantsOutputs._(field0: res.field0);
208+
final res = await _ffiWantsOutputs.substituteReceiverScript(
209+
outputScript: outputScript);
210+
return WantsOutputs._(ffiWantsOutputs: res);
211211
} on error.PayjoinError catch (e) {
212212
throw mapPayjoinError(e);
213213
}
214214
}
215215

216-
@override
217216
Future<WantsInputs> commitOutputs() async {
218217
try {
219-
final res = await super.commitOutputs();
220-
return WantsInputs._(field0: res.field0);
218+
final res = await _ffiWantsOutputs.commitOutputs();
219+
return WantsInputs._(ffiWantsInputs: res);
221220
} on error.PayjoinError catch (e) {
222221
throw mapPayjoinError(e);
223222
}
224223
}
225224
}
226225

227-
class WantsInputs extends FfiWantsInputs {
228-
WantsInputs._({required super.field0});
226+
class WantsInputs {
227+
final FfiWantsInputs _ffiWantsInputs;
228+
WantsInputs._({required ffiWantsInputs}) : _ffiWantsInputs = ffiWantsInputs;
229229

230230
/// Select receiver input such that the payjoin avoids surveillance.
231231
/// Return the input chosen that has been applied to the Proposal.
@@ -238,34 +238,31 @@ class WantsInputs extends FfiWantsInputs {
238238
/// BlockSci UIH1 and UIH2:
239239
/// if min(out) < min(in) then UIH1 else UIH2
240240
/// https://eprint.iacr.org/2022/589.pdf
241-
@override
242241
Future<InputPair> tryPreservingPrivacy(
243-
{required List<FfiInputPair> candidateInputs}) async {
242+
{required List<InputPair> candidateInputs}) async {
244243
try {
245-
final res =
246-
await super.tryPreservingPrivacy(candidateInputs: candidateInputs);
244+
final res = await _ffiWantsInputs.tryPreservingPrivacy(
245+
candidateInputs: candidateInputs);
247246
return InputPair._(field0: res.field0);
248247
} on error.PayjoinError catch (e) {
249248
throw mapPayjoinError(e);
250249
}
251250
}
252251

253-
@override
254252
Future<WantsInputs> contributeInputs(
255-
{required List<FfiInputPair> replacementInputs}) async {
253+
{required List<InputPair> replacementInputs}) async {
256254
try {
257-
final res =
258-
await super.contributeInputs(replacementInputs: replacementInputs);
259-
return WantsInputs._(field0: res.field0);
255+
final res = await _ffiWantsInputs.contributeInputs(
256+
replacementInputs: replacementInputs);
257+
return WantsInputs._(ffiWantsInputs: res);
260258
} on error.PayjoinError catch (e) {
261259
throw mapPayjoinError(e);
262260
}
263261
}
264262

265-
@override
266263
Future<ProvisionalProposal> commitInputs() async {
267264
try {
268-
final res = await super.commitInputs();
265+
final res = await _ffiWantsInputs.commitInputs();
269266
return ProvisionalProposal._(field0: res.field0);
270267
} on error.PayjoinError catch (e) {
271268
throw mapPayjoinError(e);

0 commit comments

Comments
 (0)