micronode

This commit is contained in:
Kukks
2024-01-17 09:07:18 +01:00
parent d27a31ee8e
commit cfcaa17e94
24 changed files with 2388 additions and 11 deletions

View File

@@ -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}

View File

@@ -10,8 +10,18 @@ foreach (var plugin in plugins)
var assemblyConfigurationAttribute = typeof(Program).Assembly.GetCustomAttribute<AssemblyConfigurationAttribute>();
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;";

View File

@@ -0,0 +1,45 @@

<Project Sdk="Microsoft.NET.Sdk.Razor">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<LangVersion>12</LangVersion>
</PropertyGroup>
<!-- Plugin specific properties -->
<PropertyGroup>
<Product>MicroNode</Product>
<Description>Micro ln node.</Description>
<Version>1.0.0</Version>
<CopyLocalLockFileAssemblies>true</CopyLocalLockFileAssemblies>
</PropertyGroup>
<!-- Plugin development properties -->
<PropertyGroup>
<AddRazorSupportForMvc>true</AddRazorSupportForMvc>
<PreserveCompilationContext>false</PreserveCompilationContext>
<GenerateEmbeddedFilesManifest>true</GenerateEmbeddedFilesManifest>
</PropertyGroup>
<!-- This will make sure that referencing BTCPayServer doesn't put any artifact in the published directory -->
<ItemDefinitionGroup>
<ProjectReference>
<Properties>StaticWebAssetsEnabled=false</Properties>
<Private>false</Private>
<ExcludeAssets>runtime;native;build;buildTransitive;contentFiles</ExcludeAssets>
</ProjectReference>
</ItemDefinitionGroup>
<ItemGroup>
<EmbeddedResource Include="Resources\**" />
<ProjectReference Include="..\..\submodules\btcpayserver\BTCPayServer\BTCPayServer.csproj" />
<PackageReference Include="AsyncKeyedLock" Version="6.2.6" />
<PackageReference Include="FlexLabs.EntityFrameworkCore.Upsert" Version="8.0.0" />
<PackageReference Include="Laraue.EfCoreTriggers.PostgreSql" Version="8.0.1" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.0" />
<PackageReference Include="Microsoft.CodeAnalysis.Common" Version="4.7.0" />
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.7.0" />
</ItemGroup>
</Project>

View File

@@ -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<MicroTransaction> Transactions { get; set; } = new List<MicroTransaction>();
}

View File

@@ -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<LightningInvoice> 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<LightningInvoice> GetInvoice(uint256 paymentHash, CancellationToken cancellation = new CancellationToken())
{
return await GetInvoice(paymentHash.ToString(), cancellation);
}
public async Task<LightningInvoice[]> 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<LightningInvoice[]> 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<LightningPayment> 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<LightningPayment[]> 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<LightningPayment[]> 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<LightningInvoice> 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<LightningInvoice> 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<LightningInvoice> 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<ILightningInvoiceListener> Listen(CancellationToken cancellation = new CancellationToken())
{
return new MicroListener(await _innerClient.Listen(cancellation), _microNodeService, _key);
}
public async Task<LightningNodeInformation> GetInfo(CancellationToken cancellation = new CancellationToken())
{
var info = await _innerClient.GetInfo(cancellation);
return info;
}
public async Task<LightningNodeBalance> GetBalance(CancellationToken cancellation = new CancellationToken())
{
return new LightningNodeBalance(null, new OffchainBalance()
{
Local = await _microNodeService.GetBalance(_key, cancellation)
});
}
public async Task<PayResponse> Pay(PayInvoiceParams payParams, CancellationToken cancellation = new CancellationToken())
{
return await Pay(null, payParams, cancellation);
}
public async Task<PayResponse> 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<PayResponse> 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<OpenChannelResponse> OpenChannel(OpenChannelRequest openChannelRequest, CancellationToken cancellation = new CancellationToken())
{
throw new NotSupportedException();
}
public Task<BitcoinAddress> GetDepositAddress(CancellationToken cancellation = new CancellationToken())
{
throw new NotSupportedException();
}
public Task<ConnectionResult> 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<LightningChannel[]> ListChannels(CancellationToken cancellation = new CancellationToken())
{
throw new NotSupportedException();
}
}

View File

@@ -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<MicroNodeService>();
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);
}
}

View File

@@ -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<MicroNodeContext>
{
public MicroNodeContext CreateDbContext(string[] args)
{
DbContextOptionsBuilder<MicroNodeContext> builder = new DbContextOptionsBuilder<MicroNodeContext>();
// 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<MicroTransaction> MicroTransactions { get; set; }
public DbSet<MicroAccount> MicroAccounts { get; set; }
public MicroNodeContext()
{
}
public MicroNodeContext(DbContextOptions<MicroNodeContext> builderOptions) : base(builderOptions)
{
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
modelBuilder.HasDefaultSchema("BTCPayServer.Plugins.MicroNode");
modelBuilder.Entity<MicroTransaction>()
.HasKey(t => new {t.Id, t.AccountId});
modelBuilder.Entity<MicroAccount>()
.HasKey(t => t.Key);
modelBuilder.Entity<MicroAccount>()
.HasMany<MicroTransaction>(account => account.Transactions)
.WithOne(transaction => transaction.Account)
.HasForeignKey(transaction => transaction.AccountId);
modelBuilder.Entity<MicroTransaction>()
.HasOne<MicroTransaction>().WithMany(transaction => transaction.Dependents)
.HasForeignKey(transaction => new {transaction.DependentId, transaction.AccountId})
.HasPrincipalKey(transaction => new {transaction.Id, transaction.AccountId})
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<MicroTransaction>()
.AfterInsert(trigger => trigger
.Action(action =>
{
action.Condition(@ref => @ref.New.Accounted)
.Update<MicroAccount>(
(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<MicroAccount>(
(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<MicroTransaction>(
(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<MicroTransaction>()
.AfterDelete(trigger => trigger
.Action(action =>
{
action.Condition(@ref => @ref.Old.Accounted)
.Update<MicroAccount>(
(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<MicroAccount>(
(tableRefs, microAccount) =>
microAccount.Key ==
tableRefs.Old.AccountId, // Will be updated entities with matched condition
(tableRefs, oldBalance) => new MicroAccount
{BalanceCheckpoint = oldBalance.BalanceCheckpoint + 1});
}));
modelBuilder.Entity<MicroTransaction>()
.AfterUpdate(trigger => trigger
.Action(action =>
{
action.Update<MicroAccount>(
(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<MicroTransaction>(
(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<MicroAccount>(
(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<MicroAccount>(
(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<MicroAccount>(
(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<MicroAccount>(
// (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.
}
}

View File

@@ -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<MicroNodeContext>
{
public MicroNodeContextFactory(IOptions<DatabaseOptions> options) : base(options, "BTCPayServer.Plugins.MicroNode")
{
}
public override MicroNodeContext CreateContext()
{
var builder = new DbContextOptionsBuilder<MicroNodeContext>();
ConfigureBuilder(builder);
builder.UsePostgreSqlTriggers();
return new MicroNodeContext(builder.Options);
}
}

View File

@@ -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<IActionResult> 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<IActionResult> Configure(string storeId, string command, MicroNodeStoreSettings settings,
string? masterStoreId)
{
var store = HttpContext.GetStoreData();
var network = _networkProvider.GetNetwork<BTCPayNetwork>("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<LightningSupportedPaymentMethod>()
.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<IActionResult> 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<IActionResult> ConfigureMaster(string storeId, string command, MicroNodeSettings settings)
{
var store = HttpContext.GetStoreData();
var network = _networkProvider.GetNetwork<BTCPayNetwork>("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});
}
}

View File

@@ -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<IUIExtension>(new UIExtension("MicroNode/MicroNodeNav", "header-nav"));
applicationBuilder.AddSingleton<IUIExtension>(new UIExtension("MicroNode/LNPaymentMethodSetupTabhead", "ln-payment-method-setup-tabhead"));
applicationBuilder.AddSingleton<IUIExtension>(new UIExtension("MicroNode/LNPaymentMethodSetupTab", "ln-payment-method-setup-tab"));
// applicationBuilder.AddStartupTask<MicroNodeStartupTask>();
applicationBuilder.AddSingleton<MicroNodeContextFactory>();
applicationBuilder.AddDbContext<MicroNodeContext>((provider, o) =>
{
var factory = provider.GetRequiredService<MicroNodeContextFactory>();
factory.ConfigureBuilder(o);
o.UsePostgreSqlTriggers();
});
applicationBuilder.AddSingleton<ILightningConnectionStringHandler, MicroLightningConnectionStringHandler>();
applicationBuilder.AddSingleton<MicroNodeService>();
applicationBuilder.AddHostedService(provider => provider.GetRequiredService<MicroNodeService>());
base.Execute(applicationBuilder);
}
}

View File

@@ -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<MicroNodeService> _logger;
private readonly PullPaymentHostedService _pullPaymentHostedService;
private readonly IOptions<LightningNetworkOptions> _lightningNetworkOptions;
private static readonly ConcurrentDictionary<string, long> ExpectedCounter = new();
private readonly TaskCompletionSource _init = new();
private Dictionary<string, MicroNodeSettings> _ownerSettings;
private Dictionary<string, MicroNodeStoreSettings?> _storeSettings;
private readonly BTCPayNetwork _network;
private readonly IServiceProvider _serviceProvider;
private static readonly AsyncKeyedLock.AsyncKeyedLocker<string> KeyedLocker = new();
public const string MasterSettingsKey = "MicroNodeMasterSettings";
public const string StoreSettingsKey = "MicroNodeStoreSettings";
public MicroNodeService(MicroNodeContextFactory microNodeContextFactory,
BTCPayNetworkProvider btcPayNetworkProvider,
BTCPayNetworkJsonSerializerSettings btcPayNetworkJsonSerializerSettings,
StoreRepository storeRepository,
ILogger<MicroNodeService> logger,
EventAggregator eventAggregator,
PullPaymentHostedService pullPaymentHostedService,
IOptions<LightningNetworkOptions> 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<MicroTransaction?> 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<MicroTransaction[]> MatchRecords(string key, string[]? toArray)
{
if (toArray is null)
{
return Array.Empty<MicroTransaction>();
}
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<MicroTransaction> UpsertRecord(string key, LightningInvoice invoice)
{
return (await UpsertRecords(key, new[] {invoice})).First();
}
public Task<MicroTransaction[]> UpsertRecords(string key, LightningInvoice[] invoices)
{
return UpsertRecords(ConvertToRecords(key, invoices).ToArray());
}
public async Task<MicroTransaction> 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<MicroTransaction> 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<decimal>() 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<MicroTransaction>
{
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<MicroTransaction[]> UpsertRecords(string key, Data.PayoutData[] payouts)
{
return UpsertRecords(ConvertToRecords(key, payouts));
}
public async Task<MicroTransaction> UpsertRecord(string key, LightningPayment payment)
{
return (await UpsertRecords(key, new[] {payment})).First();
}
public Task<MicroTransaction[]> UpsertRecords(string key, LightningPayment[] payments)
{
return UpsertRecords(ConvertToRecords(key, payments));
}
private async Task<MicroTransaction[]> 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<LightMoney?> GetBalance(string key, CancellationToken cancellation)
{
using var keylock = await KeyedLocker.LockAsync(key, cancellation);
return await GetBalanceCore(key, cancellation);
}
private async Task<LightMoney?> 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<MicroNodeSettings?> GetMasterSettings(string storeId, CancellationToken cancellation = default)
{
await _init.Task.WaitAsync(cancellation);
return _ownerSettings.TryGetValue(storeId, out var settings) ? settings : null;
}
public async Task<ImmutableDictionary<string, MicroNodeSettings>> GetMasterSettings(
CancellationToken cancellation = default)
{
await _init.Task.WaitAsync(cancellation);
return _ownerSettings.Where(pair => pair.Value.Enabled).ToImmutableDictionary();
}
public async Task<MicroNodeStoreSettings?> GetStoreSettings(string storeId,
CancellationToken cancellation = default)
{
await _init.Task.WaitAsync(cancellation);
return _storeSettings.TryGetValue(storeId, out var settings) ? settings : null;
}
public async Task<MicroNodeStoreSettings?> 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<ILightningClient?> GetMasterLightningClientFromKey(string key)
{
var settings = await GetMasterSettingsFromKey(key);
if (settings is null)
{
return null;
}
return await GetMasterLightningClient(settings.Value.Item2);
}
public async Task<ILightningClient?> GetMasterLightningClient(string storeId)
{
var store = await _storeRepository.FindStore(storeId);
if (store is null)
{
return null;
}
var lightningConnectionString = store.GetSupportedPaymentMethods(_btcPayNetworkProvider)
.OfType<LightningSupportedPaymentMethod>()
.FirstOrDefault(method => method.CryptoCode == _network.CryptoCode)?.CreateLightningClient(_network,
_lightningNetworkOptions.Value, _serviceProvider.GetService<LightningClientFactoryService>());
return lightningConnectionString;
}
public async Task<MicroTransaction> 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<MicroNodeSettings>(MasterSettingsKey);
_storeSettings = await _storeRepository.GetSettingsAsync<MicroNodeStoreSettings>(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<string, ILightningClient> 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<MicroTransaction>();
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<string, int> _retries = new ();
private bool InvalidateAfterRetries(string id, int max)
{
return _retries.AddOrUpdate(id, 1, (_, i) => i + 1) >= max;
}
protected override void SubscribeToEvents()
{
Subscribe<PayoutEvent>();
Subscribe<CreatePayoutEvt>();
Subscribe<StoreRemovedEvent>();
Subscribe<CheckActiveTransactions>();
base.SubscribeToEvents();
}
public async Task StopAsync(CancellationToken cancellationToken)
{
await base.StopAsync(cancellationToken);
}
private ConcurrentDictionary<string, string> _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<MicroAccount[]> 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<MicroTransaction[]> 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();
}
}

View File

@@ -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; }
}

View File

@@ -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<MicroNodeStartupTask> _logger;
public MicroNodeStartupTask(MicroNodeContextFactory microNodeContextFactory, ILogger<MicroNodeStartupTask> 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");
}
}
}

View File

@@ -0,0 +1,7 @@
namespace BTCPayServer.Plugins.MicroNode;
public class MicroNodeStoreSettings
{
public string Key { get; set; }
public string? ForwardDestination { get; set; }
}

View File

@@ -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<MicroTransaction> Dependents { get; set; }
}

View File

@@ -0,0 +1,130 @@
// <auto-generated />
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
{
/// <inheritdoc />
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<string>("Key")
.HasColumnType("text");
b.Property<long>("Balance")
.HasColumnType("bigint");
b.Property<long>("BalanceCheckpoint")
.HasColumnType("bigint");
b.Property<string>("MasterStoreId")
.HasColumnType("text");
b.HasKey("Key");
b.ToTable("MicroAccounts", "BTCPayServer.Plugins.MicroNode");
});
modelBuilder.Entity("BTCPayServer.Plugins.MicroNode.MicroTransaction", b =>
{
b.Property<string>("Id")
.HasColumnType("text");
b.Property<string>("AccountId")
.HasColumnType("text");
b.Property<bool>("Accounted")
.HasColumnType("boolean");
b.Property<bool>("Active")
.HasColumnType("boolean");
b.Property<long>("Amount")
.HasColumnType("bigint");
b.Property<string>("DependentAccountId")
.HasColumnType("text");
b.Property<string>("DependentId")
.HasColumnType("text");
b.Property<string>("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
}
}
}

View File

@@ -0,0 +1,113 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace BTCPayServer.Plugins.MicroNode.Migrations
{
/// <inheritdoc />
public partial class Initial : Migration
{
/// <inheritdoc />
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<string>(type: "text", nullable: false),
Balance = table.Column<long>(type: "bigint", nullable: false),
BalanceCheckpoint = table.Column<long>(type: "bigint", nullable: false),
MasterStoreId = table.Column<string>(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<string>(type: "text", nullable: false),
AccountId = table.Column<string>(type: "text", nullable: false),
Amount = table.Column<long>(type: "bigint", nullable: false),
Accounted = table.Column<bool>(type: "boolean", nullable: false),
Type = table.Column<string>(type: "text", nullable: true),
Active = table.Column<bool>(type: "boolean", nullable: false),
DependentId = table.Column<string>(type: "text", nullable: true),
DependentAccountId = table.Column<string>(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\"();");
}
/// <inheritdoc />
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");
}
}
}

View File

@@ -0,0 +1,127 @@
// <auto-generated />
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<string>("Key")
.HasColumnType("text");
b.Property<long>("Balance")
.HasColumnType("bigint");
b.Property<long>("BalanceCheckpoint")
.HasColumnType("bigint");
b.Property<string>("MasterStoreId")
.HasColumnType("text");
b.HasKey("Key");
b.ToTable("MicroAccounts", "BTCPayServer.Plugins.MicroNode");
});
modelBuilder.Entity("BTCPayServer.Plugins.MicroNode.MicroTransaction", b =>
{
b.Property<string>("Id")
.HasColumnType("text");
b.Property<string>("AccountId")
.HasColumnType("text");
b.Property<bool>("Accounted")
.HasColumnType("boolean");
b.Property<bool>("Active")
.HasColumnType("boolean");
b.Property<long>("Amount")
.HasColumnType("bigint");
b.Property<string>("DependentAccountId")
.HasColumnType("text");
b.Property<string>("DependentId")
.HasColumnType("text");
b.Property<string>("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
}
}
}

View File

@@ -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);
}
<form method="post" asp-action="Configure" asp-controller="MicroNode" asp-route-storeId="@storeId">
<div class="row mb-4">
<div class="col-12">
<div class="d-flex align-items-center justify-content-between mb-3">
<h3 class="mb-0">
<span>@ViewData["Title"]</span>
</h3>
<div class="d-flex gap-3 mt-3 mt-sm-0">
@if (masters.Any())
{
<button name="command" type="submit" value="save" class="btn btn-primary">Save</button>
}
@if (Model?.Key is not null)
{
<button name="command" type="submit" value="clear" class="btn btn-danger">Clear</button>
}
</div>
</div>
@if (masters.Any())
{
<div class="row">
<div class="col-xl-8 col-xxl-constrain">
<div class="form-group">
<label for="masterStoreId" class="form-label" data-required>Master</label>
<select name="masterStoreId" id="masterStoreId" asp-items="@masters" class="form-select" value="@masterId"></select>
</div>
<div class="form-group">
<label asp-for="ForwardDestination" class="form-label">Forward Destination</label>
<input asp-for="ForwardDestination" class="form-control"/>
<span asp-validation-for="ForwardDestination" class="text-danger"></span>
<p class="text-muted pt-2">Forward Destination</p>
</div>
</div>
</div>
}
else
{
<div class="alert alert-warning">
<p class="mb-0">There is no master node available to use</p>
</div>
}
@if (payments?.Any() is true)
{
<div class="row">
<div class="table-responsive">
<table class="table">
<tr>
<th>Id</th>
<th>Accounted</th>
<th>Active</th>
<th>Type</th>
<th>Amount</th>
</tr>
@foreach (var p in payments)
{
<tr>
<td>@p.Id</td>
<td>@p.Accounted</td>
<td>@p.Active</td>
<td>@p.Type</td>
<td>@LightMoney.MilliSatoshis(p.Amount).ToDecimal(LightMoneyUnit.BTC) BTC</td>
</tr>
}
</table>
</div>
</div>
}
<input type="hidden" asp-for="Key"/>
</div>
</div>
</form>

View File

@@ -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();
}
<form method="post" asp-action="ConfigureMaster" asp-controller="MicroNode" asp-route-storeId="@storeId">
<div class="row mb-4">
<div class="col-12">
<div class="d-flex align-items-center justify-content-between mb-3">
<h3 class="mb-0">
<span>@ViewData["Title"]</span>
</h3>
<div class="d-flex gap-3 mt-3 mt-sm-0">
<button name="command" type="submit" value="save" class="btn btn-primary">Save</button>
@if (Model is not null)
{
<button name="command" type="submit" value="clear" class="btn btn-danger">Clear</button>
}
</div>
</div>
<div class="row">
<div class="col-xl-8 col-xxl-constrain">
<div class="form-group form-check">
<input asp-for="Enabled" type="checkbox" class="form-check-input"/>
<label asp-for="Enabled" class="form-check-label"></label>
<span asp-validation-for="Enabled" class="text-danger"></span>
</div>
<div class="form-group form-check">
<input asp-for="AdminOnly" type="checkbox" class="form-check-input"/>
<label asp-for="AdminOnly" class="form-check-label"></label>
<span asp-validation-for="AdminOnly" class="text-danger"></span>
</div>
<div class="form-group">
<label asp-for="Name" class="form-label" ></label>
<input asp-for="Name" type="text" class="form-control" required/>
<span asp-validation-for="Name" class="text-danger"></span>
</div>
</div>
</div>
@if (Model is not null)
{
var ls = await MicroNodeService.GetMasterLiabilities(storeId, true);
<table class="table">
<tr>
<th>Store id</th>
<th>Key</th>
<th>Balance</th>
</tr>
@foreach (var l in ls)
{
var ssk = await MicroNodeService.GetStoreSettingsFromKey(l.Key);
<tr>
<td>
@ssk?.Key
</td>
<td>@l.Key</td>
<td>@LightMoney.MilliSatoshis(l.Balance).ToDecimal(LightMoneyUnit.BTC) BTC</td>
</tr>
<tr >
<td colspan="2">
<table class="table">
<tr>
<th>Id</th>
<th>Accounted</th>
<th>Active</th>
<th>Type</th>
<th>Amount</th>
</tr>
@foreach(var p in l.Transactions)
{
<tr>
<td>@p.Id</td>
<td>@p.Accounted</td>
<td>@p.Active</td>
<td>@p.Type</td>
<td>@LightMoney.MilliSatoshis(p.Amount).ToDecimal(LightMoneyUnit.BTC) BTC</td>
</tr>
}
</table>
</td>
</tr>
}
</table>
}
</div>
</div>
</form>

View File

@@ -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);
}
<div id="MicroNodeSetup" class="pt-3 tab-pane fade" role="tabpanel" aria-labelledby="LightningNodeType-MicroNode">
@if (client is not null)
{
}
else
{
<a asp-action="Configure" asp-controller="MicroNode" asp-route-storeId="@storeId">MicroNode needs to be configured beforehand.</a>
}
</div>
@if (client is not null)
{
<script>
const typePrefix = 'type=micro;key=@client.Key';
const triggerEl = document.getElementById('LightningNodeType-MicroNode')
const connStringEl = document.getElementById('ConnectionString')
const connString = connStringEl.value;
const isMicro = connString.startsWith(typePrefix);
if (isMicro) {
// deactivate currently active tab and activate Breez tab
const activeEl = document.querySelector('input[name="LightningNodeType"]:checked')
if (activeEl) {
activeEl.removeAttribute('checked')
activeEl.setAttribute('aria-selected', 'false')
document.querySelector('#LightningNodeTypeTabs .tab-pane.active').classList.remove('active', 'show')
triggerEl.setAttribute('checked', 'checked')
triggerEl.setAttribute('aria-selected', 'true')
document.getElementById('MicroNodeSetup').classList.add('active', 'show')
}
}
document.addEventListener('DOMContentLoaded', () => {
if (isBreez) {
const tabTrigger = new bootstrap.Tab(triggerEl)
triggerEl.checked = true
tabTrigger.show()
}
delegate('change', 'input[name="LightningNodeType"]', e => {
const activeEl = document.querySelector('input[name="LightningNodeType"]:checked')
if (activeEl.id === "LightningNodeType-MicroNode"){
connStringEl.value =typePrefix;
}
})
})
</script>
}

View File

@@ -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)
{
<a asp-action="Configure" asp-controller="MicroNode" asp-route-storeId="@storeId" value="Custom" type="radio" id="LightningNodeType-MicroNode" role="tab" aria-controls="MicroNodeSetup" aria-selected="false" name="LightningNodeType" ><label for="LightningNodeType-MicroNode">Configure Micro Node</label></a>
}else{
<input value="Custom" type="radio" id="LightningNodeType-MicroNode" data-bs-toggle="pill" data-bs-target="#MicroNodeSetup" role="tab" aria-controls="MicroNodeSetup" aria-selected="false" name="LightningNodeType">
<label for="LightningNodeType-MicroNode">Use Micro node</label>}

View File

@@ -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())
{
<li class="nav-item">
<a permission="@Policies.CanModifyStoreSettings" asp-controller="MicroNode" asp-action="Configure" asp-route-storeId="@storeId" class="nav-link @ViewData.IsActivePage("MicroNode")" id="Nav-MicroNode">
<span>MicroNode</span>
</a>
</li>
}
<li class="nav-item">
<a permission="@Policies.CanModifyStoreSettings" asp-controller="MicroNode" asp-action="ConfigureMaster" asp-route-storeId="@storeId" class="nav-link @ViewData.IsActivePage("MicroNodeMaster")" id="Nav-MicroNodeMaster">
<span>MicroNode Master</span>
</a>
</li>
}

View File

@@ -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