2021-04-13 10:36:49 +02:00
using System ;
using System.Collections.Generic ;
2021-07-16 09:57:37 +02:00
using System.Globalization ;
2021-04-13 10:36:49 +02:00
using System.Linq ;
using System.Text ;
using System.Threading.Tasks ;
using BTCPayServer ;
2021-07-16 09:57:37 +02:00
using BTCPayServer.Abstractions.Models ;
2021-04-13 10:36:49 +02:00
using BTCPayServer.Client.Models ;
using BTCPayServer.Data ;
using BTCPayServer.Events ;
using BTCPayServer.HostedServices ;
using BTCPayServer.Logging ;
2021-07-16 09:57:37 +02:00
using BTCPayServer.Models ;
using BTCPayServer.Models.WalletViewModels ;
2021-04-13 10:36:49 +02:00
using BTCPayServer.Payments ;
using BTCPayServer.Services ;
2021-07-16 09:57:37 +02:00
using BTCPayServer.Services.Notifications ;
using BTCPayServer.Services.Notifications.Blobs ;
using Microsoft.AspNetCore.Routing ;
2021-04-13 10:36:49 +02:00
using Microsoft.EntityFrameworkCore ;
using Microsoft.Extensions.Logging ;
using NBitcoin ;
using NBitcoin.Payment ;
using NBitcoin.RPC ;
using NBXplorer.Models ;
using Newtonsoft.Json ;
2021-06-10 11:43:45 +02:00
using Newtonsoft.Json.Linq ;
2021-04-13 10:36:49 +02:00
using NewBlockEvent = BTCPayServer . Events . NewBlockEvent ;
using PayoutData = BTCPayServer . Data . PayoutData ;
public class BitcoinLikePayoutHandler : IPayoutHandler
{
private readonly BTCPayNetworkProvider _btcPayNetworkProvider ;
private readonly ExplorerClientProvider _explorerClientProvider ;
private readonly BTCPayNetworkJsonSerializerSettings _jsonSerializerSettings ;
private readonly ApplicationDbContextFactory _dbContextFactory ;
private readonly EventAggregator _eventAggregator ;
2021-07-16 09:57:37 +02:00
private readonly NotificationSender _notificationSender ;
2021-04-13 10:36:49 +02:00
public BitcoinLikePayoutHandler ( BTCPayNetworkProvider btcPayNetworkProvider ,
ExplorerClientProvider explorerClientProvider , BTCPayNetworkJsonSerializerSettings jsonSerializerSettings ,
2021-07-16 09:57:37 +02:00
ApplicationDbContextFactory dbContextFactory , EventAggregator eventAggregator , NotificationSender notificationSender )
2021-04-13 10:36:49 +02:00
{
_btcPayNetworkProvider = btcPayNetworkProvider ;
_explorerClientProvider = explorerClientProvider ;
_jsonSerializerSettings = jsonSerializerSettings ;
_dbContextFactory = dbContextFactory ;
_eventAggregator = eventAggregator ;
2021-07-16 09:57:37 +02:00
_notificationSender = notificationSender ;
2021-04-13 10:36:49 +02:00
}
public bool CanHandle ( PaymentMethodId paymentMethod )
{
2021-09-24 07:16:25 +02:00
return paymentMethod ? . PaymentType = = BitcoinPaymentType . Instance & &
2021-04-13 10:36:49 +02:00
_btcPayNetworkProvider . GetNetwork < BTCPayNetwork > ( paymentMethod . CryptoCode ) ? . ReadonlyWallet is false ;
}
2021-07-16 09:57:37 +02:00
public async Task TrackClaim ( PaymentMethodId paymentMethodId , IClaimDestination claimDestination )
{
var network = _btcPayNetworkProvider . GetNetwork < BTCPayNetwork > ( paymentMethodId . CryptoCode ) ;
var explorerClient = _explorerClientProvider . GetExplorerClient ( network ) ;
if ( claimDestination is IBitcoinLikeClaimDestination bitcoinLikeClaimDestination )
await explorerClient . TrackAsync ( TrackedSource . Create ( bitcoinLikeClaimDestination . Address ) ) ;
}
2021-04-13 10:36:49 +02:00
public Task < IClaimDestination > ParseClaimDestination ( PaymentMethodId paymentMethodId , string destination )
{
var network = _btcPayNetworkProvider . GetNetwork < BTCPayNetwork > ( paymentMethodId . CryptoCode ) ;
destination = destination . Trim ( ) ;
try
{
2021-08-09 22:43:38 +09:00
// This doesn't work properly, (payouts are not detected), we can reactivate later when we fix the bug https://github.com/btcpayserver/btcpayserver/issues/2765
//if (destination.StartsWith($"{network.UriScheme}:", StringComparison.OrdinalIgnoreCase))
//{
// return Task.FromResult<IClaimDestination>(new UriClaimDestination(new BitcoinUrlBuilder(destination, network.NBitcoinNetwork)));
//}
2021-04-13 10:36:49 +02:00
return Task . FromResult < IClaimDestination > ( new AddressClaimDestination ( BitcoinAddress . Create ( destination , network . NBitcoinNetwork ) ) ) ;
}
catch
{
return Task . FromResult < IClaimDestination > ( null ) ;
}
}
public IPayoutProof ParseProof ( PayoutData payout )
{
if ( payout ? . Proof is null )
return null ;
var paymentMethodId = payout . GetPaymentMethodId ( ) ;
2021-09-24 07:16:25 +02:00
if ( paymentMethodId is null )
{
return null ;
}
2021-06-10 11:43:45 +02:00
var raw = JObject . Parse ( Encoding . UTF8 . GetString ( payout . Proof ) ) ;
if ( raw . TryGetValue ( "proofType" , StringComparison . InvariantCultureIgnoreCase , out var proofType ) & &
proofType . Value < string > ( ) = = ManualPayoutProof . Type )
{
return raw . ToObject < ManualPayoutProof > ( ) ;
}
var res = raw . ToObject < PayoutTransactionOnChainBlob > (
JsonSerializer . Create ( _jsonSerializerSettings . GetSerializer ( paymentMethodId . CryptoCode ) ) ) ;
2021-04-13 10:36:49 +02:00
var network = _btcPayNetworkProvider . GetNetwork < BTCPayNetwork > ( paymentMethodId . CryptoCode ) ;
2021-06-10 11:43:45 +02:00
if ( res = = null ) return null ;
2021-04-13 10:36:49 +02:00
res . LinkTemplate = network . BlockExplorerLink ;
return res ;
}
public void StartBackgroundCheck ( Action < Type [ ] > subscribe )
{
subscribe ( new [ ] { typeof ( NewOnChainTransactionEvent ) , typeof ( NewBlockEvent ) } ) ;
}
public async Task BackgroundCheck ( object o )
{
2021-07-16 09:57:37 +02:00
if ( o is NewOnChainTransactionEvent newTransaction & & newTransaction . NewTransactionEvent . TrackedSource is AddressTrackedSource addressTrackedSource )
2021-04-13 10:36:49 +02:00
{
2021-07-16 09:57:37 +02:00
await UpdatePayoutsAwaitingForPayment ( newTransaction , addressTrackedSource ) ;
2021-04-13 10:36:49 +02:00
}
if ( o is NewBlockEvent | | o is NewOnChainTransactionEvent )
{
await UpdatePayoutsInProgress ( ) ;
}
}
public Task < decimal > GetMinimumPayoutAmount ( PaymentMethodId paymentMethodId , IClaimDestination claimDestination )
{
if ( _btcPayNetworkProvider . GetNetwork < BTCPayNetwork > ( paymentMethodId . CryptoCode ) ?
. NBitcoinNetwork ?
. Consensus ?
. ConsensusFactory ?
. CreateTxOut ( ) is TxOut txout & &
claimDestination is IBitcoinLikeClaimDestination bitcoinLikeClaimDestination )
{
txout . ScriptPubKey = bitcoinLikeClaimDestination . Address . ScriptPubKey ;
return Task . FromResult ( txout . GetDustThreshold ( new FeeRate ( 1.0 m ) ) . ToDecimal ( MoneyUnit . BTC ) ) ;
}
return Task . FromResult ( 0 m ) ;
}
2021-07-16 09:57:37 +02:00
public Dictionary < PayoutState , List < ( string Action , string Text ) > > GetPayoutSpecificActions ( )
{
return new Dictionary < PayoutState , List < ( string Action , string Text ) > > ( )
{
{ PayoutState . AwaitingPayment , new List < ( string Action , string Text ) > ( )
{
( "reject-payment" , "Reject payout transaction" )
} }
} ;
}
public async Task < StatusMessageModel > DoSpecificAction ( string action , string [ ] payoutIds , string storeId )
{
switch ( action )
{
2021-08-05 07:47:25 +02:00
case "mark-paid" :
2021-07-16 09:57:37 +02:00
await using ( var context = _dbContextFactory . CreateContext ( ) )
{
var payouts = ( await context . Payouts
. Include ( p = > p . PullPaymentData )
. Include ( p = > p . PullPaymentData . StoreData )
. Where ( p = > payoutIds . Contains ( p . Id ) )
. Where ( p = > p . PullPaymentData . StoreId = = storeId & & ! p . PullPaymentData . Archived & & p . State = = PayoutState . AwaitingPayment )
2021-09-24 07:16:25 +02:00
. ToListAsync ( ) ) . Where ( data = >
PaymentMethodId . TryParse ( data . PaymentMethodId , out var paymentMethodId ) & &
CanHandle ( paymentMethodId ) )
2021-07-16 09:57:37 +02:00
. Select ( data = > ( data , ParseProof ( data ) as PayoutTransactionOnChainBlob ) ) . Where ( tuple = > tuple . Item2 ! = null & & tuple . Item2 . TransactionId ! = null & & tuple . Item2 . Accounted = = false ) ;
foreach ( var valueTuple in payouts )
{
valueTuple . Item2 . Accounted = true ;
valueTuple . data . State = PayoutState . InProgress ;
SetProofBlob ( valueTuple . data , valueTuple . Item2 ) ;
}
await context . SaveChangesAsync ( ) ;
}
return new StatusMessageModel ( )
{
Message = "Payout payments have been marked confirmed" ,
Severity = StatusMessageModel . StatusSeverity . Success
} ;
case "reject-payment" :
await using ( var context = _dbContextFactory . CreateContext ( ) )
{
var payouts = ( await context . Payouts
. Include ( p = > p . PullPaymentData )
. Include ( p = > p . PullPaymentData . StoreData )
. Where ( p = > payoutIds . Contains ( p . Id ) )
. Where ( p = > p . PullPaymentData . StoreId = = storeId & & ! p . PullPaymentData . Archived & & p . State = = PayoutState . AwaitingPayment )
2021-09-24 07:16:25 +02:00
. ToListAsync ( ) ) . Where ( data = >
PaymentMethodId . TryParse ( data . PaymentMethodId , out var paymentMethodId ) & &
CanHandle ( paymentMethodId ) )
2021-07-16 09:57:37 +02:00
. Select ( data = > ( data , ParseProof ( data ) as PayoutTransactionOnChainBlob ) ) . Where ( tuple = > tuple . Item2 ! = null & & tuple . Item2 . TransactionId ! = null & & tuple . Item2 . Accounted = = true ) ;
foreach ( var valueTuple in payouts )
{
valueTuple . Item2 . TransactionId = null ;
SetProofBlob ( valueTuple . data , valueTuple . Item2 ) ;
}
await context . SaveChangesAsync ( ) ;
}
return new StatusMessageModel ( )
{
Message = "Payout payments have been unmarked" ,
Severity = StatusMessageModel . StatusSeverity . Success
} ;
}
return new StatusMessageModel ( )
{
Message = "Unknown action" ,
Severity = StatusMessageModel . StatusSeverity . Error
} ; ;
}
2021-04-13 10:36:49 +02:00
private async Task UpdatePayoutsInProgress ( )
{
try
{
2021-07-16 09:57:37 +02:00
await using var ctx = _dbContextFactory . CreateContext ( ) ;
2021-04-13 10:36:49 +02:00
var payouts = await ctx . Payouts
. Include ( p = > p . PullPaymentData )
. Where ( p = > p . State = = PayoutState . InProgress )
. ToListAsync ( ) ;
foreach ( var payout in payouts )
{
var proof = ParseProof ( payout ) as PayoutTransactionOnChainBlob ;
var payoutBlob = payout . GetBlob ( this . _jsonSerializerSettings ) ;
if ( proof is null | | proof . Accounted is false )
{
continue ;
}
foreach ( var txid in proof . Candidates . ToList ( ) )
{
var explorer = _explorerClientProvider . GetExplorerClient ( payout . GetPaymentMethodId ( ) . CryptoCode ) ;
var tx = await explorer . GetTransactionAsync ( txid ) ;
if ( tx is null )
{
proof . Candidates . Remove ( txid ) ;
}
else if ( tx . Confirmations > = payoutBlob . MinimumConfirmation )
{
payout . State = PayoutState . Completed ;
proof . TransactionId = tx . TransactionHash ;
payout . Destination = null ;
break ;
}
else
{
var rebroadcasted = await explorer . BroadcastAsync ( tx . Transaction ) ;
if ( rebroadcasted . RPCCode = = RPCErrorCode . RPC_TRANSACTION_ERROR | |
rebroadcasted . RPCCode = = RPCErrorCode . RPC_TRANSACTION_REJECTED )
{
proof . Candidates . Remove ( txid ) ;
}
else
{
payout . State = PayoutState . InProgress ;
proof . TransactionId = tx . TransactionHash ;
continue ;
}
}
}
if ( proof . TransactionId is null & & ! proof . Candidates . Contains ( proof . TransactionId ) )
{
proof . TransactionId = null ;
}
if ( proof . Candidates . Count = = 0 )
{
payout . State = PayoutState . AwaitingPayment ;
}
else if ( proof . TransactionId is null )
{
proof . TransactionId = proof . Candidates . First ( ) ;
}
if ( payout . State = = PayoutState . Completed )
proof . Candidates = null ;
SetProofBlob ( payout , proof ) ;
}
await ctx . SaveChangesAsync ( ) ;
}
catch ( Exception ex )
{
Logs . PayServer . LogWarning ( ex , "Error while processing an update in the pull payment hosted service" ) ;
}
}
2021-07-16 09:57:37 +02:00
private async Task UpdatePayoutsAwaitingForPayment ( NewOnChainTransactionEvent newTransaction ,
AddressTrackedSource addressTrackedSource )
2021-04-13 10:36:49 +02:00
{
try
{
var network = _btcPayNetworkProvider . GetNetwork < BTCPayNetwork > ( newTransaction . CryptoCode ) ;
2021-07-16 09:57:37 +02:00
var destinationSum =
newTransaction . NewTransactionEvent . Outputs . Sum ( output = > output . Value . GetValue ( network ) ) ;
var destination = addressTrackedSource . Address . ToString ( ) ;
2021-04-13 10:36:49 +02:00
var paymentMethodId = new PaymentMethodId ( newTransaction . CryptoCode , BitcoinPaymentType . Instance ) ;
2021-07-16 09:57:37 +02:00
await using var ctx = _dbContextFactory . CreateContext ( ) ;
2021-04-13 10:36:49 +02:00
var payouts = await ctx . Payouts
. Include ( o = > o . PullPaymentData )
2021-07-16 09:57:37 +02:00
. ThenInclude ( o = > o . StoreData )
2021-04-13 10:36:49 +02:00
. Where ( p = > p . State = = PayoutState . AwaitingPayment )
. Where ( p = > p . PaymentMethodId = = paymentMethodId . ToString ( ) )
2021-07-29 20:29:34 +09:00
#pragma warning disable CA1307 // Specify StringComparison
2021-07-16 09:57:37 +02:00
. Where ( p = > destination . Equals ( p . Destination ) )
2021-07-29 20:29:34 +09:00
#pragma warning restore CA1307 // Specify StringComparison
2021-04-13 10:36:49 +02:00
. ToListAsync ( ) ;
var payoutByDestination = payouts . ToDictionary ( p = > p . Destination ) ;
2021-07-16 09:57:37 +02:00
if ( ! payoutByDestination . TryGetValue ( destination , out var payout ) )
return ;
var payoutBlob = payout . GetBlob ( _jsonSerializerSettings ) ;
if ( payoutBlob . CryptoAmount is null | |
// The round up here is not strictly necessary, this is temporary to fix existing payout before we
// were properly roundup the crypto amount
destinationSum ! =
BTCPayServer . Extensions . RoundUp ( payoutBlob . CryptoAmount . Value , network . Divisibility ) )
return ;
var derivationSchemeSettings = payout . PullPaymentData . StoreData
. GetDerivationSchemeSettings ( _btcPayNetworkProvider , newTransaction . CryptoCode ) . AccountDerivation ;
var storeWalletMatched = ( await _explorerClientProvider . GetExplorerClient ( newTransaction . CryptoCode )
. GetTransactionAsync ( derivationSchemeSettings ,
newTransaction . NewTransactionEvent . TransactionData . TransactionHash ) ) ;
//if the wallet related to the store related to the payout does not have the tx: it is external
var isInternal = storeWalletMatched is { } ;
var proof = ParseProof ( payout ) as PayoutTransactionOnChainBlob ? ?
new PayoutTransactionOnChainBlob ( ) { Accounted = isInternal } ;
var txId = newTransaction . NewTransactionEvent . TransactionData . TransactionHash ;
if ( ! proof . Candidates . Add ( txId ) ) return ;
if ( isInternal )
2021-04-13 10:36:49 +02:00
{
2021-07-16 09:57:37 +02:00
payout . State = PayoutState . InProgress ;
var walletId = new WalletId ( payout . PullPaymentData . StoreId , newTransaction . CryptoCode ) ;
_eventAggregator . Publish ( new UpdateTransactionLabel ( walletId ,
newTransaction . NewTransactionEvent . TransactionData . TransactionHash ,
UpdateTransactionLabel . PayoutTemplate ( payout . Id , payout . PullPaymentDataId , walletId . ToString ( ) ) ) ) ;
}
else
{
await _notificationSender . SendNotification ( new StoreScope ( payout . PullPaymentData . StoreId ) ,
new ExternalPayoutTransactionNotification ( )
2021-04-13 10:36:49 +02:00
{
2021-07-16 09:57:37 +02:00
PaymentMethod = payout . PaymentMethodId ,
PayoutId = payout . Id ,
StoreId = payout . PullPaymentData . StoreId
} ) ;
2021-04-13 10:36:49 +02:00
}
2021-07-16 09:57:37 +02:00
proof . TransactionId ? ? = txId ;
SetProofBlob ( payout , proof ) ;
2021-04-13 10:36:49 +02:00
await ctx . SaveChangesAsync ( ) ;
}
catch ( Exception ex )
{
Logs . PayServer . LogWarning ( ex , "Error while processing a transaction in the pull payment hosted service" ) ;
}
}
2021-07-16 09:57:37 +02:00
2021-04-13 10:36:49 +02:00
private void SetProofBlob ( PayoutData data , PayoutTransactionOnChainBlob blob )
{
var bytes = Encoding . UTF8 . GetBytes ( JsonConvert . SerializeObject ( blob , _jsonSerializerSettings . GetSerializer ( data . GetPaymentMethodId ( ) . CryptoCode ) ) ) ;
// We only update the property if the bytes actually changed, this prevent from hammering the DB too much
if ( data . Proof is null | | bytes . Length ! = data . Proof . Length | | ! bytes . SequenceEqual ( data . Proof ) )
{
data . Proof = bytes ;
}
}
}