From cfcaa17e944940da72fead7e7d31cd681936fac9 Mon Sep 17 00:00:00 2001 From: Kukks Date: Wed, 17 Jan 2024 09:07:18 +0100 Subject: [PATCH] micronode --- BTCPayServerPlugins.sln | 20 +- ConfigBuilder/Program.cs | 12 +- .../BTCPayServer.Plugins.MicroNode.csproj | 45 ++ .../MicroAccount.cs | 13 + .../MicroLightningClient.cs | 264 +++++++ .../MicroLightningConnectionStringHandler.cs | 67 ++ .../MicroNodeContext.cs | 201 +++++ .../MicroNodeContextFactory.cs | 23 + .../MicroNodeController.cs | 208 +++++ .../MicroNodePlugin.cs | 40 + .../MicroNodeService.cs | 737 ++++++++++++++++++ .../MicroNodeSettings.cs | 11 + .../MicroNodeStartupTask.cs | 37 + .../MicroNodeStoreSettings.cs | 7 + .../MicroTransaction.cs | 21 + .../20240115112915_Initial.Designer.cs | 130 +++ .../Migrations/20240115112915_Initial.cs | 113 +++ .../MicroNodeContextModelSnapshot.cs | 127 +++ .../Views/MicroNode/Configure.cshtml | 96 +++ .../Views/MicroNode/ConfigureMaster.cshtml | 102 +++ .../MicroNode/LNPaymentMethodSetupTab.cshtml | 66 ++ .../LNPaymentMethodSetupTabhead.cshtml | 19 + .../Shared/MicroNode/MicroNodeNav.cshtml | 31 + .../Views/_ViewImports.cshtml | 9 + 24 files changed, 2388 insertions(+), 11 deletions(-) create mode 100644 Plugins/BTCPayServer.Plugins.MicroNode/BTCPayServer.Plugins.MicroNode.csproj create mode 100644 Plugins/BTCPayServer.Plugins.MicroNode/MicroAccount.cs create mode 100644 Plugins/BTCPayServer.Plugins.MicroNode/MicroLightningClient.cs create mode 100644 Plugins/BTCPayServer.Plugins.MicroNode/MicroLightningConnectionStringHandler.cs create mode 100644 Plugins/BTCPayServer.Plugins.MicroNode/MicroNodeContext.cs create mode 100644 Plugins/BTCPayServer.Plugins.MicroNode/MicroNodeContextFactory.cs create mode 100644 Plugins/BTCPayServer.Plugins.MicroNode/MicroNodeController.cs create mode 100644 Plugins/BTCPayServer.Plugins.MicroNode/MicroNodePlugin.cs create mode 100644 Plugins/BTCPayServer.Plugins.MicroNode/MicroNodeService.cs create mode 100644 Plugins/BTCPayServer.Plugins.MicroNode/MicroNodeSettings.cs create mode 100644 Plugins/BTCPayServer.Plugins.MicroNode/MicroNodeStartupTask.cs create mode 100644 Plugins/BTCPayServer.Plugins.MicroNode/MicroNodeStoreSettings.cs create mode 100644 Plugins/BTCPayServer.Plugins.MicroNode/MicroTransaction.cs create mode 100644 Plugins/BTCPayServer.Plugins.MicroNode/Migrations/20240115112915_Initial.Designer.cs create mode 100644 Plugins/BTCPayServer.Plugins.MicroNode/Migrations/20240115112915_Initial.cs create mode 100644 Plugins/BTCPayServer.Plugins.MicroNode/Migrations/MicroNodeContextModelSnapshot.cs create mode 100644 Plugins/BTCPayServer.Plugins.MicroNode/Views/MicroNode/Configure.cshtml create mode 100644 Plugins/BTCPayServer.Plugins.MicroNode/Views/MicroNode/ConfigureMaster.cshtml create mode 100644 Plugins/BTCPayServer.Plugins.MicroNode/Views/Shared/MicroNode/LNPaymentMethodSetupTab.cshtml create mode 100644 Plugins/BTCPayServer.Plugins.MicroNode/Views/Shared/MicroNode/LNPaymentMethodSetupTabhead.cshtml create mode 100644 Plugins/BTCPayServer.Plugins.MicroNode/Views/Shared/MicroNode/MicroNodeNav.cshtml create mode 100644 Plugins/BTCPayServer.Plugins.MicroNode/Views/_ViewImports.cshtml diff --git a/BTCPayServerPlugins.sln b/BTCPayServerPlugins.sln index 49c4a6d..3478fb4 100644 --- a/BTCPayServerPlugins.sln +++ b/BTCPayServerPlugins.sln @@ -55,10 +55,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BTCPayServer.Plugins.Dynami EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BTCPayServer.Plugins.Bringin", "Plugins\BTCPayServer.Plugins.Bringin\BTCPayServer.Plugins.Bringin.csproj", "{D4AFEC95-64D4-4FC4-9AE4-B82F4C6D6E29}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BTCPayServer.Plugins.LDK", "Plugins\BTCPayServer.Plugins.LDK\BTCPayServer.Plugins.LDK.csproj", "{661DBF95-0F60-49C0-829A-C5997B44AF60}" -EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BTCPayServer.Plugins.Blink", "Plugins\BTCPayServer.Plugins.Blink\BTCPayServer.Plugins.Blink.csproj", "{C0714C5C-1798-4576-9892-3CE097FDE76F}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BTCPayServer.Plugins.MicroNode", "Plugins\BTCPayServer.Plugins.MicroNode\BTCPayServer.Plugins.MicroNode.csproj", "{95626F3B-7722-4AE7-9C12-EDB1E58687E2}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -267,14 +267,6 @@ Global {5934F898-00B1-4781-BD18-04DF8685BC76}.Altcoins-Debug|Any CPU.Build.0 = Debug|Any CPU {5934F898-00B1-4781-BD18-04DF8685BC76}.Altcoins-Release|Any CPU.ActiveCfg = Debug|Any CPU {5934F898-00B1-4781-BD18-04DF8685BC76}.Altcoins-Release|Any CPU.Build.0 = Debug|Any CPU - {661DBF95-0F60-49C0-829A-C5997B44AF60}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {661DBF95-0F60-49C0-829A-C5997B44AF60}.Debug|Any CPU.Build.0 = Debug|Any CPU - {661DBF95-0F60-49C0-829A-C5997B44AF60}.Release|Any CPU.ActiveCfg = Release|Any CPU - {661DBF95-0F60-49C0-829A-C5997B44AF60}.Release|Any CPU.Build.0 = Release|Any CPU - {661DBF95-0F60-49C0-829A-C5997B44AF60}.Altcoins-Debug|Any CPU.ActiveCfg = Debug|Any CPU - {661DBF95-0F60-49C0-829A-C5997B44AF60}.Altcoins-Debug|Any CPU.Build.0 = Debug|Any CPU - {661DBF95-0F60-49C0-829A-C5997B44AF60}.Altcoins-Release|Any CPU.ActiveCfg = Debug|Any CPU - {661DBF95-0F60-49C0-829A-C5997B44AF60}.Altcoins-Release|Any CPU.Build.0 = Debug|Any CPU {C0714C5C-1798-4576-9892-3CE097FDE76F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {C0714C5C-1798-4576-9892-3CE097FDE76F}.Debug|Any CPU.Build.0 = Debug|Any CPU {C0714C5C-1798-4576-9892-3CE097FDE76F}.Release|Any CPU.ActiveCfg = Release|Any CPU @@ -283,6 +275,14 @@ Global {C0714C5C-1798-4576-9892-3CE097FDE76F}.Altcoins-Debug|Any CPU.Build.0 = Debug|Any CPU {C0714C5C-1798-4576-9892-3CE097FDE76F}.Altcoins-Release|Any CPU.ActiveCfg = Debug|Any CPU {C0714C5C-1798-4576-9892-3CE097FDE76F}.Altcoins-Release|Any CPU.Build.0 = Debug|Any CPU + {95626F3B-7722-4AE7-9C12-EDB1E58687E2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {95626F3B-7722-4AE7-9C12-EDB1E58687E2}.Debug|Any CPU.Build.0 = Debug|Any CPU + {95626F3B-7722-4AE7-9C12-EDB1E58687E2}.Release|Any CPU.ActiveCfg = Release|Any CPU + {95626F3B-7722-4AE7-9C12-EDB1E58687E2}.Release|Any CPU.Build.0 = Release|Any CPU + {95626F3B-7722-4AE7-9C12-EDB1E58687E2}.Altcoins-Debug|Any CPU.ActiveCfg = Debug|Any CPU + {95626F3B-7722-4AE7-9C12-EDB1E58687E2}.Altcoins-Debug|Any CPU.Build.0 = Debug|Any CPU + {95626F3B-7722-4AE7-9C12-EDB1E58687E2}.Altcoins-Release|Any CPU.ActiveCfg = Debug|Any CPU + {95626F3B-7722-4AE7-9C12-EDB1E58687E2}.Altcoins-Release|Any CPU.Build.0 = Debug|Any CPU EndGlobalSection GlobalSection(NestedProjects) = preSolution {B19C9F52-DC47-466D-8B5C-2D202B7B003F} = {9E04ECE9-E304-4FF2-9CBC-83256E6C6962} diff --git a/ConfigBuilder/Program.cs b/ConfigBuilder/Program.cs index 8eb5504..89411a5 100644 --- a/ConfigBuilder/Program.cs +++ b/ConfigBuilder/Program.cs @@ -10,8 +10,18 @@ foreach (var plugin in plugins) var assemblyConfigurationAttribute = typeof(Program).Assembly.GetCustomAttribute(); var buildConfigurationName = assemblyConfigurationAttribute?.Configuration; var x = Directory.GetDirectories(Path.Combine(plugin, "bin")); + + var f = $"{Path.GetFullPath(plugin)}/bin/{buildConfigurationName}/net8.0/{Path.GetFileName(plugin)}.dll"; + if (File.Exists(f)) + p += $"{f};"; + else + { + + f = $"{Path.GetFullPath(plugin)}/bin/Debug/net8.0/{Path.GetFileName(plugin)}.dll"; + if (File.Exists(f)) + p += $"{f};"; + } - p += $"{Path.GetFullPath(plugin)}/bin/{buildConfigurationName}/net8.0/{Path.GetFileName(plugin)}.dll;"; // if (x.Any(s => s.EndsWith("Altcoins-Debug"))) // { // p += $"{Path.GetFullPath(plugin)}/bin/Altcoins-Debug/net8.0/{Path.GetFileName(plugin)}.dll;"; diff --git a/Plugins/BTCPayServer.Plugins.MicroNode/BTCPayServer.Plugins.MicroNode.csproj b/Plugins/BTCPayServer.Plugins.MicroNode/BTCPayServer.Plugins.MicroNode.csproj new file mode 100644 index 0000000..26249bf --- /dev/null +++ b/Plugins/BTCPayServer.Plugins.MicroNode/BTCPayServer.Plugins.MicroNode.csproj @@ -0,0 +1,45 @@ + + + + + + net8.0 + 12 + + + + + MicroNode + Micro ln node. + 1.0.0 + true + + + + true + false + true + + + + + + StaticWebAssetsEnabled=false + false + runtime;native;build;buildTransitive;contentFiles + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Plugins/BTCPayServer.Plugins.MicroNode/MicroAccount.cs b/Plugins/BTCPayServer.Plugins.MicroNode/MicroAccount.cs new file mode 100644 index 0000000..e3ef23a --- /dev/null +++ b/Plugins/BTCPayServer.Plugins.MicroNode/MicroAccount.cs @@ -0,0 +1,13 @@ +using System.Collections.Generic; + +namespace BTCPayServer.Plugins.MicroNode; + +public class MicroAccount +{ + public string Key { get; set; } + public long Balance { get; set; } + public long BalanceCheckpoint { get; set; } + public string MasterStoreId { get; set; } + + public List Transactions { get; set; } = new List(); +} \ No newline at end of file diff --git a/Plugins/BTCPayServer.Plugins.MicroNode/MicroLightningClient.cs b/Plugins/BTCPayServer.Plugins.MicroNode/MicroLightningClient.cs new file mode 100644 index 0000000..36f28c8 --- /dev/null +++ b/Plugins/BTCPayServer.Plugins.MicroNode/MicroLightningClient.cs @@ -0,0 +1,264 @@ +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using BTCPayServer.Lightning; +using NBitcoin; + +namespace BTCPayServer.Plugins.MicroNode; + +public class MicroLightningClient:ILightningClient +{ + private readonly ILightningClient _innerClient; + private readonly MicroNodeService _microNodeService; + private readonly Network _network; + private readonly string _key; + + public MicroLightningClient(ILightningClient innerClient,MicroNodeService microNodeService, Network network ,string key) + { + _innerClient = innerClient; + _microNodeService = microNodeService; + _network = network; + _key = key; + } + + public override string ToString() + { + return $"type=micro;key={_key}"; + } + + public async Task GetInvoice(string invoiceId, CancellationToken cancellation = new CancellationToken()) + { + var result = await _microNodeService.MatchRecord(_key, invoiceId); + if(result is null) + { + return null; + } + return await _innerClient.GetInvoice(invoiceId, cancellation); + } + + public async Task GetInvoice(uint256 paymentHash, CancellationToken cancellation = new CancellationToken()) + { + return await GetInvoice(paymentHash.ToString(), cancellation); + } + + public async Task ListInvoices(CancellationToken cancellation = new CancellationToken()) + { + var result = await _innerClient.ListInvoices(cancellation); + var matchedRecords = await _microNodeService.MatchRecords(_key, result?.Select(r => r.Id).ToArray()); + var ids = matchedRecords.Select(r => r.Id).ToArray(); + return result.Where(r => ids.Contains(r.Id)).ToArray(); + } + + public async Task ListInvoices(ListInvoicesParams request, CancellationToken cancellation = new CancellationToken()) + { + var result = await _innerClient.ListInvoices(request, cancellation); + var matchedRecords = await _microNodeService.MatchRecords(_key, result?.Select(r => r.Id).ToArray()); + var ids = matchedRecords.Select(r => r.Id).ToArray(); + return result.Where(r => ids.Contains(r.Id)).ToArray(); + } + + public async Task GetPayment(string paymentHash, CancellationToken cancellation = new CancellationToken()) + { + var result = await _microNodeService.MatchRecord(_key, paymentHash); + if(result is null) + { + return null; + } + // + // if(result.Type == "InternalPayment") + // { + // return FromInternalPayment(result); + // } + var payment = await _innerClient.GetPayment(paymentHash, cancellation); + await _microNodeService.UpsertRecord(_key, payment); + return payment; + } + + public async Task ListPayments(CancellationToken cancellation = new CancellationToken()) + { + var result = await _innerClient.ListPayments(cancellation); + var matchedRecords = await _microNodeService.MatchRecords(_key, result?.Select(r => r.Id).ToArray()); + + // var internalPayments = matchedRecords.Where(r => r.Type == "InternalPayment").Select(FromInternalPayment).ToArray(); + var ids = matchedRecords.Select(r => r.Id).ToArray(); + var payments= result.Where(r => ids.Contains(r.Id)).ToArray(); + await _microNodeService.UpsertRecords(_key, payments); + return payments; + // return payments.Concat(internalPayments).ToArray(); + } + + public async Task ListPayments(ListPaymentsParams request, CancellationToken cancellation = new CancellationToken()) + { + var result = await _innerClient.ListPayments(request, cancellation); + var matchedRecords = await _microNodeService.MatchRecords(_key, result?.Select(r => r.Id).ToArray()); + // var internalPayments = matchedRecords.Where(r => r.Type == "InternalPayment").Select(FromInternalPayment).ToArray(); + var ids = matchedRecords.Select(r => r.Id).ToArray(); + var payments= result.Where(r => ids.Contains(r.Id)).ToArray(); + await _microNodeService.UpsertRecords(_key, payments); + return payments; + // return payments.Concat(internalPayments).ToArray(); + } + + // public LightningPayment FromInternalPayment(MicroTransaction transaction) + // { + // if(transaction.Type != "InternalPayment") + // throw new InvalidOperationException(); + // return new LightningPayment() + // { + // //balance is in - so convert it to positive + // Amount = new LightMoney(Math.Abs(transaction.Amount)), + // Fee = LightMoney.Zero, + // Id = transaction.Id, + // Status = !transaction.Active && transaction.Accounted ? LightningPaymentStatus.Complete : + // transaction.Active ? LightningPaymentStatus.Pending : LightningPaymentStatus.Failed, + // AmountSent = new LightMoney(Math.Abs(transaction.Amount)), + // PaymentHash = transaction.Id + // }; + // } + + public async Task CreateInvoice(LightMoney amount, string description, TimeSpan expiry, + CancellationToken cancellation = new CancellationToken()) + { + var invoice = await _innerClient.CreateInvoice(amount, description, expiry, cancellation); + await _microNodeService.UpsertRecord(_key, invoice); + return invoice; + } + + public async Task CreateInvoice(CreateInvoiceParams createInvoiceRequest, CancellationToken cancellation = new CancellationToken()) + { + var invoice = await _innerClient.CreateInvoice(createInvoiceRequest, cancellation); + await _microNodeService.UpsertRecord(_key, invoice); + return invoice; + } + + + public class MicroListener: ILightningInvoiceListener + { + private readonly ILightningInvoiceListener _inner; + private readonly MicroNodeService _microNodeService; + private readonly string _key; + + public MicroListener(ILightningInvoiceListener inner, MicroNodeService microNodeService, string key) + { + _inner = inner; + _microNodeService = microNodeService; + _key = key; + } + + public async Task WaitInvoice(CancellationToken cancellation) + { + while (true) + { + var invoice = await _inner.WaitInvoice(cancellation); + var record = await _microNodeService.MatchRecord(_key, invoice.Id); + if(record is null) + { + continue; + } + await _microNodeService.UpsertRecord(_key, invoice); + return invoice; + } + } + + public void Dispose() + { + _inner.Dispose(); + } + } + + + public async Task Listen(CancellationToken cancellation = new CancellationToken()) + { + return new MicroListener(await _innerClient.Listen(cancellation), _microNodeService, _key); + } + + public async Task GetInfo(CancellationToken cancellation = new CancellationToken()) + { + var info = await _innerClient.GetInfo(cancellation); + return info; + } + + public async Task GetBalance(CancellationToken cancellation = new CancellationToken()) + { + return new LightningNodeBalance(null, new OffchainBalance() + { + Local = await _microNodeService.GetBalance(_key, cancellation) + }); + + } + + public async Task Pay(PayInvoiceParams payParams, CancellationToken cancellation = new CancellationToken()) + { + return await Pay(null, payParams, cancellation); + } + + public async Task Pay(string bolt11, PayInvoiceParams payParams, CancellationToken cancellation = new CancellationToken()) + { + var invoice = BOLT11PaymentRequest.Parse(bolt11, _network); + var id = payParams.PaymentHash?.ToString() ?? invoice.PaymentHash.ToString(); + var amount = payParams?.Amount?? invoice.MinimumAmount; + var record = await _microNodeService.InitiatePayment(_key, id,amount, + cancellation); + var result = await _innerClient.Pay(bolt11,payParams, cancellation); + await _microNodeService.UpsertRecord(_key, FromPayResponse(result, record)); + return result; + } + + public async Task Pay(string bolt11, CancellationToken cancellation = new CancellationToken()) + { + return await Pay(bolt11, null, cancellation); + } + + private LightningPayment FromPayResponse(PayResponse payResponse, MicroTransaction transaction) + { + return new LightningPayment() + { + Id = transaction.Id, + PaymentHash = transaction.Id, + Preimage = payResponse.Details?.Preimage?.ToString(), + Fee = payResponse.Details?.FeeAmount, + Amount = LightMoney.MilliSatoshis(Math.Abs(transaction.Amount)), + CreatedAt = null, + Status = payResponse.Result switch + { + PayResult.Ok => LightningPaymentStatus.Complete, + PayResult.Unknown => LightningPaymentStatus.Unknown, + PayResult.CouldNotFindRoute => LightningPaymentStatus.Failed, + PayResult.Error => LightningPaymentStatus.Failed, + _ => LightningPaymentStatus.Unknown + }, + AmountSent = transaction.Amount + }; + } + + public Task OpenChannel(OpenChannelRequest openChannelRequest, CancellationToken cancellation = new CancellationToken()) + { + throw new NotSupportedException(); + } + + public Task GetDepositAddress(CancellationToken cancellation = new CancellationToken()) + { + throw new NotSupportedException(); + } + + public Task ConnectTo(NodeInfo nodeInfo, CancellationToken cancellation = new CancellationToken()) + { + throw new NotSupportedException(); + } + + public async Task CancelInvoice(string invoiceId, CancellationToken cancellation = new CancellationToken()) + { + var result = await _microNodeService.MatchRecord(_key, invoiceId); + if(result is null) + { + return; + } + await _innerClient.CancelInvoice(invoiceId, cancellation); + } + + public Task ListChannels(CancellationToken cancellation = new CancellationToken()) + { + throw new NotSupportedException(); + } +} \ No newline at end of file diff --git a/Plugins/BTCPayServer.Plugins.MicroNode/MicroLightningConnectionStringHandler.cs b/Plugins/BTCPayServer.Plugins.MicroNode/MicroLightningConnectionStringHandler.cs new file mode 100644 index 0000000..ab3bdc0 --- /dev/null +++ b/Plugins/BTCPayServer.Plugins.MicroNode/MicroLightningConnectionStringHandler.cs @@ -0,0 +1,67 @@ +using System; +using BTCPayServer.Lightning; +using Microsoft.Extensions.DependencyInjection; +using NBitcoin; + +namespace BTCPayServer.Plugins.MicroNode; + +public class MicroLightningConnectionStringHandler : ILightningConnectionStringHandler +{ + private readonly IServiceProvider _serviceProvider; + private readonly BTCPayNetworkProvider _networkProvider; + + public MicroLightningConnectionStringHandler( + IServiceProvider serviceProvider, + BTCPayNetworkProvider networkProvider) + { + _serviceProvider = serviceProvider; + _networkProvider = networkProvider; + } + + public ILightningClient Create(string connectionString, Network network, out string error) + { + var kv = LightningConnectionStringHelper.ExtractValues(connectionString, out var type); + if (type != "micro" || kv is null) + { + error = null; + return null; + } + + if (_networkProvider.BTC.NBitcoinNetwork != network) + { + error = "Invalid network"; + return null; + } + + if (!kv.TryGetValue("key", out var key)) + { + error = "key is missing"; + return null; + } + var microNodeService = _serviceProvider.GetService(); + + var settings = microNodeService.GetMasterSettingsFromKey(key).GetAwaiter().GetResult(); + + if (settings is null) + { + error = "key is invalid"; + return null; + } + + if (settings.Value.Item1.Enabled is not true) + { + error = "MicroBank is not enabled"; + return null; + } + + var lightningClient = microNodeService.GetMasterLightningClient(settings.Value.Item2).GetAwaiter().GetResult(); + if (lightningClient is null) + { + error = "Lightning node not available"; + return null; + } + + error = null; + return new MicroLightningClient(lightningClient, microNodeService, network, key); + } +} \ No newline at end of file diff --git a/Plugins/BTCPayServer.Plugins.MicroNode/MicroNodeContext.cs b/Plugins/BTCPayServer.Plugins.MicroNode/MicroNodeContext.cs new file mode 100644 index 0000000..6bd307c --- /dev/null +++ b/Plugins/BTCPayServer.Plugins.MicroNode/MicroNodeContext.cs @@ -0,0 +1,201 @@ +using Laraue.EfCoreTriggers.Common.Extensions; +using Laraue.EfCoreTriggers.PostgreSql.Extensions; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Design; + +namespace BTCPayServer.Plugins.MicroNode; + +public class DesignTimeDbContextFactory : IDesignTimeDbContextFactory +{ + public MicroNodeContext CreateDbContext(string[] args) + { + DbContextOptionsBuilder builder = new DbContextOptionsBuilder(); + + // FIXME: Somehow the DateTimeOffset column types get messed up when not using Postgres + // https://docs.microsoft.com/en-us/ef/core/managing-schemas/migrations/providers?tabs=dotnet-core-cli + builder.UseNpgsql("User ID=postgres;Host=127.0.0.1;Port=39372;Database=designtimebtcpay"); + builder.UsePostgreSqlTriggers(); + return new MicroNodeContext(builder.Options); + } +} + +public class MicroNodeContext : DbContext +{ + public DbSet MicroTransactions { get; set; } + public DbSet MicroAccounts { get; set; } + + + public MicroNodeContext() + { + } + + public MicroNodeContext(DbContextOptions builderOptions) : base(builderOptions) + { + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + modelBuilder.HasDefaultSchema("BTCPayServer.Plugins.MicroNode"); + modelBuilder.Entity() + .HasKey(t => new {t.Id, t.AccountId}); + modelBuilder.Entity() + .HasKey(t => t.Key); + modelBuilder.Entity() + .HasMany(account => account.Transactions) + .WithOne(transaction => transaction.Account) + .HasForeignKey(transaction => transaction.AccountId); + + modelBuilder.Entity() + .HasOne().WithMany(transaction => transaction.Dependents) + .HasForeignKey(transaction => new {transaction.DependentId, transaction.AccountId}) + .HasPrincipalKey(transaction => new {transaction.Id, transaction.AccountId}) + .OnDelete(DeleteBehavior.Cascade); + + modelBuilder.Entity() + .AfterInsert(trigger => trigger + .Action(action => + { + action.Condition(@ref => @ref.New.Accounted) + .Update( + (tableRefs, microAccount) => + microAccount.Key == + tableRefs.New.AccountId, // Will be updated entities with matched condition + (tableRefs, oldBalance) => new MicroAccount + {Balance = oldBalance.Balance + tableRefs.New.Amount}); + }) + .Action(action => + { + action.Update( + (tableRefs, microAccount) => + microAccount.Key == + tableRefs.New.AccountId, // Will be updated entities with matched condition + (tableRefs, oldBalance) => new MicroAccount + {BalanceCheckpoint = oldBalance.BalanceCheckpoint + 1}); + }) + .Action(action => + { + action.Update( + (tableRefs, tx) => + tableRefs.New.Id == tx.DependentId && tx.AccountId == + tableRefs.New.AccountId, // Will be updated entities with matched condition + (tableRefs, tx) => new MicroTransaction() + {Accounted = tableRefs.New.Accounted, Active = tableRefs.New.Active}); + })); + + modelBuilder.Entity() + .AfterDelete(trigger => trigger + .Action(action => + { + action.Condition(@ref => @ref.Old.Accounted) + .Update( + (tableRefs, microAccount) => + microAccount.Key == + tableRefs.Old.AccountId, // Will be updated entities with matched condition + (tableRefs, oldBalance) => new MicroAccount + {Balance = oldBalance.Balance - tableRefs.Old.Amount}); + }) + .Action(action => + { + action.Update( + (tableRefs, microAccount) => + microAccount.Key == + tableRefs.Old.AccountId, // Will be updated entities with matched condition + (tableRefs, oldBalance) => new MicroAccount + {BalanceCheckpoint = oldBalance.BalanceCheckpoint + 1}); + })); + + modelBuilder.Entity() + .AfterUpdate(trigger => trigger + .Action(action => + { + action.Update( + (tableRefs, microAccount) => + microAccount.Key == + tableRefs.Old.AccountId, // Will be updated entities with matched condition + (tableRefs, oldBalance) => new MicroAccount + {BalanceCheckpoint = oldBalance.BalanceCheckpoint + 1}); + }) + .Action(action => + { + action.Update( + (tableRefs, tx) => + tableRefs.Old.Id == tx.DependentId && tx.AccountId == + tableRefs.New.AccountId, // Will be updated entities with matched condition + (tableRefs, tx) => new MicroTransaction() + { + Accounted = tableRefs.New.Accounted, Active = tableRefs.New.Active, + DependentId = tableRefs.New.Id + }); + }) + + // Scenario 1: Transaction is newly accounted (not previously accounted) + .Action(action => + { + action.Condition(@ref => @ref.New.Accounted && !@ref.Old.Accounted) + .Update( + (tableRefs, microAccount) => microAccount.Key == tableRefs.Old.AccountId, + (tableRefs, oldBalance) => new MicroAccount + { + Balance = oldBalance.Balance + tableRefs.New.Amount + }); + }) + + // Scenario 2: Transaction was previously accounted and remains accounted (with a potential change in amount) + .Action(action => + { + action.Condition(@ref => + @ref.New.Accounted && @ref.Old.Accounted && @ref.Old.Amount != @ref.New.Amount) + .Update( + (tableRefs, microAccount) => microAccount.Key == tableRefs.Old.AccountId, + (tableRefs, oldBalance) => new MicroAccount + { + Balance = oldBalance.Balance - tableRefs.Old.Amount + tableRefs.New.Amount + }); + }) + + // Scenario 3: Transaction is unaccounted (previously accounted) + .Action(action => + { + action.Condition(@ref => !@ref.New.Accounted && @ref.Old.Accounted) + .Update( + (tableRefs, microAccount) => microAccount.Key == tableRefs.Old.AccountId, + (tableRefs, oldBalance) => new MicroAccount + { + Balance = oldBalance.Balance - tableRefs.Old.Amount + }); + }) + ); + +// Scenario 4: Transaction state remains unchanged (neither accounted nor unaccounted) +// Assuming no update is required in this case + + + //unfortunately setting the balance this way is too complicated to generate the query + // action.Condition(@ref => @ref.Old.Accounted != @ref.New.Accounted || @ref.Old.Amount != @ref.New.Amount) + // .Update( + // (tableRefs, microAccount) => + // microAccount.Id == + // tableRefs.Old.AccountId, // Will be updated entities with matched condition + // + // // we update the balance with a few dimensions: + // // if the transaction was just accounted, we add the amount + // // if the transaction was already accounted, we remove the old amount and add the new amount + // // if the transaction was just unaccounted, we remove the amount + // (tableRefs, oldBalance) => new MicroAccount + // { + // Balance = + // tableRefs.New.Accounted && !tableRefs.Old.Accounted + // ? + // oldBalance.Balance + tableRefs.New.Amount + // : + // tableRefs.New.Accounted && tableRefs.Old.Accounted + // ? oldBalance.Balance - tableRefs.Old.Amount + tableRefs.New.Amount + // : + // !tableRefs.New.Accounted && tableRefs.Old.Accounted + // ? oldBalance.Balance - tableRefs.Old.Amount + // : oldBalance.Balance + // }); + // })); // New values for matched entities. + } +} \ No newline at end of file diff --git a/Plugins/BTCPayServer.Plugins.MicroNode/MicroNodeContextFactory.cs b/Plugins/BTCPayServer.Plugins.MicroNode/MicroNodeContextFactory.cs new file mode 100644 index 0000000..bf62b45 --- /dev/null +++ b/Plugins/BTCPayServer.Plugins.MicroNode/MicroNodeContextFactory.cs @@ -0,0 +1,23 @@ +using BTCPayServer.Abstractions.Contracts; +using BTCPayServer.Abstractions.Models; +using Laraue.EfCoreTriggers.PostgreSql.Extensions; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; + +namespace BTCPayServer.Plugins.MicroNode; + +public class MicroNodeContextFactory : BaseDbContextFactory +{ + public MicroNodeContextFactory(IOptions options) : base(options, "BTCPayServer.Plugins.MicroNode") + { + } + + public override MicroNodeContext CreateContext() + { + var builder = new DbContextOptionsBuilder(); + ConfigureBuilder(builder); + builder.UsePostgreSqlTriggers(); + + return new MicroNodeContext(builder.Options); + } +} \ No newline at end of file diff --git a/Plugins/BTCPayServer.Plugins.MicroNode/MicroNodeController.cs b/Plugins/BTCPayServer.Plugins.MicroNode/MicroNodeController.cs new file mode 100644 index 0000000..952b7c0 --- /dev/null +++ b/Plugins/BTCPayServer.Plugins.MicroNode/MicroNodeController.cs @@ -0,0 +1,208 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using BTCPayServer.Abstractions.Constants; +using BTCPayServer.Abstractions.Extensions; +using BTCPayServer.Abstractions.Models; +using BTCPayServer.Client; +using BTCPayServer.Data; +using BTCPayServer.Payments; +using BTCPayServer.Payments.Lightning; +using BTCPayServer.Services.Stores; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace BTCPayServer.Plugins.MicroNode; + +[Route("plugins/micronode")] +public class MicroNodeController : Controller +{ + private readonly MicroNodeService _microNodeService; + private readonly StoreRepository _storeRepository; + private readonly BTCPayNetworkProvider _networkProvider; + private readonly IAuthorizationService _authorizationService; + + public MicroNodeController(MicroNodeService microNodeService, StoreRepository storeRepository, + BTCPayNetworkProvider networkProvider, IAuthorizationService authorizationService) + { + _microNodeService = microNodeService; + _storeRepository = storeRepository; + _networkProvider = networkProvider; + _authorizationService = authorizationService; + } + + [HttpGet("configure/{storeId}")] + [Authorize(AuthenticationSchemes = AuthenticationSchemes.Cookie, Policy = Policies.CanModifyStoreSettings)] + public async Task Configure(string storeId) + { + var result = await _microNodeService.GetStoreSettings(storeId); + if (result is not null) + { + var xy = await _microNodeService.GetMasterSettingsFromKey(result.Key); + if (xy is not null) + { + HttpContext.Items.Add("MasterStoreId", xy.Value.Item2); + } + } + + return View(result); + } + + + [HttpPost("configure/{storeId}")] + [Authorize(AuthenticationSchemes = AuthenticationSchemes.Cookie, Policy = Policies.CanModifyStoreSettings)] + public async Task Configure(string storeId, string command, MicroNodeStoreSettings settings, + string? masterStoreId) + { + var store = HttpContext.GetStoreData(); + var network = _networkProvider.GetNetwork("BTC"); + if (network is null) + { + return NotFound(); + } + + if (masterStoreId == storeId) + { + ModelState.AddModelError("masterStoreId", "Master cannot be the same as this store"); + return View(settings); + } + + var existing = store.GetSupportedPaymentMethods(_networkProvider).OfType() + .FirstOrDefault(method => + method.PaymentId.PaymentType == LightningPaymentType.Instance && + method.PaymentId.CryptoCode == network.CryptoCode); + var isSet = settings?.Key is not null; + settings ??= new MicroNodeStoreSettings(); + settings.Key ??= Guid.NewGuid().ToString(); + var mlc = new MicroLightningClient(null, _microNodeService, network.NBitcoinNetwork,settings.Key); + var isStoreSetToThisMicro = existing?.GetExternalLightningUrl() == mlc.ToString(); + + switch (command) + { + case "save": + + if (!ModelState.IsValid) + { + return View(settings); + } + + if (!isSet && masterStoreId is null) + { + ModelState.AddModelError(nameof(settings.Key), "A master was not selected"); + return View(settings); + } + + if (!isSet) + { + var masterSettings = await _microNodeService.GetMasterSettings(masterStoreId); + if (masterSettings is null) + { + ModelState.AddModelError(nameof(settings.Key), "The master is not valid"); + return View(settings); + } + + if (!masterSettings.Enabled) + { + ModelState.AddModelError(nameof(settings.Key), "The master is not enabled"); + return View(settings); + } + + if (masterSettings.AdminOnly && + !(await _authorizationService.AuthorizeAsync(User, Policies.CanModifyServerSettings)).Succeeded) + { + ModelState.AddModelError(nameof(settings.Key), + "The master is admin only and you are not an admin"); + return View(settings); + } + + } + + + existing ??= new LightningSupportedPaymentMethod() + { + CryptoCode = "BTC" + }; + existing.SetLightningUrl(mlc); + store.SetSupportedPaymentMethod(existing); + + + await _microNodeService.Set(storeId, settings, masterStoreId); + await _storeRepository.UpdateStore(store); + TempData.SetStatusMessageModel(new StatusMessageModel() + { + Severity = StatusMessageModel.StatusSeverity.Success, + Message = "MicroNode Set" + }); + + + return RedirectToAction(nameof(Configure), new {storeId}); + case "clear": + var ss = await _microNodeService.GetStoreSettings(storeId); + if (ss is null) + { + return RedirectToAction(nameof(Configure), new {storeId}); + } + + if (isStoreSetToThisMicro) + { + store.SetSupportedPaymentMethod(existing.PaymentId, null); + await _storeRepository.UpdateStore(store); + } + + await _microNodeService.SetMaster(storeId, null); + TempData.SetStatusMessageModel(new StatusMessageModel() + { + Severity = StatusMessageModel.StatusSeverity.Success, + Message = "MicroNode Cleared" + }); + break; + } + + return RedirectToAction(nameof(Configure), new {storeId}); + } + + + [HttpGet("configure-master/{storeId}")] + [Authorize(AuthenticationSchemes = AuthenticationSchemes.Cookie, Policy = Policies.CanModifyServerSettings)] + public async Task ConfigureMaster(string storeId) + { + var result = await _microNodeService.GetMasterSettings(storeId); + return View(result); + } + + [HttpPost("configure-master/{storeId}")] + [Authorize(AuthenticationSchemes = AuthenticationSchemes.Cookie, Policy = Policies.CanModifyServerSettings)] + public async Task ConfigureMaster(string storeId, string command, MicroNodeSettings settings) + { + var store = HttpContext.GetStoreData(); + var network = _networkProvider.GetNetwork("BTC"); + if (network is null) + { + return NotFound(); + } + + if (command == "clear") + { + await _microNodeService.SetMaster(storeId, (MicroNodeSettings) null); + TempData.SetStatusMessageModel(new StatusMessageModel() + { + Severity = StatusMessageModel.StatusSeverity.Success, + Message = "MicroNode Master Cleared" + }); + return RedirectToAction(nameof(ConfigureMaster), new {storeId}); + } + + if (!ModelState.IsValid) + { + return View(settings); + } + await _microNodeService.SetMaster(storeId, settings); + TempData.SetStatusMessageModel(new StatusMessageModel() + { + Severity = StatusMessageModel.StatusSeverity.Success, + Message = "MicroNode Master Update" + }); + return RedirectToAction(nameof(ConfigureMaster), new {storeId}); + + } +} \ No newline at end of file diff --git a/Plugins/BTCPayServer.Plugins.MicroNode/MicroNodePlugin.cs b/Plugins/BTCPayServer.Plugins.MicroNode/MicroNodePlugin.cs new file mode 100644 index 0000000..93b6187 --- /dev/null +++ b/Plugins/BTCPayServer.Plugins.MicroNode/MicroNodePlugin.cs @@ -0,0 +1,40 @@ +using BTCPayServer.Abstractions.Contracts; +using BTCPayServer.Abstractions.Models; +using BTCPayServer.Abstractions.Services; +using BTCPayServer.Lightning; +using Laraue.EfCoreTriggers.PostgreSql.Extensions; +using Microsoft.Extensions.DependencyInjection; + +namespace BTCPayServer.Plugins.MicroNode; + +public class MicroNodePlugin:BaseBTCPayServerPlugin +{ + public override IBTCPayServerPlugin.PluginDependency[] Dependencies { get; } = + { + new () { Identifier = nameof(BTCPayServer), Condition = ">=1.12.0" } + }; + + + public override void Execute(IServiceCollection applicationBuilder) + { + + applicationBuilder.AddSingleton(new UIExtension("MicroNode/MicroNodeNav", "header-nav")); + applicationBuilder.AddSingleton(new UIExtension("MicroNode/LNPaymentMethodSetupTabhead", "ln-payment-method-setup-tabhead")); + applicationBuilder.AddSingleton(new UIExtension("MicroNode/LNPaymentMethodSetupTab", "ln-payment-method-setup-tab")); + // applicationBuilder.AddStartupTask(); + applicationBuilder.AddSingleton(); + applicationBuilder.AddDbContext((provider, o) => + { + var factory = provider.GetRequiredService(); + factory.ConfigureBuilder(o); + o.UsePostgreSqlTriggers(); + }); + applicationBuilder.AddSingleton(); + applicationBuilder.AddSingleton(); + applicationBuilder.AddHostedService(provider => provider.GetRequiredService()); + + + base.Execute(applicationBuilder); + + } +} \ No newline at end of file diff --git a/Plugins/BTCPayServer.Plugins.MicroNode/MicroNodeService.cs b/Plugins/BTCPayServer.Plugins.MicroNode/MicroNodeService.cs new file mode 100644 index 0000000..96b8da6 --- /dev/null +++ b/Plugins/BTCPayServer.Plugins.MicroNode/MicroNodeService.cs @@ -0,0 +1,737 @@ +#nullable enable +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using BTCPayServer.Client.Models; +using BTCPayServer.Configuration; +using BTCPayServer.Data; +using BTCPayServer.Data.Payouts.LightningLike; +using BTCPayServer.Events; +using BTCPayServer.HostedServices; +using BTCPayServer.Lightning; +using BTCPayServer.Payments; +using BTCPayServer.Payments.Lightning; +using BTCPayServer.Services; +using BTCPayServer.Services.Stores; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Newtonsoft.Json.Linq; + +namespace BTCPayServer.Plugins.MicroNode; + +public class MicroNodeService : EventHostedServiceBase +{ + private readonly MicroNodeContextFactory _microNodeContextFactory; + private readonly BTCPayNetworkProvider _btcPayNetworkProvider; + private readonly BTCPayNetworkJsonSerializerSettings _btcPayNetworkJsonSerializerSettings; + private readonly StoreRepository _storeRepository; + private readonly ILogger _logger; + private readonly PullPaymentHostedService _pullPaymentHostedService; + private readonly IOptions _lightningNetworkOptions; + private static readonly ConcurrentDictionary ExpectedCounter = new(); + private readonly TaskCompletionSource _init = new(); + private Dictionary _ownerSettings; + private Dictionary _storeSettings; + private readonly BTCPayNetwork _network; + private readonly IServiceProvider _serviceProvider; + private static readonly AsyncKeyedLock.AsyncKeyedLocker KeyedLocker = new(); + + public const string MasterSettingsKey = "MicroNodeMasterSettings"; + public const string StoreSettingsKey = "MicroNodeStoreSettings"; + + public MicroNodeService(MicroNodeContextFactory microNodeContextFactory, + BTCPayNetworkProvider btcPayNetworkProvider, + BTCPayNetworkJsonSerializerSettings btcPayNetworkJsonSerializerSettings, + StoreRepository storeRepository, + ILogger logger, + EventAggregator eventAggregator, + PullPaymentHostedService pullPaymentHostedService, + IOptions lightningNetworkOptions, + IServiceProvider serviceProvider) : base(eventAggregator, logger) + { + _network = btcPayNetworkProvider.BTC; + _microNodeContextFactory = microNodeContextFactory; + _btcPayNetworkProvider = btcPayNetworkProvider; + _btcPayNetworkJsonSerializerSettings = btcPayNetworkJsonSerializerSettings; + _storeRepository = storeRepository; + _logger = logger; + _pullPaymentHostedService = pullPaymentHostedService; + _lightningNetworkOptions = lightningNetworkOptions; + _serviceProvider = serviceProvider; + } + + public async Task MatchRecord(string key, string id) + { + await using var ctx = _microNodeContextFactory.CreateContext(); + var transaction = await ctx.MicroTransactions.FirstOrDefaultAsync(t => t.Id == id && t.AccountId == key); + return transaction ?? null; + } + + public async Task MatchRecords(string key, string[]? toArray) + { + if (toArray is null) + { + return Array.Empty(); + } + + await using var ctx = _microNodeContextFactory.CreateContext(); + var transactions = await ctx.MicroTransactions.Where(t => toArray.Contains(t.Id) && t.AccountId == key) + .ToArrayAsync(); + return transactions; + } + + public async Task UpsertRecord(string key, LightningInvoice invoice) + { + return (await UpsertRecords(key, new[] {invoice})).First(); + } + + + + public Task UpsertRecords(string key, LightningInvoice[] invoices) + { + return UpsertRecords(ConvertToRecords(key, invoices).ToArray()); + } + + public async Task UpsertRecord(string key, Data.PayoutData payout) + { + return (await UpsertRecords(key, new[] {payout})).First(); + } + + + + private MicroTransaction[] ConvertToRecords(string key, LightningInvoice[] invoices) + { + return invoices.Select(invoice => new MicroTransaction() + { + Id = invoice.Id, + AccountId = key, + Amount = invoice.AmountReceived?.MilliSatoshi ?? invoice.Amount.MilliSatoshi, + Accounted = invoice.Status == LightningInvoiceStatus.Paid, + Type = "LightningInvoice", + Active = invoice.Status == LightningInvoiceStatus.Unpaid + }).ToArray(); + } + + private MicroTransaction[] ConvertToRecords(string key, Data.PayoutData[] payouts) + { + return payouts.SelectMany(payout => + { + var b = payout.GetBlob(_btcPayNetworkJsonSerializerSettings); + + List res = new(); + res.Add(new MicroTransaction() + { + Id = payout.Id, + AccountId = key, + Amount = -LightMoney.Coins(b.CryptoAmount.Value).MilliSatoshi, + Accounted = payout.State != PayoutState.Cancelled, + Active = payout.State is PayoutState.AwaitingApproval or PayoutState.AwaitingPayment + or PayoutState.InProgress, + Type = "Payout" + }); + + if (b.Metadata?.TryGetValue("Fee", out var microNode) is true && microNode.Value() is { } payoutFee) + { + var fee = LightMoney.Coins(payoutFee); + res.Add(new MicroTransaction() + { + Id = "FeeOf" + payout.Id, + AccountId = key, + Amount = -fee.MilliSatoshi, + Accounted = payout.State != PayoutState.Cancelled, + Active = payout.State is PayoutState.AwaitingApproval or PayoutState.AwaitingPayment + or PayoutState.InProgress, + Type = "PayoutFee" + }); + } + return res.ToArray(); + }).ToArray(); + } + + private MicroTransaction[] ConvertToRecords(string key, LightningPayment[] payments) + { + return payments.SelectMany(payment => + { + var res = new List + { + new() + { + Id = payment.Id, + AccountId = key, + Amount = -(payment.AmountSent?.MilliSatoshi ?? payment.Amount.MilliSatoshi), + Accounted = payment.Status != LightningPaymentStatus.Failed, + Type = "LightningPayment", + Active = payment.Status is LightningPaymentStatus.Pending or LightningPaymentStatus.Unknown + } + }; + + if (payment.Fee is { } fee) + { + res.Add(new MicroTransaction() + { + Id = "FeeOf" + payment.Id, + DependentId = payment.Id, + AccountId = key, + Amount = -fee.MilliSatoshi, + Accounted = payment.Status != LightningPaymentStatus.Failed, + Type = "LightningPaymentFee", + Active = payment.Status is LightningPaymentStatus.Pending or LightningPaymentStatus.Unknown + }); + } + + return res.ToArray(); + }).ToArray(); + } + public Task UpsertRecords(string key, Data.PayoutData[] payouts) + { + return UpsertRecords(ConvertToRecords(key, payouts)); + } + + public async Task UpsertRecord(string key, LightningPayment payment) + { + return (await UpsertRecords(key, new[] {payment})).First(); + } + + public Task UpsertRecords(string key, LightningPayment[] payments) + { + + + return UpsertRecords(ConvertToRecords(key, payments)); + } + + private async Task UpsertRecords(MicroTransaction[] transactions) + { + ExpectedCounter.TryGetValue(transactions.First().AccountId, out var expected); + // expected += transactions.Length; + + await using var ctx = _microNodeContextFactory.CreateContext(); + var cnt = await ctx.MicroTransactions.UpsertRange(transactions).RunAsync(); + ExpectedCounter[transactions.First().AccountId] = expected + cnt ; + await ctx.SaveChangesAsync(); + return transactions; + } + + + + public async Task GetBalance(string key, CancellationToken cancellation) + { + using var keylock = await KeyedLocker.LockAsync(key, cancellation); + return await GetBalanceCore(key, cancellation); + } + + private async Task GetBalanceCore(string key, CancellationToken cancellation) + { + await using var ctx = _microNodeContextFactory.CreateContext(); + for (int i = 0; i < 5; i++) + { + var account = await ctx.MicroAccounts.FindAsync(key); + if (account is null) + { + return null; + } + + if (ExpectedCounter.TryGetValue(key, out var expected)) + { + if (account.BalanceCheckpoint < expected) + { + await Task.Delay(1000, cancellation); + continue; + } + else + { + ExpectedCounter[key] = account.BalanceCheckpoint; + } + } + else + { + ExpectedCounter.TryAdd(key, account.BalanceCheckpoint); + } + + return new LightMoney(account.Balance, LightMoneyUnit.MilliSatoshi); + } + + return null; + } + + public async Task<(MicroNodeSettings, string)?> GetMasterSettingsFromKey(string key, + CancellationToken cancellation = default) + { + await _init.Task.WaitAsync(cancellation); + + if (_keyToMasterStoreId.TryGetValue(key, out var storeId)) + { + return (_ownerSettings[storeId], storeId); + } + + await using var ctx = _microNodeContextFactory.CreateContext(); + var acct = await ctx.MicroAccounts.FindAsync(key, cancellation); + if (acct is null) + { + return null; + } + + var res = _ownerSettings.TryGetValue(acct.MasterStoreId, out var settings) + ? (settings, acct.MasterStoreId) + : ((MicroNodeSettings settings, string MasterStoreId)?) null; + + if (res is not null) + { + _keyToMasterStoreId.TryAdd(key, acct.MasterStoreId); + } + + return res; + } + + public async Task GetMasterSettings(string storeId, CancellationToken cancellation = default) + { + await _init.Task.WaitAsync(cancellation); + return _ownerSettings.TryGetValue(storeId, out var settings) ? settings : null; + } + + public async Task> GetMasterSettings( + CancellationToken cancellation = default) + { + await _init.Task.WaitAsync(cancellation); + return _ownerSettings.Where(pair => pair.Value.Enabled).ToImmutableDictionary(); + } + + public async Task GetStoreSettings(string storeId, + CancellationToken cancellation = default) + { + await _init.Task.WaitAsync(cancellation); + return _storeSettings.TryGetValue(storeId, out var settings) ? settings : null; + } + + public async Task GetStoreSettingsFromKey(string key, + CancellationToken cancellation = default) + { + await _init.Task.WaitAsync(cancellation); + var res = _storeSettings.FirstOrDefault(pair => pair.Value?.Key == key); + return res.Value; + } + + public async Task GetMasterLightningClientFromKey(string key) + { + var settings = await GetMasterSettingsFromKey(key); + if (settings is null) + { + return null; + } + + return await GetMasterLightningClient(settings.Value.Item2); + } + + + public async Task GetMasterLightningClient(string storeId) + { + var store = await _storeRepository.FindStore(storeId); + if (store is null) + { + return null; + } + + var lightningConnectionString = store.GetSupportedPaymentMethods(_btcPayNetworkProvider) + .OfType() + .FirstOrDefault(method => method.CryptoCode == _network.CryptoCode)?.CreateLightningClient(_network, + _lightningNetworkOptions.Value, _serviceProvider.GetService()); + return lightningConnectionString; + } + + public async Task InitiatePayment(string key, string paymentId, LightMoney amount, CancellationToken cancellationToken = default) + { + if (amount <= 0) + { + throw new InvalidOperationException("Cannot pay a negative amount"); + } + using var locker = await KeyedLocker.LockAsync(key, cancellationToken); + + await using var ctx = _microNodeContextFactory.CreateContext(); + var matched = await ctx.MicroTransactions.Include(transaction => transaction.Account).Where(transaction => transaction.Id == paymentId) + .ToArrayAsync(cancellationToken: cancellationToken); + + var ourRecord = matched.FirstOrDefault(transaction => transaction.AccountId == key); + + // already successfully paid + if(ourRecord is {Active: false, Accounted: true} && ourRecord.Amount == -amount.MilliSatoshi) + { + return ourRecord; + //previous payment failure + }else if(ourRecord is {Active: false, Accounted: false} && ourRecord.Amount == -amount.MilliSatoshi) + { + return ourRecord; + }else if (ourRecord is not null) + { + throw new InvalidOperationException($"A record with this payment hash was already present- Accounted:{ourRecord.Accounted} Active:{ourRecord.Active} Amount:{ourRecord.Amount}"); + } + + var masterStoreId = await GetMasterSettingsFromKey(key, cancellationToken); + + var isInternal = matched.Any(transaction => + transaction.AccountId != key && transaction.Amount >= 0 && + transaction.Account.MasterStoreId == masterStoreId.Value.Item2); + + var balance = SpendableExternalBalance(await GetBalanceCore(key, cancellationToken), isInternal, out var fee); + + if(balance < amount) + { + throw new InvalidOperationException("Insufficient balance"); + } + + var r = await UpsertRecords(new MicroTransaction[] + { + new MicroTransaction() + { + Id = paymentId, + AccountId = key, + Amount = -amount.MilliSatoshi, + Accounted = true, + Type = "LightningPayment", + Active = true + }, + new MicroTransaction() + { + Id = "FeeOf" + paymentId, + AccountId = key, + Amount = -fee.MilliSatoshi, + Accounted = true, + Type = "LightningPaymentFee", + Active = true + } + }); + return r.First(transaction => transaction.Type == "LightningPayment"); + } + + private LightMoney? SpendableExternalBalance(LightMoney? balance,bool isInternal, out LightMoney fee) + { + if (isInternal || balance < LightMoney.Satoshis(10000)) + { + fee = LightMoney.Zero; + return balance; + } + + var maxFeeAmount = (long)Math.Round(balance.MilliSatoshi * 0.03m); + fee = LightMoney.MilliSatoshis(maxFeeAmount); + return balance - fee; + } + + + public override async Task StartAsync(CancellationToken cancellationToken) + { + _logger.LogInformation("Migrating MicroNode database"); + await using var ctx = _microNodeContextFactory.CreateContext(); + var pendingMigrations = await ctx.Database.GetPendingMigrationsAsync(cancellationToken: cancellationToken); + if (pendingMigrations.Any()) + { + _logger.LogInformation("Applying {Count} migrations", pendingMigrations.Count()); + await ctx.Database.MigrateAsync(cancellationToken: cancellationToken); + } + else + { + _logger.LogInformation("No migrations to apply"); + } + + _ownerSettings = await _storeRepository.GetSettingsAsync(MasterSettingsKey); + _storeSettings = await _storeRepository.GetSettingsAsync(StoreSettingsKey); + _init.TrySetResult(); + PushEvent(new CheckActiveTransactions()); + PushEvent(new CreatePayoutEvt()); + await base.StartAsync(cancellationToken); + } + + class CheckActiveTransactions; + + class CreatePayoutEvt; + + + + protected override async Task ProcessEvent(object evt, CancellationToken cancellationToken) + { + if (evt is PayoutEvent payoutEvent) + { + await using var ctx = _microNodeContextFactory.CreateContext(); + var matchedTx = await ctx.MicroTransactions.SingleOrDefaultAsync( + transaction => transaction.Amount < 0 && transaction.Id == payoutEvent.Payout.Id, + cancellationToken: cancellationToken); + + if (matchedTx is null) + { + return; + } + + await UpsertRecord(matchedTx.AccountId, payoutEvent.Payout); + } + else if (evt is CheckActiveTransactions) + { + + Dictionary masterToLightningClients = new(); + foreach (var (key, value) in _ownerSettings) + { + //this simplistic approach should catch most cases + if(!masterToLightningClients.TryGetValue(key, out var lnClient)) + { + lnClient = await GetMasterLightningClient(key); + if (lnClient is null) + { + continue; + } + masterToLightningClients.Add(key, lnClient); + } + await lnClient.ListInvoices(cancellationToken); + await lnClient.ListPayments(cancellationToken); + } + + await using var ctx = _microNodeContextFactory.CreateContext(); + var activeTransactions = await ctx.MicroTransactions.Where(transaction => transaction.Active) + .ToArrayAsync(cancellationToken); + + var transactionsPayouts = activeTransactions.Where(transaction => transaction.Type == "Payout").ToArray(); + + var payouts = await _pullPaymentHostedService.GetPayouts(new PullPaymentHostedService.PayoutQuery() + { + PayoutIds = transactionsPayouts.Select(p => p.Id).ToArray() + }); + + var upsertRecords = new List(); + foreach (var keyGroup in transactionsPayouts.GroupBy(transaction => transaction.AccountId)) + { + var keyPayouts = payouts.Where(p => keyGroup.Any(transaction => p.Id == transaction.Id)).ToArray(); + upsertRecords.AddRange(ConvertToRecords(keyGroup.Key, keyPayouts)); + } + + + var transactionsInvoices = activeTransactions.Where(transaction => transaction.Type == "LightningInvoice") + .ToArray(); + + foreach (var keyGroup in transactionsInvoices.GroupBy(transaction => transaction.AccountId)) + { + if (!masterToLightningClients.TryGetValue(keyGroup.Key, out var lightningClient)) + { + continue; + } + await Task.WhenAll(keyGroup.Select(async transaction => + { + try + { + var invoice = await lightningClient.GetInvoice(transaction.Id, cancellationToken); + if (invoice is null && InvalidateAfterRetries(transaction.Id, 12)) + { + transaction.Active = false; + }else if(invoice is not null) + { + _retries.TryRemove(transaction.Id, out _); + upsertRecords.AddRange(ConvertToRecords(keyGroup.Key, new []{invoice})); + } + } + catch (Exception e) + { + if (InvalidateAfterRetries(transaction.Id, 12)) + { + transaction.Active = false; + } + } + })); + } + + var transactionPayments = activeTransactions.Where(transaction => transaction.Type == "LightningPayment") + .ToArray(); + + foreach (var keyGroup in transactionPayments.GroupBy(transaction => transaction.AccountId)) + { + if (!masterToLightningClients.TryGetValue(keyGroup.Key, out var lightningClient)) + { + continue; + } + await Task.WhenAll(keyGroup.Select(async transaction => + { + try + { + var invoice = await lightningClient.GetPayment(transaction.Id, cancellationToken); + if (invoice is null && InvalidateAfterRetries(transaction.Id, 60)) + { + transaction.Active = false; + }else if(invoice is not null) + { + _retries.TryRemove(transaction.Id, out _); + upsertRecords.AddRange(ConvertToRecords(keyGroup.Key, new []{invoice})); + } + } + catch (Exception e) + { + if (InvalidateAfterRetries(transaction.Id, 60)) + { + transaction.Active = false; + } + } + })); + } + + _ = Task.WhenAny(Task.Delay(TimeSpan.FromMinutes(1), cancellationToken)).ContinueWith( + task => + { + if (!task.IsCanceled) + PushEvent(new CheckActiveTransactions()); + }, cancellationToken); + + return; + } + else if (evt is CreatePayoutEvt) + { + await using var ctx = _microNodeContextFactory.CreateContext(); + var autoForwardThreshold = LightMoney.Satoshis(10000).MilliSatoshi; + var balances = await ctx.MicroAccounts.Where(account => account.Balance > autoForwardThreshold) + .ToArrayAsync(cancellationToken: cancellationToken); + foreach (var masterClients in balances.GroupBy(account => account.MasterStoreId)) + { + var lnCLient = await GetMasterLightningClient(masterClients.Key); + if (lnCLient is null) + { + continue; + } + + foreach (var client in masterClients) + { + await KeyedLocker.TryLockAsync(client.Key, async () => + { + var storeId = _storeSettings.FirstOrDefault(pair => pair.Value.Key == client.Key); + var destination = storeId.Value?.ForwardDestination; + if (destination is null) + { + return; + } + + var balance = await GetBalanceCore(client.Key, CancellationToken.None); + if (balance is null) + { + return; + } + var payout = await _pullPaymentHostedService.Claim(new ClaimRequest() + { + Value = LightMoney.MilliSatoshis(balance.MilliSatoshi).ToDecimal(LightMoneyUnit.BTC), + StoreId = masterClients.Key, + Destination = new LNURLPayClaimDestinaton(destination), + PreApprove = true, + PaymentMethodId = new PaymentMethodId("BTC", LightningPaymentType.Instance), + Metadata = JObject.FromObject(new + { + Source = $"MicroNode on store {storeId.Key}" + }) + }); + if (payout.PayoutData is not null) + { + await UpsertRecord(client.Key, payout.PayoutData); + } + }, -1, cancellationToken); + } + } + + _ = Task.WhenAny(Task.Delay(TimeSpan.FromMinutes(1), cancellationToken)).ContinueWith( + task => + { + if (!task.IsCanceled) + PushEvent(new CreatePayoutEvt()); + }, cancellationToken); + + }else if (evt is StoreRemovedEvent storeRemovedEvent) + { + _storeSettings.Remove(storeRemovedEvent.StoreId); + _ownerSettings.Remove(storeRemovedEvent.StoreId); + } + + + await base.ProcessEvent(evt, cancellationToken); + } + + private readonly ConcurrentDictionary _retries = new (); + private bool InvalidateAfterRetries(string id, int max) + { + return _retries.AddOrUpdate(id, 1, (_, i) => i + 1) >= max; + } + + protected override void SubscribeToEvents() + { + Subscribe(); + Subscribe(); + Subscribe(); + Subscribe(); + base.SubscribeToEvents(); + } + + public async Task StopAsync(CancellationToken cancellationToken) + { + await base.StopAsync(cancellationToken); + } + + private ConcurrentDictionary _keyToMasterStoreId = new(); + + public async Task Set(string storeId, MicroNodeStoreSettings? settings, string? masterStoreId = null) + { + await _init.Task; + if (settings is null) + { + _storeSettings.Remove(storeId); + _keyToMasterStoreId.TryRemove(settings!.Key, out _); + await _storeRepository.UpdateSetting(storeId, StoreSettingsKey, (MicroNodeStoreSettings?) null); + } + else + { + _storeSettings[storeId] = settings; + if (masterStoreId is not null) + { + _keyToMasterStoreId[settings.Key] = masterStoreId; + } + + await _storeRepository.UpdateSetting(storeId, StoreSettingsKey, settings); + await using var ctx = _microNodeContextFactory.CreateContext(); + await ctx.MicroAccounts.Upsert(new MicroAccount() + { + Balance = 0, + BalanceCheckpoint = 0, + MasterStoreId = masterStoreId, + Key = settings.Key, + }).NoUpdate().RunAsync(); + } + } + + public async Task SetMaster(string storeId, MicroNodeSettings? settings) + { + await _init.Task; + if (settings is null) + { + _ownerSettings.Remove(storeId); + await _storeRepository.UpdateSetting(storeId, MasterSettingsKey, (MicroNodeSettings?) null); + } + else + { + _ownerSettings[storeId] = settings; + await _storeRepository.UpdateSetting(storeId, MasterSettingsKey, settings); + await using var ctx = _microNodeContextFactory.CreateContext(); + } + } + + public async Task GetMasterLiabilities(string storeId, bool includeTxs) + { + await using var ctx = _microNodeContextFactory.CreateContext(); + var query = ctx.MicroAccounts.AsQueryable(); + if (includeTxs) + { + query = query.Include(account => account.Transactions); + } + + return await query.Where(account => account.MasterStoreId == storeId).ToArrayAsync(); + } + public async Task GetTransactions(string storeId) + { + if(!_storeSettings.TryGetValue(storeId, out var settings)) + { + return null; + } + await using var ctx = _microNodeContextFactory.CreateContext(); + return await ctx.MicroTransactions.Where(t => t.AccountId == settings!.Key).ToArrayAsync(); + } +} \ No newline at end of file diff --git a/Plugins/BTCPayServer.Plugins.MicroNode/MicroNodeSettings.cs b/Plugins/BTCPayServer.Plugins.MicroNode/MicroNodeSettings.cs new file mode 100644 index 0000000..ccfe466 --- /dev/null +++ b/Plugins/BTCPayServer.Plugins.MicroNode/MicroNodeSettings.cs @@ -0,0 +1,11 @@ +namespace BTCPayServer.Plugins.MicroNode; + +public class MicroNodeSettings +{ + public bool Enabled { get; set; } + + public string Name { get; set; } + public bool AdminOnly { get; set; } + // public decimal FeeReserve { get; set; } + // public decimal ServiceFee { get; set; } +} \ No newline at end of file diff --git a/Plugins/BTCPayServer.Plugins.MicroNode/MicroNodeStartupTask.cs b/Plugins/BTCPayServer.Plugins.MicroNode/MicroNodeStartupTask.cs new file mode 100644 index 0000000..fc5c705 --- /dev/null +++ b/Plugins/BTCPayServer.Plugins.MicroNode/MicroNodeStartupTask.cs @@ -0,0 +1,37 @@ +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using BTCPayServer.Abstractions.Contracts; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace BTCPayServer.Plugins.MicroNode; + +public class MicroNodeStartupTask : IStartupTask +{ + private readonly MicroNodeContextFactory _microNodeContextFactory; + private readonly ILogger _logger; + + public MicroNodeStartupTask(MicroNodeContextFactory microNodeContextFactory, ILogger logger) + { + _microNodeContextFactory = microNodeContextFactory; + _logger = logger; + } + + public async Task ExecuteAsync(CancellationToken cancellationToken = new()) + { + _logger.LogInformation("Migrating MicroNode database"); + await using var ctx = _microNodeContextFactory.CreateContext(); + var pendingMigrations = await ctx.Database.GetPendingMigrationsAsync(cancellationToken: cancellationToken); + if (pendingMigrations.Any()) + { + _logger.LogInformation("Applying {Count} migrations", pendingMigrations.Count()); + await ctx.Database.MigrateAsync(cancellationToken: cancellationToken); + } + else + { + _logger.LogInformation("No migrations to apply"); + } + + } +} \ No newline at end of file diff --git a/Plugins/BTCPayServer.Plugins.MicroNode/MicroNodeStoreSettings.cs b/Plugins/BTCPayServer.Plugins.MicroNode/MicroNodeStoreSettings.cs new file mode 100644 index 0000000..6e6e2c8 --- /dev/null +++ b/Plugins/BTCPayServer.Plugins.MicroNode/MicroNodeStoreSettings.cs @@ -0,0 +1,7 @@ +namespace BTCPayServer.Plugins.MicroNode; + +public class MicroNodeStoreSettings +{ + public string Key { get; set; } + public string? ForwardDestination { get; set; } +} \ No newline at end of file diff --git a/Plugins/BTCPayServer.Plugins.MicroNode/MicroTransaction.cs b/Plugins/BTCPayServer.Plugins.MicroNode/MicroTransaction.cs new file mode 100644 index 0000000..9f2250d --- /dev/null +++ b/Plugins/BTCPayServer.Plugins.MicroNode/MicroTransaction.cs @@ -0,0 +1,21 @@ +using System.Collections.Generic; + +namespace BTCPayServer.Plugins.MicroNode; + +public class MicroTransaction +{ + public string Id { get; set; } + public string AccountId { get; set; } + + public long Amount { get; set; } + public bool Accounted { get; set; } + + + public string Type { get; set; } + public bool Active { get; set; } + public MicroAccount Account { get; set; } + public string? DependentId { get; set; } + public MicroTransaction? Dependent { get; set; } + + public List Dependents { get; set; } +} \ No newline at end of file diff --git a/Plugins/BTCPayServer.Plugins.MicroNode/Migrations/20240115112915_Initial.Designer.cs b/Plugins/BTCPayServer.Plugins.MicroNode/Migrations/20240115112915_Initial.Designer.cs new file mode 100644 index 0000000..23e98e8 --- /dev/null +++ b/Plugins/BTCPayServer.Plugins.MicroNode/Migrations/20240115112915_Initial.Designer.cs @@ -0,0 +1,130 @@ +// +using BTCPayServer.Plugins.MicroNode; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace BTCPayServer.Plugins.MicroNode.Migrations +{ + [DbContext(typeof(MicroNodeContext))] + [Migration("20240115112915_Initial")] + partial class Initial + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("BTCPayServer.Plugins.MicroNode") + .HasAnnotation("ProductVersion", "8.0.0") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("BTCPayServer.Plugins.MicroNode.MicroAccount", b => + { + b.Property("Key") + .HasColumnType("text"); + + b.Property("Balance") + .HasColumnType("bigint"); + + b.Property("BalanceCheckpoint") + .HasColumnType("bigint"); + + b.Property("MasterStoreId") + .HasColumnType("text"); + + b.HasKey("Key"); + + b.ToTable("MicroAccounts", "BTCPayServer.Plugins.MicroNode"); + }); + + modelBuilder.Entity("BTCPayServer.Plugins.MicroNode.MicroTransaction", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("AccountId") + .HasColumnType("text"); + + b.Property("Accounted") + .HasColumnType("boolean"); + + b.Property("Active") + .HasColumnType("boolean"); + + b.Property("Amount") + .HasColumnType("bigint"); + + b.Property("DependentAccountId") + .HasColumnType("text"); + + b.Property("DependentId") + .HasColumnType("text"); + + b.Property("Type") + .HasColumnType("text"); + + b.HasKey("Id", "AccountId"); + + b.HasIndex("AccountId"); + + b.HasIndex("DependentId", "AccountId"); + + b.HasIndex("DependentId", "DependentAccountId"); + + b.ToTable("MicroTransactions", "BTCPayServer.Plugins.MicroNode", t => + { + t.HasTrigger("LC_TRIGGER_AFTER_DELETE_MICROTRANSACTION"); + + t.HasTrigger("LC_TRIGGER_AFTER_INSERT_MICROTRANSACTION"); + + t.HasTrigger("LC_TRIGGER_AFTER_UPDATE_MICROTRANSACTION"); + }); + + b + .HasAnnotation("LC_TRIGGER_AFTER_DELETE_MICROTRANSACTION", "CREATE FUNCTION \"BTCPayServer.Plugins.MicroNode\".\"LC_TRIGGER_AFTER_DELETE_MICROTRANSACTION\"() RETURNS trigger as $LC_TRIGGER_AFTER_DELETE_MICROTRANSACTION$\r\nBEGIN\r\n \r\n IF OLD.\"Accounted\" IS TRUE THEN \r\n UPDATE \"BTCPayServer.Plugins.MicroNode\".\"MicroAccounts\"\r\n SET \"Balance\" = \"BTCPayServer.Plugins.MicroNode\".\"MicroAccounts\".\"Balance\" - OLD.\"Amount\"\r\n WHERE \"BTCPayServer.Plugins.MicroNode\".\"MicroAccounts\".\"Key\" = OLD.\"AccountId\";\r\n END IF;\r\n UPDATE \"BTCPayServer.Plugins.MicroNode\".\"MicroAccounts\"\r\n SET \"BalanceCheckpoint\" = \"BTCPayServer.Plugins.MicroNode\".\"MicroAccounts\".\"BalanceCheckpoint\" + 1\r\n WHERE \"BTCPayServer.Plugins.MicroNode\".\"MicroAccounts\".\"Key\" = OLD.\"AccountId\";\r\nRETURN OLD;\r\nEND;\r\n$LC_TRIGGER_AFTER_DELETE_MICROTRANSACTION$ LANGUAGE plpgsql;\r\nCREATE TRIGGER LC_TRIGGER_AFTER_DELETE_MICROTRANSACTION AFTER DELETE\r\nON \"BTCPayServer.Plugins.MicroNode\".\"MicroTransactions\"\r\nFOR EACH ROW EXECUTE PROCEDURE \"BTCPayServer.Plugins.MicroNode\".\"LC_TRIGGER_AFTER_DELETE_MICROTRANSACTION\"();") + .HasAnnotation("LC_TRIGGER_AFTER_INSERT_MICROTRANSACTION", "CREATE FUNCTION \"BTCPayServer.Plugins.MicroNode\".\"LC_TRIGGER_AFTER_INSERT_MICROTRANSACTION\"() RETURNS trigger as $LC_TRIGGER_AFTER_INSERT_MICROTRANSACTION$\r\nBEGIN\r\n \r\n IF NEW.\"Accounted\" IS TRUE THEN \r\n UPDATE \"BTCPayServer.Plugins.MicroNode\".\"MicroAccounts\"\r\n SET \"Balance\" = \"BTCPayServer.Plugins.MicroNode\".\"MicroAccounts\".\"Balance\" + NEW.\"Amount\"\r\n WHERE \"BTCPayServer.Plugins.MicroNode\".\"MicroAccounts\".\"Key\" = NEW.\"AccountId\";\r\n END IF;\r\n UPDATE \"BTCPayServer.Plugins.MicroNode\".\"MicroAccounts\"\r\n SET \"BalanceCheckpoint\" = \"BTCPayServer.Plugins.MicroNode\".\"MicroAccounts\".\"BalanceCheckpoint\" + 1\r\n WHERE \"BTCPayServer.Plugins.MicroNode\".\"MicroAccounts\".\"Key\" = NEW.\"AccountId\";\r\n UPDATE \"BTCPayServer.Plugins.MicroNode\".\"MicroTransactions\"\r\n SET \"Accounted\" = NEW.\"Accounted\", \"Active\" = NEW.\"Active\"\r\n WHERE NEW.\"Id\" = \"BTCPayServer.Plugins.MicroNode\".\"MicroTransactions\".\"DependentId\" AND \"BTCPayServer.Plugins.MicroNode\".\"MicroTransactions\".\"AccountId\" = NEW.\"AccountId\";\r\nRETURN NEW;\r\nEND;\r\n$LC_TRIGGER_AFTER_INSERT_MICROTRANSACTION$ LANGUAGE plpgsql;\r\nCREATE TRIGGER LC_TRIGGER_AFTER_INSERT_MICROTRANSACTION AFTER INSERT\r\nON \"BTCPayServer.Plugins.MicroNode\".\"MicroTransactions\"\r\nFOR EACH ROW EXECUTE PROCEDURE \"BTCPayServer.Plugins.MicroNode\".\"LC_TRIGGER_AFTER_INSERT_MICROTRANSACTION\"();") + .HasAnnotation("LC_TRIGGER_AFTER_UPDATE_MICROTRANSACTION", "CREATE FUNCTION \"BTCPayServer.Plugins.MicroNode\".\"LC_TRIGGER_AFTER_UPDATE_MICROTRANSACTION\"() RETURNS trigger as $LC_TRIGGER_AFTER_UPDATE_MICROTRANSACTION$\r\nBEGIN\r\n UPDATE \"BTCPayServer.Plugins.MicroNode\".\"MicroAccounts\"\r\n SET \"BalanceCheckpoint\" = \"BTCPayServer.Plugins.MicroNode\".\"MicroAccounts\".\"BalanceCheckpoint\" + 1\r\n WHERE \"BTCPayServer.Plugins.MicroNode\".\"MicroAccounts\".\"Key\" = OLD.\"AccountId\";\r\n UPDATE \"BTCPayServer.Plugins.MicroNode\".\"MicroTransactions\"\r\n SET \"Accounted\" = NEW.\"Accounted\", \"Active\" = NEW.\"Active\", \"DependentId\" = NEW.\"Id\"\r\n WHERE OLD.\"Id\" = \"BTCPayServer.Plugins.MicroNode\".\"MicroTransactions\".\"DependentId\" AND \"BTCPayServer.Plugins.MicroNode\".\"MicroTransactions\".\"AccountId\" = NEW.\"AccountId\";\r\n \r\n IF NEW.\"Accounted\" IS TRUE AND OLD.\"Accounted\" IS FALSE THEN \r\n UPDATE \"BTCPayServer.Plugins.MicroNode\".\"MicroAccounts\"\r\n SET \"Balance\" = \"BTCPayServer.Plugins.MicroNode\".\"MicroAccounts\".\"Balance\" + NEW.\"Amount\"\r\n WHERE \"BTCPayServer.Plugins.MicroNode\".\"MicroAccounts\".\"Key\" = OLD.\"AccountId\";\r\n END IF;\r\n \r\n IF NEW.\"Accounted\" IS TRUE AND OLD.\"Accounted\" IS TRUE AND OLD.\"Amount\" <> NEW.\"Amount\" THEN \r\n UPDATE \"BTCPayServer.Plugins.MicroNode\".\"MicroAccounts\"\r\n SET \"Balance\" = \"BTCPayServer.Plugins.MicroNode\".\"MicroAccounts\".\"Balance\" - OLD.\"Amount\" + NEW.\"Amount\"\r\n WHERE \"BTCPayServer.Plugins.MicroNode\".\"MicroAccounts\".\"Key\" = OLD.\"AccountId\";\r\n END IF;\r\n \r\n IF NEW.\"Accounted\" IS FALSE AND OLD.\"Accounted\" IS TRUE THEN \r\n UPDATE \"BTCPayServer.Plugins.MicroNode\".\"MicroAccounts\"\r\n SET \"Balance\" = \"BTCPayServer.Plugins.MicroNode\".\"MicroAccounts\".\"Balance\" - OLD.\"Amount\"\r\n WHERE \"BTCPayServer.Plugins.MicroNode\".\"MicroAccounts\".\"Key\" = OLD.\"AccountId\";\r\n END IF;\r\nRETURN NEW;\r\nEND;\r\n$LC_TRIGGER_AFTER_UPDATE_MICROTRANSACTION$ LANGUAGE plpgsql;\r\nCREATE TRIGGER LC_TRIGGER_AFTER_UPDATE_MICROTRANSACTION AFTER UPDATE\r\nON \"BTCPayServer.Plugins.MicroNode\".\"MicroTransactions\"\r\nFOR EACH ROW EXECUTE PROCEDURE \"BTCPayServer.Plugins.MicroNode\".\"LC_TRIGGER_AFTER_UPDATE_MICROTRANSACTION\"();"); + }); + + modelBuilder.Entity("BTCPayServer.Plugins.MicroNode.MicroTransaction", b => + { + b.HasOne("BTCPayServer.Plugins.MicroNode.MicroAccount", "Account") + .WithMany("Transactions") + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("BTCPayServer.Plugins.MicroNode.MicroTransaction", null) + .WithMany("Dependents") + .HasForeignKey("DependentId", "AccountId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("BTCPayServer.Plugins.MicroNode.MicroTransaction", "Dependent") + .WithMany() + .HasForeignKey("DependentId", "DependentAccountId"); + + b.Navigation("Account"); + + b.Navigation("Dependent"); + }); + + modelBuilder.Entity("BTCPayServer.Plugins.MicroNode.MicroAccount", b => + { + b.Navigation("Transactions"); + }); + + modelBuilder.Entity("BTCPayServer.Plugins.MicroNode.MicroTransaction", b => + { + b.Navigation("Dependents"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Plugins/BTCPayServer.Plugins.MicroNode/Migrations/20240115112915_Initial.cs b/Plugins/BTCPayServer.Plugins.MicroNode/Migrations/20240115112915_Initial.cs new file mode 100644 index 0000000..b3552dd --- /dev/null +++ b/Plugins/BTCPayServer.Plugins.MicroNode/Migrations/20240115112915_Initial.cs @@ -0,0 +1,113 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace BTCPayServer.Plugins.MicroNode.Migrations +{ + /// + public partial class Initial : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.EnsureSchema( + name: "BTCPayServer.Plugins.MicroNode"); + + migrationBuilder.CreateTable( + name: "MicroAccounts", + schema: "BTCPayServer.Plugins.MicroNode", + columns: table => new + { + Key = table.Column(type: "text", nullable: false), + Balance = table.Column(type: "bigint", nullable: false), + BalanceCheckpoint = table.Column(type: "bigint", nullable: false), + MasterStoreId = table.Column(type: "text", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_MicroAccounts", x => x.Key); + }); + + migrationBuilder.CreateTable( + name: "MicroTransactions", + schema: "BTCPayServer.Plugins.MicroNode", + columns: table => new + { + Id = table.Column(type: "text", nullable: false), + AccountId = table.Column(type: "text", nullable: false), + Amount = table.Column(type: "bigint", nullable: false), + Accounted = table.Column(type: "boolean", nullable: false), + Type = table.Column(type: "text", nullable: true), + Active = table.Column(type: "boolean", nullable: false), + DependentId = table.Column(type: "text", nullable: true), + DependentAccountId = table.Column(type: "text", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_MicroTransactions", x => new { x.Id, x.AccountId }); + table.ForeignKey( + name: "FK_MicroTransactions_MicroAccounts_AccountId", + column: x => x.AccountId, + principalSchema: "BTCPayServer.Plugins.MicroNode", + principalTable: "MicroAccounts", + principalColumn: "Key", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_MicroTransactions_MicroTransactions_DependentId_AccountId", + columns: x => new { x.DependentId, x.AccountId }, + principalSchema: "BTCPayServer.Plugins.MicroNode", + principalTable: "MicroTransactions", + principalColumns: new[] { "Id", "AccountId" }, + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_MicroTransactions_MicroTransactions_DependentId_DependentAc~", + columns: x => new { x.DependentId, x.DependentAccountId }, + principalSchema: "BTCPayServer.Plugins.MicroNode", + principalTable: "MicroTransactions", + principalColumns: new[] { "Id", "AccountId" }); + }); + + migrationBuilder.CreateIndex( + name: "IX_MicroTransactions_AccountId", + schema: "BTCPayServer.Plugins.MicroNode", + table: "MicroTransactions", + column: "AccountId"); + + migrationBuilder.CreateIndex( + name: "IX_MicroTransactions_DependentId_AccountId", + schema: "BTCPayServer.Plugins.MicroNode", + table: "MicroTransactions", + columns: new[] { "DependentId", "AccountId" }); + + migrationBuilder.CreateIndex( + name: "IX_MicroTransactions_DependentId_DependentAccountId", + schema: "BTCPayServer.Plugins.MicroNode", + table: "MicroTransactions", + columns: new[] { "DependentId", "DependentAccountId" }); + + migrationBuilder.Sql("CREATE FUNCTION \"BTCPayServer.Plugins.MicroNode\".\"LC_TRIGGER_AFTER_DELETE_MICROTRANSACTION\"() RETURNS trigger as $LC_TRIGGER_AFTER_DELETE_MICROTRANSACTION$\r\nBEGIN\r\n \r\n IF OLD.\"Accounted\" IS TRUE THEN \r\n UPDATE \"BTCPayServer.Plugins.MicroNode\".\"MicroAccounts\"\r\n SET \"Balance\" = \"BTCPayServer.Plugins.MicroNode\".\"MicroAccounts\".\"Balance\" - OLD.\"Amount\"\r\n WHERE \"BTCPayServer.Plugins.MicroNode\".\"MicroAccounts\".\"Key\" = OLD.\"AccountId\";\r\n END IF;\r\n UPDATE \"BTCPayServer.Plugins.MicroNode\".\"MicroAccounts\"\r\n SET \"BalanceCheckpoint\" = \"BTCPayServer.Plugins.MicroNode\".\"MicroAccounts\".\"BalanceCheckpoint\" + 1\r\n WHERE \"BTCPayServer.Plugins.MicroNode\".\"MicroAccounts\".\"Key\" = OLD.\"AccountId\";\r\nRETURN OLD;\r\nEND;\r\n$LC_TRIGGER_AFTER_DELETE_MICROTRANSACTION$ LANGUAGE plpgsql;\r\nCREATE TRIGGER LC_TRIGGER_AFTER_DELETE_MICROTRANSACTION AFTER DELETE\r\nON \"BTCPayServer.Plugins.MicroNode\".\"MicroTransactions\"\r\nFOR EACH ROW EXECUTE PROCEDURE \"BTCPayServer.Plugins.MicroNode\".\"LC_TRIGGER_AFTER_DELETE_MICROTRANSACTION\"();"); + + migrationBuilder.Sql("CREATE FUNCTION \"BTCPayServer.Plugins.MicroNode\".\"LC_TRIGGER_AFTER_INSERT_MICROTRANSACTION\"() RETURNS trigger as $LC_TRIGGER_AFTER_INSERT_MICROTRANSACTION$\r\nBEGIN\r\n \r\n IF NEW.\"Accounted\" IS TRUE THEN \r\n UPDATE \"BTCPayServer.Plugins.MicroNode\".\"MicroAccounts\"\r\n SET \"Balance\" = \"BTCPayServer.Plugins.MicroNode\".\"MicroAccounts\".\"Balance\" + NEW.\"Amount\"\r\n WHERE \"BTCPayServer.Plugins.MicroNode\".\"MicroAccounts\".\"Key\" = NEW.\"AccountId\";\r\n END IF;\r\n UPDATE \"BTCPayServer.Plugins.MicroNode\".\"MicroAccounts\"\r\n SET \"BalanceCheckpoint\" = \"BTCPayServer.Plugins.MicroNode\".\"MicroAccounts\".\"BalanceCheckpoint\" + 1\r\n WHERE \"BTCPayServer.Plugins.MicroNode\".\"MicroAccounts\".\"Key\" = NEW.\"AccountId\";\r\n UPDATE \"BTCPayServer.Plugins.MicroNode\".\"MicroTransactions\"\r\n SET \"Accounted\" = NEW.\"Accounted\", \"Active\" = NEW.\"Active\"\r\n WHERE NEW.\"Id\" = \"BTCPayServer.Plugins.MicroNode\".\"MicroTransactions\".\"DependentId\" AND \"BTCPayServer.Plugins.MicroNode\".\"MicroTransactions\".\"AccountId\" = NEW.\"AccountId\";\r\nRETURN NEW;\r\nEND;\r\n$LC_TRIGGER_AFTER_INSERT_MICROTRANSACTION$ LANGUAGE plpgsql;\r\nCREATE TRIGGER LC_TRIGGER_AFTER_INSERT_MICROTRANSACTION AFTER INSERT\r\nON \"BTCPayServer.Plugins.MicroNode\".\"MicroTransactions\"\r\nFOR EACH ROW EXECUTE PROCEDURE \"BTCPayServer.Plugins.MicroNode\".\"LC_TRIGGER_AFTER_INSERT_MICROTRANSACTION\"();"); + + migrationBuilder.Sql("CREATE FUNCTION \"BTCPayServer.Plugins.MicroNode\".\"LC_TRIGGER_AFTER_UPDATE_MICROTRANSACTION\"() RETURNS trigger as $LC_TRIGGER_AFTER_UPDATE_MICROTRANSACTION$\r\nBEGIN\r\n UPDATE \"BTCPayServer.Plugins.MicroNode\".\"MicroAccounts\"\r\n SET \"BalanceCheckpoint\" = \"BTCPayServer.Plugins.MicroNode\".\"MicroAccounts\".\"BalanceCheckpoint\" + 1\r\n WHERE \"BTCPayServer.Plugins.MicroNode\".\"MicroAccounts\".\"Key\" = OLD.\"AccountId\";\r\n UPDATE \"BTCPayServer.Plugins.MicroNode\".\"MicroTransactions\"\r\n SET \"Accounted\" = NEW.\"Accounted\", \"Active\" = NEW.\"Active\", \"DependentId\" = NEW.\"Id\"\r\n WHERE OLD.\"Id\" = \"BTCPayServer.Plugins.MicroNode\".\"MicroTransactions\".\"DependentId\" AND \"BTCPayServer.Plugins.MicroNode\".\"MicroTransactions\".\"AccountId\" = NEW.\"AccountId\";\r\n \r\n IF NEW.\"Accounted\" IS TRUE AND OLD.\"Accounted\" IS FALSE THEN \r\n UPDATE \"BTCPayServer.Plugins.MicroNode\".\"MicroAccounts\"\r\n SET \"Balance\" = \"BTCPayServer.Plugins.MicroNode\".\"MicroAccounts\".\"Balance\" + NEW.\"Amount\"\r\n WHERE \"BTCPayServer.Plugins.MicroNode\".\"MicroAccounts\".\"Key\" = OLD.\"AccountId\";\r\n END IF;\r\n \r\n IF NEW.\"Accounted\" IS TRUE AND OLD.\"Accounted\" IS TRUE AND OLD.\"Amount\" <> NEW.\"Amount\" THEN \r\n UPDATE \"BTCPayServer.Plugins.MicroNode\".\"MicroAccounts\"\r\n SET \"Balance\" = \"BTCPayServer.Plugins.MicroNode\".\"MicroAccounts\".\"Balance\" - OLD.\"Amount\" + NEW.\"Amount\"\r\n WHERE \"BTCPayServer.Plugins.MicroNode\".\"MicroAccounts\".\"Key\" = OLD.\"AccountId\";\r\n END IF;\r\n \r\n IF NEW.\"Accounted\" IS FALSE AND OLD.\"Accounted\" IS TRUE THEN \r\n UPDATE \"BTCPayServer.Plugins.MicroNode\".\"MicroAccounts\"\r\n SET \"Balance\" = \"BTCPayServer.Plugins.MicroNode\".\"MicroAccounts\".\"Balance\" - OLD.\"Amount\"\r\n WHERE \"BTCPayServer.Plugins.MicroNode\".\"MicroAccounts\".\"Key\" = OLD.\"AccountId\";\r\n END IF;\r\nRETURN NEW;\r\nEND;\r\n$LC_TRIGGER_AFTER_UPDATE_MICROTRANSACTION$ LANGUAGE plpgsql;\r\nCREATE TRIGGER LC_TRIGGER_AFTER_UPDATE_MICROTRANSACTION AFTER UPDATE\r\nON \"BTCPayServer.Plugins.MicroNode\".\"MicroTransactions\"\r\nFOR EACH ROW EXECUTE PROCEDURE \"BTCPayServer.Plugins.MicroNode\".\"LC_TRIGGER_AFTER_UPDATE_MICROTRANSACTION\"();"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql("DROP FUNCTION \"BTCPayServer.Plugins.MicroNode\".\"LC_TRIGGER_AFTER_DELETE_MICROTRANSACTION\"() CASCADE;"); + + migrationBuilder.Sql("DROP FUNCTION \"BTCPayServer.Plugins.MicroNode\".\"LC_TRIGGER_AFTER_INSERT_MICROTRANSACTION\"() CASCADE;"); + + migrationBuilder.Sql("DROP FUNCTION \"BTCPayServer.Plugins.MicroNode\".\"LC_TRIGGER_AFTER_UPDATE_MICROTRANSACTION\"() CASCADE;"); + + migrationBuilder.DropTable( + name: "MicroTransactions", + schema: "BTCPayServer.Plugins.MicroNode"); + + migrationBuilder.DropTable( + name: "MicroAccounts", + schema: "BTCPayServer.Plugins.MicroNode"); + } + } +} diff --git a/Plugins/BTCPayServer.Plugins.MicroNode/Migrations/MicroNodeContextModelSnapshot.cs b/Plugins/BTCPayServer.Plugins.MicroNode/Migrations/MicroNodeContextModelSnapshot.cs new file mode 100644 index 0000000..a67b398 --- /dev/null +++ b/Plugins/BTCPayServer.Plugins.MicroNode/Migrations/MicroNodeContextModelSnapshot.cs @@ -0,0 +1,127 @@ +// +using BTCPayServer.Plugins.MicroNode; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace BTCPayServer.Plugins.MicroNode.Migrations +{ + [DbContext(typeof(MicroNodeContext))] + partial class MicroNodeContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("BTCPayServer.Plugins.MicroNode") + .HasAnnotation("ProductVersion", "8.0.0") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("BTCPayServer.Plugins.MicroNode.MicroAccount", b => + { + b.Property("Key") + .HasColumnType("text"); + + b.Property("Balance") + .HasColumnType("bigint"); + + b.Property("BalanceCheckpoint") + .HasColumnType("bigint"); + + b.Property("MasterStoreId") + .HasColumnType("text"); + + b.HasKey("Key"); + + b.ToTable("MicroAccounts", "BTCPayServer.Plugins.MicroNode"); + }); + + modelBuilder.Entity("BTCPayServer.Plugins.MicroNode.MicroTransaction", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("AccountId") + .HasColumnType("text"); + + b.Property("Accounted") + .HasColumnType("boolean"); + + b.Property("Active") + .HasColumnType("boolean"); + + b.Property("Amount") + .HasColumnType("bigint"); + + b.Property("DependentAccountId") + .HasColumnType("text"); + + b.Property("DependentId") + .HasColumnType("text"); + + b.Property("Type") + .HasColumnType("text"); + + b.HasKey("Id", "AccountId"); + + b.HasIndex("AccountId"); + + b.HasIndex("DependentId", "AccountId"); + + b.HasIndex("DependentId", "DependentAccountId"); + + b.ToTable("MicroTransactions", "BTCPayServer.Plugins.MicroNode", t => + { + t.HasTrigger("LC_TRIGGER_AFTER_DELETE_MICROTRANSACTION"); + + t.HasTrigger("LC_TRIGGER_AFTER_INSERT_MICROTRANSACTION"); + + t.HasTrigger("LC_TRIGGER_AFTER_UPDATE_MICROTRANSACTION"); + }); + + b + .HasAnnotation("LC_TRIGGER_AFTER_DELETE_MICROTRANSACTION", "CREATE FUNCTION \"BTCPayServer.Plugins.MicroNode\".\"LC_TRIGGER_AFTER_DELETE_MICROTRANSACTION\"() RETURNS trigger as $LC_TRIGGER_AFTER_DELETE_MICROTRANSACTION$\r\nBEGIN\r\n \r\n IF OLD.\"Accounted\" IS TRUE THEN \r\n UPDATE \"BTCPayServer.Plugins.MicroNode\".\"MicroAccounts\"\r\n SET \"Balance\" = \"BTCPayServer.Plugins.MicroNode\".\"MicroAccounts\".\"Balance\" - OLD.\"Amount\"\r\n WHERE \"BTCPayServer.Plugins.MicroNode\".\"MicroAccounts\".\"Key\" = OLD.\"AccountId\";\r\n END IF;\r\n UPDATE \"BTCPayServer.Plugins.MicroNode\".\"MicroAccounts\"\r\n SET \"BalanceCheckpoint\" = \"BTCPayServer.Plugins.MicroNode\".\"MicroAccounts\".\"BalanceCheckpoint\" + 1\r\n WHERE \"BTCPayServer.Plugins.MicroNode\".\"MicroAccounts\".\"Key\" = OLD.\"AccountId\";\r\nRETURN OLD;\r\nEND;\r\n$LC_TRIGGER_AFTER_DELETE_MICROTRANSACTION$ LANGUAGE plpgsql;\r\nCREATE TRIGGER LC_TRIGGER_AFTER_DELETE_MICROTRANSACTION AFTER DELETE\r\nON \"BTCPayServer.Plugins.MicroNode\".\"MicroTransactions\"\r\nFOR EACH ROW EXECUTE PROCEDURE \"BTCPayServer.Plugins.MicroNode\".\"LC_TRIGGER_AFTER_DELETE_MICROTRANSACTION\"();") + .HasAnnotation("LC_TRIGGER_AFTER_INSERT_MICROTRANSACTION", "CREATE FUNCTION \"BTCPayServer.Plugins.MicroNode\".\"LC_TRIGGER_AFTER_INSERT_MICROTRANSACTION\"() RETURNS trigger as $LC_TRIGGER_AFTER_INSERT_MICROTRANSACTION$\r\nBEGIN\r\n \r\n IF NEW.\"Accounted\" IS TRUE THEN \r\n UPDATE \"BTCPayServer.Plugins.MicroNode\".\"MicroAccounts\"\r\n SET \"Balance\" = \"BTCPayServer.Plugins.MicroNode\".\"MicroAccounts\".\"Balance\" + NEW.\"Amount\"\r\n WHERE \"BTCPayServer.Plugins.MicroNode\".\"MicroAccounts\".\"Key\" = NEW.\"AccountId\";\r\n END IF;\r\n UPDATE \"BTCPayServer.Plugins.MicroNode\".\"MicroAccounts\"\r\n SET \"BalanceCheckpoint\" = \"BTCPayServer.Plugins.MicroNode\".\"MicroAccounts\".\"BalanceCheckpoint\" + 1\r\n WHERE \"BTCPayServer.Plugins.MicroNode\".\"MicroAccounts\".\"Key\" = NEW.\"AccountId\";\r\n UPDATE \"BTCPayServer.Plugins.MicroNode\".\"MicroTransactions\"\r\n SET \"Accounted\" = NEW.\"Accounted\", \"Active\" = NEW.\"Active\"\r\n WHERE NEW.\"Id\" = \"BTCPayServer.Plugins.MicroNode\".\"MicroTransactions\".\"DependentId\" AND \"BTCPayServer.Plugins.MicroNode\".\"MicroTransactions\".\"AccountId\" = NEW.\"AccountId\";\r\nRETURN NEW;\r\nEND;\r\n$LC_TRIGGER_AFTER_INSERT_MICROTRANSACTION$ LANGUAGE plpgsql;\r\nCREATE TRIGGER LC_TRIGGER_AFTER_INSERT_MICROTRANSACTION AFTER INSERT\r\nON \"BTCPayServer.Plugins.MicroNode\".\"MicroTransactions\"\r\nFOR EACH ROW EXECUTE PROCEDURE \"BTCPayServer.Plugins.MicroNode\".\"LC_TRIGGER_AFTER_INSERT_MICROTRANSACTION\"();") + .HasAnnotation("LC_TRIGGER_AFTER_UPDATE_MICROTRANSACTION", "CREATE FUNCTION \"BTCPayServer.Plugins.MicroNode\".\"LC_TRIGGER_AFTER_UPDATE_MICROTRANSACTION\"() RETURNS trigger as $LC_TRIGGER_AFTER_UPDATE_MICROTRANSACTION$\r\nBEGIN\r\n UPDATE \"BTCPayServer.Plugins.MicroNode\".\"MicroAccounts\"\r\n SET \"BalanceCheckpoint\" = \"BTCPayServer.Plugins.MicroNode\".\"MicroAccounts\".\"BalanceCheckpoint\" + 1\r\n WHERE \"BTCPayServer.Plugins.MicroNode\".\"MicroAccounts\".\"Key\" = OLD.\"AccountId\";\r\n UPDATE \"BTCPayServer.Plugins.MicroNode\".\"MicroTransactions\"\r\n SET \"Accounted\" = NEW.\"Accounted\", \"Active\" = NEW.\"Active\", \"DependentId\" = NEW.\"Id\"\r\n WHERE OLD.\"Id\" = \"BTCPayServer.Plugins.MicroNode\".\"MicroTransactions\".\"DependentId\" AND \"BTCPayServer.Plugins.MicroNode\".\"MicroTransactions\".\"AccountId\" = NEW.\"AccountId\";\r\n \r\n IF NEW.\"Accounted\" IS TRUE AND OLD.\"Accounted\" IS FALSE THEN \r\n UPDATE \"BTCPayServer.Plugins.MicroNode\".\"MicroAccounts\"\r\n SET \"Balance\" = \"BTCPayServer.Plugins.MicroNode\".\"MicroAccounts\".\"Balance\" + NEW.\"Amount\"\r\n WHERE \"BTCPayServer.Plugins.MicroNode\".\"MicroAccounts\".\"Key\" = OLD.\"AccountId\";\r\n END IF;\r\n \r\n IF NEW.\"Accounted\" IS TRUE AND OLD.\"Accounted\" IS TRUE AND OLD.\"Amount\" <> NEW.\"Amount\" THEN \r\n UPDATE \"BTCPayServer.Plugins.MicroNode\".\"MicroAccounts\"\r\n SET \"Balance\" = \"BTCPayServer.Plugins.MicroNode\".\"MicroAccounts\".\"Balance\" - OLD.\"Amount\" + NEW.\"Amount\"\r\n WHERE \"BTCPayServer.Plugins.MicroNode\".\"MicroAccounts\".\"Key\" = OLD.\"AccountId\";\r\n END IF;\r\n \r\n IF NEW.\"Accounted\" IS FALSE AND OLD.\"Accounted\" IS TRUE THEN \r\n UPDATE \"BTCPayServer.Plugins.MicroNode\".\"MicroAccounts\"\r\n SET \"Balance\" = \"BTCPayServer.Plugins.MicroNode\".\"MicroAccounts\".\"Balance\" - OLD.\"Amount\"\r\n WHERE \"BTCPayServer.Plugins.MicroNode\".\"MicroAccounts\".\"Key\" = OLD.\"AccountId\";\r\n END IF;\r\nRETURN NEW;\r\nEND;\r\n$LC_TRIGGER_AFTER_UPDATE_MICROTRANSACTION$ LANGUAGE plpgsql;\r\nCREATE TRIGGER LC_TRIGGER_AFTER_UPDATE_MICROTRANSACTION AFTER UPDATE\r\nON \"BTCPayServer.Plugins.MicroNode\".\"MicroTransactions\"\r\nFOR EACH ROW EXECUTE PROCEDURE \"BTCPayServer.Plugins.MicroNode\".\"LC_TRIGGER_AFTER_UPDATE_MICROTRANSACTION\"();"); + }); + + modelBuilder.Entity("BTCPayServer.Plugins.MicroNode.MicroTransaction", b => + { + b.HasOne("BTCPayServer.Plugins.MicroNode.MicroAccount", "Account") + .WithMany("Transactions") + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("BTCPayServer.Plugins.MicroNode.MicroTransaction", null) + .WithMany("Dependents") + .HasForeignKey("DependentId", "AccountId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("BTCPayServer.Plugins.MicroNode.MicroTransaction", "Dependent") + .WithMany() + .HasForeignKey("DependentId", "DependentAccountId"); + + b.Navigation("Account"); + + b.Navigation("Dependent"); + }); + + modelBuilder.Entity("BTCPayServer.Plugins.MicroNode.MicroAccount", b => + { + b.Navigation("Transactions"); + }); + + modelBuilder.Entity("BTCPayServer.Plugins.MicroNode.MicroTransaction", b => + { + b.Navigation("Dependents"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Plugins/BTCPayServer.Plugins.MicroNode/Views/MicroNode/Configure.cshtml b/Plugins/BTCPayServer.Plugins.MicroNode/Views/MicroNode/Configure.cshtml new file mode 100644 index 0000000..3bf4a08 --- /dev/null +++ b/Plugins/BTCPayServer.Plugins.MicroNode/Views/MicroNode/Configure.cshtml @@ -0,0 +1,96 @@ +@using BTCPayServer +@using BTCPayServer.Client +@using BTCPayServer.Lightning +@using BTCPayServer.Plugins.MicroNode +@using Microsoft.AspNetCore.Authorization +@using Microsoft.AspNetCore.Mvc.TagHelpers +@model BTCPayServer.Plugins.MicroNode.MicroNodeStoreSettings? +@inject IAuthorizationService AuthorizationService +@inject MicroNodeService MicroNodeService +@{ + ViewData.SetActivePage("MicroNode ", "Configure", "Configure"); + var storeId = Context.GetCurrentStoreId(); + var isAdmin = (await AuthorizationService.AuthorizeAsync(User, Policies.CanModifyServerSettings)).Succeeded; + var masters = (await MicroNodeService.GetMasterSettings()) + .Where(pair => pair.Key != storeId && (isAdmin || pair.Value.AdminOnly is false)) + .Select(m => new SelectListItem(m.Value.Name, m.Key)).ToArray(); + var masterId = Context.Items.TryGetValue("MasterId", out var masterIdObj) ? masterIdObj?.ToString() : null; + + var payments = await MicroNodeService.GetTransactions(storeId); +} +
+
+
+
+

+ @ViewData["Title"] +

+
+ @if (masters.Any()) + { + + } + @if (Model?.Key is not null) + { + + } +
+
+ @if (masters.Any()) + { + +
+
+
+ + +
+
+ + + +

Forward Destination

+ +
+
+
+ } + else + { +
+

There is no master node available to use

+
+ } + @if (payments?.Any() is true) + { +
+
+ + + + + + + + + + @foreach (var p in payments) + { + + + + + + + + } +
IdAccountedActiveTypeAmount
@p.Id@p.Accounted@p.Active@p.Type@LightMoney.MilliSatoshis(p.Amount).ToDecimal(LightMoneyUnit.BTC) BTC
+
+
+ } + + + +
+
+
\ No newline at end of file diff --git a/Plugins/BTCPayServer.Plugins.MicroNode/Views/MicroNode/ConfigureMaster.cshtml b/Plugins/BTCPayServer.Plugins.MicroNode/Views/MicroNode/ConfigureMaster.cshtml new file mode 100644 index 0000000..10eb59f --- /dev/null +++ b/Plugins/BTCPayServer.Plugins.MicroNode/Views/MicroNode/ConfigureMaster.cshtml @@ -0,0 +1,102 @@ +@using BTCPayServer +@using BTCPayServer.Lightning +@using BTCPayServer.Plugins.MicroNode +@using Microsoft.AspNetCore.Mvc.TagHelpers +@model BTCPayServer.Plugins.MicroNode.MicroNodeSettings +@inject MicroNodeService MicroNodeService +@{ + ViewData.SetActivePage("MicroNode ", "Configure Master", "ConfigureMaster"); + var storeId = Context.GetCurrentStoreId(); +} +
+
+
+
+

+ @ViewData["Title"] +

+
+ + @if (Model is not null) + { + + } +
+
+ +
+
+
+ + + +
+
+ + + +
+
+ + + +
+ +
+
+ + @if (Model is not null) + { + var ls = await MicroNodeService.GetMasterLiabilities(storeId, true); + + + + + + + + + + + @foreach (var l in ls) + { + var ssk = await MicroNodeService.GetStoreSettingsFromKey(l.Key); + + + + + + + + + + } +
Store idKeyBalance
+ @ssk?.Key + @l.Key@LightMoney.MilliSatoshis(l.Balance).ToDecimal(LightMoneyUnit.BTC) BTC
+ + + + + + + + + + @foreach(var p in l.Transactions) + { + + + + + + + + } +
IdAccountedActiveTypeAmount
@p.Id@p.Accounted@p.Active@p.Type@LightMoney.MilliSatoshis(p.Amount).ToDecimal(LightMoneyUnit.BTC) BTC
+
+ + } +
+
+
\ No newline at end of file diff --git a/Plugins/BTCPayServer.Plugins.MicroNode/Views/Shared/MicroNode/LNPaymentMethodSetupTab.cshtml b/Plugins/BTCPayServer.Plugins.MicroNode/Views/Shared/MicroNode/LNPaymentMethodSetupTab.cshtml new file mode 100644 index 0000000..e78e399 --- /dev/null +++ b/Plugins/BTCPayServer.Plugins.MicroNode/Views/Shared/MicroNode/LNPaymentMethodSetupTab.cshtml @@ -0,0 +1,66 @@ +@inject MicroNodeService MicroNodeService; +@using BTCPayServer.Plugins.MicroNode +@using Microsoft.AspNetCore.Mvc.TagHelpers +@using BTCPayServer.Abstractions.TagHelpers +@model BTCPayServer.Models.StoreViewModels.LightningNodeViewModel +@{ + var storeId = Model.StoreId; + if (Model.CryptoCode != "BTC") + { + return; + } + var client = await MicroNodeService.GetStoreSettings(storeId); +} + +
+ @if (client is not null) + { + } + else + { + MicroNode needs to be configured beforehand. + } +
+@if (client is not null) +{ + +} \ No newline at end of file diff --git a/Plugins/BTCPayServer.Plugins.MicroNode/Views/Shared/MicroNode/LNPaymentMethodSetupTabhead.cshtml b/Plugins/BTCPayServer.Plugins.MicroNode/Views/Shared/MicroNode/LNPaymentMethodSetupTabhead.cshtml new file mode 100644 index 0000000..b2c69e3 --- /dev/null +++ b/Plugins/BTCPayServer.Plugins.MicroNode/Views/Shared/MicroNode/LNPaymentMethodSetupTabhead.cshtml @@ -0,0 +1,19 @@ +@inject MicroNodeService MicroNodeService; +@using BTCPayServer.Plugins.MicroNode +@model BTCPayServer.Models.StoreViewModels.LightningNodeViewModel + +@if (Model.CryptoCode != "BTC") +{ + return; +} +@{ + var client = await MicroNodeService.GetStoreSettings(Model.StoreId); + var storeId = Model.StoreId; +} +@if (client is null) +{ + + +}else{ + +} diff --git a/Plugins/BTCPayServer.Plugins.MicroNode/Views/Shared/MicroNode/MicroNodeNav.cshtml b/Plugins/BTCPayServer.Plugins.MicroNode/Views/Shared/MicroNode/MicroNodeNav.cshtml new file mode 100644 index 0000000..cbef838 --- /dev/null +++ b/Plugins/BTCPayServer.Plugins.MicroNode/Views/Shared/MicroNode/MicroNodeNav.cshtml @@ -0,0 +1,31 @@ +@using BTCPayServer.Abstractions.Contracts +@using BTCPayServer.Abstractions.Extensions +@using BTCPayServer.Client +@using BTCPayServer.Plugins.MicroNode +@using Microsoft.AspNetCore.Mvc.TagHelpers +@inject IScopeProvider ScopeProvider +@inject MicroNodeService MicroNodeService +@{ + var storeId = ScopeProvider.GetCurrentStoreId(); +} +@if (!string.IsNullOrEmpty(storeId)) +{ + var masters = (await MicroNodeService.GetMasterSettings()).Select(m => new SelectListItem(m.Value.Name, m.Key)).ToArray(); + @if (masters.Any()) + { + + + + + } + + +} diff --git a/Plugins/BTCPayServer.Plugins.MicroNode/Views/_ViewImports.cshtml b/Plugins/BTCPayServer.Plugins.MicroNode/Views/_ViewImports.cshtml new file mode 100644 index 0000000..cf06ff9 --- /dev/null +++ b/Plugins/BTCPayServer.Plugins.MicroNode/Views/_ViewImports.cshtml @@ -0,0 +1,9 @@ +@using BTCPayServer.Abstractions.Extensions +@inject BTCPayServer.Abstractions.Services.Safe Safe +@addTagHelper *, BTCPayServer.Abstractions +@addTagHelper *, BTCPayServer.TagHelpers +@addTagHelper *, BTCPayServer.Views.TagHelpers +@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers + +@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers +@addTagHelper *, BTCPayServer \ No newline at end of file