mirror of
https://github.com/aljazceru/btcpayserver.git
synced 2025-12-19 06:54:19 +01:00
Sync payjoin receiver implementation to the bip
This commit is contained in:
@@ -194,17 +194,13 @@ namespace BTCPayServer.Tests
|
|||||||
var receiverCoin = await receiverUser.ReceiveUTXO(Money.Satoshis(810), network);
|
var receiverCoin = await receiverUser.ReceiveUTXO(Money.Satoshis(810), network);
|
||||||
|
|
||||||
var clientShouldError = unsupportedFormats.Contains(senderAddressType);
|
var clientShouldError = unsupportedFormats.Contains(senderAddressType);
|
||||||
string errorCode = null;
|
string errorCode = receiverAddressType == senderAddressType ? null : "unavailable|any UTXO available";
|
||||||
|
var invoice = receiverUser.BitPay.CreateInvoice(new Invoice() { Price = 50000, Currency = "sats", FullNotifications = true });
|
||||||
if (unsupportedFormats.Contains(receiverAddressType))
|
if (unsupportedFormats.Contains(receiverAddressType))
|
||||||
{
|
{
|
||||||
errorCode = "unsupported-inputs";
|
Assert.Null(TestAccount.GetPayjoinEndpoint(invoice, cashCow.Network));
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
else if (receiverAddressType != senderAddressType)
|
|
||||||
{
|
|
||||||
errorCode = "out-of-utxos";
|
|
||||||
}
|
|
||||||
var invoice = receiverUser.BitPay.CreateInvoice(new Invoice() { Price = 50000, Currency = "sats", FullNotifications = true });
|
|
||||||
|
|
||||||
var invoiceAddress = BitcoinAddress.Create(invoice.BitcoinAddress, cashCow.Network);
|
var invoiceAddress = BitcoinAddress.Create(invoice.BitcoinAddress, cashCow.Network);
|
||||||
var txBuilder = network.NBitcoinNetwork.CreateTransactionBuilder();
|
var txBuilder = network.NBitcoinNetwork.CreateTransactionBuilder();
|
||||||
|
|
||||||
@@ -551,7 +547,7 @@ namespace BTCPayServer.Tests
|
|||||||
var receiverCoin = await receiverUser.ReceiveUTXO(Money.Satoshis(810), network);
|
var receiverCoin = await receiverUser.ReceiveUTXO(Money.Satoshis(810), network);
|
||||||
string lastInvoiceId = null;
|
string lastInvoiceId = null;
|
||||||
|
|
||||||
var vector = (SpentCoin: Money.Satoshis(810), InvoiceAmount: Money.Satoshis(700), Paid: Money.Satoshis(700), Fee: Money.Satoshis(110), InvoicePaid: true, ExpectedError: "not-enough-money");
|
var vector = (SpentCoin: Money.Satoshis(810), InvoiceAmount: Money.Satoshis(700), Paid: Money.Satoshis(700), Fee: Money.Satoshis(110), InvoicePaid: true, ExpectedError: "not-enough-money", OriginalTxBroadcasted: true);
|
||||||
async Task<PSBT> RunVector(bool skipLockedCheck = false)
|
async Task<PSBT> RunVector(bool skipLockedCheck = false)
|
||||||
{
|
{
|
||||||
var coin = await senderUser.ReceiveUTXO(vector.SpentCoin, network);
|
var coin = await senderUser.ReceiveUTXO(vector.SpentCoin, network);
|
||||||
@@ -592,6 +588,19 @@ namespace BTCPayServer.Tests
|
|||||||
Assert.Equal("paid", invoice.Status);
|
Assert.Equal("paid", invoice.Status);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
psbt.Finalize();
|
||||||
|
var broadcasted = await tester.PayTester.GetService<ExplorerClientProvider>().GetExplorerClient("BTC").BroadcastAsync(psbt.ExtractTransaction(), true);
|
||||||
|
if (vector.OriginalTxBroadcasted)
|
||||||
|
{
|
||||||
|
Assert.Equal("txn-already-in-mempool", broadcasted.RPCCodeMessage);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Assert.True(broadcasted.Success);
|
||||||
|
}
|
||||||
|
receiverCoin = await receiverUser.ReceiveUTXO(receiverCoin.Amount, network);
|
||||||
|
await LockAllButReceiverCoin();
|
||||||
return pj;
|
return pj;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -610,32 +619,35 @@ namespace BTCPayServer.Tests
|
|||||||
Logs.Tester.LogInformation("Here we send exactly the right amount. This should fails as\n" +
|
Logs.Tester.LogInformation("Here we send exactly the right amount. This should fails as\n" +
|
||||||
"there is not enough to pay the additional payjoin input. (going below the min relay fee" +
|
"there is not enough to pay the additional payjoin input. (going below the min relay fee" +
|
||||||
"However, the original tx has been broadcasted!");
|
"However, the original tx has been broadcasted!");
|
||||||
vector = (SpentCoin: Money.Satoshis(810), InvoiceAmount: Money.Satoshis(700), Paid: Money.Satoshis(700), Fee: Money.Satoshis(110), InvoicePaid: true, ExpectedError: "not-enough-money");
|
vector = (SpentCoin: Money.Satoshis(810), InvoiceAmount: Money.Satoshis(700), Paid: Money.Satoshis(700), Fee: Money.Satoshis(110), InvoicePaid: true, ExpectedError: "not-enough-money", OriginalTxBroadcasted: true);
|
||||||
await RunVector();
|
await RunVector();
|
||||||
await LockAllButReceiverCoin();
|
|
||||||
|
|
||||||
Logs.Tester.LogInformation("We don't pay enough");
|
Logs.Tester.LogInformation("We don't pay enough");
|
||||||
vector = (SpentCoin: Money.Satoshis(810), InvoiceAmount: Money.Satoshis(700), Paid: Money.Satoshis(690), Fee: Money.Satoshis(110), InvoicePaid: false, ExpectedError: "invoice-not-fully-paid");
|
vector = (SpentCoin: Money.Satoshis(810), InvoiceAmount: Money.Satoshis(700), Paid: Money.Satoshis(690), Fee: Money.Satoshis(110), InvoicePaid: false, ExpectedError: "invoice-not-fully-paid", OriginalTxBroadcasted: true);
|
||||||
await RunVector();
|
await RunVector();
|
||||||
|
|
||||||
Logs.Tester.LogInformation("We pay correctly");
|
Logs.Tester.LogInformation("We pay correctly");
|
||||||
vector = (SpentCoin: Money.Satoshis(810), InvoiceAmount: Money.Satoshis(500), Paid: Money.Satoshis(500), Fee: Money.Satoshis(110), InvoicePaid: true, ExpectedError: null as string);
|
vector = (SpentCoin: Money.Satoshis(810), InvoiceAmount: Money.Satoshis(500), Paid: Money.Satoshis(500), Fee: Money.Satoshis(110), InvoicePaid: true, ExpectedError: null as string, OriginalTxBroadcasted: false);
|
||||||
await RunVector();
|
await RunVector();
|
||||||
await LockAllButReceiverCoin();
|
|
||||||
|
|
||||||
Logs.Tester.LogInformation("We pay a little bit more the invoice with enough fees to support additional input\n" +
|
PSBT proposedPSBT = null;
|
||||||
"The receiver should have added a fake output");
|
var outputCountReceived = new bool[2];
|
||||||
vector = (SpentCoin: Money.Satoshis(910), InvoiceAmount: Money.Satoshis(500), Paid: Money.Satoshis(700), Fee: Money.Satoshis(110), InvoicePaid: true, ExpectedError: null as string);
|
do
|
||||||
var proposedPSBT = await RunVector();
|
{
|
||||||
Assert.Equal(2, proposedPSBT.Outputs.Count);
|
Logs.Tester.LogInformation("We pay a little bit more the invoice with enough fees to support additional input\n" +
|
||||||
await LockAllButReceiverCoin();
|
"The receiver should have added a fake output");
|
||||||
|
vector = (SpentCoin: Money.Satoshis(910), InvoiceAmount: Money.Satoshis(500), Paid: Money.Satoshis(700), Fee: Money.Satoshis(110), InvoicePaid: true, ExpectedError: null as string, OriginalTxBroadcasted: false);
|
||||||
|
proposedPSBT = await RunVector();
|
||||||
|
Assert.True(proposedPSBT.Outputs.Count == 1 || proposedPSBT.Outputs.Count == 2);
|
||||||
|
outputCountReceived[proposedPSBT.Outputs.Count - 1] = true;
|
||||||
|
cashCow.Generate(1);
|
||||||
|
} while (outputCountReceived.All(o => o));
|
||||||
|
|
||||||
Logs.Tester.LogInformation("We pay correctly, but no utxo\n" +
|
Logs.Tester.LogInformation("We pay correctly, but no utxo\n" +
|
||||||
"However, this has the side effect of having the receiver broadcasting the original tx");
|
"However, this has the side effect of having the receiver broadcasting the original tx");
|
||||||
await payjoinRepository.TryLock(receiverCoin.Outpoint);
|
await payjoinRepository.TryLock(receiverCoin.Outpoint);
|
||||||
vector = (SpentCoin: Money.Satoshis(810), InvoiceAmount: Money.Satoshis(500), Paid: Money.Satoshis(500), Fee: Money.Satoshis(110), InvoicePaid: true, ExpectedError: "out-of-utxos");
|
vector = (SpentCoin: Money.Satoshis(810), InvoiceAmount: Money.Satoshis(500), Paid: Money.Satoshis(500), Fee: Money.Satoshis(110), InvoicePaid: true, ExpectedError: "unavailable|any UTXO available", OriginalTxBroadcasted: true);
|
||||||
await RunVector(true);
|
await RunVector(true);
|
||||||
await LockAllButReceiverCoin();
|
|
||||||
|
|
||||||
var originalSenderUser = senderUser;
|
var originalSenderUser = senderUser;
|
||||||
retry:
|
retry:
|
||||||
@@ -645,7 +657,7 @@ namespace BTCPayServer.Tests
|
|||||||
// The send pay remaining 86 sat from his pocket
|
// The send pay remaining 86 sat from his pocket
|
||||||
// So total paid by sender should be 86 + 510 + 200 so we should get 1090 - (86 + 510 + 200) == 294 back)
|
// So total paid by sender should be 86 + 510 + 200 so we should get 1090 - (86 + 510 + 200) == 294 back)
|
||||||
Logs.Tester.LogInformation($"Check if we can take fee on overpaid utxo{(senderUser == receiverUser ? " (to self)" : "")}");
|
Logs.Tester.LogInformation($"Check if we can take fee on overpaid utxo{(senderUser == receiverUser ? " (to self)" : "")}");
|
||||||
vector = (SpentCoin: Money.Satoshis(1090), InvoiceAmount: Money.Satoshis(500), Paid: Money.Satoshis(510), Fee: Money.Satoshis(200), InvoicePaid: true, ExpectedError: null as string);
|
vector = (SpentCoin: Money.Satoshis(1090), InvoiceAmount: Money.Satoshis(500), Paid: Money.Satoshis(510), Fee: Money.Satoshis(200), InvoicePaid: true, ExpectedError: null as string, OriginalTxBroadcasted: false);
|
||||||
proposedPSBT = await RunVector();
|
proposedPSBT = await RunVector();
|
||||||
Assert.Equal(2, proposedPSBT.Outputs.Count);
|
Assert.Equal(2, proposedPSBT.Outputs.Count);
|
||||||
Assert.Contains(proposedPSBT.Outputs, o => o.Value == Money.Satoshis(500) + receiverCoin.Amount);
|
Assert.Contains(proposedPSBT.Outputs, o => o.Value == Money.Satoshis(500) + receiverCoin.Amount);
|
||||||
@@ -684,7 +696,7 @@ namespace BTCPayServer.Tests
|
|||||||
// Same as above. Except the sender send one satoshi less, so the change
|
// Same as above. Except the sender send one satoshi less, so the change
|
||||||
// output would get below dust and would be removed completely.
|
// output would get below dust and would be removed completely.
|
||||||
// So we remove as much fee as we can, and still accept the transaction because it is above minrelay fee
|
// So we remove as much fee as we can, and still accept the transaction because it is above minrelay fee
|
||||||
vector = (SpentCoin: Money.Satoshis(1089), InvoiceAmount: Money.Satoshis(500), Paid: Money.Satoshis(510), Fee: Money.Satoshis(200), InvoicePaid: true, ExpectedError: null as string);
|
vector = (SpentCoin: Money.Satoshis(1089), InvoiceAmount: Money.Satoshis(500), Paid: Money.Satoshis(510), Fee: Money.Satoshis(200), InvoicePaid: true, ExpectedError: null as string, OriginalTxBroadcasted: false);
|
||||||
proposedPSBT = await RunVector();
|
proposedPSBT = await RunVector();
|
||||||
Assert.Equal(2, proposedPSBT.Outputs.Count);
|
Assert.Equal(2, proposedPSBT.Outputs.Count);
|
||||||
// We should have our payment
|
// We should have our payment
|
||||||
@@ -848,7 +860,7 @@ namespace BTCPayServer.Tests
|
|||||||
|
|
||||||
//Attempt 3: Send the same inputs from invoice 1 to invoice 2 while invoice 1 tx has not been broadcasted
|
//Attempt 3: Send the same inputs from invoice 1 to invoice 2 while invoice 1 tx has not been broadcasted
|
||||||
//Result: Reject Tx1 but accept tx 2 as its inputs were never accepted by invoice 1
|
//Result: Reject Tx1 but accept tx 2 as its inputs were never accepted by invoice 1
|
||||||
await senderUser.SubmitPayjoin(invoice2, Invoice2Coin1, btcPayNetwork, "inputs-already-used");
|
await senderUser.SubmitPayjoin(invoice2, Invoice2Coin1, btcPayNetwork, expectedError: "unavailable|Some of those inputs have already been used");
|
||||||
var Invoice2Coin2ResponseTx = await senderUser.SubmitPayjoin(invoice2, Invoice2Coin2, btcPayNetwork);
|
var Invoice2Coin2ResponseTx = await senderUser.SubmitPayjoin(invoice2, Invoice2Coin2, btcPayNetwork);
|
||||||
|
|
||||||
var contributedInputsInvoice2Coin2ResponseTx =
|
var contributedInputsInvoice2Coin2ResponseTx =
|
||||||
|
|||||||
@@ -136,6 +136,13 @@ namespace BTCPayServer.Tests
|
|||||||
modify(store);
|
modify(store);
|
||||||
storeController.UpdateStore(store).GetAwaiter().GetResult();
|
storeController.UpdateStore(store).GetAwaiter().GetResult();
|
||||||
}
|
}
|
||||||
|
public Task ModifyStoreAsync(Action<StoreViewModel> modify)
|
||||||
|
{
|
||||||
|
var storeController = GetController<StoresController>();
|
||||||
|
StoreViewModel store = (StoreViewModel)((ViewResult)storeController.UpdateStore()).Model;
|
||||||
|
modify(store);
|
||||||
|
return storeController.UpdateStore(store);
|
||||||
|
}
|
||||||
|
|
||||||
public T GetController<T>(bool setImplicitStore = true) where T : Controller
|
public T GetController<T>(bool setImplicitStore = true) where T : Controller
|
||||||
{
|
{
|
||||||
@@ -192,18 +199,9 @@ namespace BTCPayServer.Tests
|
|||||||
return new WalletId(StoreId, cryptoCode);
|
return new WalletId(StoreId, cryptoCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task EnablePayJoin()
|
public Task EnablePayJoin()
|
||||||
{
|
{
|
||||||
var storeController = parent.PayTester.GetController<StoresController>(UserId, StoreId);
|
return ModifyStoreAsync(s => s.PayJoinEnabled = true);
|
||||||
var storeVM =
|
|
||||||
Assert.IsType<StoreViewModel>(Assert
|
|
||||||
.IsType<ViewResult>(storeController.UpdateStore()).Model);
|
|
||||||
|
|
||||||
storeVM.PayJoinEnabled = true;
|
|
||||||
|
|
||||||
Assert.Equal(nameof(storeController.UpdateStore),
|
|
||||||
Assert.IsType<RedirectToActionResult>(
|
|
||||||
await storeController.UpdateStore(storeVM)).ActionName);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public GenerateWalletResponse GenerateWalletResponseV { get; set; }
|
public GenerateWalletResponse GenerateWalletResponseV { get; set; }
|
||||||
@@ -332,7 +330,7 @@ namespace BTCPayServer.Tests
|
|||||||
var endpoint = GetPayjoinEndpoint(invoice, psbt.Network);
|
var endpoint = GetPayjoinEndpoint(invoice, psbt.Network);
|
||||||
if (endpoint == null)
|
if (endpoint == null)
|
||||||
{
|
{
|
||||||
return null;
|
throw new InvalidOperationException("No payjoin endpoint for the invoice");
|
||||||
}
|
}
|
||||||
var pjClient = parent.PayTester.GetService<PayjoinClient>();
|
var pjClient = parent.PayTester.GetService<PayjoinClient>();
|
||||||
var storeRepository = parent.PayTester.GetService<StoreRepository>();
|
var storeRepository = parent.PayTester.GetService<StoreRepository>();
|
||||||
@@ -356,7 +354,10 @@ namespace BTCPayServer.Tests
|
|||||||
else
|
else
|
||||||
{
|
{
|
||||||
var ex = await Assert.ThrowsAsync<PayjoinReceiverException>(async () => await pjClient.RequestPayjoin(endpoint, settings, psbt, default));
|
var ex = await Assert.ThrowsAsync<PayjoinReceiverException>(async () => await pjClient.RequestPayjoin(endpoint, settings, psbt, default));
|
||||||
Assert.Equal(expectedError, ex.ErrorCode);
|
var split = expectedError.Split('|');
|
||||||
|
Assert.Equal(split[0], ex.ErrorCode);
|
||||||
|
if (split.Length > 1)
|
||||||
|
Assert.Contains(split[1], ex.ReceiverMessage);
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -381,9 +382,13 @@ namespace BTCPayServer.Tests
|
|||||||
new StringContent(content, Encoding.UTF8, "text/plain"));
|
new StringContent(content, Encoding.UTF8, "text/plain"));
|
||||||
if (expectedError != null)
|
if (expectedError != null)
|
||||||
{
|
{
|
||||||
|
var split = expectedError.Split('|');
|
||||||
Assert.False(response.IsSuccessStatusCode);
|
Assert.False(response.IsSuccessStatusCode);
|
||||||
var error = JObject.Parse(await response.Content.ReadAsStringAsync());
|
var error = JObject.Parse(await response.Content.ReadAsStringAsync());
|
||||||
Assert.Equal(expectedError, error["errorCode"].Value<string>());
|
if (split.Length > 0)
|
||||||
|
Assert.Equal(split[0], error["errorCode"].Value<string>());
|
||||||
|
if (split.Length > 1)
|
||||||
|
Assert.Contains(split[1], error["message"].Value<string>());
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ using System.Diagnostics.CodeAnalysis;
|
|||||||
using BTCPayServer.Data;
|
using BTCPayServer.Data;
|
||||||
using NBitcoin.DataEncoders;
|
using NBitcoin.DataEncoders;
|
||||||
using Amazon.S3.Model;
|
using Amazon.S3.Model;
|
||||||
|
using BTCPayServer.Logging;
|
||||||
|
|
||||||
namespace BTCPayServer.Payments.PayJoin
|
namespace BTCPayServer.Payments.PayJoin
|
||||||
{
|
{
|
||||||
@@ -89,6 +90,7 @@ namespace BTCPayServer.Payments.PayJoin
|
|||||||
private readonly NBXplorerDashboard _dashboard;
|
private readonly NBXplorerDashboard _dashboard;
|
||||||
private readonly DelayedTransactionBroadcaster _broadcaster;
|
private readonly DelayedTransactionBroadcaster _broadcaster;
|
||||||
private readonly WalletRepository _walletRepository;
|
private readonly WalletRepository _walletRepository;
|
||||||
|
private readonly BTCPayServerEnvironment _env;
|
||||||
|
|
||||||
public PayJoinEndpointController(BTCPayNetworkProvider btcPayNetworkProvider,
|
public PayJoinEndpointController(BTCPayNetworkProvider btcPayNetworkProvider,
|
||||||
InvoiceRepository invoiceRepository, ExplorerClientProvider explorerClientProvider,
|
InvoiceRepository invoiceRepository, ExplorerClientProvider explorerClientProvider,
|
||||||
@@ -97,7 +99,8 @@ namespace BTCPayServer.Payments.PayJoin
|
|||||||
EventAggregator eventAggregator,
|
EventAggregator eventAggregator,
|
||||||
NBXplorerDashboard dashboard,
|
NBXplorerDashboard dashboard,
|
||||||
DelayedTransactionBroadcaster broadcaster,
|
DelayedTransactionBroadcaster broadcaster,
|
||||||
WalletRepository walletRepository)
|
WalletRepository walletRepository,
|
||||||
|
BTCPayServerEnvironment env)
|
||||||
{
|
{
|
||||||
_btcPayNetworkProvider = btcPayNetworkProvider;
|
_btcPayNetworkProvider = btcPayNetworkProvider;
|
||||||
_invoiceRepository = invoiceRepository;
|
_invoiceRepository = invoiceRepository;
|
||||||
@@ -109,6 +112,7 @@ namespace BTCPayServer.Payments.PayJoin
|
|||||||
_dashboard = dashboard;
|
_dashboard = dashboard;
|
||||||
_broadcaster = broadcaster;
|
_broadcaster = broadcaster;
|
||||||
_walletRepository = walletRepository;
|
_walletRepository = walletRepository;
|
||||||
|
_env = env;
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("")]
|
[HttpPost("")]
|
||||||
@@ -131,14 +135,18 @@ namespace BTCPayServer.Payments.PayJoin
|
|||||||
new JProperty("message", "This version of payjoin is not supported.")
|
new JProperty("message", "This version of payjoin is not supported.")
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
FeeRate senderMinFeeRate = minfeerate < 0.0m ? null : new FeeRate(minfeerate);
|
|
||||||
Money allowedSenderFeeContribution = Money.Satoshis(maxadditionalfeecontribution >= 0 ? maxadditionalfeecontribution : long.MaxValue);
|
|
||||||
var network = _btcPayNetworkProvider.GetNetwork<BTCPayNetwork>(cryptoCode);
|
var network = _btcPayNetworkProvider.GetNetwork<BTCPayNetwork>(cryptoCode);
|
||||||
if (network == null)
|
if (network == null)
|
||||||
{
|
{
|
||||||
return BadRequest(CreatePayjoinError("invalid-network", "Incorrect network"));
|
return BadRequest(CreatePayjoinError("invalid-network", "Incorrect network"));
|
||||||
}
|
}
|
||||||
|
await using var ctx = new PayjoinReceiverContext(_invoiceRepository, _explorerClientProvider.GetExplorerClient(network), _payJoinRepository);
|
||||||
|
ObjectResult CreatePayjoinErrorAndLog(int httpCode, PayjoinReceiverWellknownErrors err, string debug)
|
||||||
|
{
|
||||||
|
ctx.Logs.Write($"Payjoin error: {debug}");
|
||||||
|
return StatusCode(httpCode, CreatePayjoinError(err, debug));
|
||||||
|
}
|
||||||
var explorer = _explorerClientProvider.GetExplorerClient(network);
|
var explorer = _explorerClientProvider.GetExplorerClient(network);
|
||||||
if (Request.ContentLength is long length)
|
if (Request.ContentLength is long length)
|
||||||
{
|
{
|
||||||
@@ -159,7 +167,6 @@ namespace BTCPayServer.Payments.PayJoin
|
|||||||
rawBody = (await reader.ReadToEndAsync()) ?? string.Empty;
|
rawBody = (await reader.ReadToEndAsync()) ?? string.Empty;
|
||||||
}
|
}
|
||||||
|
|
||||||
Transaction originalTx = null;
|
|
||||||
FeeRate originalFeeRate = null;
|
FeeRate originalFeeRate = null;
|
||||||
bool psbtFormat = true;
|
bool psbtFormat = true;
|
||||||
|
|
||||||
@@ -167,7 +174,7 @@ namespace BTCPayServer.Payments.PayJoin
|
|||||||
{
|
{
|
||||||
if (!psbt.IsAllFinalized())
|
if (!psbt.IsAllFinalized())
|
||||||
return BadRequest(CreatePayjoinError("psbt-not-finalized", "The PSBT should be finalized"));
|
return BadRequest(CreatePayjoinError("psbt-not-finalized", "The PSBT should be finalized"));
|
||||||
originalTx = psbt.ExtractTransaction();
|
ctx.OriginalTransaction = psbt.ExtractTransaction();
|
||||||
}
|
}
|
||||||
// BTCPay Server implementation support a transaction instead of PSBT
|
// BTCPay Server implementation support a transaction instead of PSBT
|
||||||
else
|
else
|
||||||
@@ -175,7 +182,7 @@ namespace BTCPayServer.Payments.PayJoin
|
|||||||
psbtFormat = false;
|
psbtFormat = false;
|
||||||
if (!Transaction.TryParse(rawBody, network.NBitcoinNetwork, out var tx))
|
if (!Transaction.TryParse(rawBody, network.NBitcoinNetwork, out var tx))
|
||||||
return BadRequest(CreatePayjoinError("invalid-format", "invalid transaction or psbt"));
|
return BadRequest(CreatePayjoinError("invalid-format", "invalid transaction or psbt"));
|
||||||
originalTx = tx;
|
ctx.OriginalTransaction = tx;
|
||||||
psbt = PSBT.FromTransaction(tx, network.NBitcoinNetwork);
|
psbt = PSBT.FromTransaction(tx, network.NBitcoinNetwork);
|
||||||
psbt = (await explorer.UpdatePSBTAsync(new UpdatePSBTRequest() { PSBT = psbt })).PSBT;
|
psbt = (await explorer.UpdatePSBTAsync(new UpdatePSBTRequest() { PSBT = psbt })).PSBT;
|
||||||
for (int i = 0; i < tx.Inputs.Count; i++)
|
for (int i = 0; i < tx.Inputs.Count; i++)
|
||||||
@@ -185,10 +192,9 @@ namespace BTCPayServer.Payments.PayJoin
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async Task BroadcastNow()
|
bool spareChangeCase = psbt.Outputs.Count == 1;
|
||||||
{
|
FeeRate senderMinFeeRate = !spareChangeCase && minfeerate >= 0.0m ? new FeeRate(minfeerate) : null;
|
||||||
await _explorerClientProvider.GetExplorerClient(network).BroadcastAsync(originalTx);
|
Money allowedSenderFeeContribution = Money.Satoshis(!spareChangeCase && maxadditionalfeecontribution >= 0 ? maxadditionalfeecontribution : long.MaxValue);
|
||||||
}
|
|
||||||
|
|
||||||
var sendersInputType = psbt.GetInputsScriptPubKeyType();
|
var sendersInputType = psbt.GetInputsScriptPubKeyType();
|
||||||
if (psbt.CheckSanity() is var errors && errors.Count != 0)
|
if (psbt.CheckSanity() is var errors && errors.Count != 0)
|
||||||
@@ -221,9 +227,10 @@ namespace BTCPayServer.Payments.PayJoin
|
|||||||
}
|
}
|
||||||
////////////
|
////////////
|
||||||
|
|
||||||
var mempool = await explorer.BroadcastAsync(originalTx, true);
|
var mempool = await explorer.BroadcastAsync(ctx.OriginalTransaction, true);
|
||||||
if (!mempool.Success)
|
if (!mempool.Success)
|
||||||
{
|
{
|
||||||
|
ctx.DoNotBroadcast();
|
||||||
return BadRequest(CreatePayjoinError("invalid-transaction",
|
return BadRequest(CreatePayjoinError("invalid-transaction",
|
||||||
$"Provided transaction isn't mempool eligible {mempool.RPCCodeMessage}"));
|
$"Provided transaction isn't mempool eligible {mempool.RPCCodeMessage}"));
|
||||||
}
|
}
|
||||||
@@ -232,11 +239,6 @@ namespace BTCPayServer.Payments.PayJoin
|
|||||||
bool paidSomething = false;
|
bool paidSomething = false;
|
||||||
Money due = null;
|
Money due = null;
|
||||||
Dictionary<OutPoint, UTXO> selectedUTXOs = new Dictionary<OutPoint, UTXO>();
|
Dictionary<OutPoint, UTXO> selectedUTXOs = new Dictionary<OutPoint, UTXO>();
|
||||||
|
|
||||||
async Task UnlockUTXOs()
|
|
||||||
{
|
|
||||||
await _payJoinRepository.TryUnlock(selectedUTXOs.Select(o => o.Key).ToArray());
|
|
||||||
}
|
|
||||||
PSBTOutput originalPaymentOutput = null;
|
PSBTOutput originalPaymentOutput = null;
|
||||||
BitcoinAddress paymentAddress = null;
|
BitcoinAddress paymentAddress = null;
|
||||||
InvoiceEntity invoice = null;
|
InvoiceEntity invoice = null;
|
||||||
@@ -244,7 +246,7 @@ namespace BTCPayServer.Payments.PayJoin
|
|||||||
foreach (var output in psbt.Outputs)
|
foreach (var output in psbt.Outputs)
|
||||||
{
|
{
|
||||||
var key = output.ScriptPubKey.Hash + "#" + network.CryptoCode.ToUpperInvariant();
|
var key = output.ScriptPubKey.Hash + "#" + network.CryptoCode.ToUpperInvariant();
|
||||||
invoice = (await _invoiceRepository.GetInvoicesFromAddresses(new[] {key})).FirstOrDefault();
|
invoice = (await _invoiceRepository.GetInvoicesFromAddresses(new[] { key })).FirstOrDefault();
|
||||||
if (invoice is null)
|
if (invoice is null)
|
||||||
continue;
|
continue;
|
||||||
derivationSchemeSettings = invoice.GetSupportedPaymentMethod<DerivationSchemeSettings>(paymentMethodId)
|
derivationSchemeSettings = invoice.GetSupportedPaymentMethod<DerivationSchemeSettings>(paymentMethodId)
|
||||||
@@ -256,13 +258,11 @@ namespace BTCPayServer.Payments.PayJoin
|
|||||||
if (!PayjoinClient.SupportedFormats.Contains(receiverInputsType))
|
if (!PayjoinClient.SupportedFormats.Contains(receiverInputsType))
|
||||||
{
|
{
|
||||||
//this should never happen, unless the store owner changed the wallet mid way through an invoice
|
//this should never happen, unless the store owner changed the wallet mid way through an invoice
|
||||||
return StatusCode(500, CreatePayjoinError("unavailable", $"This service is unavailable for now"));
|
return CreatePayjoinErrorAndLog(503, PayjoinReceiverWellknownErrors.Unavailable, "Our wallet does not support payjoin");
|
||||||
}
|
}
|
||||||
if (sendersInputType is ScriptPubKeyType t && t != receiverInputsType)
|
if (sendersInputType is ScriptPubKeyType t && t != receiverInputsType)
|
||||||
{
|
{
|
||||||
return StatusCode(503,
|
return CreatePayjoinErrorAndLog(503, PayjoinReceiverWellknownErrors.Unavailable, "We do not have any UTXO available for making a payjoin with the sender's inputs type");
|
||||||
CreatePayjoinError("out-of-utxos",
|
|
||||||
"We do not have any UTXO available for making a payjoin with the sender's inputs type"));
|
|
||||||
}
|
}
|
||||||
var paymentMethod = invoice.GetPaymentMethod(paymentMethodId);
|
var paymentMethod = invoice.GetPaymentMethod(paymentMethodId);
|
||||||
var paymentDetails =
|
var paymentDetails =
|
||||||
@@ -271,6 +271,7 @@ namespace BTCPayServer.Payments.PayJoin
|
|||||||
continue;
|
continue;
|
||||||
if (invoice.GetAllBitcoinPaymentData().Any())
|
if (invoice.GetAllBitcoinPaymentData().Any())
|
||||||
{
|
{
|
||||||
|
ctx.DoNotBroadcast();
|
||||||
return UnprocessableEntity(CreatePayjoinError("already-paid",
|
return UnprocessableEntity(CreatePayjoinError("already-paid",
|
||||||
$"The invoice this PSBT is paying has already been partially or completely paid"));
|
$"The invoice this PSBT is paying has already been partially or completely paid"));
|
||||||
}
|
}
|
||||||
@@ -282,26 +283,24 @@ namespace BTCPayServer.Payments.PayJoin
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!await _payJoinRepository.TryLockInputs(originalTx.Inputs.Select(i => i.PrevOut).ToArray()))
|
if (!await _payJoinRepository.TryLockInputs(ctx.OriginalTransaction.Inputs.Select(i => i.PrevOut).ToArray()))
|
||||||
{
|
{
|
||||||
return BadRequest(CreatePayjoinError("inputs-already-used",
|
return CreatePayjoinErrorAndLog(503, PayjoinReceiverWellknownErrors.Unavailable, "Some of those inputs have already been used to make another payjoin transaction");
|
||||||
"Some of those inputs have already been used to make payjoin transaction"));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var utxos = (await explorer.GetUTXOsAsync(derivationSchemeSettings.AccountDerivation))
|
var utxos = (await explorer.GetUTXOsAsync(derivationSchemeSettings.AccountDerivation))
|
||||||
.GetUnspentUTXOs(false);
|
.GetUnspentUTXOs(false);
|
||||||
// In case we are paying ourselves, be need to make sure
|
// In case we are paying ourselves, be need to make sure
|
||||||
// we can't take spent outpoints.
|
// we can't take spent outpoints.
|
||||||
var prevOuts = originalTx.Inputs.Select(o => o.PrevOut).ToHashSet();
|
var prevOuts = ctx.OriginalTransaction.Inputs.Select(o => o.PrevOut).ToHashSet();
|
||||||
utxos = utxos.Where(u => !prevOuts.Contains(u.Outpoint)).ToArray();
|
utxos = utxos.Where(u => !prevOuts.Contains(u.Outpoint)).ToArray();
|
||||||
Array.Sort(utxos, UTXODeterministicComparer.Instance);
|
Array.Sort(utxos, UTXODeterministicComparer.Instance);
|
||||||
|
foreach (var utxo in (await SelectUTXO(network, utxos, psbt.Inputs.Select(input => input.WitnessUtxo.Value.ToDecimal(MoneyUnit.BTC)), output.Value.ToDecimal(MoneyUnit.BTC),
|
||||||
foreach (var utxo in (await SelectUTXO(network, utxos, psbt.Inputs.Select(input => input.WitnessUtxo.Value.ToDecimal(MoneyUnit.BTC)), output.Value.ToDecimal(MoneyUnit.BTC),
|
|
||||||
psbt.Outputs.Where(psbtOutput => psbtOutput.Index != output.Index).Select(psbtOutput => psbtOutput.Value.ToDecimal(MoneyUnit.BTC)))).selectedUTXO)
|
psbt.Outputs.Where(psbtOutput => psbtOutput.Index != output.Index).Select(psbtOutput => psbtOutput.Value.ToDecimal(MoneyUnit.BTC)))).selectedUTXO)
|
||||||
{
|
{
|
||||||
selectedUTXOs.Add(utxo.Outpoint, utxo);
|
selectedUTXOs.Add(utxo.Outpoint, utxo);
|
||||||
}
|
}
|
||||||
|
ctx.LockedUTXOs = selectedUTXOs.Select(u => u.Key).ToArray();
|
||||||
originalPaymentOutput = output;
|
originalPaymentOutput = output;
|
||||||
paymentAddress = paymentDetails.GetDepositAddress(network.NBitcoinNetwork);
|
paymentAddress = paymentDetails.GetDepositAddress(network.NBitcoinNetwork);
|
||||||
break;
|
break;
|
||||||
@@ -321,14 +320,11 @@ namespace BTCPayServer.Payments.PayJoin
|
|||||||
|
|
||||||
if (selectedUTXOs.Count == 0)
|
if (selectedUTXOs.Count == 0)
|
||||||
{
|
{
|
||||||
await BroadcastNow();
|
return CreatePayjoinErrorAndLog(503, PayjoinReceiverWellknownErrors.Unavailable, "We do not have any UTXO available for contributing to a payjoin");
|
||||||
return StatusCode(503,
|
|
||||||
CreatePayjoinError("out-of-utxos",
|
|
||||||
"We do not have any UTXO available for making a payjoin for now"));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var originalPaymentValue = originalPaymentOutput.Value;
|
var originalPaymentValue = originalPaymentOutput.Value;
|
||||||
await _broadcaster.Schedule(DateTimeOffset.UtcNow + TimeSpan.FromMinutes(2.0), originalTx, network);
|
await _broadcaster.Schedule(DateTimeOffset.UtcNow + TimeSpan.FromMinutes(2.0), ctx.OriginalTransaction, network);
|
||||||
|
|
||||||
//check if wallet of store is configured to be hot wallet
|
//check if wallet of store is configured to be hot wallet
|
||||||
var extKeyStr = await explorer.GetMetadataAsync<string>(
|
var extKeyStr = await explorer.GetMetadataAsync<string>(
|
||||||
@@ -337,13 +333,11 @@ namespace BTCPayServer.Payments.PayJoin
|
|||||||
if (extKeyStr == null)
|
if (extKeyStr == null)
|
||||||
{
|
{
|
||||||
// This should not happen, as we check the existance of private key before creating invoice with payjoin
|
// This should not happen, as we check the existance of private key before creating invoice with payjoin
|
||||||
await UnlockUTXOs();
|
return CreatePayjoinErrorAndLog(503, PayjoinReceiverWellknownErrors.Unavailable, "The HD Key of the store changed");
|
||||||
await BroadcastNow();
|
|
||||||
return StatusCode(500, CreatePayjoinError("unavailable", $"This service is unavailable for now"));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Money contributedAmount = Money.Zero;
|
Money contributedAmount = Money.Zero;
|
||||||
var newTx = originalTx.Clone();
|
var newTx = ctx.OriginalTransaction.Clone();
|
||||||
var ourNewOutput = newTx.Outputs[originalPaymentOutput.Index];
|
var ourNewOutput = newTx.Outputs[originalPaymentOutput.Index];
|
||||||
HashSet<TxOut> isOurOutput = new HashSet<TxOut>();
|
HashSet<TxOut> isOurOutput = new HashSet<TxOut>();
|
||||||
isOurOutput.Add(ourNewOutput);
|
isOurOutput.Add(ourNewOutput);
|
||||||
@@ -362,40 +356,44 @@ namespace BTCPayServer.Payments.PayJoin
|
|||||||
ourNewOutput.Value += contributedAmount;
|
ourNewOutput.Value += contributedAmount;
|
||||||
var minRelayTxFee = this._dashboard.Get(network.CryptoCode).Status.BitcoinStatus?.MinRelayTxFee ??
|
var minRelayTxFee = this._dashboard.Get(network.CryptoCode).Status.BitcoinStatus?.MinRelayTxFee ??
|
||||||
new FeeRate(1.0m);
|
new FeeRate(1.0m);
|
||||||
|
|
||||||
// Probably receiving some spare change, let's add an output to make
|
// Probably receiving some spare change, let's add an output to make
|
||||||
// it looks more like a normal transaction
|
// it looks more like a normal transaction
|
||||||
if (newTx.Outputs.Count == 1)
|
if (spareChangeCase)
|
||||||
{
|
{
|
||||||
var change = await explorer.GetUnusedAsync(derivationSchemeSettings.AccountDerivation, DerivationFeature.Change);
|
ctx.Logs.Write($"The payjoin receiver sent only a single output");
|
||||||
var randomChangeAmount = RandomUtils.GetUInt64() % (ulong)contributedAmount.Satoshi;
|
if (RandomUtils.GetUInt64() % 2 == 0)
|
||||||
|
{
|
||||||
|
var change = await explorer.GetUnusedAsync(derivationSchemeSettings.AccountDerivation, DerivationFeature.Change);
|
||||||
|
var randomChangeAmount = RandomUtils.GetUInt64() % (ulong)contributedAmount.Satoshi;
|
||||||
|
|
||||||
// Randomly round the amount to make the payment output look like a change output
|
// Randomly round the amount to make the payment output look like a change output
|
||||||
var roundMultiple = (ulong)Math.Pow(10, (ulong)Math.Log10(randomChangeAmount));
|
var roundMultiple = (ulong)Math.Pow(10, (ulong)Math.Log10(randomChangeAmount));
|
||||||
while (roundMultiple > 1_000UL)
|
while (roundMultiple > 1_000UL)
|
||||||
{
|
|
||||||
if (RandomUtils.GetUInt32() % 2 == 0)
|
|
||||||
{
|
{
|
||||||
roundMultiple = roundMultiple / 10;
|
if (RandomUtils.GetUInt32() % 2 == 0)
|
||||||
|
{
|
||||||
|
roundMultiple = roundMultiple / 10;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
randomChangeAmount = (randomChangeAmount / roundMultiple) * roundMultiple;
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
else
|
|
||||||
{
|
|
||||||
randomChangeAmount = (randomChangeAmount / roundMultiple) * roundMultiple;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var fakeChange = newTx.Outputs.CreateNewTxOut(randomChangeAmount, change.ScriptPubKey);
|
var fakeChange = newTx.Outputs.CreateNewTxOut(randomChangeAmount, change.ScriptPubKey);
|
||||||
if (fakeChange.IsDust(minRelayTxFee))
|
if (fakeChange.IsDust(minRelayTxFee))
|
||||||
{
|
{
|
||||||
randomChangeAmount = fakeChange.GetDustThreshold(minRelayTxFee);
|
randomChangeAmount = fakeChange.GetDustThreshold(minRelayTxFee);
|
||||||
fakeChange.Value = randomChangeAmount;
|
fakeChange.Value = randomChangeAmount;
|
||||||
}
|
}
|
||||||
if (randomChangeAmount < contributedAmount)
|
if (randomChangeAmount < contributedAmount)
|
||||||
{
|
{
|
||||||
ourNewOutput.Value -= fakeChange.Value;
|
ourNewOutput.Value -= fakeChange.Value;
|
||||||
newTx.Outputs.Add(fakeChange);
|
newTx.Outputs.Add(fakeChange);
|
||||||
isOurOutput.Add(fakeChange);
|
isOurOutput.Add(fakeChange);
|
||||||
|
ctx.Logs.Write($"Added a fake change output of {fakeChange.Value} {network.CryptoCode} in the payjoin proposal");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -461,10 +459,7 @@ namespace BTCPayServer.Payments.PayJoin
|
|||||||
var newFeePaid = newTx.GetFee(txBuilder.FindSpentCoins(newTx));
|
var newFeePaid = newTx.GetFee(txBuilder.FindSpentCoins(newTx));
|
||||||
if (new FeeRate(newFeePaid, newVSize) < (senderMinFeeRate ?? minRelayTxFee))
|
if (new FeeRate(newFeePaid, newVSize) < (senderMinFeeRate ?? minRelayTxFee))
|
||||||
{
|
{
|
||||||
await UnlockUTXOs();
|
return CreatePayjoinErrorAndLog(422, PayjoinReceiverWellknownErrors.NotEnoughMoney, "Not enough money is sent to pay for the additional payjoin inputs");
|
||||||
await BroadcastNow();
|
|
||||||
return UnprocessableEntity(CreatePayjoinError("not-enough-money",
|
|
||||||
"Not enough money is sent to pay for the additional payjoin inputs"));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -487,8 +482,8 @@ namespace BTCPayServer.Payments.PayJoin
|
|||||||
// broadcast the payjoin.
|
// broadcast the payjoin.
|
||||||
var originalPaymentData = new BitcoinLikePaymentData(paymentAddress,
|
var originalPaymentData = new BitcoinLikePaymentData(paymentAddress,
|
||||||
originalPaymentOutput.Value,
|
originalPaymentOutput.Value,
|
||||||
new OutPoint(originalTx.GetHash(), originalPaymentOutput.Index),
|
new OutPoint(ctx.OriginalTransaction.GetHash(), originalPaymentOutput.Index),
|
||||||
originalTx.RBF);
|
ctx.OriginalTransaction.RBF);
|
||||||
originalPaymentData.ConfirmationCount = -1;
|
originalPaymentData.ConfirmationCount = -1;
|
||||||
originalPaymentData.PayjoinInformation = new PayjoinInformation()
|
originalPaymentData.PayjoinInformation = new PayjoinInformation()
|
||||||
{
|
{
|
||||||
@@ -499,25 +494,23 @@ namespace BTCPayServer.Payments.PayJoin
|
|||||||
var payment = await _invoiceRepository.AddPayment(invoice.Id, DateTimeOffset.UtcNow, originalPaymentData, network, true);
|
var payment = await _invoiceRepository.AddPayment(invoice.Id, DateTimeOffset.UtcNow, originalPaymentData, network, true);
|
||||||
if (payment is null)
|
if (payment is null)
|
||||||
{
|
{
|
||||||
await UnlockUTXOs();
|
|
||||||
await BroadcastNow();
|
|
||||||
return UnprocessableEntity(CreatePayjoinError("already-paid",
|
return UnprocessableEntity(CreatePayjoinError("already-paid",
|
||||||
$"The original transaction has already been accounted"));
|
$"The original transaction has already been accounted"));
|
||||||
}
|
}
|
||||||
await _btcPayWalletProvider.GetWallet(network).SaveOffchainTransactionAsync(originalTx);
|
await _btcPayWalletProvider.GetWallet(network).SaveOffchainTransactionAsync(ctx.OriginalTransaction);
|
||||||
_eventAggregator.Publish(new InvoiceEvent(invoice, 1002, InvoiceEvent.ReceivedPayment) {Payment = payment});
|
_eventAggregator.Publish(new InvoiceEvent(invoice, 1002, InvoiceEvent.ReceivedPayment) { Payment = payment });
|
||||||
_eventAggregator.Publish(new UpdateTransactionLabel()
|
_eventAggregator.Publish(new UpdateTransactionLabel()
|
||||||
{
|
{
|
||||||
WalletId = new WalletId(invoice.StoreId, network.CryptoCode),
|
WalletId = new WalletId(invoice.StoreId, network.CryptoCode),
|
||||||
TransactionLabels = selectedUTXOs.GroupBy(pair => pair.Key.Hash ).Select(utxo =>
|
TransactionLabels = selectedUTXOs.GroupBy(pair => pair.Key.Hash).Select(utxo =>
|
||||||
new KeyValuePair<uint256, List<(string color, string label)>>(utxo.Key,
|
new KeyValuePair<uint256, List<(string color, string label)>>(utxo.Key,
|
||||||
new List<(string color, string label)>()
|
new List<(string color, string label)>()
|
||||||
{
|
{
|
||||||
UpdateTransactionLabel.PayjoinExposedLabelTemplate(invoice.Id)
|
UpdateTransactionLabel.PayjoinExposedLabelTemplate(invoice.Id)
|
||||||
}))
|
}))
|
||||||
.ToDictionary(pair => pair.Key, pair => pair.Value)
|
.ToDictionary(pair => pair.Key, pair => pair.Value)
|
||||||
});
|
});
|
||||||
|
ctx.Success();
|
||||||
// BTCPay Server support PSBT set as hex
|
// BTCPay Server support PSBT set as hex
|
||||||
if (psbtFormat && HexEncoder.IsWellFormed(rawBody))
|
if (psbtFormat && HexEncoder.IsWellFormed(rawBody))
|
||||||
{
|
{
|
||||||
@@ -549,6 +542,21 @@ namespace BTCPayServer.Payments.PayJoin
|
|||||||
return o;
|
return o;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private JObject CreatePayjoinError(PayjoinReceiverWellknownErrors error, string debug)
|
||||||
|
{
|
||||||
|
var o = new JObject();
|
||||||
|
o.Add(new JProperty("errorCode", PayjoinReceiverHelper.GetErrorCode(error)));
|
||||||
|
if (string.IsNullOrEmpty(debug) || !_env.IsDevelopping)
|
||||||
|
{
|
||||||
|
o.Add(new JProperty("message", PayjoinReceiverHelper.GetMessage(error)));
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
o.Add(new JProperty("message", debug));
|
||||||
|
}
|
||||||
|
return o;
|
||||||
|
}
|
||||||
|
|
||||||
public enum PayjoinUtxoSelectionType
|
public enum PayjoinUtxoSelectionType
|
||||||
{
|
{
|
||||||
Unavailable,
|
Unavailable,
|
||||||
@@ -562,7 +570,7 @@ namespace BTCPayServer.Payments.PayJoin
|
|||||||
if (availableUtxos.Length == 0)
|
if (availableUtxos.Length == 0)
|
||||||
return (Array.Empty<UTXO>(), PayjoinUtxoSelectionType.Unavailable);
|
return (Array.Empty<UTXO>(), PayjoinUtxoSelectionType.Unavailable);
|
||||||
// Assume the merchant wants to get rid of the dust
|
// Assume the merchant wants to get rid of the dust
|
||||||
HashSet<OutPoint> locked = new HashSet<OutPoint>();
|
HashSet<OutPoint> locked = new HashSet<OutPoint>();
|
||||||
// We don't want to make too many db roundtrip which would be inconvenient for the sender
|
// We don't want to make too many db roundtrip which would be inconvenient for the sender
|
||||||
int maxTries = 30;
|
int maxTries = 30;
|
||||||
int currentTry = 0;
|
int currentTry = 0;
|
||||||
@@ -572,17 +580,17 @@ namespace BTCPayServer.Payments.PayJoin
|
|||||||
//
|
//
|
||||||
// "UIH2": one input is larger than any output. This heuristically implies that no output is a payment, or, to say it better, it implies that this is not a normal wallet-created payment, it's something strange/exotic.
|
// "UIH2": one input is larger than any output. This heuristically implies that no output is a payment, or, to say it better, it implies that this is not a normal wallet-created payment, it's something strange/exotic.
|
||||||
//src: https://gist.github.com/AdamISZ/4551b947789d3216bacfcb7af25e029e#gistcomment-2796539
|
//src: https://gist.github.com/AdamISZ/4551b947789d3216bacfcb7af25e029e#gistcomment-2796539
|
||||||
|
|
||||||
foreach (var availableUtxo in availableUtxos)
|
foreach (var availableUtxo in availableUtxos)
|
||||||
{
|
{
|
||||||
if (currentTry >= maxTries)
|
if (currentTry >= maxTries)
|
||||||
break;
|
break;
|
||||||
|
|
||||||
var invalid = false;
|
var invalid = false;
|
||||||
foreach (var input in otherInputs.Concat(new[] {availableUtxo.Value.GetValue(network)}))
|
foreach (var input in otherInputs.Concat(new[] { availableUtxo.Value.GetValue(network) }))
|
||||||
{
|
{
|
||||||
var computedOutputs =
|
var computedOutputs =
|
||||||
otherOutputs.Concat(new[] {mainPaymentOutput + availableUtxo.Value.GetValue(network)});
|
otherOutputs.Concat(new[] { mainPaymentOutput + availableUtxo.Value.GetValue(network) });
|
||||||
if (computedOutputs.Any(output => input > output))
|
if (computedOutputs.Any(output => input > output))
|
||||||
{
|
{
|
||||||
//UIH 1 & 2
|
//UIH 1 & 2
|
||||||
@@ -597,7 +605,7 @@ namespace BTCPayServer.Payments.PayJoin
|
|||||||
}
|
}
|
||||||
if (await _payJoinRepository.TryLock(availableUtxo.Outpoint))
|
if (await _payJoinRepository.TryLock(availableUtxo.Outpoint))
|
||||||
{
|
{
|
||||||
return (new[] {availableUtxo}, PayjoinUtxoSelectionType.HeuristicBased);
|
return (new[] { availableUtxo }, PayjoinUtxoSelectionType.HeuristicBased);
|
||||||
}
|
}
|
||||||
|
|
||||||
locked.Add(availableUtxo.Outpoint);
|
locked.Add(availableUtxo.Outpoint);
|
||||||
@@ -609,7 +617,7 @@ namespace BTCPayServer.Payments.PayJoin
|
|||||||
break;
|
break;
|
||||||
if (await _payJoinRepository.TryLock(utxo.Outpoint))
|
if (await _payJoinRepository.TryLock(utxo.Outpoint))
|
||||||
{
|
{
|
||||||
return (new[] {utxo}, PayjoinUtxoSelectionType.Ordered);
|
return (new[] { utxo }, PayjoinUtxoSelectionType.Ordered);
|
||||||
}
|
}
|
||||||
currentTry++;
|
currentTry++;
|
||||||
}
|
}
|
||||||
|
|||||||
69
BTCPayServer/Payments/PayJoin/PayjoinReceiverContext.cs
Normal file
69
BTCPayServer/Payments/PayJoin/PayjoinReceiverContext.cs
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using BTCPayServer.Logging;
|
||||||
|
using BTCPayServer.Services.Invoices;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using NBitcoin;
|
||||||
|
using NBitcoin.JsonConverters;
|
||||||
|
using NBitpayClient;
|
||||||
|
using NBXplorer;
|
||||||
|
|
||||||
|
namespace BTCPayServer.Payments.PayJoin
|
||||||
|
{
|
||||||
|
public class PayjoinReceiverContext
|
||||||
|
{
|
||||||
|
private readonly InvoiceRepository _invoiceRepository;
|
||||||
|
private readonly ExplorerClient _explorerClient;
|
||||||
|
private readonly PayJoinRepository _payJoinRepository;
|
||||||
|
|
||||||
|
public PayjoinReceiverContext(InvoiceRepository invoiceRepository, ExplorerClient explorerClient, PayJoinRepository payJoinRepository)
|
||||||
|
{
|
||||||
|
_invoiceRepository = invoiceRepository;
|
||||||
|
_explorerClient = explorerClient;
|
||||||
|
_payJoinRepository = payJoinRepository;
|
||||||
|
}
|
||||||
|
public Invoice Invoice { get; set; }
|
||||||
|
public NBitcoin.Transaction OriginalTransaction { get; set; }
|
||||||
|
public InvoiceLogs Logs { get; } = new InvoiceLogs();
|
||||||
|
public OutPoint[] LockedUTXOs { get; set; }
|
||||||
|
public async Task DisposeAsync()
|
||||||
|
{
|
||||||
|
List<Task> disposing = new List<Task>();
|
||||||
|
if (Invoice != null)
|
||||||
|
{
|
||||||
|
disposing.Add(_invoiceRepository.AddInvoiceLogs(Invoice.Id, Logs));
|
||||||
|
}
|
||||||
|
if (!doNotBroadcast && OriginalTransaction != null)
|
||||||
|
{
|
||||||
|
disposing.Add(_explorerClient.BroadcastAsync(OriginalTransaction));
|
||||||
|
}
|
||||||
|
if (!success && LockedUTXOs != null)
|
||||||
|
{
|
||||||
|
disposing.Add(_payJoinRepository.TryUnlock(LockedUTXOs));
|
||||||
|
}
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await Task.WhenAll(disposing);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
BTCPayServer.Logging.Logs.PayServer.LogWarning(ex, "Error while disposing the PayjoinReceiverContext");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool doNotBroadcast = false;
|
||||||
|
public void DoNotBroadcast()
|
||||||
|
{
|
||||||
|
doNotBroadcast = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool success = false;
|
||||||
|
public void Success()
|
||||||
|
{
|
||||||
|
doNotBroadcast = true;
|
||||||
|
success = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ using System.Net.Http;
|
|||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
using BTCPayServer.Payments.Changelly.Models;
|
||||||
using Google.Apis.Http;
|
using Google.Apis.Http;
|
||||||
using NBitcoin;
|
using NBitcoin;
|
||||||
using Newtonsoft.Json;
|
using Newtonsoft.Json;
|
||||||
@@ -302,55 +303,61 @@ namespace BTCPayServer.Services
|
|||||||
NeedUTXOInformation,
|
NeedUTXOInformation,
|
||||||
InvalidTransaction
|
InvalidTransaction
|
||||||
}
|
}
|
||||||
|
public class PayjoinReceiverHelper
|
||||||
|
{
|
||||||
|
static IEnumerable<(PayjoinReceiverWellknownErrors EnumValue, string ErrorCode, string Message)> Get()
|
||||||
|
{
|
||||||
|
yield return (PayjoinReceiverWellknownErrors.LeakingData, "leaking-data", "Key path information or GlobalXPubs should not be included in the original PSBT.");
|
||||||
|
yield return (PayjoinReceiverWellknownErrors.PSBTNotFinalized, "psbt-not-finalized", "The original PSBT must be finalized.");
|
||||||
|
yield return (PayjoinReceiverWellknownErrors.Unavailable, "unavailable", "The payjoin endpoint is not available for now.");
|
||||||
|
yield return (PayjoinReceiverWellknownErrors.NotEnoughMoney, "not-enough-money", "The receiver added some inputs but could not bump the fee of the payjoin proposal.");
|
||||||
|
yield return (PayjoinReceiverWellknownErrors.InsanePSBT, "insane-psbt", "Some consistency check on the PSBT failed.");
|
||||||
|
yield return (PayjoinReceiverWellknownErrors.VersionUnsupported, "version-unsupported", "This version of payjoin is not supported.");
|
||||||
|
yield return (PayjoinReceiverWellknownErrors.NeedUTXOInformation, "need-utxo-information", "The witness UTXO or non witness UTXO is missing.");
|
||||||
|
yield return (PayjoinReceiverWellknownErrors.InvalidTransaction, "invalid-transaction", "The original transaction is invalid for payjoin");
|
||||||
|
}
|
||||||
|
public static string GetErrorCode(PayjoinReceiverWellknownErrors err)
|
||||||
|
{
|
||||||
|
return Get().Single(o => o.EnumValue == err).ErrorCode;
|
||||||
|
}
|
||||||
|
public static PayjoinReceiverWellknownErrors? GetWellknownError(string errorCode)
|
||||||
|
{
|
||||||
|
var t = Get().FirstOrDefault(o => o.ErrorCode == errorCode);
|
||||||
|
if (t == default)
|
||||||
|
return null;
|
||||||
|
return t.EnumValue;
|
||||||
|
}
|
||||||
|
static string UnknownError = "Unknown error from the receiver";
|
||||||
|
public static string GetMessage(string errorCode)
|
||||||
|
{
|
||||||
|
return Get().FirstOrDefault(o => o.ErrorCode == errorCode).Message ?? UnknownError;
|
||||||
|
}
|
||||||
|
public static string GetMessage(PayjoinReceiverWellknownErrors err)
|
||||||
|
{
|
||||||
|
return Get().Single(o => o.EnumValue == err).Message;
|
||||||
|
}
|
||||||
|
}
|
||||||
public class PayjoinReceiverException : PayjoinException
|
public class PayjoinReceiverException : PayjoinException
|
||||||
{
|
{
|
||||||
public PayjoinReceiverException(string errorCode, string receiverDebugMessage) : base(FormatMessage(errorCode, receiverDebugMessage))
|
public PayjoinReceiverException(string errorCode, string receiverMessage) : base(FormatMessage(errorCode))
|
||||||
{
|
{
|
||||||
ErrorCode = errorCode;
|
ErrorCode = errorCode;
|
||||||
ReceiverDebugMessage = receiverDebugMessage;
|
ReceiverMessage = receiverMessage;
|
||||||
WellknownError = errorCode switch
|
WellknownError = PayjoinReceiverHelper.GetWellknownError(errorCode);
|
||||||
{
|
ErrorMessage = PayjoinReceiverHelper.GetMessage(errorCode);
|
||||||
"leaking-data" => PayjoinReceiverWellknownErrors.LeakingData,
|
|
||||||
"psbt-not-finalized" => PayjoinReceiverWellknownErrors.PSBTNotFinalized,
|
|
||||||
"unavailable" => PayjoinReceiverWellknownErrors.Unavailable,
|
|
||||||
"out-of-utxos" => PayjoinReceiverWellknownErrors.OutOfUTXOS,
|
|
||||||
"not-enough-money" => PayjoinReceiverWellknownErrors.NotEnoughMoney,
|
|
||||||
"insane-psbt" => PayjoinReceiverWellknownErrors.InsanePSBT,
|
|
||||||
"version-unsupported" => PayjoinReceiverWellknownErrors.VersionUnsupported,
|
|
||||||
"need-utxo-information" => PayjoinReceiverWellknownErrors.NeedUTXOInformation,
|
|
||||||
"invalid-transaction" => PayjoinReceiverWellknownErrors.InvalidTransaction,
|
|
||||||
_ => null
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
public string ErrorCode { get; }
|
public string ErrorCode { get; }
|
||||||
public string ErrorMessage { get; }
|
public string ErrorMessage { get; }
|
||||||
public string ReceiverDebugMessage { get; }
|
public string ReceiverMessage { get; }
|
||||||
|
|
||||||
public PayjoinReceiverWellknownErrors? WellknownError
|
public PayjoinReceiverWellknownErrors? WellknownError
|
||||||
{
|
{
|
||||||
get;
|
get;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string FormatMessage(string errorCode, string receiverDebugMessage)
|
private static string FormatMessage(string errorCode)
|
||||||
{
|
{
|
||||||
return $"{errorCode}: {GetMessage(errorCode)}";
|
return $"{errorCode}: {PayjoinReceiverHelper.GetMessage(errorCode)}";
|
||||||
}
|
|
||||||
|
|
||||||
private static string GetMessage(string errorCode)
|
|
||||||
{
|
|
||||||
return errorCode switch
|
|
||||||
{
|
|
||||||
"leaking-data" => "Key path information or GlobalXPubs should not be included in the original PSBT.",
|
|
||||||
"psbt-not-finalized" => "The original PSBT must be finalized.",
|
|
||||||
"unavailable" => "The payjoin endpoint is not available for now.",
|
|
||||||
"out-of-utxos" => "The receiver does not have any UTXO to contribute in a payjoin proposal.",
|
|
||||||
"not-enough-money" => "The receiver added some inputs but could not bump the fee of the payjoin proposal.",
|
|
||||||
"insane-psbt" => "Some consistency check on the PSBT failed.",
|
|
||||||
"version-unsupported" => "This version of payjoin is not supported.",
|
|
||||||
"need-utxo-information" => "The witness UTXO or non witness UTXO is missing",
|
|
||||||
"invalid-transaction" => "The original transaction is invalid for payjoin",
|
|
||||||
_ => "Unknown error"
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user