Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Separate receiver and sender logic in steps #13

Merged
merged 8 commits into from
Jun 24, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 42 additions & 28 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,33 +1,47 @@
## [0.13.0-dev.2]

### Fixed

- Fix `assumeInteractiveReceiver` return type

### Refactor

- Separate receiver and sender logic in example app.

## [0.13.0]

### Features & Modules

#### Send module
- ##### V1
- `RequestBuilder` exposes `fromPsbtAndUri`, `buildWithAdditionalFee`, `buildRecommended`, `buildNonIncentivizing`, `alwaysDisableOutputSubstitution`.
- `RequestContext` exposes `extractContextV1` & `extractContextV2`.
- `ContextV1` exposes `processResponse`.
- ##### V2
- `ContextV2` exposes `processResponse`.

- ##### V1
- `RequestBuilder` exposes `fromPsbtAndUri`, `buildWithAdditionalFee`, `buildRecommended`, `buildNonIncentivizing`, `alwaysDisableOutputSubstitution`.
- `RequestContext` exposes `extractContextV1` & `extractContextV2`.
- `ContextV1` exposes `processResponse`.
- ##### V2
- `ContextV2` exposes `processResponse`.

#### Receive module
- ##### V1
- `UncheckedProposal` exposes `fromRequest`, `extractTxToScheduleBroadcast`, `checkBroadcastSuitability`, `buildNonIncentivizing`,
`assumeInteractiveReceiver` &`alwaysDisableOutputSubstitution`.
- `MaybeInputsOwned` exposes `checkInputsNotOwned`.
- `MaybeMixedInputScripts` exposes `checkNoMixedInputScripts`.
- `MaybeInputsSeen` exposes `checkNoInputsSeenBefore`.
- `OutputsUnknown` exposes `identifyReceiverOutputs`.
- `ProvisionalProposal` exposes `substituteOutputAddress`, `contributeNonWitnessInput`, `contributeWitnessInput`, `tryPreservingPrivacy` &
`finalizeProposal`.
- `PayjoinProposal` exposes `isOutputSubstitutionDisabled`, `ownedVouts`, `psbt` & `utxosToBeLocked`.
- ##### V2
- `Enroller` exposes `fromDirectoryConfig`, `processResponse` & `extractRequest`.
- `Enrolled` exposes `extractRequest`, `processResponse` & `fallbackTarget`.
- `UncheckedProposal` exposes `extractTxToScheduleBroadcast`, `checkBroadcastSuitability` & `assumeInteractiveReceiver`.
- `MaybeInputsOwned` exposes `checkInputsNotOwned`.
- `MaybeMixedInputScripts` exposes `checkNoMixedInputScripts`.
- `MaybeInputsSeen` exposes `checkNoInputsSeenBefore`.
- `OutputsUnknown` exposes `identifyReceiverOutputs`.
- `ProvisionalProposal` exposes `substituteOutputAddress`, `contributeNonWitnessInput`, `contributeWitnessInput`, `tryPreservingPrivacy` &
`finalizeProposal`.
- `PayjoinProposal` exposes `deserializeRes`, `extractV1Req`, `extractV2Request`, `isOutputSubstitutionDisabled`, `ownedVouts`, `psbt` &
`utxosToBeLocked`.

- ##### V1
- `UncheckedProposal` exposes `fromRequest`, `extractTxToScheduleBroadcast`, `checkBroadcastSuitability`, `buildNonIncentivizing`,
`assumeInteractiveReceiver` &`alwaysDisableOutputSubstitution`.
- `MaybeInputsOwned` exposes `checkInputsNotOwned`.
- `MaybeMixedInputScripts` exposes `checkNoMixedInputScripts`.
- `MaybeInputsSeen` exposes `checkNoInputsSeenBefore`.
- `OutputsUnknown` exposes `identifyReceiverOutputs`.
- `ProvisionalProposal` exposes `substituteOutputAddress`, `contributeNonWitnessInput`, `contributeWitnessInput`, `tryPreservingPrivacy` &
`finalizeProposal`.
- `PayjoinProposal` exposes `isOutputSubstitutionDisabled`, `ownedVouts`, `psbt` & `utxosToBeLocked`.
- ##### V2
- `Enroller` exposes `fromDirectoryConfig`, `processResponse` & `extractRequest`.
- `Enrolled` exposes `extractRequest`, `processResponse` & `fallbackTarget`.
- `UncheckedProposal` exposes `extractTxToScheduleBroadcast`, `checkBroadcastSuitability` & `assumeInteractiveReceiver`.
- `MaybeInputsOwned` exposes `checkInputsNotOwned`.
- `MaybeMixedInputScripts` exposes `checkNoMixedInputScripts`.
- `MaybeInputsSeen` exposes `checkNoInputsSeenBefore`.
- `OutputsUnknown` exposes `identifyReceiverOutputs`.
- `ProvisionalProposal` exposes `substituteOutputAddress`, `contributeNonWitnessInput`, `contributeWitnessInput`, `tryPreservingPrivacy` &
`finalizeProposal`.
- `PayjoinProposal` exposes `deserializeRes`, `extractV1Req`, `extractV2Request`, `isOutputSubstitutionDisabled`, `ownedVouts`, `psbt` &
`utxosToBeLocked`.
9 changes: 7 additions & 2 deletions example/integration_test/bitcoin_core_full_cycle_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import 'package:flutter/cupertino.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:payjoin_flutter/common.dart' as common;
import 'package:payjoin_flutter/send.dart';
import 'package:payjoin_flutter/uri.dart' as pay_join_uri;
import 'package:payjoin_flutter_example/btc_client.dart';
import 'package:payjoin_flutter_example/payjoin_library.dart';
Expand Down Expand Up @@ -35,11 +36,15 @@ void main() {
final amount = await uri.amount();
final senderPsbt =
(await sender.walletCreateFundedPsbt(amount, address, 2000))["psbt"];
final requestContext = await (await RequestBuilder.fromPsbtAndUri(
psbtBase64: senderPsbt, uri: uri))
.buildRecommended(minFeeRate: 0);
final (_, ctx) = await requestContext.extractContextV1();
debugPrint(
"\nOriginal sender psbt: $senderPsbt",
);
final (provisionalProposal, ctx) =
await payJoinLib.handlePjRequest(senderPsbt, pjUri, (e) async {
final provisionalProposal =
await payJoinLib.handlePjRequest(senderPsbt, (e) async {
final script = ScriptBuf(bytes: e);
final address = await (await Address.fromScript(
script: script, network: Network.regtest))
Expand Down
2 changes: 0 additions & 2 deletions example/lib/bdk_client.dart
Original file line number Diff line number Diff line change
Expand Up @@ -82,8 +82,6 @@ class BdkClient {

Future<int> getBalance() async {
final balance = await wallet.getBalance();
final res = "Total Balance: ${balance.total.toString()}";
debugPrint(res);
return balance.total;
}

Expand Down
104 changes: 62 additions & 42 deletions example/lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import 'package:bdk_flutter/bdk_flutter.dart';
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:payjoin_flutter/common.dart' as common;
import 'package:payjoin_flutter/send.dart';
import 'package:payjoin_flutter/uri.dart' as pay_join_uri;
import 'package:payjoin_flutter_example/bdk_client.dart';
import 'package:payjoin_flutter_example/payjoin_library.dart';
Expand Down Expand Up @@ -50,7 +51,9 @@ class _PayJoinState extends State<PayJoin> {
String displayText = "";
String pjUri = "";
late PartiallySignedTransaction senderPsbt;
late PartiallySignedTransaction processedAndFinalizedPsbt;
late String receiverPsbtBase64;
late ContextV1 contextV1;

@override
void initState() {
sender.restoreWallet();
Expand Down Expand Up @@ -112,6 +115,8 @@ class _PayJoinState extends State<PayJoin> {
setState(() {
displayText = "sync complete";
});
debugPrint(
"receiver balance: ${(await receiver.getBalance()).toString()}");
debugPrint(
"sender balance: ${(await sender.getBalance()).toString()}");
},
Expand Down Expand Up @@ -140,37 +145,50 @@ class _PayJoinState extends State<PayJoin> {
fontWeight: FontWeight.w800),
)),
TextButton(
onPressed: () async {
final balance = await sender.getBalance();
debugPrint("Sender Balance: ${balance.toString()}");
final uri = await pay_join_uri.Uri.fromString(pjUri);
final address = await uri.address();
int amount =
(((await uri.amount()) ?? 0) * 100000000).toInt();
final psbt = (await sender.createPsbt(address, amount, 2000));
debugPrint(
"\nOriginal sender psbt: ${await psbt.serialize()}",
);
setState(() {
senderPsbt = psbt;
});
},
child: Text(
"Create Sender psbt using receiver pjUri",
style: GoogleFonts.manrope(
color: Colors.black,
fontSize: 14,
fontWeight: FontWeight.w800),
)),
onPressed: () async {
final balance = await sender.getBalance();
debugPrint("Sender Balance: ${balance.toString()}");
final uri = await pay_join_uri.Uri.fromString(pjUri);
final address = await uri.address();
int amount = (((await uri.amount()) ?? 0) * 100000000).toInt();
final psbt = (await sender.createPsbt(address, amount, 2000));
debugPrint(
"\nOriginal sender psbt: ${await psbt.serialize()}",
);

final requestContext =
(await (await RequestBuilder.fromPsbtAndUri(
psbtBase64: await psbt.serialize(), uri: uri))
.buildRecommended(minFeeRate: 0));
// In a real scenario, the sender would send the request to
// the payjoin endpoint of the receiver, here we just keep
// the context for the last step.
final (_, ctx) = await requestContext.extractContextV1();

setState(() {
senderPsbt = psbt;
contextV1 = ctx;
});
},
child: Text(
"Create Sender request psbt using receiver pjUri",
style: GoogleFonts.manrope(
color: Colors.black,
fontSize: 14,
fontWeight: FontWeight.w800),
),
),
TextButton(
onPressed: () async {
final (provisionalProposal, contextV1) = await payJoinLibrary
.handlePjRequest(await senderPsbt.serialize(), pjUri,
(e) async {
final script = ScriptBuf(bytes: e);
final provisionalProposal =
await payJoinLibrary.handlePjRequest(
await senderPsbt.serialize(),
(e) async {
final script = ScriptBuf(bytes: e);

return (await receiver.getAddressInfo(script));
});
return (await receiver.getAddressInfo(script));
},
);
final unspent = await receiver.listUnspent();
// Select receiver payjoin inputs.
Map<int, common.OutPoint> candidateInputs = {
Expand All @@ -183,7 +201,7 @@ class _PayJoinState extends State<PayJoin> {
.tryPreservingPrivacy(candidateInputs: candidateInputs);
var selectedUtxo = unspent.firstWhere(
(i) =>
i.outpoint.txid.toString() == selectedOutpoint.txid &&
i.outpoint.txid == selectedOutpoint.txid &&
i.outpoint.vout == selectedOutpoint.vout,
orElse: () => throw Exception('UTXO not found'));
var txoToContribute = common.TxOut(
Expand All @@ -203,37 +221,39 @@ class _PayJoinState extends State<PayJoin> {
address: await newReceiverAddress.address.asString());
final payJoinProposal = await provisionalProposal
.finalizeProposal(processPsbt: (e) async {
debugPrint("\n Original receiver unsigned psbt: $e");
debugPrint("\n Receiver response unsigned psbt: $e");
return await (await receiver.signPsbt(
await PartiallySignedTransaction.fromString(e)))
.serialize();
});
final receiverPsbt = await payJoinProposal.psbt();
debugPrint("\n Original receiver psbt: $receiverPsbt");
final receiverProcessedPsbt = await contextV1.processResponse(
response: utf8.encode(receiverPsbt));
final senderProcessedPsbt = (await sender.signPsbt(
await PartiallySignedTransaction.fromString(
receiverProcessedPsbt)));
debugPrint("\n Receiver response psbt: $receiverPsbt");
setState(() {
processedAndFinalizedPsbt = senderProcessedPsbt;
receiverPsbtBase64 = receiverPsbt;
});
},
child: Text(
"Process and finalize receiver Pj request",
"Create Receiver response psbt",
style: GoogleFonts.manrope(
color: Colors.black,
fontSize: 14,
fontWeight: FontWeight.w800),
)),
TextButton(
onPressed: () async {
final res =
await sender.broadcastPsbt(processedAndFinalizedPsbt);
final processedReceiverResponsePsbt =
await contextV1.processResponse(
response: utf8.encode(receiverPsbtBase64));
final finalizedPsbt = (await sender.signPsbt(
await PartiallySignedTransaction.fromString(
processedReceiverResponsePsbt)));
debugPrint(
'Processed and finalized sender psbt: ${await finalizedPsbt.serialize()}');
final res = await sender.broadcastPsbt(finalizedPsbt);
debugPrint("Broadcast success: $res");
},
child: Text(
"Broadcast processed psbt",
"Process response and broadcast final Sender psbt",
style: GoogleFonts.manrope(
color: Colors.black,
fontSize: 14,
Expand Down
65 changes: 40 additions & 25 deletions example/lib/payjoin_library.dart
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import 'dart:async';
import 'dart:convert';
import 'dart:typed_data';

import 'package:flutter/cupertino.dart';
import 'package:payjoin_flutter/common.dart' as common;
import 'package:payjoin_flutter/receive/v1.dart' as v1;
import 'package:payjoin_flutter/receive/v2.dart';
import 'package:payjoin_flutter/send.dart' as send;
import 'package:payjoin_flutter/uri.dart' as pj_uri;

Expand All @@ -22,39 +24,52 @@ class PayJoinLibrary {
}
}

Future<(v1.ProvisionalProposal, send.ContextV1)> handlePjRequest(
String psbtBase64,
String uriStr,
Future<bool> Function(Uint8List) isOwned) async {
final uri = await pj_uri.Uri.fromString(uriStr);
final (req, cxt) = await (await (await send.RequestBuilder.fromPsbtAndUri(
psbtBase64: psbtBase64, uri: uri))
.buildWithAdditionalFee(
maxFeeContribution: 10000,
minFeeRate: 0,
clampFeeContribution: false))
.extractContextV1();
Future<v1.ProvisionalProposal> handlePjRequest(
String psbtBase64, Future<bool> Function(Uint8List) isOwned) async {
final body = utf8.encode(psbtBase64);

final headers = common.Headers(map: {
'content-type': 'text/plain',
'content-length': req.body.length.toString(),
'content-length': body.length.toString(),
});
final unchecked = await v1.UncheckedProposal.fromRequest(
body: req.body.toList(),
query: (await req.url.query())!,
headers: headers);
final provisionalProposal = await handleUnckedProposal(unchecked, isOwned);
return (provisionalProposal, cxt);
body: body.toList(), query: '', headers: headers);
final provisionalProposal =
await handleUncheckedProposal(unchecked, isOwned);
return provisionalProposal;
}

Future<v1.ProvisionalProposal> handleUnckedProposal(
Future<v1.ProvisionalProposal> handleUncheckedProposal(
v1.UncheckedProposal uncheckedProposal,
Future<bool> Function(Uint8List) isOwned) async {
// in a payment processor where the sender could go offline, this is where you schedule to broadcast the original_tx
var _ = await uncheckedProposal.extractTxToScheduleBroadcast();
final inputsOwned = await uncheckedProposal.checkBroadcastSuitability(
canBroadcast: (e) async {
return true;
});
// A consumer wallet has some manual interaction to initiate a payjoin, it
// is not a server that can receive a lot of requests without the user
// being aware of it. Therefore we say a consumer wallet app is an
// interactive receiver and an automatic payment processor is
// non-interactive.
//
// The way to check a proposal for these cases are different:
// - For an interactive receiver, you can just call
// `assumeInteractiveReceiver` as used here in the example code.
// - For a non-interactive receiver, you would extract the original tx
// with `extractTxToScheduleBroadcast` and check if it can be
// broadcasted in `checkBroadcastSuitability`. This way, if the sender
// doesn't complete the payjoin, you can still broadcast the original
// tx and get your funds. This protects against sender maliciousness of
// probing your utxo set amongst other things.

final inputsOwned = await uncheckedProposal.assumeInteractiveReceiver();
/*
// Non-interactive receiver example code:
final originalTx = await uncheckedProposal.extractTxToScheduleBroadcast();
final inputsOwned = await uncheckedProposal.checkBroadcastSuitability(
canBroadcast: (e) async {
// Here you would check if the original tx is a valid tx that pays you
// and that can be broadcasted.
return true;
});
*/

// Receive Check 2: receiver can't sign for proposal inputs
final mixedInputScripts =
await inputsOwned.checkInputsNotOwned(isOwned: isOwned);
Expand Down
4 changes: 2 additions & 2 deletions example/pubspec.lock
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,8 @@ packages:
dependency: "direct main"
description:
path: "."
ref: "v0.31.2-dev.2"
resolved-ref: "18649436038282f379e9a4679656bcd084434ddd"
ref: main
resolved-ref: "821fe90d20c955e3cdfbfecea3b91fe24885552a"
url: "https://github.com/LtbLightning/bdk-flutter"
source: git
version: "0.31.2-dev.2"
Expand Down
Loading