2022-04-24 05:19:34 +02:00
using System ;
2023-07-20 15:05:14 +02:00
using System.Collections.Concurrent ;
2022-04-24 05:19:34 +02:00
using System.Collections.Generic ;
using System.Linq ;
2024-10-19 21:33:34 +09:00
using System.Threading ;
2022-04-24 05:19:34 +02:00
using System.Threading.Tasks ;
2023-03-17 13:50:37 +01:00
using BTCPayServer.Abstractions.Contracts ;
2023-05-26 16:49:32 +02:00
using BTCPayServer.Client ;
2022-04-24 05:19:34 +02:00
using BTCPayServer.Client.Models ;
using BTCPayServer.Configuration ;
using BTCPayServer.Data ;
using BTCPayServer.Data.Payouts.LightningLike ;
2022-08-17 09:45:51 +02:00
using BTCPayServer.HostedServices ;
2022-04-24 05:19:34 +02:00
using BTCPayServer.Lightning ;
using BTCPayServer.Payments ;
2024-04-04 16:31:04 +09:00
using BTCPayServer.Payments.Bitcoin ;
2022-04-24 05:19:34 +02:00
using BTCPayServer.Payments.Lightning ;
2024-05-01 10:22:07 +09:00
using BTCPayServer.Payouts ;
2022-04-24 05:19:34 +02:00
using BTCPayServer.Services ;
2024-04-04 16:31:04 +09:00
using BTCPayServer.Services.Invoices ;
2022-04-24 05:19:34 +02:00
using BTCPayServer.Services.Stores ;
2024-10-19 21:33:34 +09:00
using LNURL ;
2024-03-14 10:29:14 +01:00
using Microsoft.EntityFrameworkCore ;
2022-04-24 05:19:34 +02:00
using Microsoft.Extensions.Logging ;
using Microsoft.Extensions.Options ;
2023-07-20 15:05:14 +02:00
using NBitcoin ;
2024-10-19 21:33:34 +09:00
using Newtonsoft.Json.Linq ;
using static BTCPayServer . Data . Payouts . LightningLike . UILightningLikePayoutController ;
2024-03-14 10:29:14 +01:00
using MarkPayoutRequest = BTCPayServer . HostedServices . MarkPayoutRequest ;
2022-04-24 05:19:34 +02:00
using PayoutData = BTCPayServer . Data . PayoutData ;
2023-02-21 15:06:34 +09:00
using PayoutProcessorData = BTCPayServer . Data . PayoutProcessorData ;
2022-04-24 05:19:34 +02:00
namespace BTCPayServer.PayoutProcessors.Lightning ;
2023-07-20 15:05:14 +02:00
public class LightningAutomatedPayoutProcessor : BaseAutomatedPayoutProcessor < LightningAutomatedPayoutBlob >
2022-04-24 05:19:34 +02:00
{
2024-10-19 21:33:34 +09:00
private readonly BTCPayNetworkJsonSerializerSettings _btcPayNetworkJsonSerializerSettings ;
private readonly LightningClientFactoryService _lightningClientFactoryService ;
private readonly UserService _userService ;
private readonly IOptions < LightningNetworkOptions > _options ;
private readonly PullPaymentHostedService _pullPaymentHostedService ;
private readonly LightningLikePayoutHandler _payoutHandler ;
public BTCPayNetwork Network = > _payoutHandler . Network ;
private readonly PaymentMethodHandlerDictionary _handlers ;
public LightningAutomatedPayoutProcessor (
PayoutMethodId payoutMethodId ,
BTCPayNetworkJsonSerializerSettings btcPayNetworkJsonSerializerSettings ,
LightningClientFactoryService lightningClientFactoryService ,
PayoutMethodHandlerDictionary payoutHandlers ,
UserService userService ,
ILoggerFactory logger , IOptions < LightningNetworkOptions > options ,
StoreRepository storeRepository , PayoutProcessorData payoutProcessorSettings ,
ApplicationDbContextFactory applicationDbContextFactory ,
PaymentMethodHandlerDictionary handlers ,
IPluginHookService pluginHookService ,
EventAggregator eventAggregator ,
PullPaymentHostedService pullPaymentHostedService ) :
base ( PaymentTypes . LN . GetPaymentMethodId ( GetPayoutHandler ( payoutHandlers , payoutMethodId ) . Network . CryptoCode ) , logger , storeRepository , payoutProcessorSettings , applicationDbContextFactory ,
handlers , pluginHookService , eventAggregator )
{
_btcPayNetworkJsonSerializerSettings = btcPayNetworkJsonSerializerSettings ;
_lightningClientFactoryService = lightningClientFactoryService ;
_userService = userService ;
_options = options ;
_pullPaymentHostedService = pullPaymentHostedService ;
_payoutHandler = GetPayoutHandler ( payoutHandlers , payoutMethodId ) ;
_handlers = handlers ;
}
private static LightningLikePayoutHandler GetPayoutHandler ( PayoutMethodHandlerDictionary payoutHandlers , PayoutMethodId payoutMethodId )
{
return ( LightningLikePayoutHandler ) payoutHandlers [ payoutMethodId ] ;
}
public async Task < ResultVM > HandlePayout ( PayoutData payoutData , ILightningClient lightningClient , CancellationToken cancellationToken )
{
using var scope = _payoutHandler . PayoutsPaymentProcessing . StartTracking ( ) ;
if ( payoutData . State ! = PayoutState . AwaitingPayment | | ! scope . TryTrack ( payoutData . Id ) )
return InvalidState ( payoutData . Id ) ;
var blob = payoutData . GetBlob ( _btcPayNetworkJsonSerializerSettings ) ;
var res = await _pullPaymentHostedService . MarkPaid ( new MarkPayoutRequest ( )
{
State = PayoutState . InProgress ,
PayoutId = payoutData . Id ,
Proof = null
} ) ;
if ( res ! = MarkPayoutRequest . PayoutPaidResult . Ok )
return InvalidState ( payoutData . Id ) ;
ResultVM result ;
var claim = await _payoutHandler . ParseClaimDestination ( blob . Destination , cancellationToken ) ;
switch ( claim . destination )
{
case LNURLPayClaimDestinaton lnurlPayClaimDestinaton :
var lnurlResult = await GetInvoiceFromLNURL ( payoutData , _payoutHandler , blob ,
lnurlPayClaimDestinaton , cancellationToken ) ;
if ( lnurlResult . Item2 is not null )
{
result = lnurlResult . Item2 ;
}
else
{
result = await TrypayBolt ( lightningClient , blob , payoutData , lnurlResult . Item1 , cancellationToken ) ;
}
break ;
case BoltInvoiceClaimDestination item1 :
result = await TrypayBolt ( lightningClient , blob , payoutData , item1 . PaymentRequest , cancellationToken ) ;
break ;
default :
result = new ResultVM
{
PayoutId = payoutData . Id ,
Result = PayResult . Error ,
Destination = blob . Destination ,
Message = claim . error
} ;
break ;
}
bool updateBlob = false ;
if ( result . Result is PayResult . Error or PayResult . CouldNotFindRoute & & payoutData . State = = PayoutState . AwaitingPayment )
{
var errorCount = IncrementErrorCount ( blob ) ;
updateBlob = true ;
if ( errorCount > = 10 )
payoutData . State = PayoutState . Cancelled ;
}
if ( payoutData . State ! = PayoutState . InProgress | | payoutData . Proof is not null )
{
await _pullPaymentHostedService . MarkPaid ( new MarkPayoutRequest ( )
{
State = payoutData . State ,
PayoutId = payoutData . Id ,
Proof = payoutData . GetProofBlobJson ( ) ,
2024-10-19 22:07:20 +09:00
UpdateBlob = updateBlob ? blob : null
2024-10-19 21:33:34 +09:00
} ) ;
}
return result ;
}
private ResultVM InvalidState ( string payoutId ) = >
new ResultVM
{
PayoutId = payoutId ,
Result = PayResult . Error ,
Message = "The payout isn't in a valid state"
} ;
private int IncrementErrorCount ( PayoutBlob blob )
2022-04-24 05:19:34 +02:00
{
2024-10-19 21:33:34 +09:00
int count ;
if ( blob . AdditionalData . TryGetValue ( "ErrorCount" , out var v ) & & v . Type = = JTokenType . Integer )
{
count = v . Value < int > ( ) + 1 ;
blob . AdditionalData [ "ErrorCount" ] = count ;
}
else
{
count = 1 ;
blob . AdditionalData . Add ( "ErrorCount" , count ) ;
}
return count ;
2022-04-24 05:19:34 +02:00
}
2024-10-19 21:33:34 +09:00
async Task < ( BOLT11PaymentRequest , ResultVM ) > GetInvoiceFromLNURL ( PayoutData payoutData ,
LightningLikePayoutHandler handler , PayoutBlob blob , LNURLPayClaimDestinaton lnurlPayClaimDestinaton , CancellationToken cancellationToken )
2024-05-01 10:22:07 +09:00
{
2024-10-19 21:33:34 +09:00
var endpoint = lnurlPayClaimDestinaton . LNURL . IsValidEmail ( )
? LNURL . LNURL . ExtractUriFromInternetIdentifier ( lnurlPayClaimDestinaton . LNURL )
: LNURL . LNURL . Parse ( lnurlPayClaimDestinaton . LNURL , out _ ) ;
var httpClient = handler . CreateClient ( endpoint ) ;
var lnurlInfo =
( LNURLPayRequest ) await LNURL . LNURL . FetchInformation ( endpoint , "payRequest" ,
httpClient , cancellationToken ) ;
var lm = new LightMoney ( payoutData . Amount . Value , LightMoneyUnit . BTC ) ;
if ( lm > lnurlInfo . MaxSendable | | lm < lnurlInfo . MinSendable )
{
payoutData . State = PayoutState . Cancelled ;
return ( null , new ResultVM
{
PayoutId = payoutData . Id ,
Result = PayResult . Error ,
Destination = blob . Destination ,
Message =
$"The LNURL provided would not generate an invoice of {lm.ToDecimal(LightMoneyUnit.Satoshi)} sats"
} ) ;
}
try
{
var lnurlPayRequestCallbackResponse =
await lnurlInfo . SendRequest ( lm , this . Network . NBitcoinNetwork , httpClient , cancellationToken : cancellationToken ) ;
return ( lnurlPayRequestCallbackResponse . GetPaymentRequest ( this . Network . NBitcoinNetwork ) , null ) ;
}
catch ( LNUrlException e )
{
return ( null ,
new ResultVM
{
PayoutId = payoutData . Id ,
Result = PayResult . Error ,
Destination = blob . Destination ,
Message = e . Message
} ) ;
}
2024-05-01 10:22:07 +09:00
}
2022-04-24 05:19:34 +02:00
2024-10-19 21:33:34 +09:00
async Task < ResultVM > TrypayBolt (
ILightningClient lightningClient , PayoutBlob payoutBlob , PayoutData payoutData , BOLT11PaymentRequest bolt11PaymentRequest , CancellationToken cancellationToken )
2022-04-24 05:19:34 +02:00
{
2024-10-19 21:33:34 +09:00
var boltAmount = bolt11PaymentRequest . MinimumAmount . ToDecimal ( LightMoneyUnit . BTC ) ;
// BoltAmount == 0: Any amount is OK.
// While we could allow paying more than the minimum amount from the boltAmount,
// Core-Lightning do not support it! It would just refuse to pay more than the boltAmount.
if ( boltAmount ! = payoutData . Amount . Value & & boltAmount ! = 0.0 m )
2024-03-14 10:29:14 +01:00
{
2024-10-19 21:33:34 +09:00
payoutData . State = PayoutState . Cancelled ;
return new ResultVM
{
PayoutId = payoutData . Id ,
Result = PayResult . Error ,
Message = $"The BOLT11 invoice amount ({boltAmount} {payoutData.Currency}) did not match the payout's amount ({payoutData.Amount.GetValueOrDefault()} {payoutData.Currency})" ,
Destination = payoutBlob . Destination
} ;
}
if ( bolt11PaymentRequest . ExpiryDate < DateTimeOffset . Now )
2022-04-24 05:19:34 +02:00
{
2024-10-19 21:33:34 +09:00
payoutData . State = PayoutState . Cancelled ;
return new ResultVM
{
PayoutId = payoutData . Id ,
Result = PayResult . Error ,
Message = $"The BOLT11 invoice expiry date ({bolt11PaymentRequest.ExpiryDate}) has expired" ,
Destination = payoutBlob . Destination
} ;
2022-04-24 05:19:34 +02:00
}
2024-10-19 21:33:34 +09:00
var proofBlob = new PayoutLightningBlob { PaymentHash = bolt11PaymentRequest . PaymentHash . ToString ( ) } ;
PayResponse pay = null ;
2024-03-14 10:29:14 +01:00
try
2022-04-24 05:19:34 +02:00
{
2024-10-19 21:33:34 +09:00
Exception exception = null ;
try
2022-04-24 05:19:34 +02:00
{
2024-10-19 21:33:34 +09:00
pay = await lightningClient . Pay ( bolt11PaymentRequest . ToString ( ) ,
new PayInvoiceParams ( )
2024-03-14 10:29:14 +01:00
{
2024-10-19 21:33:34 +09:00
Amount = new LightMoney ( ( decimal ) payoutData . Amount , LightMoneyUnit . BTC )
} , cancellationToken ) ;
if ( pay ? . Result is PayResult . CouldNotFindRoute )
{
// Payment failed for sure... we can try again later!
payoutData . State = PayoutState . AwaitingPayment ;
return new ResultVM
{
PayoutId = payoutData . Id ,
Result = PayResult . CouldNotFindRoute ,
Message = $"Unable to find a route for the payment, check your channel liquidity" ,
Destination = payoutBlob . Destination
} ;
}
}
catch ( Exception ex )
{
exception = ex ;
2023-07-20 15:05:14 +02:00
}
2024-10-19 21:33:34 +09:00
LightningPayment payment = null ;
try
{
payment = await lightningClient . GetPayment ( bolt11PaymentRequest . PaymentHash . ToString ( ) , cancellationToken ) ;
}
catch ( Exception ex )
{
exception = ex ;
}
if ( payment is null )
{
payoutData . State = PayoutState . Cancelled ;
var exceptionMessage = "" ;
if ( exception is not null )
exceptionMessage = $" ({exception.Message})" ;
if ( exceptionMessage = = "" )
exceptionMessage = $" ({pay?.ErrorDetail})" ;
return new ResultVM
{
PayoutId = payoutData . Id ,
Result = PayResult . Error ,
Message = $"Unable to confirm the payment of the invoice" + exceptionMessage ,
Destination = payoutBlob . Destination
} ;
}
if ( payment . Preimage is not null )
proofBlob . Preimage = payment . Preimage ;
if ( payment . Status = = LightningPaymentStatus . Complete )
{
payoutData . State = PayoutState . Completed ;
payoutData . SetProofBlob ( proofBlob , null ) ;
return new ResultVM
{
PayoutId = payoutData . Id ,
Result = PayResult . Ok ,
Destination = payoutBlob . Destination ,
Message = payment . AmountSent ! = null
? $"Paid out {payment.AmountSent.ToDecimal(LightMoneyUnit.BTC)} {payoutData.Currency}"
: "Paid out"
} ;
}
else if ( payment . Status = = LightningPaymentStatus . Failed )
{
payoutData . State = PayoutState . AwaitingPayment ;
string reason = "" ;
if ( pay ? . ErrorDetail is string err )
reason = $" ({err})" ;
return new ResultVM
{
PayoutId = payoutData . Id ,
Result = PayResult . Error ,
Destination = payoutBlob . Destination ,
Message = $"The payment failed{reason}"
} ;
}
else
{
payoutData . State = PayoutState . InProgress ;
return new ResultVM
{
PayoutId = payoutData . Id ,
Result = PayResult . Unknown ,
Destination = payoutBlob . Destination ,
Message = "The payment has been initiated but is still in-flight."
} ;
}
}
catch ( OperationCanceledException )
2024-03-14 10:29:14 +01:00
{
2024-10-19 21:33:34 +09:00
// Timeout, potentially caused by hold invoices
// Payment will be saved as pending, the LightningPendingPayoutListener will handle settling/cancelling
payoutData . State = PayoutState . InProgress ;
payoutData . SetProofBlob ( proofBlob , null ) ;
return new ResultVM
2023-07-20 15:05:14 +02:00
{
2024-10-19 21:33:34 +09:00
PayoutId = payoutData . Id ,
Result = PayResult . Ok ,
Destination = payoutBlob . Destination ,
Message = "The payment timed out. We will verify if it completed later."
} ;
2022-04-24 05:19:34 +02:00
}
}
2023-01-06 14:18:07 +01:00
2024-04-04 16:31:04 +09:00
protected override async Task < bool > ProcessShouldSave ( object paymentMethodConfig , List < PayoutData > payouts )
2024-10-19 21:33:34 +09:00
{
var lightningSupportedPaymentMethod = ( LightningPaymentMethodConfig ) paymentMethodConfig ;
if ( lightningSupportedPaymentMethod . IsInternalNode & &
! await _storeRepository . InternalNodePayoutAuthorized ( PayoutProcessorSettings . StoreId ) )
{
return false ;
}
2024-03-14 10:29:14 +01:00
2024-10-19 21:33:34 +09:00
var client =
lightningSupportedPaymentMethod . CreateLightningClient ( Network , _options . Value ,
_lightningClientFactoryService ) ;
await Task . WhenAll ( payouts . Select ( data = > HandlePayout ( data , client , CancellationToken ) ) ) ;
2024-03-14 10:29:14 +01:00
2024-10-19 21:33:34 +09:00
//we return false because this processor handles db updates on its own
return false ;
}
2022-04-24 05:19:34 +02:00
}