Pay with Payjoin (bip79)
MODIFY
WalletWasabi.Gui/Controls/WalletExplorer/SendControlViewModel.cs
WalletWasabi.Gui/Controls/WalletExplorer/SendControlViewModel.cs
@@ -38,6 +38,7 @@ using WalletWasabi.Hwi.Models;
using WalletWasabi.Logging;
using WalletWasabi.Models;
using WalletWasabi.Wallets;
+using WalletWasabi.WebClients.PayJoin;
namespace WalletWasabi.Gui.Controls.WalletExplorer
{
@@ -74,6 +75,7 @@ namespace WalletWasabi.Gui.Controls.WalletExplorer
private FeeDisplayFormat _feeDisplayFormat;
private bool _isSliderFeeUsed = true;
private double _feeControlOpacity;
+ private Uri _payjoinEndPoint;
protected SendControlViewModel(Wallet wallet, string title)
: base(title)
@@ -216,6 +218,15 @@ namespace WalletWasabi.Gui.Controls.WalletExplorer
{
AmountText = url.Amount.ToString(false, true);
}
+
+ if (url.UnknowParameters.TryGetValue("bpu", out var endPoint))
+ {
+ PayjoinEndPoint = new Uri(endPoint);
+ }
+ else
+ {
+ PayjoinEndPoint = null;
+ }
});
BuildTransactionCommand = ReactiveCommand.CreateFromTask(async () =>
@@ -352,7 +363,8 @@ namespace WalletWasabi.Gui.Controls.WalletExplorer
}
}
- BuildTransactionResult result = await Task.Run(() => Wallet.BuildTransaction(Password, intent, feeStrategy, allowUnconfirmed: true, allowedInputs: selectedCoinReferences));
+ IPayjoinClient payjoinClient = PayjoinEndPoint is {} ? (IPayjoinClient)new PayjoinClient(PayjoinEndPoint, Global.TorManager.TorSocks5EndPoint) : new NullPayjoinClient();
+ BuildTransactionResult result = await Task.Run(() => Wallet.BuildTransaction(Password, intent, feeStrategy, payjoinClient, allowUnconfirmed: true, allowedInputs: selectedCoinReferences));
await DoAfterBuildTransaction(result);
}
@@ -603,6 +615,12 @@ namespace WalletWasabi.Gui.Controls.WalletExplorer
private set => this.RaiseAndSetIfChanged(ref _isCustomChangeAddress, value);
}
+ public Uri PayjoinEndPoint
+ {
+ get => _payjoinEndPoint;
+ set => this.RaiseAndSetIfChanged(ref _payjoinEndPoint, value);
+ }
+
public ReactiveCommand<Unit, Unit> BuildTransactionCommand { get; }
public ReactiveCommand<Unit, bool> MaxCommand { get; }
@@ -897,7 +915,7 @@ namespace WalletWasabi.Gui.Controls.WalletExplorer
return ErrorDescriptors.Empty;
}
- if (AddressStringParser.TryParseBitcoinUrl(Address, Global.Network, out _))
+ if (AddressStringParser.TryParseBitcoinUrl(Address, Global.Network, out var url))
{
return ErrorDescriptors.Empty;
}

MODIFY
WalletWasabi.Tests/RegressionTests/BuildTests.cs
WalletWasabi.Tests/RegressionTests/BuildTests.cs
@@ -118,6 +118,7 @@ namespace WalletWasabi.Tests.RegressionTests
new DestinationRequest(scp, MoneyRequest.CreateAllRemaining(), "zero"),
new DestinationRequest(scp, MoneyRequest.CreateAllRemaining(), "zero")),
FeeStrategy.SevenDaysConfirmationTargetStrategy,
+ null,
false));
// Get some money, make it confirm.
@@ -145,30 +146,30 @@ namespace WalletWasabi.Tests.RegressionTests
var operations = new PaymentIntent(
new DestinationRequest(scp, Money.Coins(1m), subtractFee: true),
new DestinationRequest(scp, Money.Coins(0.5m)));
- Assert.Throws<InsufficientBalanceException>(() => wallet.BuildTransaction(password, operations, FeeStrategy.TwentyMinutesConfirmationTargetStrategy, false));
+ Assert.Throws<InsufficientBalanceException>(() => wallet.BuildTransaction(password, operations, FeeStrategy.TwentyMinutesConfirmationTargetStrategy, null, false));
// No enough money (only one confirmed coin, no unconfirmed allowed)
operations = new PaymentIntent(scp, Money.Coins(1.5m));
Assert.Throws<InsufficientBalanceException>(() => wallet.BuildTransaction(null, operations, FeeStrategy.TwentyMinutesConfirmationTargetStrategy));
// No enough money (only one confirmed coin, unconfirmed allowed)
- Assert.Throws<InsufficientBalanceException>(() => wallet.BuildTransaction(null, operations, FeeStrategy.TwentyMinutesConfirmationTargetStrategy, true));
+ Assert.Throws<InsufficientBalanceException>(() => wallet.BuildTransaction(null, operations, FeeStrategy.TwentyMinutesConfirmationTargetStrategy, null, true));
// Add new money with no confirmation
var txId2 = await rpc.SendToAddressAsync(keyManager.GetNextReceiveKey("bar", out _).GetP2wpkhAddress(network), Money.Coins(2m));
await Task.Delay(1000); // Wait tx to arrive and get processed.
// Enough money (one confirmed coin and one unconfirmed coin, unconfirmed are NOT allowed)
- Assert.Throws<InsufficientBalanceException>(() => wallet.BuildTransaction(null, operations, FeeStrategy.TwentyMinutesConfirmationTargetStrategy, false));
+ Assert.Throws<InsufficientBalanceException>(() => wallet.BuildTransaction(null, operations, FeeStrategy.TwentyMinutesConfirmationTargetStrategy, null, false));
// Enough money (one unconfirmed coin, unconfirmed are allowed)
- var btx = wallet.BuildTransaction(password, operations, FeeStrategy.TwentyMinutesConfirmationTargetStrategy, true);
+ var btx = wallet.BuildTransaction(password, operations, FeeStrategy.TwentyMinutesConfirmationTargetStrategy, null, true);
var spentCoin = Assert.Single(btx.SpentCoins);
Assert.False(spentCoin.Confirmed);
// Enough money (one confirmed coin and one unconfirmed coin, unconfirmed are allowed)
operations = new PaymentIntent(scp, Money.Coins(2.5m));
- btx = wallet.BuildTransaction(password, operations, FeeStrategy.TwentyMinutesConfirmationTargetStrategy, true);
+ btx = wallet.BuildTransaction(password, operations, FeeStrategy.TwentyMinutesConfirmationTargetStrategy, null, true);
Assert.Equal(2, btx.SpentCoins.Count());
Assert.Equal(1, btx.SpentCoins.Count(c => c.Confirmed));
Assert.Equal(1, btx.SpentCoins.Count(c => !c.Confirmed));

MODIFY
WalletWasabi.Tests/RegressionTests/SendTests.cs
WalletWasabi.Tests/RegressionTests/SendTests.cs
@@ -429,7 +429,7 @@ namespace WalletWasabi.Tests.RegressionTests
// covers:
// disallow unconfirmed with allowed inputs
- res = wallet.BuildTransaction(password, toSend, FeeStrategy.TwentyMinutesConfirmationTargetStrategy, false, allowedInputs: allowedInputs);
+ res = wallet.BuildTransaction(password, toSend, FeeStrategy.TwentyMinutesConfirmationTargetStrategy, null, false, allowedInputs: allowedInputs);
activeOutput = res.InnerWalletOutputs.Single(x => x.ScriptPubKey == receive);
Assert.Single(res.InnerWalletOutputs);

MODIFY
WalletWasabi.Tests/UnitTests/Clients/MockTorHttpClient.cs
WalletWasabi.Tests/UnitTests/Clients/MockTorHttpClient.cs
@@ -18,13 +18,21 @@ namespace WalletWasabi.Tests.UnitTests.Clients
public bool IsTorUsed => true;
public Func<HttpMethod, string, string[], Task<HttpResponseMessage>> OnSendAsync_Method { get; set; }
+ public Func<HttpMethod, string, string, Task<HttpResponseMessage>> OnSendAsync { get; set; }
- public Task<HttpResponseMessage> SendAsync(HttpMethod method, string relativeUri, HttpContent content = null, CancellationToken cancel = default)
+ public async Task<HttpResponseMessage> SendAsync(HttpMethod method, string relativeUri, HttpContent content = null, CancellationToken cancel = default)
+ {
+ if (string.IsNullOrWhiteSpace(relativeUri))
+ {
+ return await OnSendAsync(method, "", await content.ReadAsStringAsync());
+ }
+ else
{
var sepPos = relativeUri.IndexOf('?');
var action = relativeUri[..sepPos];
var parameters = relativeUri[(sepPos + 1)..].Split('&', StringSplitOptions.RemoveEmptyEntries);
- return OnSendAsync_Method(method, action, parameters);
+ return await OnSendAsync_Method(method, action, parameters);
+ }
}
public Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancel = default)

ADD
WalletWasabi.Tests/UnitTests/Transactions/PayjoinTests.cs
+using NBitcoin;
+using System;
+using System.Collections.Generic;
+using System.Threading.Tasks;
+using System.Linq;
+using WalletWasabi.Blockchain.Analysis.Clustering;
+using WalletWasabi.Blockchain.Keys;
+using WalletWasabi.Blockchain.TransactionBuilding;
+using WalletWasabi.Blockchain.TransactionOutputs;
+using WalletWasabi.Blockchain.Transactions;
+using WalletWasabi.Exceptions;
+using WalletWasabi.Models;
+using WalletWasabi.Tests.UnitTests.Clients;
+using WalletWasabi.WebClients.PayJoin;
+using Xunit;
+using System.Net.Http;
+using System.Net;
+using System.Text;
+
+namespace WalletWasabi.Tests.UnitTests.Transactions
+{
+ public class PayjoinTests
+ {
+ [Fact]
+ public void LazyPayjoinServerTest()
+ {
+ // This test the scenario where the payjoin server returns the same
+ // transaction that we sent to it and adds no inputs. This can give
+ // us the fake sense of privacy but it should be valid.
+ var httpClient = new MockTorHttpClient{
+ OnSendAsync = (method, path, content) => {
+ var psbt = PSBT.Parse(content, Network.Main);
+ psbt.ClearInputs();
+ var message = new HttpResponseMessage(HttpStatusCode.OK);
+ message.Content = new StringContent(psbt.ToHex(), Encoding.UTF8, "text/plain");
+ return Task.FromResult(message);
+ }
+ };
+ var payjoinClient = new PayjoinClient(httpClient);
+ var transactionFactory = CreateTransactionFactory(new[]
+ {
+ ("Pablo", 0, 0.1m, confirmed: true, anonymitySet: 1)
+ }, payjoinClient: payjoinClient);
+
+ var allowedCoins = transactionFactory.Coins.ToArray();
+
+ var amount = Money.Coins(0.001m);
+ var payment = new PaymentIntent(new Key().ScriptPubKey, amount);
+
+ var tx = transactionFactory.BuildTransaction(payment, new FeeRate(2m), allowedCoins.Select(x => x.OutPoint));
+
+ Assert.Equal(TransactionCheckResult.Success, tx.Transaction.Transaction.Check());
+ Assert.True(tx.Signed);
+ Assert.Single(tx.InnerWalletOutputs);
+ Assert.Single(tx.OuterWalletOutputs);
+ }
+
+
+ [Fact]
+ public void HonestPayjoinServerTest()
+ {
+ // This test the scenario where the payjoin server behaves as expected.
+ var httpClient = new MockTorHttpClient{
+ OnSendAsync = (method, path, content) => {
+ var clientPSBT = PSBT.Parse(content, Network.Main);
+ var clientTx = clientPSBT.ExtractTransaction();
+ foreach (var input in clientTx.Inputs)
+ {
+ input.WitScript = WitScript.Empty;
+ }
+ var serverCoinKey = new Key();
+ var serverCoin = Coin(0.345m, serverCoinKey.PubKey.WitHash.ScriptPubKey);
+ clientTx.Inputs.Add(serverCoin.Outpoint);
+ clientTx.Outputs[0].Value += (Money)serverCoin.Amount;
+ var newPsbt = PSBT.FromTransaction(clientTx, Network.Main);
+ var serverCoinToSign = newPsbt.Inputs.FindIndexedInput(serverCoin.Outpoint);
+ serverCoinToSign.UpdateFromCoin(serverCoin);
+ serverCoinToSign.Sign(serverCoinKey);
+ serverCoinToSign.FinalizeInput();
+
+ var message = new HttpResponseMessage(HttpStatusCode.OK);
+ message.Content = new StringContent(newPsbt.ToHex(), Encoding.UTF8, "text/plain");
+ return Task.FromResult(message);
+ }
+ };
+ var payjoinClient = new PayjoinClient(httpClient);
+ var transactionFactory = CreateTransactionFactory(new[]
+ {
+ ("Pablo", 0, 0.1m, confirmed: true, anonymitySet: 1)
+ }, payjoinClient: payjoinClient);
+
+ var allowedCoins = transactionFactory.Coins.ToArray();
+
+ var amount = Money.Coins(0.001m);
+ var payment = new PaymentIntent(new Key().PubKey.WitHash.ScriptPubKey, amount);
+
+ var tx = transactionFactory.BuildTransaction(payment, new FeeRate(2m), allowedCoins.Select(x => x.OutPoint));
+
+ Assert.Equal(TransactionCheckResult.Success, tx.Transaction.Transaction.Check());
+ Assert.True(tx.Signed);
+ Assert.Single(tx.InnerWalletOutputs);
+ Assert.Single(tx.OuterWalletOutputs);
+ }
+
+ private TransactionFactory CreateTransactionFactory(
+ IEnumerable<(string Label, int KeyIndex, decimal Amount, bool Confirmed, int AnonymitySet)> coins,
+ bool allowUnconfirmed = true,
+ bool watchOnly = false,
+ IPayjoinClient payjoinClient = null)
+ {
+ var (password, keyManager) = watchOnly ? WatchOnlyKeyManager() : DefaultKeyManager();
+
+ keyManager.AssertCleanKeysIndexed();
+
+ var keys = keyManager.GetKeys().Take(10).ToArray();
+ var scoins = coins.Select(x => Coin(x.Label, keys[x.KeyIndex], x.Amount, x.Confirmed, x.AnonymitySet)).ToArray();
+ foreach (var coin in scoins)
+ {
+ foreach (var sameLabelCoin in scoins.Where(c => !c.Label.IsEmpty && c.Label == coin.Label))
+ {
+ sameLabelCoin.Clusters = coin.Clusters;
+ }
+ }
+ var coinsView = new CoinsView(scoins);
+ payjoinClient ??= new NullPayjoinClient();
+ return new TransactionFactory(Network.Main, keyManager, coinsView, payjoinClient, password, allowUnconfirmed);
+ }
+
+ private static (string, KeyManager) DefaultKeyManager()
+ {
+ var password = "blahblahblah";
+ return (password, KeyManager.CreateNew(out var _, password));
+ }
+
+ private static (string, KeyManager) WatchOnlyKeyManager()
+ {
+ var (password, keyManager) = DefaultKeyManager();
+ return (password, KeyManager.CreateNewWatchOnly(keyManager.ExtPubKey));
+ }
+
+ private static SmartCoin Coin(string label, HdPubKey pubKey, decimal amount, bool confirmed = true, int anonymitySet = 1)
+ {
+ var randomIndex = new Func<int>(() => new Random().Next(0, 200));
+ var height = confirmed ? new Height(randomIndex()) : Height.Mempool;
+ SmartLabel slabel = label;
+ var spentOutput = new[]
+ {
+ new OutPoint(RandomUtils.GetUInt256(), (uint)randomIndex())
+ };
+ pubKey.SetLabel(slabel);
+ pubKey.SetKeyState(KeyState.Used);
+ return new SmartCoin(RandomUtils.GetUInt256(), (uint)randomIndex(), pubKey.P2wpkhScript, Money.Coins(amount), spentOutput, height, false, anonymitySet, slabel, pubKey: pubKey);
+ }
+
+ public static PSBT GenerateRandomTransaction()
+ {
+ var key = new Key();
+ var tx =
+ Network.Main.CreateTransactionBuilder()
+ .AddCoins(Coin(0.5m, key.PubKey.WitHash.ScriptPubKey))
+ .AddKeys(key)
+ .Send(new Key().PubKey.WitHash.ScriptPubKey, Money.Coins(0.5m))
+ .BuildPSBT(true);
+ tx.Finalize();
+ return tx;
+ }
+
+ private static ICoin Coin(decimal amount, Script scriptPubKey)
+ {
+ return new Coin(GetRandomOutPoint(), new TxOut(Money.Coins(amount), scriptPubKey));
+ }
+
+ private static OutPoint GetRandomOutPoint()
+ {
+ return new OutPoint(RandomUtils.GetUInt256(), 0);
+ }
+ }
+
+ public static class PSBTExtensions
+ {
+ public static void ClearInputs(this PSBT psbt)
+ {
+ foreach (var input in psbt.Inputs)
+ {
+ input.FinalScriptSig = null;
+ input.FinalScriptWitness = null;
+ }
+ }
+ }
+}

MODIFY
WalletWasabi.Tests/UnitTests/Transactions/TransactionFactoryTests.cs
WalletWasabi.Tests/UnitTests/Transactions/TransactionFactoryTests.cs
@@ -211,7 +211,7 @@ namespace WalletWasabi.Tests.UnitTests.Transactions
}
var coinsView = new CoinsView(scoins.ToArray());
- var transactionFactory = new TransactionFactory(Network.Main, keyManager, coinsView, password);
+ var transactionFactory = new TransactionFactory(Network.Main, keyManager, coinsView, null, password);
// Two 0.9btc coins are enough
var payment = new PaymentIntent(new Key().ScriptPubKey, Money.Coins(1.75m), label: "Sophie");
@@ -584,7 +584,7 @@ namespace WalletWasabi.Tests.UnitTests.Transactions
}
}
var coinsView = new CoinsView(scoins);
- return new TransactionFactory(Network.Main, keyManager, coinsView, password, allowUnconfirmed);
+ return new TransactionFactory(Network.Main, keyManager, coinsView, null, password, allowUnconfirmed);
}
private static (string, KeyManager) DefaultKeyManager()

MODIFY
WalletWasabi/Blockchain/Transactions/TransactionFactory.cs
WalletWasabi/Blockchain/Transactions/TransactionFactory.cs
@@ -3,6 +3,8 @@ using NBitcoin.Policy;
using System;
using System.Collections.Generic;
using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
using WalletWasabi.Blockchain.Analysis.Clustering;
using WalletWasabi.Blockchain.Keys;
using WalletWasabi.Blockchain.TransactionBuilding;
@@ -11,19 +13,21 @@ using WalletWasabi.Exceptions;
using WalletWasabi.Helpers;
using WalletWasabi.Logging;
using WalletWasabi.Models;
+using WalletWasabi.WebClients.PayJoin;
namespace WalletWasabi.Blockchain.Transactions
{
public class TransactionFactory
{
/// <param name="allowUnconfirmed">Allow to spend unconfirmed transactions, if necessary.</param>
- public TransactionFactory(Network network, KeyManager keyManager, ICoinsView coins, string password = "", bool allowUnconfirmed = false)
+ public TransactionFactory(Network network, KeyManager keyManager, ICoinsView coins, IPayjoinClient payJoinClient, string password = "", bool allowUnconfirmed = false)
{
Network = network;
KeyManager = keyManager;
Coins = coins;
Password = password;
AllowUnconfirmed = allowUnconfirmed;
+ PayjoinClient = payJoinClient;
}
public Network Network { get; }
@@ -31,6 +35,7 @@ namespace WalletWasabi.Blockchain.Transactions
public ICoinsView Coins { get; }
public string Password { get; }
public bool AllowUnconfirmed { get; }
+ public IPayjoinClient PayjoinClient { get; }
/// <exception cref="ArgumentException"></exception>
/// <exception cref="ArgumentNullException"></exception>
@@ -220,6 +225,18 @@ namespace WalletWasabi.Blockchain.Transactions
builder = builder.AddKeys(signingKeys.ToArray());
builder.SetLockTime(lockTimeSelector());
builder.SignPSBT(psbt);
+
+ if (KeyManager.MasterFingerprint is HDFingerprint fp)
+ {
+ foreach (var coin in spentCoins)
+ {
+ var rootKeyPath = new RootedKeyPath(fp, coin.HdPubKey.FullKeyPath);
+ psbt.AddKeyPath(coin.HdPubKey.PubKey, rootKeyPath, coin.ScriptPubKey);
+ }
+ }
+
+ psbt = PayjoinClient.RequestPayjoin(psbt, KeyManager.ExtPubKey, new RootedKeyPath(KeyManager.MasterFingerprint.Value, KeyManager.DefaultAccountKeyPath), CancellationToken.None).GetAwaiter().GetResult();
+ builder.SignPSBT(psbt);
psbt.Finalize();
tx = psbt.ExtractTransaction();
@@ -243,15 +260,6 @@ namespace WalletWasabi.Blockchain.Transactions
}
}
- if (KeyManager.MasterFingerprint is HDFingerprint fp)
- {
- foreach (var coin in spentCoins)
- {
- var rootKeyPath = new RootedKeyPath(fp, coin.HdPubKey.FullKeyPath);
- psbt.AddKeyPath(coin.HdPubKey.PubKey, rootKeyPath, coin.ScriptPubKey);
- }
- }
-
var label = SmartLabel.Merge(payments.Requests.Select(x => x.Label).Concat(spentCoins.Select(x => x.Label)));
var outerWalletOutputs = new List<SmartCoin>();
var innerWalletOutputs = new List<SmartCoin>();

MODIFY
WalletWasabi/Wallets/Wallet.cs
WalletWasabi/Wallets/Wallet.cs
@@ -31,6 +31,7 @@ using WalletWasabi.Logging;
using WalletWasabi.Models;
using WalletWasabi.Services;
using WalletWasabi.Stores;
+using WalletWasabi.WebClients.PayJoin;
using WalletWasabi.WebClients.Wasabi;
namespace WalletWasabi.Wallets
@@ -216,10 +217,11 @@ namespace WalletWasabi.Wallets
string password,
PaymentIntent payments,
FeeStrategy feeStrategy,
+ IPayjoinClient payjoinClient = null,
bool allowUnconfirmed = false,
IEnumerable<OutPoint> allowedInputs = null)
{
- var builder = new TransactionFactory(Network, KeyManager, Coins, password, allowUnconfirmed);
+ var builder = new TransactionFactory(Network, KeyManager, Coins, payjoinClient ?? new NullPayjoinClient(), password, allowUnconfirmed);
return builder.BuildTransaction(
payments,
() =>

ADD
WalletWasabi/WebClients/PayJoin/IPayjoinClient.cs
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+using NBitcoin;
+
+namespace WalletWasabi.WebClients.PayJoin
+{
+ public interface IPayjoinClient
+ {
+ Task<PSBT> RequestPayjoin(PSBT originalTx, IHDKey accountKey, RootedKeyPath rootedKeyPath, CancellationToken cancellationToken);
+ }
+}
\ No newline at end of file

ADD
WalletWasabi/WebClients/PayJoin/PayjoinClient.cs
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Net;
+using System.Net.Http;
+using System.Text;
+using System.Threading;
+using System.Threading.Tasks;
+using NBitcoin;
+using Newtonsoft.Json;
+using Newtonsoft.Json.Linq;
+using WalletWasabi.Helpers;
+using WalletWasabi.TorSocks5;
+
+namespace WalletWasabi.WebClients.PayJoin
+{
+ public class PayjoinClient : IPayjoinClient
+ {
+ public PayjoinClient(Uri paymentUrl, EndPoint torSocks5EndPoint)
+ {
+ TorSocks5EndPoint = Guard.NotNull(nameof(torSocks5EndPoint), torSocks5EndPoint);
+ PaymentUrl = Guard.NotNull(nameof(paymentUrl), paymentUrl);
+ HttpClient = new TorHttpClient(PaymentUrl, TorSocks5EndPoint);
+ }
+
+ // For testing only
+ internal PayjoinClient(ITorHttpClient httpClient)
+ {
+ HttpClient = Guard.NotNull(nameof(httpClient), httpClient);
+ }
+
+ private EndPoint TorSocks5EndPoint { get; }
+ private Uri PaymentUrl { get; }
+ private ITorHttpClient HttpClient { get; }
+
+ public async Task<PSBT> RequestPayjoin(PSBT originalTx, IHDKey accountKey, RootedKeyPath rootedKeyPath, CancellationToken cancellationToken)
+ {
+ Guard.NotNull(nameof(originalTx), originalTx);
+ if (originalTx.IsAllFinalized())
+ throw new InvalidOperationException("The original PSBT should not be finalized.");
+
+ var sentBefore = -originalTx.GetBalance(ScriptPubKeyType.Segwit, accountKey, rootedKeyPath);
+ var oldGlobalTx = originalTx.GetGlobalTransaction();
+
+ if (!originalTx.TryGetEstimatedFeeRate(out var originalFeeRate) || !originalTx.TryGetVirtualSize(out var oldVirtualSize))
+ throw new ArgumentException("originalTx should have utxo information", nameof(originalTx));
+ var originalFee = originalTx.GetFee();
+ var cloned = originalTx.Clone();
+ if (!cloned.TryFinalize(out var _))
+ {
+ return null;
+ }
+
+ // We make sure we don't send unnecessary information to the receiver
+ foreach (var finalized in cloned.Inputs.Where(i => i.IsFinalized()))
+ {
+ finalized.ClearForFinalize();
+ }
+
+ foreach (var output in cloned.Outputs)
+ {
+ output.HDKeyPaths.Clear();
+ }
+
+ cloned.GlobalXPubs.Clear();
+
+ var bpuResponse = await HttpClient.SendAsync(HttpMethod.Post, "",
+ new StringContent(cloned.ToHex(), Encoding.UTF8, "text/plain"), cancellationToken).ConfigureAwait(false);
+ if (!bpuResponse.IsSuccessStatusCode)
+ {
+ var errorStr = await bpuResponse.Content.ReadAsStringAsync();
+ try
+ {
+ var error = JObject.Parse(errorStr);
+ throw new PayjoinReceiverException((int)bpuResponse.StatusCode,
+ error["errorCode"].Value<string>(),
+ error["message"].Value<string>());
+ }
+ catch (JsonReaderException)
+ {
+ // will throw
+ bpuResponse.EnsureSuccessStatusCode();
+ throw;
+ }
+ }
+
+ var hexOrBase64 = await bpuResponse.Content.ReadAsStringAsync();
+ var newPSBT = PSBT.Parse(hexOrBase64, originalTx.Network);
+
+ if (newPSBT.CheckSanity() is IList<PSBTError> errors2 && errors2.Count != 0)
+ {
+ throw new PayjoinSenderException($"The PSBT of the receiver is insane ({errors2[0]})");
+ }
+
+ // Checking that the PSBT of the receiver is clean
+ if (newPSBT.GlobalXPubs.Any())
+ {
+ throw new PayjoinSenderException("GlobalXPubs should not be included in the receiver's PSBT");
+ }
+
+ if (newPSBT.Outputs.Any(o => o.HDKeyPaths.Count != 0) || newPSBT.Inputs.Any(o => o.HDKeyPaths.Count != 0))
+ {
+ throw new PayjoinSenderException("Keypath information should not be included in the receiver's PSBT");
+ }
+
+ // We make sure we don't sign things what should not be signed
+ foreach (var output in newPSBT.Outputs)
+ {
+ output.HDKeyPaths.Clear();
+ foreach (var originalOutput in originalTx.Outputs)
+ {
+ if (output.ScriptPubKey == originalOutput.ScriptPubKey)
+ output.UpdateFromCoin(originalOutput.GetCoin());
+ }
+ }
+
+ var newGlobalTx = newPSBT.GetGlobalTransaction();
+ if (newGlobalTx.Version != oldGlobalTx.Version)
+ throw new PayjoinSenderException("The version field of the transaction has been modified");
+ if (newGlobalTx.LockTime != oldGlobalTx.LockTime)
+ throw new PayjoinSenderException("The LockTime field of the transaction has been modified");
+
+ // Do not trust on inputs order because the payjoin server should shuffle them.
+ foreach (var input in originalTx.Inputs)
+ {
+ var newInput = newPSBT.Inputs.FindIndexedInput(input.PrevOut);
+ foreach (var keyPath in input.HDKeyPaths)
+ {
+ newInput.HDKeyPaths.Add(keyPath.Key, keyPath.Value);
+ }
+ }
+
+ // Making sure that our inputs are finalized, and that some of our inputs have not been added
+ int ourInputCount = 0;
+ var accountHDScriptPubkey = new HDKeyScriptPubKey(accountKey, ScriptPubKeyType.Segwit);
+ foreach (var input in newPSBT.Inputs.CoinsFor(accountHDScriptPubkey, accountKey, rootedKeyPath))
+ {
+ if (oldGlobalTx.Inputs.FindIndexedInput(input.PrevOut) is IndexedTxIn ourInput)
+ {
+ ourInputCount++;
+ if (input.IsFinalized())
+ throw new PayjoinSenderException("A PSBT input from us should not be finalized");
+ if (newGlobalTx.Inputs[input.Index].Sequence != ourInput.TxIn.Sequence)
+ throw new PayjoinSenderException("The sequence of one of our input has been modified");
+ }
+ else
+ {
+ throw new PayjoinSenderException(
+ "The payjoin receiver added some of our own inputs in the proposal");
+ }
+ }
+
+ foreach (var input in newPSBT.Inputs)
+ {
+ if (originalTx.Inputs.FindIndexedInput(input.PrevOut) is null)
+ {
+ if (!input.IsFinalized())
+ throw new PayjoinSenderException("The payjoin receiver included a non finalized input");
+ // Making sure that the receiver's inputs are finalized and match format
+ var payjoinInputType = input.GetInputScriptPubKeyType();
+ if (payjoinInputType is null || payjoinInputType.Value != ScriptPubKeyType.Segwit)
+ {
+ throw new PayjoinSenderException("The payjoin receiver included an input that is not the same segwit input type");
+ }
+ }
+ }
+
+ // Making sure that the receiver's inputs are finalized
+ foreach (var input in newPSBT.Inputs)
+ {
+ if (originalTx.Inputs.FindIndexedInput(input.PrevOut) is null && !input.IsFinalized())
+ throw new PayjoinSenderException("The payjoin receiver included a non finalized input");
+ }
+
+ if (ourInputCount < originalTx.Inputs.Count)
+ throw new PayjoinSenderException("The payjoin receiver removed some of our inputs");
+
+ // We limit the number of inputs the receiver can add
+ var addedInputs = newPSBT.Inputs.Count - originalTx.Inputs.Count;
+ if (originalTx.Inputs.Count < addedInputs)
+ throw new PayjoinSenderException("The payjoin receiver added too much inputs");
+
+
+ var sentAfter = -newPSBT.GetBalance(ScriptPubKeyType.Segwit, accountKey, rootedKeyPath);
+
+ if (sentAfter > sentBefore)
+ {
+ if (!newPSBT.TryGetEstimatedFeeRate(out var newFeeRate) || !newPSBT.TryGetVirtualSize(out var newVirtualSize))
+ throw new PayjoinSenderException("The payjoin receiver did not included UTXO information to calculate fee correctly");
+ // Let's check the difference is only for the fee and that feerate
+ // did not changed that much
+ var expectedFee = originalFeeRate.GetFee(newVirtualSize);
+ // Signing precisely is hard science, give some breathing room for error.
+ expectedFee += newPSBT.Inputs.Count * Money.Satoshis(2);
+
+ // If the payjoin is removing some dust, we may pay a bit more as a whole output has been removed
+ var removedOutputs = Math.Max(0, originalTx.Outputs.Count - newPSBT.Outputs.Count);
+ expectedFee += removedOutputs * originalFeeRate.GetFee(294);
+
+ var actualFee = newFeeRate.GetFee(newVirtualSize);
+ if (actualFee > expectedFee && actualFee - expectedFee > Money.Satoshis(546))
+ throw new PayjoinSenderException("The payjoin receiver is paying too much fee");
+ }
+
+ return newPSBT;
+ }
+ }
+
+ public static class PSBTExtensions
+ {
+ public static ScriptPubKeyType? GetInputsScriptPubKeyType(this PSBT psbt)
+ {
+ if (!psbt.IsAllFinalized() || psbt.Inputs.Any(i => i.WitnessUtxo == null))
+ throw new InvalidOperationException("The psbt should be finalized with witness information");
+ var coinsPerTypes = psbt.Inputs.Select(i =>
+ {
+ return ((PSBTCoin)i, i.GetInputScriptPubKeyType());
+ }).GroupBy(o => o.Item2, o => o.Item1).ToArray();
+ if (coinsPerTypes.Length != 1)
+ return default;
+ return coinsPerTypes[0].Key;
+ }
+
+ public static ScriptPubKeyType? GetInputScriptPubKeyType(this PSBTInput i)
+ {
+ if (i.WitnessUtxo.ScriptPubKey.IsScriptType(ScriptType.P2WPKH))
+ return ScriptPubKeyType.Segwit;
+ if (i.WitnessUtxo.ScriptPubKey.IsScriptType(ScriptType.P2SH) &&
+ i.FinalScriptWitness.ToScript().IsScriptType(ScriptType.P2WPKH))
+ return ScriptPubKeyType.SegwitP2SH;
+ return null as ScriptPubKeyType?;
+ }
+ }
+}

ADD
WalletWasabi/WebClients/PayJoin/PayjoinException.cs
+using System;
+
+namespace WalletWasabi.WebClients.PayJoin
+{
+ public class PayjoinException : Exception
+ {
+ public PayjoinException(string message) : base(message)
+ {
+ }
+ }
+}

ADD
WalletWasabi/WebClients/PayJoin/PayjoinReceiverException.cs
+namespace WalletWasabi.WebClients.PayJoin
+{
+ public class PayjoinReceiverException : PayjoinException
+ {
+ public PayjoinReceiverException(int httpCode, string errorCode, string message) : base(FormatMessage(httpCode,
+ errorCode, message))
+ {
+ HttpCode = httpCode;
+ ErrorCode = errorCode;
+ ErrorMessage = message;
+ }
+
+ public int HttpCode { get; }
+ public string ErrorCode { get; }
+ public string ErrorMessage { get; }
+
+ private static string FormatMessage(in int httpCode, string errorCode, string message)
+ {
+ return $"{errorCode}: {message} (HTTP: {httpCode})";
+ }
+ }
+}

ADD
WalletWasabi/WebClients/PayJoin/PayjoinSenderException.cs
+namespace WalletWasabi.WebClients.PayJoin
+{
+ public class PayjoinSenderException : PayjoinException
+ {
+ public PayjoinSenderException(string message) : base(message)
+ {
+ }
+ }
+}

Pay with Payjoin (bip79)