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 ( ) ) ) } ;
}
}
}