diff --git a/BTCPayServer/EventAggregator.cs b/BTCPayServer/EventAggregator.cs index c8b1b456d..ab257320e 100644 --- a/BTCPayServer/EventAggregator.cs +++ b/BTCPayServer/EventAggregator.cs @@ -71,13 +71,18 @@ namespace BTCPayServer } public void Publish(T evt) where T : class + { + Publish(evt, typeof(T)); + } + + public void Publish(object evt, Type evtType) { if (evt == null) throw new ArgumentNullException(nameof(evt)); List> actionList = new List>(); lock (_Subscriptions) { - if (_Subscriptions.TryGetValue(typeof(T), out Dictionary> actions)) + if (_Subscriptions.TryGetValue(evtType, out Dictionary> actions)) { actionList = actions.Values.ToList(); } diff --git a/BTCPayServer/Extensions.cs b/BTCPayServer/Extensions.cs index 81be23217..534e2ff33 100644 --- a/BTCPayServer/Extensions.cs +++ b/BTCPayServer/Extensions.cs @@ -17,6 +17,7 @@ using NBXplorer; using NBXplorer.Models; using System.Linq; using System.Threading; +using BTCPayServer.Services.Wallets; namespace BTCPayServer { @@ -27,7 +28,7 @@ namespace BTCPayServer return activeProvider != "Microsoft.EntityFrameworkCore.Sqlite"; } - public static async Task> GetTransactions(this ExplorerClient client, uint256[] hashes, CancellationToken cts = default(CancellationToken)) + public static async Task> GetTransactions(this BTCPayWallet client, uint256[] hashes, CancellationToken cts = default(CancellationToken)) { hashes = hashes.Distinct().ToArray(); var transactions = hashes diff --git a/BTCPayServer/Services/Invoices/InvoiceWatcher.cs b/BTCPayServer/Services/Invoices/InvoiceWatcher.cs index ed375a7a9..088870c17 100644 --- a/BTCPayServer/Services/Invoices/InvoiceWatcher.cs +++ b/BTCPayServer/Services/Invoices/InvoiceWatcher.cs @@ -15,6 +15,7 @@ using Hangfire; using BTCPayServer.Services.Wallets; using BTCPayServer.Controllers; using BTCPayServer.Events; +using Microsoft.AspNetCore.Hosting; namespace BTCPayServer.Services.Invoices { @@ -24,23 +25,42 @@ namespace BTCPayServer.Services.Invoices } public class InvoiceWatcher : IHostedService { + class UpdateInvoiceContext + { + public UpdateInvoiceContext() + { + + } + + public Dictionary KnownStates { get; set; } + public Dictionary ModifiedKnownStates { get; set; } = new Dictionary(); + public InvoiceEntity Invoice { get; set; } + public List Events { get; set; } = new List(); + + bool _Dirty = false; + public void MarkDirty() + { + _Dirty = true; + } + + public bool Dirty => _Dirty; + } + InvoiceRepository _InvoiceRepository; - ExplorerClient _ExplorerClient; EventAggregator _EventAggregator; BTCPayWallet _Wallet; BTCPayNetworkProvider _NetworkProvider; - public InvoiceWatcher(ExplorerClient explorerClient, + public InvoiceWatcher( + IHostingEnvironment env, BTCPayNetworkProvider networkProvider, InvoiceRepository invoiceRepository, EventAggregator eventAggregator, BTCPayWallet wallet, InvoiceWatcherAccessor accessor) { - LongPollingMode = explorerClient.Network == Network.RegTest; - PollInterval = explorerClient.Network == Network.RegTest ? TimeSpan.FromSeconds(10.0) : TimeSpan.FromMinutes(1.0); + PollInterval = TimeSpan.FromMinutes(1.0); _Wallet = wallet ?? throw new ArgumentNullException(nameof(wallet)); - _ExplorerClient = explorerClient ?? throw new ArgumentNullException(nameof(explorerClient)); _InvoiceRepository = invoiceRepository ?? throw new ArgumentNullException(nameof(invoiceRepository)); _EventAggregator = eventAggregator ?? throw new ArgumentNullException(nameof(eventAggregator)); _NetworkProvider = networkProvider; @@ -48,11 +68,6 @@ namespace BTCPayServer.Services.Invoices } CompositeDisposable leases = new CompositeDisposable(); - public bool LongPollingMode - { - get; set; - } - async Task NotifyReceived(Script scriptPubKey) { var invoice = await _InvoiceRepository.GetInvoiceIdFromScriptPubKey(scriptPubKey); @@ -70,7 +85,7 @@ namespace BTCPayServer.Services.Invoices private async Task UpdateInvoice(string invoiceId) { - UTXOChanges changes = null; + Dictionary changes = new Dictionary(); while (true) { try @@ -79,22 +94,30 @@ namespace BTCPayServer.Services.Invoices if (invoice == null) break; var stateBefore = invoice.Status; - var postSaveActions = new List(); - var result = await UpdateInvoice(changes, invoice, postSaveActions).ConfigureAwait(false); - changes = result.Changes; - if (result.NeedSave) - { + var updateContext = new UpdateInvoiceContext() + { + Invoice = invoice, + KnownStates = changes + }; + await UpdateInvoice(updateContext).ConfigureAwait(false); + if (updateContext.Dirty) + { await _InvoiceRepository.UpdateInvoiceStatus(invoice.Id, invoice.Status, invoice.ExceptionStatus).ConfigureAwait(false); _EventAggregator.Publish(new InvoiceDataChangedEvent() { InvoiceId = invoice.Id }); } var changed = stateBefore != invoice.Status; - foreach(var saveAction in postSaveActions) + foreach (var evt in updateContext.Events) { - saveAction(); + _EventAggregator.Publish(evt, evt.GetType()); } - + + foreach(var modifiedKnownState in updateContext.ModifiedKnownStates) + { + changes.AddOrReplace(modifiedKnownState.Key, modifiedKnownState.Value); + } + if (invoice.Status == "complete" || ((invoice.Status == "invalid" || invoice.Status == "expired") && invoice.MonitoringExpiration < DateTimeOffset.UtcNow)) { @@ -119,28 +142,34 @@ namespace BTCPayServer.Services.Invoices } - private async Task<(bool NeedSave, UTXOChanges Changes)> UpdateInvoice(UTXOChanges changes, InvoiceEntity invoice, List postSaveActions) + private async Task UpdateInvoice(UpdateInvoiceContext context) { - bool needSave = false; + var invoice = context.Invoice; //Fetch unknown payments - var strategy = invoice.GetDerivationStrategies(_NetworkProvider).First(s => s.Network.IsBTC); - changes = await _ExplorerClient.SyncAsync(strategy.DerivationStrategyBase, changes, !LongPollingMode, _Cts.Token).ConfigureAwait(false); - - - var utxos = changes.Confirmed.UTXOs.Concat(changes.Unconfirmed.UTXOs).ToArray(); - List receivedCoins = new List(); - foreach (var received in utxos) - if (invoice.AvailableAddressHashes.Contains(received.ScriptPubKey.Hash.ToString())) - receivedCoins.Add(received.AsCoin()); - - var alreadyAccounted = new HashSet(invoice.Payments.Select(p => p.Outpoint)); - bool dirtyAddress = false; - foreach (var coin in receivedCoins.Where(c => !alreadyAccounted.Contains(c.Outpoint))) + var strategies = invoice.GetDerivationStrategies(_NetworkProvider).ToArray(); + var getCoinsResponsesAsync = strategies + .Select(d => _Wallet.GetCoins(d, context.KnownStates.TryGet(d.Network), _Cts.Token)) + .ToArray(); + await Task.WhenAll(getCoinsResponsesAsync); + var getCoinsResponses = getCoinsResponsesAsync.Select(g => g.Result).ToArray(); + foreach (var response in getCoinsResponses) { - var payment = await _InvoiceRepository.AddPayment(invoice.Id, coin).ConfigureAwait(false); - invoice.Payments.Add(payment); - postSaveActions.Add(() => _EventAggregator.Publish(new InvoicePaymentEvent(invoice.Id))); - dirtyAddress = true; + response.Coins = response.Coins.Where(c => invoice.AvailableAddressHashes.Contains(c.ScriptPubKey.Hash.ToString())).ToArray(); + } + var coins = getCoinsResponses.Where(s => s.Coins.Length != 0).FirstOrDefault(); + + bool dirtyAddress = false; + if (coins != null) + { + context.ModifiedKnownStates.Add(coins.Strategy.Network, coins.State); + var alreadyAccounted = new HashSet(invoice.Payments.Select(p => p.Outpoint)); + foreach (var coin in coins.Coins.Where(c => !alreadyAccounted.Contains(c.Outpoint))) + { + var payment = await _InvoiceRepository.AddPayment(invoice.Id, coin).ConfigureAwait(false); + invoice.Payments.Add(payment); + context.Events.Add(new InvoicePaymentEvent(invoice.Id)); + dirtyAddress = true; + } } ////// var network = _NetworkProvider.GetNetwork("BTC"); @@ -149,10 +178,10 @@ namespace BTCPayServer.Services.Invoices var accounting = cryptoData.Calculate(); if (invoice.Status == "new" && invoice.ExpirationTime < DateTimeOffset.UtcNow) { - needSave = true; + context.MarkDirty(); await _InvoiceRepository.UnaffectAddress(invoice.Id); - postSaveActions.Add(() => _EventAggregator.Publish(new InvoiceStatusChangedEvent(invoice, "expired"))); + context.Events.Add(new InvoiceStatusChangedEvent(invoice, "expired")); invoice.Status = "expired"; } @@ -163,16 +192,16 @@ namespace BTCPayServer.Services.Invoices { if (invoice.Status == "new") { - postSaveActions.Add(() => _EventAggregator.Publish(new InvoiceStatusChangedEvent(invoice, "paid"))); + context.Events.Add(new InvoiceStatusChangedEvent(invoice, "paid")); invoice.Status = "paid"; invoice.ExceptionStatus = null; await _InvoiceRepository.UnaffectAddress(invoice.Id); - needSave = true; + context.MarkDirty(); } else if (invoice.Status == "expired") { invoice.ExceptionStatus = "paidLate"; - needSave = true; + context.MarkDirty(); } } @@ -180,17 +209,17 @@ namespace BTCPayServer.Services.Invoices { invoice.ExceptionStatus = "paidOver"; await _InvoiceRepository.UnaffectAddress(invoice.Id); - needSave = true; + context.MarkDirty(); } if (totalPaid < accounting.TotalDue && invoice.Payments.Count != 0 && invoice.ExceptionStatus != "paidPartial") { Logs.PayServer.LogInformation("Paid to " + cryptoData.DepositAddress); invoice.ExceptionStatus = "paidPartial"; - needSave = true; + context.MarkDirty(); if (dirtyAddress) { - var address = await _Wallet.ReserveAddressAsync(strategy); + var address = await _Wallet.ReserveAddressAsync(coins.Strategy); Logs.PayServer.LogInformation("Generate new " + address); await _InvoiceRepository.NewAddress(invoice.Id, address, network); } @@ -223,9 +252,9 @@ namespace BTCPayServer.Services.Invoices (chainTotalConfirmed < accounting.TotalDue)) { await _InvoiceRepository.UnaffectAddress(invoice.Id); - postSaveActions.Add(() => _EventAggregator.Publish(new InvoiceStatusChangedEvent(invoice, "invalid"))); + context.Events.Add(new InvoiceStatusChangedEvent(invoice, "invalid")); invoice.Status = "invalid"; - needSave = true; + context.MarkDirty(); } else { @@ -233,9 +262,9 @@ namespace BTCPayServer.Services.Invoices if (totalConfirmed >= accounting.TotalDue) { await _InvoiceRepository.UnaffectAddress(invoice.Id); - postSaveActions.Add(() => _EventAggregator.Publish(new InvoiceStatusChangedEvent(invoice, "confirmed"))); + context.Events.Add(new InvoiceStatusChangedEvent(invoice, "confirmed")); invoice.Status = "confirmed"; - needSave = true; + context.MarkDirty(); } } } @@ -247,17 +276,16 @@ namespace BTCPayServer.Services.Invoices var totalConfirmed = transactions.Select(t => t.Payment.GetValue(cryptoDataAll, cryptoData.CryptoCode)).Sum(); if (totalConfirmed >= accounting.TotalDue) { - postSaveActions.Add(() => _EventAggregator.Publish(new InvoiceStatusChangedEvent(invoice, "complete"))); + context.Events.Add(new InvoiceStatusChangedEvent(invoice, "complete")); invoice.Status = "complete"; - needSave = true; + context.MarkDirty(); } } - return (needSave, changes); } private async Task> GetPaymentsWithTransaction(InvoiceEntity invoice) { - var transactions = await _ExplorerClient.GetTransactions(invoice.Payments.Select(t => t.Outpoint.Hash).ToArray()); + var transactions = await _Wallet.GetTransactions(invoice.Payments.Select(t => t.Outpoint.Hash).ToArray()); var spentTxIn = new Dictionary(); var result = invoice.Payments.Select(p => p.Outpoint).ToHashSet(); diff --git a/BTCPayServer/Services/Wallets/BTCPayWallet.cs b/BTCPayServer/Services/Wallets/BTCPayWallet.cs index d9ed20bb8..ec6320c94 100644 --- a/BTCPayServer/Services/Wallets/BTCPayWallet.cs +++ b/BTCPayServer/Services/Wallets/BTCPayWallet.cs @@ -7,9 +7,22 @@ using System.Text; using System.Linq; using System.Threading.Tasks; using BTCPayServer.Data; +using System.Threading; +using NBXplorer.Models; namespace BTCPayServer.Services.Wallets { + public class KnownState + { + public uint256 UnconfirmedHash { get; set; } + public uint256 ConfirmedHash { get; set; } + } + public class GetCoinsResult + { + public Coin[] Coins { get; set; } + public KnownState State { get; set; } + public DerivationStrategy Strategy { get; set; } + } public class BTCPayWallet { private ExplorerClient _Client; @@ -25,6 +38,7 @@ namespace BTCPayServer.Services.Wallets _Client = client; _DBFactory = factory; _Serializer = new NBXplorer.Serializer(_Client.Network); + LongPollingMode = client.Network == Network.RegTest; } @@ -39,6 +53,24 @@ namespace BTCPayServer.Services.Wallets await _Client.TrackAsync(derivationStrategy.DerivationStrategyBase); } + public Task GetTransactionAsync(uint256 txId, CancellationToken cancellation = default(CancellationToken)) + { + return _Client.GetTransactionAsync(txId, cancellation); + } + + public bool LongPollingMode { get; set; } + public async Task GetCoins(DerivationStrategy strategy, KnownState state, CancellationToken cancellation = default(CancellationToken)) + { + var changes = await _Client.SyncAsync(strategy.DerivationStrategyBase, state?.ConfirmedHash, state?.UnconfirmedHash, !LongPollingMode, cancellation).ConfigureAwait(false); + var utxos = changes.Confirmed.UTXOs.Concat(changes.Unconfirmed.UTXOs).Select(c => c.AsCoin()).ToArray(); + return new GetCoinsResult() + { + Coins = utxos, + State = new KnownState() { ConfirmedHash = changes.Confirmed.Hash, UnconfirmedHash = changes.Unconfirmed.Hash }, + Strategy = strategy, + }; + } + private byte[] ToBytes(T obj) { return ZipUtils.Zip(_Serializer.ToString(obj));