From cb4468d3b336b429f5f3bfe400df344f442e0ff1 Mon Sep 17 00:00:00 2001 From: "nicolas.dorier" Date: Wed, 10 Jan 2018 18:30:45 +0900 Subject: [PATCH] Fixing payment in different crypto --- BTCPayServer.Tests/docker-compose.yml | 4 +- BTCPayServer/BTCPayServer.csproj | 2 +- .../Controllers/InvoiceController.UI.cs | 3 +- BTCPayServer/ExplorerClientProvider.cs | 2 + BTCPayServer/HostedServices/InvoiceWatcher.cs | 158 +++++++++++------- .../InvoicingModels/InvoiceDetailsModel.cs | 2 +- .../Services/Invoices/InvoiceEntity.cs | 23 ++- .../Services/Invoices/InvoiceRepository.cs | 14 +- BTCPayServer/Services/Wallets/BTCPayWallet.cs | 16 +- BTCPayServer/Views/Invoice/Checkout.cshtml | 2 +- BTCPayServer/Views/Invoice/Invoice.cshtml | 4 + 11 files changed, 155 insertions(+), 75 deletions(-) diff --git a/BTCPayServer.Tests/docker-compose.yml b/BTCPayServer.Tests/docker-compose.yml index d0b8dd7ff..551f1fb9c 100644 --- a/BTCPayServer.Tests/docker-compose.yml +++ b/BTCPayServer.Tests/docker-compose.yml @@ -38,7 +38,7 @@ services: - postgres bitcoin-nbxplorer: - image: nicolasdorier/nbxplorer:1.0.0.42 + image: nicolasdorier/nbxplorer:1.0.0.45 ports: - "32838:32838" expose: @@ -56,7 +56,7 @@ services: - bitcoind litecoin-nbxplorer: - image: nicolasdorier/nbxplorer:1.0.0.43 + image: nicolasdorier/nbxplorer:1.0.0.45 ports: - "32839:32839" expose: diff --git a/BTCPayServer/BTCPayServer.csproj b/BTCPayServer/BTCPayServer.csproj index c2726fbf9..198c84c3a 100644 --- a/BTCPayServer/BTCPayServer.csproj +++ b/BTCPayServer/BTCPayServer.csproj @@ -24,7 +24,7 @@ - + diff --git a/BTCPayServer/Controllers/InvoiceController.UI.cs b/BTCPayServer/Controllers/InvoiceController.UI.cs index de1e8788e..e19637f28 100644 --- a/BTCPayServer/Controllers/InvoiceController.UI.cs +++ b/BTCPayServer/Controllers/InvoiceController.UI.cs @@ -56,6 +56,7 @@ namespace BTCPayServer.Controllers Fiat = FormatCurrency((decimal)dto.Price, dto.Currency), NotificationUrl = invoice.NotificationURL, ProductInformation = invoice.ProductInformation, + StatusException = invoice.ExceptionStatus }; foreach (var data in invoice.GetCryptoData()) @@ -74,7 +75,7 @@ namespace BTCPayServer.Controllers } var payments = invoice - .Payments + .GetPayments() .Select(async payment => { var m = new InvoiceDetailsModel.Payment(); diff --git a/BTCPayServer/ExplorerClientProvider.cs b/BTCPayServer/ExplorerClientProvider.cs index 90688bc8b..7c8200fe8 100644 --- a/BTCPayServer/ExplorerClientProvider.cs +++ b/BTCPayServer/ExplorerClientProvider.cs @@ -34,6 +34,8 @@ namespace BTCPayServer public ExplorerClient GetExplorerClient(BTCPayNetwork network) { + if (network == null) + throw new ArgumentNullException(nameof(network)); return GetExplorerClient(network.CryptoCode); } diff --git a/BTCPayServer/HostedServices/InvoiceWatcher.cs b/BTCPayServer/HostedServices/InvoiceWatcher.cs index d36b02034..bca6eb3ad 100644 --- a/BTCPayServer/HostedServices/InvoiceWatcher.cs +++ b/BTCPayServer/HostedServices/InvoiceWatcher.cs @@ -149,16 +149,19 @@ namespace BTCPayServer.HostedServices invoice.Status = "expired"; } - foreach (NetworkCoins coins in await GetCoinsPerNetwork(context, invoice)) + var derivationStrategies = invoice.GetDerivationStrategies(_NetworkProvider).ToArray(); + foreach (NetworkCoins coins in await GetCoinsPerNetwork(context, invoice, derivationStrategies)) { bool dirtyAddress = false; if (coins.State != null) context.ModifiedKnownStates.AddOrReplace(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 alreadyAccounted = new HashSet(invoice.GetPayments(coins.Strategy.Network).Select(p => p.Outpoint)); + foreach (var coin in coins.TimestampedCoins.Where(c => !alreadyAccounted.Contains(c.Coin.Outpoint))) { - var payment = await _InvoiceRepository.AddPayment(invoice.Id, coin, coins.Strategy.Network.CryptoCode).ConfigureAwait(false); + var payment = await _InvoiceRepository.AddPayment(invoice.Id, coin.DateTime, coin.Coin, coins.Strategy.Network.CryptoCode).ConfigureAwait(false); +#pragma warning disable CS0618 invoice.Payments.Add(payment); +#pragma warning restore CS0618 context.Events.Add(new InvoicePaymentEvent(invoice.Id)); dirtyAddress = true; } @@ -169,7 +172,7 @@ namespace BTCPayServer.HostedServices if (invoice.Status == "new" || invoice.Status == "expired") { - var totalPaid = (await GetPaymentsWithTransaction(network, invoice)).Select(p => p.Payment.GetValue(cryptoDataAll, cryptoData.CryptoCode)).Sum(); + var totalPaid = (await GetPaymentsWithTransaction(derivationStrategies, invoice)).Select(p => p.Payment.GetValue(cryptoDataAll, cryptoData.CryptoCode)).Sum(); if (totalPaid >= accounting.TotalDue) { if (invoice.Status == "new") @@ -194,7 +197,7 @@ namespace BTCPayServer.HostedServices context.MarkDirty(); } - if (totalPaid < accounting.TotalDue && invoice.Payments.Count != 0 && invoice.ExceptionStatus != "paidPartial") + if (totalPaid < accounting.TotalDue && invoice.GetPayments().Count != 0 && invoice.ExceptionStatus != "paidPartial") { invoice.ExceptionStatus = "paidPartial"; context.MarkDirty(); @@ -209,7 +212,7 @@ namespace BTCPayServer.HostedServices if (invoice.Status == "paid") { - var transactions = await GetPaymentsWithTransaction(network, invoice); + var transactions = await GetPaymentsWithTransaction(derivationStrategies, invoice); if (invoice.SpeedPolicy == SpeedPolicy.HighSpeed) { transactions = transactions.Where(t => t.Confirmations >= 1 || !t.Transaction.RBF); @@ -247,7 +250,7 @@ namespace BTCPayServer.HostedServices if (invoice.Status == "confirmed") { - var transactions = await GetPaymentsWithTransaction(network, invoice); + var transactions = await GetPaymentsWithTransaction(derivationStrategies, invoice); transactions = transactions.Where(t => t.Confirmations >= 6); var totalConfirmed = transactions.Select(t => t.Payment.GetValue(cryptoDataAll, cryptoData.CryptoCode)).Sum(); if (totalConfirmed >= accounting.TotalDue) @@ -260,9 +263,8 @@ namespace BTCPayServer.HostedServices } } - private async Task> GetCoinsPerNetwork(UpdateInvoiceContext context, InvoiceEntity invoice) + private async Task> GetCoinsPerNetwork(UpdateInvoiceContext context, InvoiceEntity invoice, DerivationStrategy[] strategies) { - var strategies = invoice.GetDerivationStrategies(_NetworkProvider).ToArray(); var getCoinsResponsesAsync = strategies .Select(d => _Wallet.GetCoins(d, context.KnownStates.TryGet(d.Network))) .ToArray(); @@ -270,66 +272,108 @@ namespace BTCPayServer.HostedServices var getCoinsResponses = getCoinsResponsesAsync.Select(g => g.Result).ToArray(); foreach (var response in getCoinsResponses) { - response.Coins = response.Coins.Where(c => invoice.AvailableAddressHashes.Contains(c.ScriptPubKey.Hash.ToString() + response.Strategy.Network.CryptoCode)).ToArray(); + response.TimestampedCoins = response.TimestampedCoins.Where(c => invoice.AvailableAddressHashes.Contains(c.Coin.ScriptPubKey.Hash.ToString() + response.Strategy.Network.CryptoCode)).ToArray(); } - return getCoinsResponses.Where(s => s.Coins.Length != 0).ToArray(); + return getCoinsResponses.Where(s => s.TimestampedCoins.Length != 0).ToArray(); } - private async Task> GetPaymentsWithTransaction(BTCPayNetwork network, InvoiceEntity invoice) + private async Task> GetPaymentsWithTransaction(DerivationStrategy[] derivations, InvoiceEntity invoice) { - var transactions = await _Wallet.GetTransactions(network, invoice.Payments.Select(t => t.Outpoint.Hash).ToArray()); - - var spentTxIn = new Dictionary(); - var result = invoice.Payments.Select(p => p.Outpoint).ToHashSet(); - List payments = new List(); - foreach (var payment in invoice.Payments) + List updatedPaymentEntities = new List(); + List accountedPayments = new List(); + foreach (var network in derivations.Select(d => d.Network)) { - TransactionResult tx; - if (!transactions.TryGetValue(payment.Outpoint.Hash, out tx)) + var transactions = await _Wallet.GetTransactions(network, invoice.GetPayments(network).Select(t => t.Outpoint.Hash).ToArray()); + var conflicts = GetConflicts(transactions.Select(t => t.Value)); + foreach (var payment in invoice.GetPayments(network)) { - result.Remove(payment.Outpoint); - continue; - } - AccountedPaymentEntity accountedPayment = new AccountedPaymentEntity() - { - Confirmations = tx.Confirmations, - Transaction = tx.Transaction, - Payment = payment - }; - payments.Add(accountedPayment); - foreach (var txin in tx.Transaction.Inputs) - { - if (!spentTxIn.TryAdd(txin.PrevOut, accountedPayment)) - { - //We get a double spend - var existing = spentTxIn[txin.PrevOut]; + TransactionResult tx; + if (!transactions.TryGetValue(payment.Outpoint.Hash, out tx)) + continue; - //Take the most recent, the full node is already comparing fees correctly so we have the most likely to be confirmed - if (accountedPayment.Confirmations > 1 || existing.Payment.ReceivedTime < accountedPayment.Payment.ReceivedTime) - { - spentTxIn[txin.PrevOut] = accountedPayment; - result.Remove(existing.Payment.Outpoint); - } + AccountedPaymentEntity accountedPayment = new AccountedPaymentEntity() + { + Confirmations = tx.Confirmations, + Transaction = tx.Transaction, + Payment = payment + }; + var txId = accountedPayment.Transaction.GetHash(); + var txConflict = conflicts.GetConflict(txId); + var accounted = txConflict == null || txConflict.IsWinner(txId); + if (accounted != payment.Accounted) + { + updatedPaymentEntities.Add(payment); + payment.Accounted = accounted; } + + if (accounted) + accountedPayments.Add(accountedPayment); } } - - List updated = new List(); - var accountedPayments = payments.Where(p => - { - var accounted = result.Contains(p.Payment.Outpoint); - if (p.Payment.Accounted != accounted) - { - p.Payment.Accounted = accounted; - updated.Add(p.Payment); - } - return accounted; - }).ToArray(); - - await _InvoiceRepository.UpdatePayments(payments); + await _InvoiceRepository.UpdatePayments(updatedPaymentEntities); return accountedPayments; } + + class TransactionConflict + { + public Dictionary Transactions { get; set; } = new Dictionary(); + + + uint256 _Winner; + public bool IsWinner(uint256 txId) + { + if (_Winner == null) + { + var confirmed = Transactions.FirstOrDefault(t => t.Value.Confirmations >= 1); + if (!confirmed.Equals(default(KeyValuePair))) + { + _Winner = confirmed.Key; + } + else + { + // Take the most recent (bitcoin node would not forward a conflict without a successfull RBF) + _Winner = Transactions + .OrderByDescending(t => t.Value.Timestamp) + .First() + .Key; + } + } + return _Winner == txId; + } + } + class TransactionConflicts : List + { + public TransactionConflicts(IEnumerable collection) : base(collection) + { + + } + + public TransactionConflict GetConflict(uint256 txId) + { + return this.FirstOrDefault(c => c.Transactions.ContainsKey(txId)); + } + } + private TransactionConflicts GetConflicts(IEnumerable transactions) + { + Dictionary conflictsByOutpoint = new Dictionary(); + foreach (var tx in transactions) + { + var hash = tx.Transaction.GetHash(); + foreach (var input in tx.Transaction.Inputs) + { + TransactionConflict conflict = new TransactionConflict(); + if (!conflictsByOutpoint.TryAdd(input.PrevOut, conflict)) + { + conflict = conflictsByOutpoint[input.PrevOut]; + } + if (!conflict.Transactions.ContainsKey(hash)) + conflict.Transactions.Add(hash, tx); + } + } + return new TransactionConflicts(conflictsByOutpoint.Where(c => c.Value.Transactions.Count > 1).Select(c => c.Value)); + } + TimeSpan _PollInterval; public TimeSpan PollInterval { diff --git a/BTCPayServer/Models/InvoicingModels/InvoiceDetailsModel.cs b/BTCPayServer/Models/InvoicingModels/InvoiceDetailsModel.cs index 4998955e6..d0f28cfb2 100644 --- a/BTCPayServer/Models/InvoicingModels/InvoiceDetailsModel.cs +++ b/BTCPayServer/Models/InvoicingModels/InvoiceDetailsModel.cs @@ -70,7 +70,7 @@ namespace BTCPayServer.Models.InvoicingModels { get; set; } - + public string StatusException { get; set; } public DateTimeOffset CreatedDate { get; set; diff --git a/BTCPayServer/Services/Invoices/InvoiceEntity.cs b/BTCPayServer/Services/Invoices/InvoiceEntity.cs index 142938da2..82d096b9d 100644 --- a/BTCPayServer/Services/Invoices/InvoiceEntity.cs +++ b/BTCPayServer/Services/Invoices/InvoiceEntity.cs @@ -180,7 +180,7 @@ namespace BTCPayServer.Services.Invoices var network = networks.GetNetwork(strat.Name); if (network != null) { - if (network == networks.BTC && btcReturned) + if (network == networks.BTC) btcReturned = true; yield return BTCPayServer.DerivationStrategy.Parse(strat.Value.Value(), network); } @@ -220,10 +220,27 @@ namespace BTCPayServer.Services.Invoices { get; set; } + + [Obsolete("Use GetPayments instead")] public List Payments { get; set; } + +#pragma warning disable CS0618 + public List GetPayments() + { + return Payments.ToList(); + } + public List GetPayments(string cryptoCode) + { + return Payments.Where(p=>p.CryptoCode == cryptoCode).ToList(); + } + public List GetPayments(BTCPayNetwork network) + { + return GetPayments(network.CryptoCode); + } +#pragma warning restore CS0618 public bool Refundable { get; @@ -490,9 +507,9 @@ namespace BTCPayServer.Services.Invoices bool paidEnough = totalDue <= paid; int txCount = 0; var payments = - ParentEntity.Payments + ParentEntity.GetPayments() .Where(p => p.Accounted) - .OrderByDescending(p => p.ReceivedTime) + .OrderBy(p => p.ReceivedTime) .Select(_ => { var txFee = _.GetValue(cryptoData, CryptoCode, cryptoData[_.GetCryptoCode()].TxFee); diff --git a/BTCPayServer/Services/Invoices/InvoiceRepository.cs b/BTCPayServer/Services/Invoices/InvoiceRepository.cs index 31b9da143..c5cce5c2e 100644 --- a/BTCPayServer/Services/Invoices/InvoiceRepository.cs +++ b/BTCPayServer/Services/Invoices/InvoiceRepository.cs @@ -107,7 +107,9 @@ namespace BTCPayServer.Services.Invoices List textSearch = new List(); invoice = Clone(invoice); invoice.Id = Encoders.Base58.EncodeData(RandomUtils.GetBytes(16)); +#pragma warning disable CS0618 invoice.Payments = new List(); +#pragma warning restore CS0618 invoice.StoreId = storeId; using (var context = _ContextFactory.CreateContext()) { @@ -309,12 +311,14 @@ namespace BTCPayServer.Services.Invoices private InvoiceEntity ToEntity(InvoiceData invoice) { var entity = ToObject(invoice.Blob); +#pragma warning disable CS0618 entity.Payments = invoice.Payments.Select(p => { var paymentEntity = ToObject(p.Blob); paymentEntity.Accounted = p.Accounted; return paymentEntity; }).ToList(); +#pragma warning restore CS0618 entity.ExceptionStatus = invoice.ExceptionStatus; entity.Status = invoice.Status; entity.RefundMail = invoice.CustomerEmail; @@ -419,7 +423,7 @@ namespace BTCPayServer.Services.Invoices AddToTextSearch(invoiceId, addresses.Select(a => a.ToString()).ToArray()); } - public async Task AddPayment(string invoiceId, Coin receivedCoin, string cryptoCode) + public async Task AddPayment(string invoiceId, DateTimeOffset date, Coin receivedCoin, string cryptoCode) { using (var context = _ContextFactory.CreateContext()) { @@ -430,7 +434,7 @@ namespace BTCPayServer.Services.Invoices Output = receivedCoin.TxOut, CryptoCode = cryptoCode, #pragma warning restore CS0618 - ReceivedTime = DateTime.UtcNow + ReceivedTime = date.UtcDateTime }; PaymentData data = new PaymentData @@ -448,7 +452,7 @@ namespace BTCPayServer.Services.Invoices } } - public async Task UpdatePayments(List payments) + public async Task UpdatePayments(List payments) { if (payments.Count == 0) return; @@ -457,8 +461,8 @@ namespace BTCPayServer.Services.Invoices foreach (var payment in payments) { var data = new PaymentData(); - data.Id = payment.Payment.Outpoint.ToString(); - data.Accounted = payment.Payment.Accounted; + data.Id = payment.Outpoint.ToString(); + data.Accounted = payment.Accounted; context.Attach(data); context.Entry(data).Property(o => o.Accounted).IsModified = true; } diff --git a/BTCPayServer/Services/Wallets/BTCPayWallet.cs b/BTCPayServer/Services/Wallets/BTCPayWallet.cs index 0494f81dc..4fb277ae8 100644 --- a/BTCPayServer/Services/Wallets/BTCPayWallet.cs +++ b/BTCPayServer/Services/Wallets/BTCPayWallet.cs @@ -19,7 +19,12 @@ namespace BTCPayServer.Services.Wallets } public class NetworkCoins { - public Coin[] Coins { get; set; } + public class TimestampedCoin + { + public DateTimeOffset DateTime { get; set; } + public Coin Coin { get; set; } + } + public TimestampedCoin[] TimestampedCoins { get; set; } public KnownState State { get; set; } public DerivationStrategy Strategy { get; set; } } @@ -50,6 +55,10 @@ namespace BTCPayServer.Services.Wallets public Task GetTransactionAsync(BTCPayNetwork network, uint256 txId, CancellationToken cancellation = default(CancellationToken)) { + if (network == null) + throw new ArgumentNullException(nameof(network)); + if (txId == null) + throw new ArgumentNullException(nameof(txId)); var client = _Client.GetExplorerClient(network); return client.GetTransactionAsync(txId, cancellation); } @@ -58,12 +67,11 @@ namespace BTCPayServer.Services.Wallets { var client = _Client.GetExplorerClient(strategy.Network); if (client == null) - return new NetworkCoins() { Coins = new Coin[0], State = null, Strategy = strategy }; + return new NetworkCoins() { TimestampedCoins = new NetworkCoins.TimestampedCoin[0], State = null, Strategy = strategy }; var changes = await client.SyncAsync(strategy.DerivationStrategyBase, state?.ConfirmedHash, state?.UnconfirmedHash, true, cancellation).ConfigureAwait(false); - var utxos = changes.Confirmed.UTXOs.Concat(changes.Unconfirmed.UTXOs).Select(c => c.AsCoin()).ToArray(); return new NetworkCoins() { - Coins = utxos, + TimestampedCoins = changes.Confirmed.UTXOs.Concat(changes.Unconfirmed.UTXOs).Select(c => new NetworkCoins.TimestampedCoin() { Coin = c.AsCoin(), DateTime = c.Timestamp }).ToArray(), State = new KnownState() { ConfirmedHash = changes.Confirmed.Hash, UnconfirmedHash = changes.Unconfirmed.Hash }, Strategy = strategy, }; diff --git a/BTCPayServer/Views/Invoice/Checkout.cshtml b/BTCPayServer/Views/Invoice/Checkout.cshtml index 50e892b10..38e7c0ae2 100644 --- a/BTCPayServer/Views/Invoice/Checkout.cshtml +++ b/BTCPayServer/Views/Invoice/Checkout.cshtml @@ -613,7 +613,7 @@
@foreach(var crypto in Model.AvailableCryptos) { - @crypto.CryptoCode + @crypto.CryptoCode }
} diff --git a/BTCPayServer/Views/Invoice/Invoice.cshtml b/BTCPayServer/Views/Invoice/Invoice.cshtml index 0a214fd44..b22312c53 100644 --- a/BTCPayServer/Views/Invoice/Invoice.cshtml +++ b/BTCPayServer/Views/Invoice/Invoice.cshtml @@ -57,6 +57,10 @@ Status @Model.Status + + Status Exception + @ModelStatusException + Refund email @Model.RefundEmail