btcpayserver/BTCPayServer/Payments/PayJoin/PayJoinEndpointController.cs

419 lines
20 KiB
C#
Raw Normal View History

2020-01-06 13:57:32 +01:00
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using BTCPayServer.Data;
using BTCPayServer.Filters;
using BTCPayServer.Payments.Bitcoin;
using BTCPayServer.Services.Invoices;
using BTCPayServer.Services.Stores;
using BTCPayServer.Services.Wallets;
using Microsoft.AspNetCore.Cors;
using Microsoft.AspNetCore.Mvc;
using NBitcoin;
using NBitcoin.DataEncoders;
using NBXplorer;
using NBXplorer.Models;
using NicolasDorier.RateLimits;
namespace BTCPayServer.Payments.PayJoin
{
[Route("{cryptoCode}/bpu")]
public class PayJoinEndpointController : ControllerBase
{
private readonly BTCPayNetworkProvider _btcPayNetworkProvider;
private readonly InvoiceRepository _invoiceRepository;
private readonly ExplorerClientProvider _explorerClientProvider;
private readonly StoreRepository _storeRepository;
private readonly BTCPayWalletProvider _btcPayWalletProvider;
private readonly PayJoinStateProvider _payJoinStateProvider;
public PayJoinEndpointController(BTCPayNetworkProvider btcPayNetworkProvider,
InvoiceRepository invoiceRepository, ExplorerClientProvider explorerClientProvider,
StoreRepository storeRepository, BTCPayWalletProvider btcPayWalletProvider,
PayJoinStateProvider payJoinStateProvider)
{
_btcPayNetworkProvider = btcPayNetworkProvider;
_invoiceRepository = invoiceRepository;
_explorerClientProvider = explorerClientProvider;
_storeRepository = storeRepository;
_btcPayWalletProvider = btcPayWalletProvider;
_payJoinStateProvider = payJoinStateProvider;
}
[HttpPost("{invoice}")]
[IgnoreAntiforgeryToken]
[EnableCors(CorsPolicies.All)]
[MediaTypeConstraint("text/plain")]
[RateLimitsFilter(ZoneLimits.PayJoin, Scope = RateLimitsScope.RemoteAddress)]
public async Task<IActionResult> Submit(string cryptoCode, string invoice)
{
var network = _btcPayNetworkProvider.GetNetwork<BTCPayNetwork>(cryptoCode);
if (network == null)
{
return UnprocessableEntity("Incorrect network");
}
string rawBody;
using (StreamReader reader = new StreamReader(Request.Body, Encoding.UTF8))
{
rawBody = await reader.ReadToEndAsync();
}
if (string.IsNullOrEmpty(rawBody))
{
return UnprocessableEntity("raw tx not provided");
}
PSBT psbt = null;
if (!Transaction.TryParse(rawBody, network.NBitcoinNetwork, out var transaction) &&
!PSBT.TryParse(rawBody, network.NBitcoinNetwork, out psbt))
{
return UnprocessableEntity("invalid raw transaction or psbt");
}
if (psbt != null)
{
transaction = psbt.ExtractTransaction();
}
if (transaction.Check() != TransactionCheckResult.Success)
{
return UnprocessableEntity($"invalid tx: {transaction.Check()}");
}
if (transaction.Inputs.Any(txin => txin.ScriptSig == null || txin.WitScript == null))
{
return UnprocessableEntity($"all inputs must be segwit and signed");
}
var explorerClient = _explorerClientProvider.GetExplorerClient(network);
var mempool = await explorerClient.BroadcastAsync(transaction, true);
if (!mempool.Success)
{
return UnprocessableEntity($"provided transaction isn't mempool eligible {mempool.RPCCodeMessage}");
}
var paymentMethodId = new PaymentMethodId(network.CryptoCode, PaymentTypes.BTCLike);
//multiple outs could mean a payment being done to multiple invoices to multiple stores in one payjoin tx which makes life unbearable
//UNLESS the request specified an invoice Id, which is mandatory :)
var matchingInvoice = await _invoiceRepository.GetInvoice(invoice);
if (matchingInvoice == null)
{
return UnprocessableEntity($"invalid invoice");
}
2020-03-05 19:04:08 +01:00
2020-01-06 13:57:32 +01:00
var invoicePaymentMethod = matchingInvoice.GetPaymentMethod(paymentMethodId);
//get outs to our current invoice address
var currentPaymentMethodDetails =
invoicePaymentMethod.GetPaymentMethodDetails() as BitcoinLikeOnChainPaymentMethod;
if (!currentPaymentMethodDetails.PayJoin.Enabled)
{
return UnprocessableEntity($"cannot handle payjoin tx");
}
2020-03-05 19:04:08 +01:00
//the invoice must be active, and the status must be new OR paid if
if (matchingInvoice.IsExpired() ||
((matchingInvoice.GetInvoiceState().Status == InvoiceStatus.Paid &&
currentPaymentMethodDetails.PayJoin.OriginalTransactionHash == null) ||
matchingInvoice.GetInvoiceState().Status != InvoiceStatus.New))
{
return UnprocessableEntity($"cannot handle payjoin tx");
}
2020-01-06 13:57:32 +01:00
if (currentPaymentMethodDetails.PayJoin.OriginalTransactionHash != null &&
2020-03-05 19:04:08 +01:00
currentPaymentMethodDetails.PayJoin.OriginalTransactionHash != transaction.GetHash() &&
!transaction.RBF)
2020-01-06 13:57:32 +01:00
{
return UnprocessableEntity($"cannot handle payjoin tx");
}
var address = currentPaymentMethodDetails.GetDepositAddress(network.NBitcoinNetwork);
var matchingTXOuts = transaction.Outputs.Where(txout => txout.IsTo(address));
var nonMatchingTXOuts = transaction.Outputs.Where(txout => !txout.IsTo(address));
if (!matchingTXOuts.Any())
{
return UnprocessableEntity($"tx does not pay invoice");
}
var store = await _storeRepository.FindStore(matchingInvoice.StoreId);
//check if store is enabled
var derivationSchemeSettings = store.GetSupportedPaymentMethods(_btcPayNetworkProvider)
.OfType<DerivationSchemeSettings>().SingleOrDefault(settings =>
settings.PaymentId == paymentMethodId && store.GetEnabledPaymentIds(_btcPayNetworkProvider)
.Contains(settings.PaymentId));
if (derivationSchemeSettings == null)
{
return UnprocessableEntity($"cannot handle payjoin tx");
}
var state = _payJoinStateProvider.GetOrAdd(new WalletId(matchingInvoice.StoreId, cryptoCode),
derivationSchemeSettings.AccountDerivation);
//check if any of the inputs have been spotted in other txs sent our way..Reject anything but the original
//also reject if the invoice being payjoined to already has a record
2020-03-05 19:04:08 +01:00
var validity = state.CheckIfTransactionValid(transaction, invoice);
if (validity == PayJoinState.TransactionValidityResult.Invalid_Inputs_Seen || validity == PayJoinState.TransactionValidityResult.Invalid_PartialMatch)
2020-01-06 13:57:32 +01:00
{
return UnprocessableEntity($"cannot handle payjoin tx");
}
//check if wallet of store is configured to be hot wallet
var extKeyStr = await explorerClient.GetMetadataAsync<string>(
derivationSchemeSettings.AccountDerivation,
WellknownMetadataKeys.MasterHDKey);
if (extKeyStr == null)
{
return UnprocessableEntity($"cannot handle payjoin tx");
}
var extKey = ExtKey.Parse(extKeyStr, network.NBitcoinNetwork);
var signingKeySettings = derivationSchemeSettings.GetSigningAccountKeySettings();
if (signingKeySettings.RootFingerprint is null)
signingKeySettings.RootFingerprint = extKey.GetPublicKey().GetHDFingerPrint();
RootedKeyPath rootedKeyPath = signingKeySettings.GetRootedKeyPath();
if (rootedKeyPath == null)
{
return UnprocessableEntity($"cannot handle payjoin tx");
// The master fingerprint and/or account key path of your seed are not set in the wallet settings
}
// The user gave the root key, let's try to rebase the PSBT, and derive the account private key
if (rootedKeyPath.MasterFingerprint == extKey.GetPublicKey().GetHDFingerPrint())
{
extKey = extKey.Derive(rootedKeyPath.KeyPath);
}
//check if the store uses segwit -- mixing inputs of different types is suspicious
if (derivationSchemeSettings.AccountDerivation.ScriptPubKeyType() == ScriptPubKeyType.Legacy)
{
return UnprocessableEntity($"cannot handle payjoin tx");
}
//get previous payments so that we can check if their address is also used in the txouts)
var previousPayments = matchingInvoice.GetPayments(network)
.Select(entity => entity.GetCryptoPaymentData() as BitcoinLikePaymentData);
if (transaction.Outputs.Any(
2020-03-05 19:04:08 +01:00
txout => previousPayments.Any(data => !txout.IsTo(address) && txout.IsTo(data.GetDestination()))))
2020-01-06 13:57:32 +01:00
{
//Meh, address reuse from the customer would be happening with this tx, skip
return UnprocessableEntity($"cannot handle payjoin tx");
}
//get any utxos we exposed already that match any of the inputs sent to us.
var utxosToContributeToThisPayment = state.GetExposed(transaction);
var invoicePaymentMethodAccounting = invoicePaymentMethod.Calculate();
if (invoicePaymentMethodAccounting.Due != matchingTXOuts.Sum(txout => txout.Value) &&
!utxosToContributeToThisPayment.Any())
{
//the invoice would be under/overpaid with this tx and we have not exposed utxos so no worries
return UnprocessableEntity($"cannot handle payjoin tx");
}
//if we have not exposed any utxos to any of the inputs
if (!utxosToContributeToThisPayment.Any())
{
var wallet = _btcPayWalletProvider.GetWallet(network);
//get all utxos we have so far exposed
var coins = state.GetRecords().SelectMany(list =>
list.CoinsExposed.Select(coin => coin.OutPoint.Hash));
//get all utxos we have NOT so far exposed
var availableUtxos = (await wallet.GetUnspentCoins(derivationSchemeSettings.AccountDerivation)).Where(
coin =>
!coins.Contains(coin.OutPoint.Hash));
if (availableUtxos.Any())
{
//clean up the state by removing utxos from the exposed list that we no longer have
state.PruneExposedButSpentCoins(availableUtxos);
//if we have coins that were exposed before but were not spent, prioritize them
var exposedAlready = state.GetExposedCoins();
if (exposedAlready.Any())
{
utxosToContributeToThisPayment = SelectCoins(network, exposedAlready,
invoicePaymentMethodAccounting.Due.ToDecimal(MoneyUnit.BTC),
nonMatchingTXOuts.Select(txout => txout.Value.ToDecimal(MoneyUnit.BTC)));
state.PruneExposedBySpentCoins(utxosToContributeToThisPayment.Select(coin => coin.OutPoint));
}
else
{
utxosToContributeToThisPayment = SelectCoins(network, availableUtxos,
invoicePaymentMethodAccounting.Due.ToDecimal(MoneyUnit.BTC),
nonMatchingTXOuts.Select(txout => txout.Value.ToDecimal(MoneyUnit.BTC)));
}
}
}
//we don't have any utxos to provide to this tx
if (!utxosToContributeToThisPayment.Any())
{
return UnprocessableEntity($"cannot handle payjoin tx");
}
//we rebuild the tx using 1 output to the invoice designed address
var cjOutputContributedAmount = utxosToContributeToThisPayment.Sum(coin => coin.Value.GetValue(network));
var cjOutputSum = matchingTXOuts.Sum(txout => txout.Value.ToDecimal(MoneyUnit.BTC)) +
cjOutputContributedAmount;
var newTx = transaction.Clone();
if (matchingTXOuts.Count() > 1)
{
//if there are more than 1 outputs to our address, consolidate them to 1 + coinjoined amount to avoid unnecessary utxos
newTx.Outputs.Clear();
newTx.Outputs.Add(new Money(cjOutputSum, MoneyUnit.BTC), address.ScriptPubKey);
foreach (var nonmatchingTxOut in nonMatchingTXOuts)
{
newTx.Outputs.Add(nonmatchingTxOut.Value, nonmatchingTxOut.ScriptPubKey);
}
}
else
{
//set the value of the out to our address to the sum of the coinjoined amount
foreach (var txOutput in newTx.Outputs.Where(txOutput =>
txOutput.Value == matchingTXOuts.First().Value &&
txOutput.ScriptPubKey == matchingTXOuts.First().ScriptPubKey))
{
txOutput.Value = new Money(cjOutputSum, MoneyUnit.BTC);
break;
}
}
newTx.Inputs.AddRange(utxosToContributeToThisPayment.Select(coin =>
new TxIn(coin.OutPoint) {Sequence = newTx.Inputs.First().Sequence}));
if (psbt != null)
{
psbt = PSBT.FromTransaction(newTx, network.NBitcoinNetwork);
psbt = (await explorerClient.UpdatePSBTAsync(new UpdatePSBTRequest()
{
DerivationScheme = derivationSchemeSettings.AccountDerivation,
PSBT = psbt,
RebaseKeyPaths = derivationSchemeSettings.GetPSBTRebaseKeyRules().ToList()
})).PSBT;
psbt = psbt.SignWithKeys(utxosToContributeToThisPayment
.Select(coin => extKey.Derive(coin.KeyPath).PrivateKey)
.ToArray());
2020-03-05 19:04:08 +01:00
if (validity == PayJoinState.TransactionValidityResult.Valid_SameInputs)
{
//if the invoice was rbfed, remove the current record and replace it with the new one
state.RemoveRecord(invoice);
}
if (validity == PayJoinState.TransactionValidityResult.Valid_NoMatch)
2020-01-06 13:57:32 +01:00
{
await AddRecord(invoice, state, transaction, utxosToContributeToThisPayment,
cjOutputContributedAmount, cjOutputSum, newTx, currentPaymentMethodDetails,
invoicePaymentMethod);
}
return Ok(HexEncoder.IsWellFormed(rawBody) ? psbt.ToHex() : psbt.ToBase64());
}
else
{
// Since we're going to modify the transaction, we're going invalidate all signatures
foreach (TxIn newTxInput in newTx.Inputs)
{
newTxInput.WitScript = WitScript.Empty;
}
newTx.Sign(
utxosToContributeToThisPayment.Select(coin =>
extKey.Derive(coin.KeyPath).PrivateKey.GetWif(network.NBitcoinNetwork)),
utxosToContributeToThisPayment.Select(coin => coin.Coin));
2020-03-05 19:04:08 +01:00
if (validity == PayJoinState.TransactionValidityResult.Valid_SameInputs)
{
//if the invoice was rbfed, remove the current record and replace it with the new one
state.RemoveRecord(invoice);
}
if (validity == PayJoinState.TransactionValidityResult.Valid_NoMatch)
2020-01-06 13:57:32 +01:00
{
await AddRecord(invoice, state, transaction, utxosToContributeToThisPayment,
cjOutputContributedAmount, cjOutputSum, newTx, currentPaymentMethodDetails,
invoicePaymentMethod);
}
return Ok(newTx.ToHex());
}
}
private async Task AddRecord(string invoice, PayJoinState joinState, Transaction transaction,
List<ReceivedCoin> utxosToContributeToThisPayment, decimal cjOutputContributedAmount, decimal cjOutputSum,
Transaction newTx,
BitcoinLikeOnChainPaymentMethod currentPaymentMethodDetails, PaymentMethod invoicePaymentMethod)
{
//keep a record of the tx and check if we have seen the tx before or any of its inputs
//on a timer service: if x amount of times passes, broadcast this tx
joinState.AddRecord(new PayJoinStateRecordedItem()
{
Timestamp = DateTimeOffset.Now,
Transaction = transaction,
OriginalTransactionHash = transaction.GetHash(),
CoinsExposed = utxosToContributeToThisPayment,
ContributedAmount = cjOutputContributedAmount,
TotalOutputAmount = cjOutputSum,
ProposedTransactionHash = newTx.GetHash(),
InvoiceId = invoice
});
//we also store a record in the payment method details of the invoice,
//Tn case the server is shut down and a payjoin payment is made before it is turned back on.
//Otherwise we would end up marking the invoice as overPaid with our own inputs!
currentPaymentMethodDetails.PayJoin = new PayJoinPaymentState()
{
Enabled = true,
CoinsExposed = utxosToContributeToThisPayment,
ContributedAmount = cjOutputContributedAmount,
TotalOutputAmount = cjOutputSum,
ProposedTransactionHash = newTx.GetHash(),
OriginalTransactionHash = transaction.GetHash(),
};
invoicePaymentMethod.SetPaymentMethodDetails(currentPaymentMethodDetails);
await _invoiceRepository.UpdateInvoicePaymentMethod(invoice, invoicePaymentMethod);
}
private List<ReceivedCoin> SelectCoins(BTCPayNetwork network, IEnumerable<ReceivedCoin> availableUtxos,
decimal paymentAmount, IEnumerable<decimal> otherOutputs)
{
// UIH = "unnecessary input heuristic", basically "a wallet wouldn't choose more utxos to spend in this scenario".
//
// "UIH1" : one output is smaller than any input. This heuristically implies that that output is not a payment, and must therefore be a change output.
//
// "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
foreach (var availableUtxo in availableUtxos)
{
//we can only check against our input as we dont know the value of the rest.
var input = availableUtxo.Value.GetValue(network);
var paymentAmountSum = input + paymentAmount;
if (otherOutputs.Concat(new[] {paymentAmountSum}).Any(output => input > output))
{
//UIH 1 & 2
continue;
}
return new List<ReceivedCoin> {availableUtxo};
}
//For now we just grab a utxo "at random"
Random r = new Random();
return new List<ReceivedCoin>() {availableUtxos.ElementAt(r.Next(0, availableUtxos.Count()))};
}
}
}