diff --git a/BTCPayServer.Tests/BTCPayServerTester.cs b/BTCPayServer.Tests/BTCPayServerTester.cs index bc07d8d92..4f6d2a72d 100644 --- a/BTCPayServer.Tests/BTCPayServerTester.cs +++ b/BTCPayServer.Tests/BTCPayServerTester.cs @@ -108,7 +108,6 @@ namespace BTCPayServer.Tests _Host.Start(); Runtime = (BTCPayServerRuntime)_Host.Services.GetService(typeof(BTCPayServerRuntime)); var watcher = (InvoiceWatcher)_Host.Services.GetService(typeof(InvoiceWatcher)); - watcher.PollInterval = TimeSpan.FromMilliseconds(500); } public BTCPayServerRuntime Runtime diff --git a/BTCPayServer.Tests/Logging/Logs.cs b/BTCPayServer.Tests/Logging/Logs.cs index e47eb7239..0ff3ead84 100644 --- a/BTCPayServer.Tests/Logging/Logs.cs +++ b/BTCPayServer.Tests/Logging/Logs.cs @@ -71,7 +71,7 @@ namespace BTCPayServer.Tests.Logging public void LogInformation(string msg) { if (msg != null) - _Helper.WriteLine(Name + ": " + msg); + _Helper.WriteLine(DateTimeOffset.UtcNow + " :" + Name + ": " + msg); } } public class Logs diff --git a/BTCPayServer.Tests/TestAccount.cs b/BTCPayServer.Tests/TestAccount.cs index 733096628..07167c9df 100644 --- a/BTCPayServer.Tests/TestAccount.cs +++ b/BTCPayServer.Tests/TestAccount.cs @@ -10,6 +10,7 @@ using System.Collections.Generic; using System.Text; using System.Threading.Tasks; using Xunit; +using NBXplorer.DerivationStrategy; namespace BTCPayServer.Tests { @@ -55,14 +56,17 @@ namespace BTCPayServer.Tests var store = parent.PayTester.GetController(UserId); await store.CreateStore(new CreateStoreViewModel() { Name = "Test Store" }); StoreId = store.CreatedStoreId; + DerivationScheme = new DerivationStrategyFactory(parent.Network).Parse(ExtKey.Neuter().ToString() + "-[legacy]"); await store.UpdateStore(StoreId, new StoreViewModel() { - DerivationScheme = ExtKey.Neuter().ToString() + "-[legacy]", + DerivationScheme = DerivationScheme.ToString(), SpeedPolicy = SpeedPolicy.MediumSpeed }, "Save"); return store; } + public DerivationStrategyBase DerivationScheme { get; set; } + private async Task RegisterAsync() { var account = parent.PayTester.GetController(); diff --git a/BTCPayServer.Tests/UnitTest1.cs b/BTCPayServer.Tests/UnitTest1.cs index d61ea15aa..9e7936102 100644 --- a/BTCPayServer.Tests/UnitTest1.cs +++ b/BTCPayServer.Tests/UnitTest1.cs @@ -45,22 +45,22 @@ namespace BTCPayServer.Tests Assert.Equal(Money.Coins(1.1m), entity.GetCryptoDue()); Assert.Equal(Money.Coins(1.1m), entity.GetTotalCryptoDue()); - entity.Payments.Add(new PaymentEntity() { Output = new TxOut(Money.Coins(0.5m), new Key()) }); + entity.Payments.Add(new PaymentEntity() { Output = new TxOut(Money.Coins(0.5m), new Key()), Accounted = true }); //Since we need to spend one more txout, it should be 1.1 - 0,5 + 0.1 Assert.Equal(Money.Coins(0.7m), entity.GetCryptoDue()); Assert.Equal(Money.Coins(1.2m), entity.GetTotalCryptoDue()); - entity.Payments.Add(new PaymentEntity() { Output = new TxOut(Money.Coins(0.2m), new Key()) }); + entity.Payments.Add(new PaymentEntity() { Output = new TxOut(Money.Coins(0.2m), new Key()), Accounted = true }); Assert.Equal(Money.Coins(0.6m), entity.GetCryptoDue()); Assert.Equal(Money.Coins(1.3m), entity.GetTotalCryptoDue()); - entity.Payments.Add(new PaymentEntity() { Output = new TxOut(Money.Coins(0.6m), new Key()) }); + entity.Payments.Add(new PaymentEntity() { Output = new TxOut(Money.Coins(0.6m), new Key()), Accounted = true }); Assert.Equal(Money.Zero, entity.GetCryptoDue()); Assert.Equal(Money.Coins(1.3m), entity.GetTotalCryptoDue()); - entity.Payments.Add(new PaymentEntity() { Output = new TxOut(Money.Coins(0.2m), new Key()) }); + entity.Payments.Add(new PaymentEntity() { Output = new TxOut(Money.Coins(0.2m), new Key()), Accounted = true }); Assert.Equal(Money.Zero, entity.GetCryptoDue()); Assert.Equal(Money.Coins(1.3m), entity.GetTotalCryptoDue()); @@ -194,6 +194,63 @@ namespace BTCPayServer.Tests } } + [Fact] + public void CanRBFPayment() + { + using (var tester = ServerTester.Create()) + { + tester.Start(); + var user = tester.NewAccount(); + user.GrantAccess(); + var invoice = user.BitPay.CreateInvoice(new Invoice() + { + Price = 5000.0, + Currency = "USD" + }, Facade.Merchant); + + var payment1 = Money.Coins(0.04m); + var payment2 = Money.Coins(0.08m); + var tx1 = new uint256(tester.ExplorerNode.SendCommand("sendtoaddress", new object[] + { + invoice.BitcoinAddress.ToString(), + payment1.ToString(), + null, //comment + null, //comment_to + false, //subtractfeefromamount + true, //replaceable + }).ResultString); + var invoiceAddress = BitcoinAddress.Create(invoice.BitcoinAddress, tester.Network); + + Eventually(() => + { + tester.SimulateCallback(invoiceAddress); + invoice = user.BitPay.GetInvoice(invoice.Id); + Assert.Equal(payment1, invoice.BtcPaid); + invoiceAddress = BitcoinAddress.Create(invoice.BitcoinAddress, tester.Network); + }); + + var tx = tester.ExplorerNode.GetRawTransaction(new uint256(tx1)); + foreach (var input in tx.Inputs) + { + input.ScriptSig = Script.Empty; //Strip signatures + } + var change = tx.Outputs.First(o => o.Value != payment1); + var output = tx.Outputs.First(o => o.Value == payment1); + output.Value = payment2; + output.ScriptPubKey = invoiceAddress.ScriptPubKey; + change.Value -= (payment2 - payment1) * 2; //Add more fees + var replaced = tester.ExplorerNode.SignRawTransaction(tx); + tester.ExplorerNode.SendRawTransaction(replaced); + var test = tester.ExplorerClient.Sync(user.DerivationScheme, null); + Eventually(() => + { + tester.SimulateCallback(invoiceAddress); + invoice = user.BitPay.GetInvoice(invoice.Id); + Assert.Equal(payment2, invoice.BtcPaid); + }); + } + } + [Fact] public void InvoiceFlowThroughDifferentStatesCorrectly() { diff --git a/BTCPayServer.Tests/docker-compose.yml b/BTCPayServer.Tests/docker-compose.yml index 9c1e7b8e9..6b478ec93 100644 --- a/BTCPayServer.Tests/docker-compose.yml +++ b/BTCPayServer.Tests/docker-compose.yml @@ -21,7 +21,7 @@ services: - "tests:127.0.0.1" nbxplorer: - image: nicolasdorier/nbxplorer:1.0.0.25 + image: nicolasdorier/nbxplorer:1.0.0.28 ports: - "32838:32838" expose: @@ -33,6 +33,7 @@ services: NBXPLORER_RPCPASSWORD: DwubwWsoo3 NBXPLORER_NODEENDPOINT: bitcoind:39388 NBXPLORER_BIND: 0.0.0.0:32838 + NBXPLORER_VERBOSE: 1 NBXPLORER_NOAUTH: 1 links: - bitcoind diff --git a/BTCPayServer/BTCPayServer.csproj b/BTCPayServer/BTCPayServer.csproj index 6bd81eab8..b9805bd88 100644 --- a/BTCPayServer/BTCPayServer.csproj +++ b/BTCPayServer/BTCPayServer.csproj @@ -2,7 +2,7 @@ Exe netcoreapp2.0 - 1.0.0.27 + 1.0.0.28 diff --git a/BTCPayServer/Data/InvoiceData.cs b/BTCPayServer/Data/InvoiceData.cs index 68a2b7a08..1420355fc 100644 --- a/BTCPayServer/Data/InvoiceData.cs +++ b/BTCPayServer/Data/InvoiceData.cs @@ -71,5 +71,9 @@ namespace BTCPayServer.Data get; set; } + public List AddressInvoices + { + get; set; + } } } diff --git a/BTCPayServer/Data/PaymentData.cs b/BTCPayServer/Data/PaymentData.cs index a2260be52..fb7c98784 100644 --- a/BTCPayServer/Data/PaymentData.cs +++ b/BTCPayServer/Data/PaymentData.cs @@ -25,5 +25,9 @@ namespace BTCPayServer.Data { get; set; } + public bool Accounted + { + get; set; + } } } diff --git a/BTCPayServer/Extensions.cs b/BTCPayServer/Extensions.cs index 4136a1331..03601d5c7 100644 --- a/BTCPayServer/Extensions.cs +++ b/BTCPayServer/Extensions.cs @@ -11,11 +11,26 @@ using System; using System.Collections.Generic; using System.Text; using System.Text.Encodings.Web; +using NBitcoin; +using System.Threading.Tasks; +using NBXplorer; +using NBXplorer.Models; +using System.Linq; +using System.Threading; namespace BTCPayServer { public static class Extensions { + public static async Task> GetTransactions(this ExplorerClient client, uint256[] hashes, CancellationToken cts = default(CancellationToken)) + { + hashes = hashes.Distinct().ToArray(); + var transactions = hashes + .Select(async o => await client.GetTransactionAsync(o, cts)) + .ToArray(); + await Task.WhenAll(transactions).ConfigureAwait(false); + return transactions.Select(t => t.Result).Where(t => t != null).ToDictionary(o => o.Transaction.GetHash()); + } public static string WithTrailingSlash(this string str) { if (str.EndsWith("/")) diff --git a/BTCPayServer/Migrations/20171105235734_PaymentAccounted.Designer.cs b/BTCPayServer/Migrations/20171105235734_PaymentAccounted.Designer.cs new file mode 100644 index 000000000..c27bee0a5 --- /dev/null +++ b/BTCPayServer/Migrations/20171105235734_PaymentAccounted.Designer.cs @@ -0,0 +1,479 @@ +// +using BTCPayServer.Data; +using BTCPayServer.Services.Invoices; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage; +using Microsoft.EntityFrameworkCore.Storage.Internal; +using System; + +namespace BTCPayServer.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20171105235734_PaymentAccounted")] + partial class PaymentAccounted + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "2.0.0-rtm-26452"); + + modelBuilder.Entity("BTCPayServer.Data.AddressInvoiceData", b => + { + b.Property("Address") + .ValueGeneratedOnAdd(); + + b.Property("CreatedTime"); + + b.Property("InvoiceDataId"); + + b.HasKey("Address"); + + b.HasIndex("InvoiceDataId"); + + b.ToTable("AddressInvoices"); + }); + + modelBuilder.Entity("BTCPayServer.Data.HistoricalAddressInvoiceData", b => + { + b.Property("InvoiceDataId"); + + b.Property("Address"); + + b.Property("Assigned"); + + b.Property("UnAssigned"); + + b.HasKey("InvoiceDataId", "Address"); + + b.ToTable("HistoricalAddressInvoices"); + }); + + modelBuilder.Entity("BTCPayServer.Data.InvoiceData", b => + { + b.Property("Id") + .ValueGeneratedOnAdd(); + + b.Property("Blob"); + + b.Property("Created"); + + b.Property("CustomerEmail"); + + b.Property("ExceptionStatus"); + + b.Property("ItemCode"); + + b.Property("OrderId"); + + b.Property("Status"); + + b.Property("StoreDataId"); + + b.HasKey("Id"); + + b.HasIndex("StoreDataId"); + + b.ToTable("Invoices"); + }); + + modelBuilder.Entity("BTCPayServer.Data.PairedSINData", b => + { + b.Property("Id") + .ValueGeneratedOnAdd(); + + b.Property("Facade"); + + b.Property("Label"); + + b.Property("PairingTime"); + + b.Property("SIN"); + + b.Property("StoreDataId"); + + b.HasKey("Id"); + + b.HasIndex("SIN"); + + b.HasIndex("StoreDataId"); + + b.ToTable("PairedSINData"); + }); + + modelBuilder.Entity("BTCPayServer.Data.PairingCodeData", b => + { + b.Property("Id") + .ValueGeneratedOnAdd(); + + b.Property("DateCreated"); + + b.Property("Expiration"); + + b.Property("Facade"); + + b.Property("Label"); + + b.Property("SIN"); + + b.Property("StoreDataId"); + + b.Property("TokenValue"); + + b.HasKey("Id"); + + b.ToTable("PairingCodes"); + }); + + modelBuilder.Entity("BTCPayServer.Data.PaymentData", b => + { + b.Property("Id") + .ValueGeneratedOnAdd(); + + b.Property("Accounted"); + + b.Property("Blob"); + + b.Property("InvoiceDataId"); + + b.HasKey("Id"); + + b.HasIndex("InvoiceDataId"); + + b.ToTable("Payments"); + }); + + modelBuilder.Entity("BTCPayServer.Data.PendingInvoiceData", b => + { + b.Property("Id") + .ValueGeneratedOnAdd(); + + b.HasKey("Id"); + + b.ToTable("PendingInvoices"); + }); + + modelBuilder.Entity("BTCPayServer.Data.RefundAddressesData", b => + { + b.Property("Id") + .ValueGeneratedOnAdd(); + + b.Property("Blob"); + + b.Property("InvoiceDataId"); + + b.HasKey("Id"); + + b.HasIndex("InvoiceDataId"); + + b.ToTable("RefundAddresses"); + }); + + modelBuilder.Entity("BTCPayServer.Data.SettingData", b => + { + b.Property("Id") + .ValueGeneratedOnAdd(); + + b.Property("Value"); + + b.HasKey("Id"); + + b.ToTable("Settings"); + }); + + modelBuilder.Entity("BTCPayServer.Data.StoreData", b => + { + b.Property("Id") + .ValueGeneratedOnAdd(); + + b.Property("DerivationStrategy"); + + b.Property("SpeedPolicy"); + + b.Property("StoreBlob"); + + b.Property("StoreCertificate"); + + b.Property("StoreName"); + + b.Property("StoreWebsite"); + + b.HasKey("Id"); + + b.ToTable("Stores"); + }); + + modelBuilder.Entity("BTCPayServer.Data.UserStore", b => + { + b.Property("ApplicationUserId"); + + b.Property("StoreDataId"); + + b.Property("Role"); + + b.HasKey("ApplicationUserId", "StoreDataId"); + + b.HasIndex("StoreDataId"); + + b.ToTable("UserStore"); + }); + + modelBuilder.Entity("BTCPayServer.Models.ApplicationUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd(); + + b.Property("AccessFailedCount"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken(); + + b.Property("Email") + .HasMaxLength(256); + + b.Property("EmailConfirmed"); + + b.Property("LockoutEnabled"); + + b.Property("LockoutEnd"); + + b.Property("NormalizedEmail") + .HasMaxLength(256); + + b.Property("NormalizedUserName") + .HasMaxLength(256); + + b.Property("PasswordHash"); + + b.Property("PhoneNumber"); + + b.Property("PhoneNumberConfirmed"); + + b.Property("RequiresEmailConfirmation"); + + b.Property("SecurityStamp"); + + b.Property("TwoFactorEnabled"); + + b.Property("UserName") + .HasMaxLength(256); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasName("UserNameIndex"); + + b.ToTable("AspNetUsers"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd(); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken(); + + b.Property("Name") + .HasMaxLength(256); + + b.Property("NormalizedName") + .HasMaxLength(256); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasName("RoleNameIndex"); + + b.ToTable("AspNetRoles"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd(); + + b.Property("ClaimType"); + + b.Property("ClaimValue"); + + b.Property("RoleId") + .IsRequired(); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd(); + + b.Property("ClaimType"); + + b.Property("ClaimValue"); + + b.Property("UserId") + .IsRequired(); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider"); + + b.Property("ProviderKey"); + + b.Property("ProviderDisplayName"); + + b.Property("UserId") + .IsRequired(); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId"); + + b.Property("RoleId"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId"); + + b.Property("LoginProvider"); + + b.Property("Name"); + + b.Property("Value"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens"); + }); + + modelBuilder.Entity("BTCPayServer.Data.AddressInvoiceData", b => + { + b.HasOne("BTCPayServer.Data.InvoiceData", "InvoiceData") + .WithMany() + .HasForeignKey("InvoiceDataId"); + }); + + modelBuilder.Entity("BTCPayServer.Data.HistoricalAddressInvoiceData", b => + { + b.HasOne("BTCPayServer.Data.InvoiceData") + .WithMany("HistoricalAddressInvoices") + .HasForeignKey("InvoiceDataId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("BTCPayServer.Data.InvoiceData", b => + { + b.HasOne("BTCPayServer.Data.StoreData", "StoreData") + .WithMany() + .HasForeignKey("StoreDataId"); + }); + + modelBuilder.Entity("BTCPayServer.Data.PaymentData", b => + { + b.HasOne("BTCPayServer.Data.InvoiceData", "InvoiceData") + .WithMany("Payments") + .HasForeignKey("InvoiceDataId"); + }); + + modelBuilder.Entity("BTCPayServer.Data.RefundAddressesData", b => + { + b.HasOne("BTCPayServer.Data.InvoiceData", "InvoiceData") + .WithMany("RefundAddresses") + .HasForeignKey("InvoiceDataId"); + }); + + modelBuilder.Entity("BTCPayServer.Data.UserStore", b => + { + b.HasOne("BTCPayServer.Models.ApplicationUser", "ApplicationUser") + .WithMany("UserStores") + .HasForeignKey("ApplicationUserId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("BTCPayServer.Data.StoreData", "StoreData") + .WithMany("UserStores") + .HasForeignKey("StoreDataId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole") + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("BTCPayServer.Models.ApplicationUser") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("BTCPayServer.Models.ApplicationUser") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole") + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("BTCPayServer.Models.ApplicationUser") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("BTCPayServer.Models.ApplicationUser") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/BTCPayServer/Migrations/20171105235734_PaymentAccounted.cs b/BTCPayServer/Migrations/20171105235734_PaymentAccounted.cs new file mode 100644 index 000000000..49d2c1e15 --- /dev/null +++ b/BTCPayServer/Migrations/20171105235734_PaymentAccounted.cs @@ -0,0 +1,25 @@ +using Microsoft.EntityFrameworkCore.Migrations; +using System; +using System.Collections.Generic; + +namespace BTCPayServer.Migrations +{ + public partial class PaymentAccounted : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "Accounted", + table: "Payments", + nullable: false, + defaultValue: false); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "Accounted", + table: "Payments"); + } + } +} diff --git a/BTCPayServer/Migrations/ApplicationDbContextModelSnapshot.cs b/BTCPayServer/Migrations/ApplicationDbContextModelSnapshot.cs index c71037f37..02037b4a9 100644 --- a/BTCPayServer/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/BTCPayServer/Migrations/ApplicationDbContextModelSnapshot.cs @@ -132,6 +132,8 @@ namespace BTCPayServer.Migrations b.Property("Id") .ValueGeneratedOnAdd(); + b.Property("Accounted"); + b.Property("Blob"); b.Property("InvoiceDataId"); diff --git a/BTCPayServer/Services/Invoices/InvoiceEntity.cs b/BTCPayServer/Services/Invoices/InvoiceEntity.cs index 618636f67..fcba93830 100644 --- a/BTCPayServer/Services/Invoices/InvoiceEntity.cs +++ b/BTCPayServer/Services/Invoices/InvoiceEntity.cs @@ -9,6 +9,7 @@ using NBitpayClient; using Newtonsoft.Json.Linq; using NBitcoin.DataEncoders; using BTCPayServer.Data; +using NBXplorer.Models; namespace BTCPayServer.Services.Invoices { @@ -132,6 +133,7 @@ namespace BTCPayServer.Services.Invoices int txCount = 1; var payments = Payments + .Where(p => p.Accounted) .OrderByDescending(p => p.ReceivedTime) .Select(_ => { @@ -260,6 +262,12 @@ namespace BTCPayServer.Services.Invoices set; } + public HashSet AvailableAddressHashes + { + get; + set; + } + public bool IsExpired() { return DateTimeOffset.UtcNow > ExpirationTime; @@ -300,7 +308,7 @@ namespace BTCPayServer.Services.Invoices dto.Token = Encoders.Base58.EncodeData(RandomUtils.GetBytes(16)); //No idea what it is useful for dto.Guid = Guid.NewGuid().ToString(); - var paid = Payments.Select(p => p.Output.Value).Sum(); + var paid = Payments.Where(p => p.Accounted).Select(p => p.Output.Value).Sum(); dto.BTCPaid = paid.ToString(); dto.BTCDue = GetCryptoDue().ToString(); @@ -321,6 +329,17 @@ namespace BTCPayServer.Services.Invoices } } + public class AccountedPaymentEntity + { + public int Confirmations + { + get; + set; + } + public PaymentEntity Payment { get; set; } + public Transaction Transaction { get; set; } + } + public class PaymentEntity { public DateTimeOffset ReceivedTime @@ -335,5 +354,9 @@ namespace BTCPayServer.Services.Invoices { get; set; } + public bool Accounted + { + get; set; + } } } diff --git a/BTCPayServer/Services/Invoices/InvoiceRepository.cs b/BTCPayServer/Services/Invoices/InvoiceRepository.cs index 57ee42e4f..2aa4c343c 100644 --- a/BTCPayServer/Services/Invoices/InvoiceRepository.cs +++ b/BTCPayServer/Services/Invoices/InvoiceRepository.cs @@ -262,8 +262,7 @@ namespace BTCPayServer.Services.Invoices await context.SaveChangesAsync().ConfigureAwait(false); } } - - public async Task GetInvoice(string storeId, string id, bool includeHistoricalAddresses = false) + public async Task GetInvoice(string storeId, string id, bool inludeAddressData = false) { using (var context = _ContextFactory.CreateContext()) { @@ -272,8 +271,8 @@ namespace BTCPayServer.Services.Invoices .Invoices .Include(o => o.Payments) .Include(o => o.RefundAddresses); - if (includeHistoricalAddresses) - query = query.Include(o => o.HistoricalAddressInvoices); + if (inludeAddressData) + query = query.Include(o => o.HistoricalAddressInvoices).Include(o => o.AddressInvoices); query = query.Where(i => i.Id == id); if (storeId != null) @@ -290,7 +289,12 @@ namespace BTCPayServer.Services.Invoices private InvoiceEntity ToEntity(InvoiceData invoice) { var entity = ToObject(invoice.Blob); - entity.Payments = invoice.Payments.Select(p => ToObject(p.Blob)).ToList(); + entity.Payments = invoice.Payments.Select(p => + { + var paymentEntity = ToObject(p.Blob); + paymentEntity.Accounted = p.Accounted; + return paymentEntity; + }).ToList(); entity.ExceptionStatus = invoice.ExceptionStatus; entity.Status = invoice.Status; entity.RefundMail = invoice.CustomerEmail; @@ -299,6 +303,10 @@ namespace BTCPayServer.Services.Invoices { entity.HistoricalAddresses = invoice.HistoricalAddressInvoices.ToArray(); } + if (invoice.AddressInvoices != null) + { + entity.AvailableAddressHashes = invoice.AddressInvoices.Select(a => a.Address).ToHashSet(); + } return entity; } @@ -412,10 +420,29 @@ namespace BTCPayServer.Services.Invoices await context.Payments.AddAsync(data).ConfigureAwait(false); await context.SaveChangesAsync().ConfigureAwait(false); + AddToTextSearch(invoiceId, receivedCoin.Outpoint.Hash.ToString()); return entity; } } + public async Task UpdatePayments(List payments) + { + if (payments.Count == 0) + return; + using (var context = _ContextFactory.CreateContext()) + { + foreach (var payment in payments) + { + var data = new PaymentData(); + data.Id = payment.Payment.Outpoint.ToString(); + data.Accounted = payment.Payment.Accounted; + context.Attach(data); + context.Entry(data).Property(o => o.Accounted).IsModified = true; + } + await context.SaveChangesAsync().ConfigureAwait(false); + } + } + private T ToObject(byte[] value) { return NBitcoin.JsonConverters.Serializer.ToObject(ZipUtils.Unzip(value), Network); diff --git a/BTCPayServer/Services/Invoices/InvoiceWatcher.cs b/BTCPayServer/Services/Invoices/InvoiceWatcher.cs index 7942491d0..c8541ae30 100644 --- a/BTCPayServer/Services/Invoices/InvoiceWatcher.cs +++ b/BTCPayServer/Services/Invoices/InvoiceWatcher.cs @@ -65,7 +65,7 @@ namespace BTCPayServer.Services.Invoices { try { - var invoice = await _InvoiceRepository.GetInvoice(null, invoiceId).ConfigureAwait(false); + var invoice = await _InvoiceRepository.GetInvoice(null, invoiceId, true).ConfigureAwait(false); if (invoice == null) break; var stateBefore = invoice.Status; @@ -113,28 +113,18 @@ namespace BTCPayServer.Services.Invoices changes = await _ExplorerClient.SyncAsync(strategy, changes, !LongPollingMode, _Cts.Token).ConfigureAwait(false); var utxos = changes.Confirmed.UTXOs.Concat(changes.Unconfirmed.UTXOs).ToArray(); - var invoiceIds = utxos.Select(u => _InvoiceRepository.GetInvoiceIdFromScriptPubKey(u.Output.ScriptPubKey)).ToArray(); - utxos = - utxos - .Where((u, i) => invoiceIds[i].GetAwaiter().GetResult() == invoice.Id) - .ToArray(); - List receivedCoins = new List(); foreach (var received in utxos) - if (received.Output.ScriptPubKey == invoice.DepositAddress.ScriptPubKey) + if (invoice.AvailableAddressHashes.Contains(received.Output.ScriptPubKey.Hash.ToString())) receivedCoins.Add(new Coin(received.Outpoint, received.Output)); var alreadyAccounted = new HashSet(invoice.Payments.Select(p => p.Outpoint)); - BitcoinAddress generatedAddress = null; bool dirtyAddress = false; foreach (var coin in receivedCoins.Where(c => !alreadyAccounted.Contains(c.Outpoint))) { var payment = await _InvoiceRepository.AddPayment(invoice.Id, coin).ConfigureAwait(false); invoice.Payments.Add(payment); - if (coin.ScriptPubKey == invoice.DepositAddress.ScriptPubKey && generatedAddress == null) - { - dirtyAddress = true; - } + dirtyAddress = true; } ////// @@ -147,7 +137,7 @@ namespace BTCPayServer.Services.Invoices if (invoice.Status == "new" || invoice.Status == "expired") { - var totalPaid = invoice.Payments.Select(p => p.Output.Value).Sum(); + var totalPaid = (await GetPaymentsWithTransaction(invoice)).Select(p => p.Payment.Output.Value).Sum(); if (totalPaid >= invoice.GetTotalCryptoDue()) { if (invoice.Status == "new") @@ -196,15 +186,15 @@ namespace BTCPayServer.Services.Invoices var transactions = await GetPaymentsWithTransaction(invoice); if (invoice.SpeedPolicy == SpeedPolicy.HighSpeed) { - transactions = transactions.Where(t => !t.Transaction.Transaction.RBF); + transactions = transactions.Where(t => !t.Transaction.RBF); } else if (invoice.SpeedPolicy == SpeedPolicy.MediumSpeed) { - transactions = transactions.Where(t => t.Transaction.Confirmations >= 1); + transactions = transactions.Where(t => t.Confirmations >= 1); } else if (invoice.SpeedPolicy == SpeedPolicy.LowSpeed) { - transactions = transactions.Where(t => t.Transaction.Confirmations >= 6); + transactions = transactions.Where(t => t.Confirmations >= 6); } var totalConfirmed = transactions.Select(t => t.Payment.Output.Value).Sum(); @@ -227,7 +217,7 @@ namespace BTCPayServer.Services.Invoices if (invoice.Status == "confirmed") { var transactions = await GetPaymentsWithTransaction(invoice); - transactions = transactions.Where(t => t.Transaction.Confirmations >= 6); + transactions = transactions.Where(t => t.Confirmations >= 6); var totalConfirmed = transactions.Select(t => t.Payment.Output.Value).Sum(); if (totalConfirmed >= invoice.GetTotalCryptoDue()) { @@ -237,18 +227,62 @@ namespace BTCPayServer.Services.Invoices needSave = true; } } - return (needSave, changes); } - private async Task> GetPaymentsWithTransaction(InvoiceEntity invoice) + private async Task> GetPaymentsWithTransaction(InvoiceEntity invoice) { - var getPayments = invoice.Payments - .Select(async o => (Payment: o, Transaction: await _ExplorerClient.GetTransactionAsync(o.Outpoint.Hash, _Cts.Token))) - .ToArray(); - await Task.WhenAll(getPayments).ConfigureAwait(false); - var transactions = getPayments.Select(c => (Payment: c.Result.Payment, Transaction: c.Result.Transaction)); - return transactions; + var transactions = await _ExplorerClient.GetTransactions(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) + { + TransactionResult tx; + if (!transactions.TryGetValue(payment.Outpoint.Hash, out tx)) + { + 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]; + + //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); + } + } + } + } + + 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); + return accountedPayments; } TimeSpan _PollInterval; diff --git a/BTCPayServer/TransactionComparer.cs b/BTCPayServer/TransactionComparer.cs new file mode 100644 index 000000000..aeddfb3e9 --- /dev/null +++ b/BTCPayServer/TransactionComparer.cs @@ -0,0 +1,30 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using NBitcoin; + +namespace BTCPayServer +{ + public class TransactionComparer : IEqualityComparer + { + + private static TransactionComparer _Instance = new TransactionComparer(); + public static TransactionComparer Instance + { + get + { + return _Instance; + } + } + public bool Equals(Transaction x, Transaction y) + { + return x.GetHash() == y.GetHash(); + } + + public int GetHashCode(Transaction obj) + { + return obj.GetHash().GetHashCode(); + } + } +}