using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Tasks; using BTCPayServer.Client.Models; using BTCPayServer.Data; using BTCPayServer.Events; using BTCPayServer.HostedServices; using BTCPayServer.Logging; using BTCPayServer.Payments.PayJoin; using BTCPayServer.Services.Invoices; using BTCPayServer.Services.Wallets; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using NBitcoin; using NBitcoin.RPC; using NBXplorer; using NBXplorer.DerivationStrategy; using NBXplorer.Models; namespace BTCPayServer.Payments.Bitcoin { /// /// This class listener NBXplorer instances to detect incoming on-chain, bitcoin like payment /// public class NBXplorerListener : IHostedService { readonly EventAggregator _Aggregator; private readonly UTXOLocker _utxoLocker; readonly ExplorerClientProvider _ExplorerClients; private readonly PaymentService _paymentService; private readonly PaymentMethodHandlerDictionary _handlers; readonly InvoiceRepository _InvoiceRepository; private TaskCompletionSource _RunningTask; private CancellationTokenSource _Cts; readonly BTCPayWalletProvider _Wallets; public NBXplorerListener(ExplorerClientProvider explorerClients, BTCPayWalletProvider wallets, InvoiceRepository invoiceRepository, EventAggregator aggregator, UTXOLocker payjoinRepository, PaymentService paymentService, PaymentMethodHandlerDictionary handlers, Logs logs) { this.Logs = logs; PollInterval = TimeSpan.FromMinutes(1.0); _Wallets = wallets; _InvoiceRepository = invoiceRepository; _ExplorerClients = explorerClients; _Aggregator = aggregator; _utxoLocker = payjoinRepository; _paymentService = paymentService; _handlers = handlers; } readonly CompositeDisposable leases = new CompositeDisposable(); readonly ConcurrentDictionary _SessionsByCryptoCode = new ConcurrentDictionary(); private Timer _ListenPoller; TimeSpan _PollInterval; public Logs Logs { get; } public TimeSpan PollInterval { get { return _PollInterval; } set { _PollInterval = value; if (_ListenPoller != null) { _ListenPoller.Change(0, (int)value.TotalMilliseconds); } } } public Task StartAsync(CancellationToken cancellationToken) { _RunningTask = new TaskCompletionSource(); _Cts = new CancellationTokenSource(); leases.Add(_Aggregator.Subscribe(nbxplorerEvent => { if (nbxplorerEvent.NewState == NBXplorerState.Ready) { var wallet = _Wallets.GetWallet(nbxplorerEvent.Network); if (_Wallets.IsAvailable(wallet.Network)) { _ = Listen(wallet); } } })); _ListenPoller = new Timer(s => { foreach (var wallet in _Wallets.GetWallets()) { if (_Wallets.IsAvailable(wallet.Network)) { _ = Listen(wallet); } } }, null, 0, (int)PollInterval.TotalMilliseconds); leases.Add(_ListenPoller); return Task.CompletedTask; } private async Task Listen(BTCPayWallet wallet) { var network = wallet.Network; bool cleanup = false; try { if (_SessionsByCryptoCode.ContainsKey(network.CryptoCode)) return; var client = _ExplorerClients.GetExplorerClient(network); if (client == null) return; if (_Cts.IsCancellationRequested) return; var session = await client.CreateWebsocketNotificationSessionLegacyAsync(_Cts.Token).ConfigureAwait(false); if (!_SessionsByCryptoCode.TryAdd(network.CryptoCode, session)) { await session.DisposeAsync(); return; } cleanup = true; using (session) { await session.ListenNewBlockAsync(_Cts.Token).ConfigureAwait(false); await session.ListenAllTrackedSourceAsync(cancellation: _Cts.Token).ConfigureAwait(false); Logs.PayServer.LogInformation($"{network.CryptoCode}: Checking if any pending invoice got paid while offline..."); int paymentCount = await FindPaymentViaPolling(wallet, network); Logs.PayServer.LogInformation($"{network.CryptoCode}: {paymentCount} payments happened while offline"); Logs.PayServer.LogInformation($"Connected to WebSocket of NBXplorer ({network.CryptoCode})"); var pmi = PaymentTypes.CHAIN.GetPaymentMethodId(network.CryptoCode); while (!_Cts.IsCancellationRequested) { var newEvent = await session.NextEventAsync(_Cts.Token).ConfigureAwait(false); switch (newEvent) { case NBXplorer.Models.NewBlockEvent evt: await UpdatePaymentStates(wallet); _Aggregator.Publish(new Events.NewBlockEvent() { PaymentMethodId = pmi, AdditionalInfo = evt }); break; case NBXplorer.Models.NewTransactionEvent evt: if (evt.DerivationStrategy != null) { wallet.InvalidateCache(evt.DerivationStrategy); foreach (var output in network.GetValidOutputs(evt)) { if (!output.matchedOutput.Value.IsCompatible(network)) continue; var key = network.GetTrackedDestination(output.Item1.ScriptPubKey); var invoice = await _InvoiceRepository.GetInvoiceFromAddress(pmi, key); if (invoice != null) { var handler = _handlers[pmi]; var details = new BitcoinLikePaymentData(output.outPoint, evt.TransactionData.Transaction.RBF, output.matchedOutput.KeyPath, output.matchedOutput.KeyIndex) { AssetId = output.matchedOutput.Value.GetAssetId(network) }; var paymentData = new Data.PaymentData() { Id = output.outPoint.ToString(), Created = DateTimeOffset.UtcNow, Status = IsSettled(invoice, details) ? PaymentStatus.Settled : PaymentStatus.Processing, Amount = output.matchedOutput.Value.GetValue(network), Currency = network.CryptoCode }.Set(invoice, handler, details); var alreadyExist = invoice .GetPayments(false).Any(c => c.Id == paymentData.Id && c.PaymentMethodId == pmi); if (!alreadyExist) { var payment = await _paymentService.AddPayment(paymentData, [output.outPoint.Hash.ToString()]); if (payment != null) await ReceivedPayment(wallet, invoice, payment, evt.DerivationStrategy); } else { await UpdatePaymentStates(wallet, invoice.Id); } } } } _Aggregator.Publish(new NewOnChainTransactionEvent() { PaymentMethodId = pmi, NewTransactionEvent = evt }); break; default: Logs.PayServer.LogWarning("Received unknown message from NBXplorer"); break; } } } } catch when (_Cts.IsCancellationRequested) { } catch (Exception ex) { Logs.PayServer.LogError(ex, $"Error while connecting to WebSocket of NBXplorer ({network.CryptoCode})"); } finally { if (cleanup) { Logs.PayServer.LogInformation($"Disconnected from WebSocket of NBXplorer ({network.CryptoCode})"); _SessionsByCryptoCode.TryRemove(network.CryptoCode, out WebsocketNotificationSession unused); if (_SessionsByCryptoCode.IsEmpty && _Cts.IsCancellationRequested) { _RunningTask.TrySetResult(true); } } } } async Task UpdatePaymentStates(BTCPayWallet wallet) { var invoices = await _InvoiceRepository.GetMonitoredInvoices(PaymentTypes.CHAIN.GetPaymentMethodId(wallet.Network.CryptoCode)); await Task.WhenAll(invoices.Select(i => UpdatePaymentStates(wallet, i)).ToArray()); } async Task UpdatePaymentStates(BTCPayWallet wallet, string invoiceId, bool fireEvents = true) { var invoice = await _InvoiceRepository.GetInvoice(invoiceId, false); if (invoice == null) return null; return await UpdatePaymentStates(wallet, invoice, fireEvents); } async Task UpdatePaymentStates(BTCPayWallet wallet, InvoiceEntity invoice, bool fireEvents = true) { var pmi = PaymentTypes.CHAIN.GetPaymentMethodId(wallet.Network.CryptoCode); var handler = (BitcoinLikePaymentHandler)_handlers[pmi]; List updatedPaymentEntities = new List(); var transactions = await wallet.GetTransactions(invoice.GetPayments(false) .Where(p => p.PaymentMethodId == pmi) .Select(p => handler.ParsePaymentDetails(p.Details).Outpoint.Hash) .ToArray(), true); bool? originalPJBroadcasted = null; bool? originalPJBroadcastable = null; PayjoinInformation payjoinInformation = null; var paymentEntitiesByPrevOut = new Dictionary(); foreach (var payment in invoice.GetPayments(false).Where(p => p.PaymentMethodId == pmi)) { var paymentData = handler.ParsePaymentDetails(payment.Details); if (!transactions.TryGetValue(paymentData.Outpoint.Hash, out TransactionResult tx)) continue; bool accounted = true; if (tx.Confirmations == 0 || tx.Confirmations == -1) { // Let's check if it was orphaned by broadcasting it again var explorerClient = _ExplorerClients.GetExplorerClient(wallet.Network); try { var result = await explorerClient.BroadcastAsync(tx.Transaction, testMempoolAccept: tx.Confirmations == -1, _Cts.Token); accounted = result.Success || result.RPCCode == RPCErrorCode.RPC_TRANSACTION_ALREADY_IN_CHAIN || !( // Happen if a blocks mined a replacement // Or if the tx is a double spend of something already in the mempool without rbf result.RPCCode == RPCErrorCode.RPC_TRANSACTION_ERROR || // Happen if RBF is on and fee insufficient result.RPCCode == RPCErrorCode.RPC_TRANSACTION_REJECTED); if (!accounted && payment.Accounted && tx.Confirmations != -1) { var logs = new InvoiceLogs(); logs.Write($"The transaction {tx.TransactionHash} has been replaced.", InvoiceEventData.EventSeverity.Warning); await _InvoiceRepository.AddInvoiceLogs(invoice.Id, logs); } if (paymentData.PayjoinInformation is PayjoinInformation pj) { payjoinInformation = pj; originalPJBroadcasted = accounted && tx.Confirmations >= 0; originalPJBroadcastable = accounted; } } // RPC might be unavailable, we can't check double spend so let's assume there is none catch { } } bool updated = false; if (paymentData.ConfirmationCount != tx.Confirmations) { var oldConfCount = paymentData.ConfirmationCount; paymentData.ConfirmationCount = Math.Min(tx.Confirmations, wallet.Network.MaxTrackedConfirmation); if (oldConfCount != paymentData.ConfirmationCount) { payment.SetDetails(handler, paymentData); updated = true; } } var prevStatus = payment.Status; // If a payment is replacing another, use the same network fee as the replaced one. if (accounted) { foreach (var prevout in tx.Transaction.Inputs.Select(o => o.PrevOut)) { if (paymentEntitiesByPrevOut.TryGetValue(prevout, out var replaced) && !replaced.Accounted) { payment.PaymentMethodFee = replaced.PaymentMethodFee; if (payjoinInformation is PayjoinInformation pj && pj.CoinjoinTransactionHash == tx.TransactionHash) { // This payment is a coinjoin, so the value of // the payment output is different from the real value of the payment payment.Value = pj.CoinjoinValue.ToDecimal(MoneyUnit.BTC); payment.SetDetails(handler, paymentData); } updated = true; } } payment.Status = IsSettled(invoice, paymentData) ? PaymentStatus.Settled : PaymentStatus.Processing; } else { payment.Status = PaymentStatus.Unaccounted; } updated |= prevStatus != payment.Status; foreach (var prevout in tx.Transaction.Inputs.Select(o => o.PrevOut)) { paymentEntitiesByPrevOut.TryAdd(prevout, payment); } if (updated) updatedPaymentEntities.Add(payment); } // If the origin tx of a payjoin has been broadcasted, then we know we can // reuse our outpoint for another PJ if (originalPJBroadcasted is true || // If the original tx is not broadcastable anymore, we can just unlock the utxos // If the PJ succeeded those outpoints won't be selected anymore. // If the PJ didn't, then those endpoints are reusable. originalPJBroadcastable is false) { await _utxoLocker.TryUnlock(payjoinInformation.ContributedOutPoints); } await _paymentService.UpdatePayments(updatedPaymentEntities); if (fireEvents && updatedPaymentEntities.Count != 0) _Aggregator.Publish(new Events.InvoiceNeedUpdateEvent(invoice.Id)); return invoice; } public static int ConfirmationRequired(InvoiceEntity invoice, BitcoinLikePaymentData paymentData) => (invoice, paymentData) switch { ({ SpeedPolicy: SpeedPolicy.HighSpeed }, { RBF: true }) => 1, ({ SpeedPolicy: SpeedPolicy.HighSpeed }, _) => 0, ({ SpeedPolicy: SpeedPolicy.MediumSpeed }, _) => 1, ({ SpeedPolicy: SpeedPolicy.LowMediumSpeed }, _) => 2, ({ SpeedPolicy: SpeedPolicy.LowSpeed }, _) => 6, _ => 6, }; static bool IsSettled(InvoiceEntity invoice, BitcoinLikePaymentData paymentData) => ConfirmationRequired(invoice, paymentData) <= paymentData.ConfirmationCount; private async Task FindPaymentViaPolling(BTCPayWallet wallet, BTCPayNetwork network) { var handler = _handlers.GetBitcoinHandler(wallet.Network); int totalPayment = 0; var invoices = await _InvoiceRepository.GetMonitoredInvoices(PaymentTypes.CHAIN.GetPaymentMethodId(network.CryptoCode)); var coinsPerDerivationStrategy = new Dictionary(); foreach (var i in invoices) { var invoice = i; var alreadyAccounted = invoice.GetAllBitcoinPaymentData(handler, false).Select(p => p.Outpoint).ToHashSet(); var strategy = _handlers.GetDerivationStrategy(invoice, network); if (strategy == null) continue; var cryptoId = PaymentTypes.CHAIN.GetPaymentMethodId(network.CryptoCode); if (!invoice.Support(cryptoId)) continue; if (!coinsPerDerivationStrategy.TryGetValue(strategy, out var coins)) { coins = await wallet.GetUnspentCoins(strategy); coinsPerDerivationStrategy.Add(strategy, coins); } coins = coins.Where(c => invoice.Addresses.Contains((cryptoId, network.GetTrackedDestination(c.ScriptPubKey)))).ToArray(); foreach (var coin in coins.Where(c => !alreadyAccounted.Contains(c.OutPoint))) { if (!coin.Value.IsCompatible(network)) continue; var transaction = await wallet.GetTransactionAsync(coin.OutPoint.Hash); var paymentData = new Data.PaymentData() { Id = coin.OutPoint.ToString(), Created = DateTimeOffset.UtcNow, Status = PaymentStatus.Processing, Amount = coin.Value.GetValue(network), Currency = network.CryptoCode }.Set(invoice, handler, new BitcoinLikePaymentData(coin.OutPoint, transaction?.Transaction is null ? true : transaction.Transaction.RBF, coin.KeyPath, coin.KeyIndex) { AssetId = coin.Value.GetAssetId(network) }); var payment = await _paymentService.AddPayment(paymentData, [coin.OutPoint.Hash.ToString()]).ConfigureAwait(false); alreadyAccounted.Add(coin.OutPoint); if (payment != null) { invoice = await ReceivedPayment(wallet, invoice, payment, strategy); if (invoice == null) continue; totalPayment++; } } } return totalPayment; } private async Task ReceivedPayment(BTCPayWallet wallet, InvoiceEntity invoice, PaymentEntity payment, DerivationStrategyBase strategy) { // We want the invoice watcher to look at our invoice after we bumped the payment method fee, so fireEvent=false. invoice = (await UpdatePaymentStates(wallet, invoice.Id, fireEvents: false)); if (invoice == null) return null; var prompt = invoice.GetPaymentPrompt(payment.PaymentMethodId); if (!_handlers.TryGetValue(prompt.PaymentMethodId, out _)) return null; var bitcoinPaymentMethod = (Payments.Bitcoin.BitcoinPaymentPromptDetails)_handlers.ParsePaymentPromptDetails(prompt); if (bitcoinPaymentMethod.FeeMode == NetworkFeeMode.MultiplePaymentsOnly && prompt.PaymentMethodFee == 0.0m) { prompt.PaymentMethodFee = bitcoinPaymentMethod.PaymentMethodFeeRate.GetFee(100).ToDecimal(MoneyUnit.BTC); // assume price for 100 bytes await this._InvoiceRepository.UpdatePrompt(invoice.Id, prompt); invoice = await _InvoiceRepository.GetInvoice(invoice.Id); } wallet.InvalidateCache(strategy); _Aggregator.Publish(new InvoiceEvent(invoice, InvoiceEvent.ReceivedPayment) { Payment = payment }); return invoice; } public async Task StopAsync(CancellationToken cancellationToken) { if (_Cts != null) { leases.Dispose(); if (_SessionsByCryptoCode.IsEmpty) _RunningTask.TrySetResult(true); _Cts.Cancel(); await Task.WhenAny(_RunningTask.Task, Task.Delay(-1, cancellationToken)); Logs.PayServer.LogInformation($"{this.GetType().Name} successfully exited..."); _Cts = null; } } } }