diff --git a/docs/spark-swaps.md b/docs/spark-swaps.md
index 428a2f67..3da445e6 100644
--- a/docs/spark-swaps.md
+++ b/docs/spark-swaps.md
@@ -2,16 +2,16 @@
Spark is a swap-out provider next to Loop and 40swap. A node moves Lightning liquidity to a NodeGuard on-chain wallet through a [Spark](https://www.spark.money/) wallet of NodeGuard's:
1. The node pays an invoice of the Spark wallet.
-2. What that payment brought in exits on-chain to an address of the destination wallet, reserved when the swap exits, in a cooperative exit brokered by the Spark Service Provider (SSP).
+2. What that payment brought in waits in the Spark wallet until the wallet is full, then exits on-chain with the wallet's other paid swaps, to an address of the destination wallet reserved at that moment, in one cooperative exit brokered by the Spark Service Provider (SSP).
-NodeGuard is not a Spark wallet product. A Spark wallet is transit only: each swap's sats leave in that swap's own exit, and the guardrails below alert when sats stay behind.
+NodeGuard is not a Spark wallet product. A Spark wallet is transit only: each swap's sats leave in an exit of their own leaves, and the guardrails below alert when sats stay behind.
The Spark client is the [NSpark](https://github.com/orklabs/nspark) .NET SDK, from the [Elenpay fork](https://github.com/Elenpay/nspark) in `vendor/nspark` (a git submodule). By default, on mainnet, the operators are Lightspark, Breez and Flashnet (2-of-3) and the SSP is Lightspark's.
## How a swap runs
**Creation.** Swaps are created by auto-liquidity, the New Swap dialog, or the `RequestSwapOut` RPC. A swap goes through the node's Spark wallet (see [Signing](#signing)).
-1. A node may have up to its **Max swaps in flight** Spark swaps open at once (the node setting on the Nodes page; 0 counts as 1). An in-process lock serializes the check and the record, so concurrent requests can't go over it.
+1. A node may have up to its **Spark max swaps in flight** Spark swaps being paid at once (the node's liquidity settings on the Nodes page; 0 counts as 1). Once paid, a swap waits in the wallet, whose max balance limits it, and no longer counts. An in-process lock serializes the check and the record, so concurrent requests can't go over it.
2. A swap that would bring the wallet over its max balance, counting what its swaps not paid yet will still bring in, is refused.
3. The Spark wallet issues an invoice through the SSP. An invoice without a receive request is refused before anything is paid: the swap's funds are found only through the transfer the SSP reports for it.
4. The swap is recorded before anything is paid: its payment hash, destination wallet and the Spark identity that will receive it. No address is reserved yet: the swap needs one only when it exits.
@@ -19,20 +19,21 @@ The Spark client is the [NSpark](https://github.com/orklabs/nspark) .NET SDK, fr
**Payment.** `MonitorSwapsJob` runs every 10 minutes, or every minute in a dev environment. It looks the payment up on the node by its hash. The swap fails if the payment failed, or if the node never saw it and the invoice has expired. Once the payment has settled, the swap is `SparkSwapExitJob`'s.
-**Exit.** `SparkSwapExitJob` runs every `SPARK_EXIT_INTERVAL_MINUTES` (10 by default, 1 in a dev environment). Each paid swap exits on its own, from the Spark wallet it was paid into (found by its identity, whichever wallet the node uses now), to its own destination address, in saved steps: paid, then leaves attributed, then exit sent, then payout confirmed.
+**Exit.** `SparkSwapExitJob` runs every `SPARK_EXIT_INTERVAL_MINUTES` (10 by default, 1 in a dev environment). Paid swaps exit from the Spark wallet they were paid into (found by its identity, whichever wallet the node uses now), in saved steps: paid, then leaves attributed, then exit sent, then payout confirmed.
1. **Leaves attributed.** A Spark balance is made of leaves of fixed sizes, and the payment arrives as one inbound transfer whose leaves add up to what it brought in. The job claims it and records on the swap the transfer (the one the SSP reports for the swap's receive request), its leaves and their total. A transfer is given to one swap only, and a swap only ever takes its own: while the SSP reports none, the swap waits, and nothing is matched by amount. Only a claimed transfer is attributed: one whose claim failed is claimed again on the next run. A transfer that can never be claimed leaves the swap waiting until the overdue alert.
-2. **Exit sent.** The job reserves the swap's address in the destination wallet and saves it on the swap, then exactly those leaves exit to it. No leaf is split or swapped with the SSP, so the remote signer can sign the exit too. The exit fee is capped by `SPARK_MAX_EXIT_FEE_SATS`, and the exit's txid is saved right away.
+2. **Waiting for the wallet to fill.** A cooperative exit's fee doesn't grow with its leaves (Lightspark charges per exit; open-ssp's exit transaction always has the same shape), so paid swaps wait in their Spark wallet and exit together. The wallet is **full** once the room left under its max balance (counting what its swaps not paid yet will bring in) is below the smallest Spark min swap of the nodes using it, so no node can add a swap; a wallet no node uses counts as full. A full wallet's swaps exit once none of its swaps is still being paid. There is no max wait: to exit a wallet's swaps early, lower its max balance.
+3. **Exit sent.** The full wallet's attributed swaps exit in batches, one per destination wallet (an exit pays one address), oldest swaps first, of at most 100 leaves (open-ssp's limit; a swap's leaves are never split across exits). The job reserves one address in the destination wallet and saves it on every swap of the batch, then exactly their leaves exit to it. No leaf is split or swapped with the SSP, so the remote signer can sign the exit too. The exit fee is capped by `SPARK_MAX_EXIT_FEE_SATS`, and the exit's txid is saved on every swap right away.
- The exit goes at `SPARK_EXIT_SPEED`, `SLOW` by default: the cheapest, at the cost of time. The SSP quotes each speed separately, a user fee plus an L1 broadcast fee at the fee rate that speed aims for: `250 × sats_per_vbyte + 750` in all ([Spark fees](https://docs.spark.money/wallets/estimate-fees), [Withdraw to L1](https://docs.spark.money/wallets/withdraw-to-l1)). Spark doesn't promise a confirmation time for any speed; under high fees a `SLOW` exit can take hours.
- - Leaves near their timelock floor are renewed first. A renewal keeps a leaf's id (the operators renew it in place), so the swap still exits its own leaves. If they can't move yet (a renewal failed, or they are frozen), the swap waits and tries again on the next run, and the overdue alert below flags it. No other leaves are ever taken in their place.
- - If the leaves already exited (a crash before the txid was saved), the job waits for the payout instead of exiting again.
-3. **Payout confirmed.** The exit's leaves stay the wallet's (owned, locked) until its transaction confirms; the swap stays pending meanwhile and still counts against the node's Max swaps in flight. The swap completes when the destination wallet sees a confirmed payout to the reserved address. Matching on the address, rather than the exit's txid, survives the SSP fee-bumping the exit. The payout is checked before exiting, so an exit whose txid was not saved never runs twice.
+ - Leaves near their timelock floor are renewed first. A renewal keeps a leaf's id (the operators renew it in place), so each swap still exits its own leaves. A swap whose leaves can't move yet (a renewal failed, or they are frozen) leaves the batch and waits for the next one; the others exit without it, and the overdue alert below flags it. No other leaves are ever taken in its place.
+ - If the leaves already exited (a crash before the txid was saved), the job waits for the payout instead of exiting again. The swaps sharing a reserved address are the batch from then on.
+4. **Payout confirmed.** The exit's leaves stay the wallet's (owned, locked) until its transaction confirms; the swaps stay pending meanwhile. They complete together when the destination wallet sees a confirmed payout to the reserved address. Matching on the address, rather than the exit's txid, survives the SSP fee-bumping the exit. The payout is checked before exiting, so an exit whose txid was not saved never runs twice.
-All attributions run before any exit, and exits go strictly one at a time, oldest swap first. This is separate from Max swaps in flight: that limit decides how many swaps a node may have open; the job only orders their exits. With Max swaps in flight at 3, three swaps are paid in parallel, and the next run exits the first, then the second, then the third.
+All attributions run before any exit, and exits go strictly one at a time, oldest batch first.
**Fees and accounting.** What the swap delivered is what landed on-chain: its payout, shown as "Received on-chain" on the Swaps page. The fees add up to the amount minus the payout, plus the routing fee:
- **Routing fee:** what the node paid on Lightning. It includes Spark's fixed 15 bps (0.15%), which Spark charges to the sender on the Lightning route through the invoice's route hints ([Spark fees](https://docs.spark.money/wallets/estimate-fees)); receiving into Spark is free.
- **Service fee:** shown as Spark's 15 bps "in routing fee", not added to the total. Only if the payment brought in less than the amount is that shortfall recorded as a service fee.
-- **On-chain fee:** the move from Spark to L1: the SSP's exit fee and the L1 broadcast at the exit speed, what the swap's leaves held minus the payout.
+- **On-chain fee:** the move from Spark to L1: the SSP's exit fee and the L1 broadcast at the exit speed, what the batch's leaves held minus its payout, shared among its swaps in proportion to what each brought in. Each swap's payout is what it brought in minus its share.
Each fee shows its BTC amount and its percentage of the swap, with ppm and USD in the tooltip.
@@ -84,8 +85,10 @@ While Spark is enabled, invalid settings stop NodeGuard at startup.
| `SPARK_THRESHOLD` | derived from the operator count | Operators' signing threshold |
| `SPARK_SSP_URL` | Lightspark's on mainnet; required on regtest | The SSP's GraphQL endpoint |
| `SPARK_SSP_IDENTITY_PUBKEY` | Lightspark's on mainnet; fetched from the SSP on regtest | Required with a custom SSP on mainnet |
-| `SPARK_MAX_EXIT_FEE_SATS` | `20000` | Highest SSP exit fee accepted per swap |
+| `SPARK_MAX_EXIT_FEE_SATS` | `20000` | Highest SSP exit fee accepted per exit (a batch of swaps) |
| `SPARK_MAX_BALANCE_SATS` | `10000000` | Most sats a Spark wallet may hold, counting a new swap and its swaps not paid yet, unless it has its own max balance |
+| `SPARK_SWAP_MIN_SATS` | `50000` | A node's smallest Spark swap, at least 10000, unless the node sets its own |
+| `SPARK_SWAP_MAX_SATS` | `2000000` | A node's largest Spark swap, up to `MAXIMUM_SWAP_OUT_SIZE_BTC`, unless the node sets its own |
| `SPARK_EXIT_INTERVAL_MINUTES` | `10`, `1` in a dev environment | How often paid swaps exit on-chain |
| `SPARK_EXIT_SPEED` | `SLOW` | `FAST`, `MEDIUM` or `SLOW`: the fee rate swap exits and Withdraw all aim for. `SLOW` is the cheapest and confirms last; the overdue alert waits 6, 12 or 24 hours accordingly |
| `SPARK_PAYMENT_TIMEOUT_MINUTES` | `10` | How long LND keeps trying routes for a swap's payment, 1 to 25 (the invoice lasts 30 minutes); a node can set its own in its liquidity settings |
@@ -98,7 +101,9 @@ With the remote signer, `REMOTE_SIGNER_ENDPOINT` must be the Function URL with n
## Using it
-- **Auto-liquidity.** Give the node a Spark weight and a Spark wallet on the Nodes page; the Loop, 40swap and Spark weights sum to 100. The node's Max swaps in flight limits Spark swaps like any other, and Spark is skipped while the node's Spark wallet is unavailable. A Spark swap is sized down to the room left under the wallet's max balance (counting its swaps not paid yet), and Spark is skipped when that room is below the node's minimum swap. If no other weighted provider is left, the run is skipped without reserving an address.
+- **Auto-liquidity.** Give the node a Spark weight and a Spark wallet on the Nodes page; the Loop, 40swap and Spark weights sum to 100. Spark is skipped while the node's Spark wallet is unavailable.
+ - Each provider has its own min and max swap size and its own max swaps in flight on the node (Loop, 40swap and Spark; Spark's sizes default to `SPARK_SWAP_MIN_SATS` / `SPARK_SWAP_MAX_SATS`), so Loop can run one swap at a time while Spark runs several. Auto-liquidity picks the provider first, among those below their max swaps in flight whose min fits what is above the balance threshold, then swaps the most that provider takes. With every weighted provider at its max, the run is skipped.
+ - A Spark swap is also sized down to the room left under the wallet's max balance (counting its swaps not paid yet), and Spark is skipped when that room is below the node's Spark min swap. If no other weighted provider is left, the run is skipped without reserving an address.
- **New Swap dialog.** Spark lists the nodes whose Spark wallet is ready. There is no up-front quote: the SSP quotes the exit for the leaves the payment brings in. The confirmation shows the caps instead, with their percentage of the swap.
- **gRPC.**
- `RequestSwapOut` (`provider: SWAP_PROVIDER_SPARK`) returns no destination address: the swap reserves it when it exits, and `GetSwapOut` reports it from then on.
@@ -110,7 +115,7 @@ With the remote signer, `REMOTE_SIGNER_ENDPOINT` must be the Function URL with n
The swap monitor checks each Spark wallet after each pass. Each alert below is audited once and logged with structured fields:
- `SparkBalanceStuck`: for an hour, a Spark wallet has held more than its swaps in flight account for. Until a swap completes, what it brought in (or its amount, until that is known) is its own and never counts as stuck, including while its exit waits to confirm.
-- `SparkExitOverdue`: a swap was paid and still has no confirmed payout after 6 hours at `FAST`, 12 at `MEDIUM` or 24 at `SLOW` (`SPARK_EXIT_SPEED`).
+- `SparkExitOverdue`: a paid swap still has no confirmed payout 6 hours at `FAST`, 12 at `MEDIUM` or 24 at `SLOW` (`SPARK_EXIT_SPEED`) after it should have moved on: after it was paid while its transfer isn't attributed, or after its exit was first attempted. A swap waiting for its wallet to fill is not late.
Each wallet's balance is shown in its details on the Wallets page and exported as the `nodeguard.spark.balance` gauge (meter `NodeGuard.Spark`), tagged `spark.wallet` with the wallet's id (`remote-signer` for the remote signer's).
@@ -131,7 +136,7 @@ The remote signer refuses to sign Spark operations with a `Compromised` seed. A
`just spark-up` starts a regtest Spark network (three operators, the open-source open-ssp SSP and its Lightning node) on the Polar chain; see [docker/spark/README.md](../docker/spark/README.md).
-`just test-e2e` and the CI `e2e-test` job run the whole E2E suite with the network up, including `SparkSwapOutE2ETests`. It creates a Spark wallet over gRPC, gives it to alice in the database with Max swaps in flight 2, restarts NodeGuard (the seed must decrypt after a restart), and starts two swaps at once. Each must exit its own leaves to its own address and record its payout, with its on-chain and service fees adding up to the amount minus the payout, and the Spark wallet must end empty. A second test holds a swap's exit (NBXplorer stopped, so no exit address can be reserved), ages its leaves to their timelock floor with the local operators' test RPC (`mock.MockService/modify_node_timelock`), then lets the exit run: it must renew the leaves (one through a node renewal, the rest through refund renewals) and move exactly them, under their own ids, with the amounts adding up.
+`just test-e2e` and the CI `e2e-test` job run the whole E2E suite with the network up, including `SparkSwapOutE2ETests`. It creates a Spark wallet over gRPC with a max balance two swaps fill, gives it to alice in the database with Spark max swaps in flight 2 and a Spark min swap no third swap fits, restarts NodeGuard (the seed must decrypt after a restart), and starts two swaps at once. Once both are paid the wallet is full: they must exit together, in one exit to one address, each recording its share of the payout, with its on-chain and service fees adding up to the amount minus its payout, and the Spark wallet must end empty. A second test holds a swap's exit (NBXplorer stopped, so no exit address can be reserved), ages its leaves to their timelock floor with the local operators' test RPC (`mock.MockService/modify_node_timelock`), then lets the exit run: it must renew the leaves (one through a node renewal, the rest through refund renewals) and move exactly them, under their own ids, with the amounts adding up.
## Before enabling it on mainnet
diff --git a/src/Data/Models/Node.cs b/src/Data/Models/Node.cs
index 9fd8a4ac..d981a3a3 100644
--- a/src/Data/Models/Node.cs
+++ b/src/Data/Models/Node.cs
@@ -80,19 +80,50 @@ public class Node : Entity
public bool AutoLiquidityManagementEnabled { get; set; }
///
- /// Minimum swap amount in satoshis
+ /// Minimum Loop swap amount in satoshis
///
- public long SwapMinAmountSats { get; set; }
+ public long LoopSwapMinAmountSats { get; set; }
///
- /// Maximum swap amount in satoshis
+ /// Maximum Loop swap amount in satoshis
///
- public long SwapMaxAmountSats { get; set; }
+ public long LoopSwapMaxAmountSats { get; set; }
///
- /// Maximum number of concurrent swaps to limit HTLC locking
+ /// Minimum 40swap swap amount in satoshis
///
- public int MaxSwapsInFlight { get; set; }
+ public long FortySwapMinAmountSats { get; set; }
+
+ ///
+ /// Maximum 40swap swap amount in satoshis
+ ///
+ public long FortySwapMaxAmountSats { get; set; }
+
+ ///
+ /// Minimum Spark swap amount in satoshis; null means SPARK_SWAP_MIN_SATS
+ ///
+ public long? SparkSwapMinAmountSats { get; set; }
+
+ ///
+ /// Maximum Spark swap amount in satoshis; null means SPARK_SWAP_MAX_SATS
+ ///
+ public long? SparkSwapMaxAmountSats { get; set; }
+
+ ///
+ /// Maximum number of concurrent Loop swaps, to limit HTLC locking
+ ///
+ public int LoopMaxSwapsInFlight { get; set; }
+
+ ///
+ /// Maximum number of concurrent 40swap swaps, to limit HTLC locking
+ ///
+ public int FortySwapMaxSwapsInFlight { get; set; }
+
+ ///
+ /// Maximum number of Spark swaps being paid at once, to limit HTLC locking. Once paid, a Spark swap waits in
+ /// its Spark wallet, whose max balance limits it
+ ///
+ public int SparkMaxSwapsInFlight { get; set; }
///
/// Maximum swap routing fee ratio acceptable for swaps as a decimal between 0 and 1
@@ -135,7 +166,7 @@ public class Node : Entity
///
/// Weight for the Spark swap provider selection (default 0). Spark is skipped while the node's
- /// Spark wallet is unavailable; MaxSwapsInFlight limits Spark swaps like any other
+ /// Spark wallet is unavailable; SparkMaxSwapsInFlight limits its swaps
///
public int SparkSwapWeight { get; set; } = 0;
diff --git a/src/Data/Models/Swaps.cs b/src/Data/Models/Swaps.cs
index 26c4c1d6..6dd19d10 100644
--- a/src/Data/Models/Swaps.cs
+++ b/src/Data/Models/Swaps.cs
@@ -189,6 +189,12 @@ public class SwapOut : Entity
///
public long? SparkReceivedSats { get; set; }
+ ///
+ /// Spark swaps only: when the swap's exit was first attempted, in a batch with the other paid swaps of its Spark
+ /// wallet once the wallet was full. Until then the swap waits in the wallet
+ ///
+ public DateTimeOffset? SparkExitDatetime { get; set; }
+
///
/// What the swap paid on-chain to , once confirmed: what it delivered.
/// Recorded for Spark swaps
diff --git a/src/Data/Repositories/Interfaces/ISwapOutRepository.cs b/src/Data/Repositories/Interfaces/ISwapOutRepository.cs
index 202f78ff..aa3488b1 100644
--- a/src/Data/Repositories/Interfaces/ISwapOutRepository.cs
+++ b/src/Data/Repositories/Interfaces/ISwapOutRepository.cs
@@ -42,9 +42,13 @@ public interface ISwapOutRepository
Task<(bool, string?)> AddAsync(SwapOut swap);
Task<(bool, string?)> AddRangeAsync(List swaps);
(bool, string?) Update(SwapOut swap);
+
+ /// Saves several swaps in one transaction: all of them, or none
+ (bool, string?) UpdateRange(IReadOnlyCollection swaps);
///
- /// Get all swaps that are currently in flight (not completed, failed, or timed out) for a specific node, swaps older than 24 hours are ignored to avoid swaps deadlocks if many are stuck
+ /// Get all swaps that are currently in flight (not completed, failed, or timed out) for a specific node, swaps older than 24 hours are ignored to avoid swaps deadlocks if many are stuck.
+ /// A paid Spark swap is not in flight any more: it waits in its Spark wallet, whose max balance limits it
///
Task> GetInFlightSwapsByNode(int nodeId);
diff --git a/src/Data/Repositories/SwapOutRepository.cs b/src/Data/Repositories/SwapOutRepository.cs
index c102ad10..4bf8d872 100644
--- a/src/Data/Repositories/SwapOutRepository.cs
+++ b/src/Data/Repositories/SwapOutRepository.cs
@@ -172,6 +172,27 @@ public async Task> GetAllPending()
return _repository.Update(swap, context);
}
+ public (bool, string?) UpdateRange(IReadOnlyCollection swaps)
+ {
+ using var context = _dbContextFactory.CreateDbContext();
+
+ foreach (var swap in swaps)
+ {
+ swap.SetUpdateDatetime();
+ }
+
+ try
+ {
+ // One SaveChanges: one transaction
+ context.UpdateRange(swaps);
+ return (context.SaveChanges() > 0, null);
+ }
+ catch (Exception e)
+ {
+ return (false, e.Message);
+ }
+ }
+
public async Task> GetInFlightSwapsByNode(int nodeId)
{
await using var context = await _dbContextFactory.CreateDbContextAsync();
@@ -182,7 +203,8 @@ public async Task> GetInFlightSwapsByNode(int nodeId)
var swaps = await context.SwapOuts
.Where(s => s.NodeId == nodeId &&
s.Status == SwapOutStatus.Pending &&
- s.CreationDatetime >= twentyFourHoursAgo)
+ s.CreationDatetime >= twentyFourHoursAgo &&
+ !(s.Provider == SwapProvider.Spark && s.LightningFeeSats != null))
.ToListAsync();
return swaps;
diff --git a/src/Jobs/AutoLiquidityManagementJob.cs b/src/Jobs/AutoLiquidityManagementJob.cs
index 7775dbad..39943589 100644
--- a/src/Jobs/AutoLiquidityManagementJob.cs
+++ b/src/Jobs/AutoLiquidityManagementJob.cs
@@ -120,16 +120,9 @@ public async Task Execute(IJobExecutionContext context)
_logger.LogInformation("{JobName} ended", nameof(AutoLiquidityManagementJob));
}
- ///
- /// Weighted random choice among the providers with a weight that can take a swap now. Spark is left
- /// out while the node has no Spark wallet, or its wallet is unavailable or has less room under its max
- /// balance than the node's minimum swap (the node's Max swaps in flight already limits its swaps); null when
- /// no weighted provider is left. All weights at 0 means Loop, as before. Also returns the Spark wallet's room,
- /// to clamp a Spark swap.
- ///
- private async Task<(SwapProvider? Provider, long SparkRoomSats)> SelectSwapProviderAsync(Node node)
+ /// The providers with a weight; all weights at 0 means Loop, as before.
+ private List<(SwapProvider Provider, int Weight)> WeightedProviders(Node node)
{
- var sparkRoom = 0L;
var weights = new List<(SwapProvider Provider, int Weight)>
{
(SwapProvider.Loop, node.LoopSwapWeight),
@@ -140,53 +133,90 @@ public async Task Execute(IJobExecutionContext context)
if (weights.All(w => w.Weight <= 0))
{
_logger.LogWarning("Node {NodeName} has total weight of 0, defaulting to Loop", node.Name);
- return (SwapProvider.Loop, sparkRoom);
+ return [(SwapProvider.Loop, 1)];
}
- if (node.SparkSwapWeight > 0)
+ return weights.Where(w => w.Weight > 0).ToList();
+ }
+
+ ///
+ /// Weighted random choice among (the weighted providers below their max swaps in
+ /// flight) that can take a swap now, and the swap it takes: the most it may, within its max swap, the excess
+ /// balance and, for Spark, the room left in the node's Spark wallet under its max balance. A provider is left out
+ /// when that is below its min swap, and Spark also while the node has no Spark wallet or its wallet is
+ /// unavailable. Null when no provider is left, with whether the excess balance was below every one's min swap.
+ ///
+ private async Task<(SwapProvider? Provider, long AmountSats, bool BelowMinimum)> SelectSwapProviderAsync(Node node,
+ IReadOnlyList<(SwapProvider Provider, int Weight)> weights, long excessBalance)
+ {
+ var candidates = new List<(SwapProvider Provider, int Weight, long AmountSats)>();
+ var belowMinimum = true;
+ foreach (var (provider, weight) in weights)
{
- var (reason, room) = await SparkAvailabilityAsync(node);
- sparkRoom = room;
- if (reason is not null)
+ var limits = SwapProviderLimits.For(node, provider, _sparkSettings);
+ var amount = Math.Min(excessBalance, limits.MaxSats);
+ if (amount < limits.MinSats)
{
- _logger.LogInformation("Node {NodeName}: Spark cannot take a swap now ({Reason}), choosing among the other providers",
- node.Name, reason);
- weights.RemoveAll(w => w.Provider == SwapProvider.Spark);
+ _logger.LogDebug("Node {NodeName}: {Provider} left out, the excess balance ({Excess} sats) is below its min swap ({Min} sats)",
+ node.Name, provider, excessBalance, limits.MinSats);
+ continue;
}
+
+ belowMinimum = false;
+ if (provider == SwapProvider.Spark)
+ {
+ var (reason, room) = await SparkAvailabilityAsync(node, limits.MinSats);
+ if (reason is not null)
+ {
+ _logger.LogInformation("Node {NodeName}: Spark cannot take a swap now ({Reason}), choosing among the other providers",
+ node.Name, reason);
+ continue;
+ }
+
+ // A Spark swap only takes what the node's Spark wallet can still hold under its max balance
+ if (amount > room)
+ {
+ _logger.LogInformation("Node {NodeName}: Spark swap clamped from {Amount} to {Room} sats by its Spark wallet's max balance",
+ node.Name, amount, room);
+ amount = room;
+ }
+ }
+
+ candidates.Add((provider, weight, amount));
}
- var candidates = weights.Where(w => w.Weight > 0).ToList();
- var totalWeight = candidates.Sum(w => w.Weight);
- if (totalWeight == 0)
+ if (candidates.Count == 0)
{
- return (null, sparkRoom);
+ return (null, 0, belowMinimum);
}
// Weighted random selection
+ var totalWeight = candidates.Sum(c => c.Weight);
var randomValue = Random.Shared.Next(0, totalWeight);
- var selectedProvider = candidates[^1].Provider;
+ var selected = candidates[^1];
var cumulative = 0;
- foreach (var (provider, weight) in candidates)
+ foreach (var candidate in candidates)
{
- cumulative += weight;
+ cumulative += candidate.Weight;
if (randomValue < cumulative)
{
- selectedProvider = provider;
+ selected = candidate;
break;
}
}
_logger.LogDebug("Selected {Provider} for node {NodeName} (Loop weight: {LoopWeight}, 40swap weight: {FortySwapWeight}, Spark weight: {SparkWeight}, random: {Random})",
- selectedProvider, node.Name, node.LoopSwapWeight, node.FortySwapWeight, node.SparkSwapWeight, randomValue);
+ selected.Provider, node.Name, node.LoopSwapWeight, node.FortySwapWeight, node.SparkSwapWeight, randomValue);
- return (selectedProvider, sparkRoom);
+ return (selected.Provider, selected.AmountSats, false);
}
///
/// Why Spark can't take this node's swap now (null when it can), and how many sats the node's Spark wallet
- /// can still take under its max balance, counting what its swaps not paid yet will still bring in
+ /// can still take under its max balance, counting what its swaps not paid yet will still bring in. Room below
+ /// (the node's Spark min swap) can't take a swap either.
///
- private async Task<(string? Reason, long RoomSats)> SparkAvailabilityAsync(Node node)
+ private async Task<(string? Reason, long RoomSats)> SparkAvailabilityAsync(Node node, long minSwapSats)
{
if (_sparkSettings.WalletFor(node) is not { } sparkWallet) return ("the node has no Spark wallet", 0);
@@ -197,11 +227,10 @@ public async Task Execute(IJobExecutionContext context)
if (!status.IsReady) return (status.Reason ?? status.State.ToString(), 0);
var balance = (await _sparkWallet.GetBalanceAsync(sparkWallet)).SatsBalance;
- var unpaid = SparkGuardrails.UnpaidSats((await _swapOutRepository.GetAllPending())
+ var room = SparkGuardrails.RoomSats(wallet.MaxBalanceSats, balance, (await _swapOutRepository.GetAllPending())
.Where(s => string.Equals(s.SparkIdentity, status.IdentityPublicKey, StringComparison.OrdinalIgnoreCase)));
- var room = wallet.MaxBalanceSats - balance.Owned - balance.Incoming - unpaid;
- return room < node.SwapMinAmountSats
- ? ($"{wallet.Name} has {Math.Max(0, room)} sats of room under its max balance, below the node's minimum swap", room)
+ return room < minSwapSats
+ ? ($"{wallet.Name} has {Math.Max(0, room)} sats of room under its max balance, below the node's Spark min swap", room)
: (null, room);
}
@@ -241,17 +270,21 @@ public async Task ManageNodeLiquidity(Node node, Canc
return ManageNodeLiquidityResult.BalanceBelowThreshold;
}
- // Get in-flight swaps for this node (optimistic locking)
+ // Swaps in flight count per provider (optimistic locking): one may run serialized while another runs in
+ // parallel. A provider at its max is left out; with every weighted one at its max, nothing is swapped
var inFlightSwaps = await _swapOutRepository.GetInFlightSwapsByNode(node.Id);
- var inFlightCount = inFlightSwaps.Count;
+ var weighted = WeightedProviders(node);
+ var available = weighted
+ .Where(w => inFlightSwaps.Count(s => s.Provider == w.Provider) < SwapProviderLimits.For(node, w.Provider, _sparkSettings).MaxInFlight)
+ .ToList();
- _logger.LogDebug("Node {NodeName} has {InFlightCount} swaps in flight, max: {MaxSwapsInFlight}",
- node.Name, inFlightCount, node.MaxSwapsInFlight);
+ _logger.LogDebug("Node {NodeName} has {InFlightCount} swaps in flight; providers below their max: {Providers}",
+ node.Name, inFlightSwaps.Count, string.Join(", ", available.Select(w => w.Provider)));
- if (inFlightCount >= node.MaxSwapsInFlight)
+ if (available.Count == 0)
{
- _logger.LogInformation("Node {NodeName} has reached max swaps in flight ({MaxSwapsInFlight})",
- node.Name, node.MaxSwapsInFlight);
+ _logger.LogInformation("Node {NodeName} has reached max swaps in flight of every weighted provider ({Providers})",
+ node.Name, string.Join(", ", weighted.Select(w => w.Provider)));
return ManageNodeLiquidityResult.MaxSwapsInFlightReached;
}
@@ -276,39 +309,22 @@ public async Task ManageNodeLiquidity(Node node, Canc
return ManageNodeLiquidityResult.BudgetExhausted;
}
- // Determine swap amount (bounded by min, max, and excess balance)
- // We can only swap what's above the threshold (excess balance)
- var maxPossibleSwap = Math.Min(excessBalance, node.SwapMaxAmountSats);
-
- // If we don't have enough to meet the minimum swap size, skip
- if (maxPossibleSwap < node.SwapMinAmountSats)
- {
- var maxPossibleSwapBtc = new Money(maxPossibleSwap, MoneyUnit.Satoshi).ToDecimal(MoneyUnit.BTC);
- var minSwapBtc = new Money(node.SwapMinAmountSats, MoneyUnit.Satoshi).ToDecimal(MoneyUnit.BTC);
- _logger.LogDebug("Node {NodeName} - max possible swap {MaxPossible} BTC is below minimum {Min} BTC. Excess: {Excess} BTC",
- node.Name, maxPossibleSwapBtc, minSwapBtc, excessBalanceBtc);
- return ManageNodeLiquidityResult.ExcessBalanceBelowMinimum;
- }
-
- // Swap the maximum we can, ensuring it meets the minimum
- var swapAmount = Math.Max(node.SwapMinAmountSats, maxPossibleSwap);
-
- // Select swap provider based on weights, before reserving an address for a swap that may not happen
- var (provider, sparkRoom) = await SelectSwapProviderAsync(node);
+ // Pick the provider among those whose own min and max swap fit what's above the threshold (the excess
+ // balance), before reserving an address for a swap that may not happen: the swap is the most it takes
+ var (provider, swapAmount, belowMinimum) = await SelectSwapProviderAsync(node, available, excessBalance);
if (provider is not { } selectedProvider)
{
+ if (belowMinimum)
+ {
+ _logger.LogDebug("Node {NodeName} - excess balance {Excess} BTC is below the min swap of every weighted provider",
+ node.Name, excessBalanceBtc);
+ return ManageNodeLiquidityResult.ExcessBalanceBelowMinimum;
+ }
+
_logger.LogInformation("Node {NodeName}: no weighted swap provider can take a swap now, skipping", node.Name);
return ManageNodeLiquidityResult.NoProviderAvailable;
}
- // A Spark swap only takes what the node's Spark wallet can still hold under its max balance
- if (selectedProvider == SwapProvider.Spark && swapAmount > sparkRoom)
- {
- _logger.LogInformation("Node {NodeName}: Spark swap clamped from {Amount} to {Room} sats by its Spark wallet's max balance",
- node.Name, swapAmount, sparkRoom);
- swapAmount = sparkRoom;
- }
-
var swapAmountBtc = new Money(swapAmount, MoneyUnit.Satoshi).ToDecimal(MoneyUnit.BTC);
_logger.LogDebug("Node {NodeName} - Initiating swap for {Amount} BTC", node.Name, swapAmountBtc);
diff --git a/src/Jobs/SparkSwapExitJob.cs b/src/Jobs/SparkSwapExitJob.cs
index f63e24d6..ee3a1878 100644
--- a/src/Jobs/SparkSwapExitJob.cs
+++ b/src/Jobs/SparkSwapExitJob.cs
@@ -27,10 +27,11 @@
namespace NodeGuard.Jobs;
///
-/// Exits paid Spark swaps on-chain, every SPARK_EXIT_INTERVAL_MINUTES: each swap exits the leaves its own payment
-/// brought in, to its own destination address. Swaps go strictly one at a time, oldest first, each step saved
-/// before the next (paid, then leaves attributed, then exit sent, then payout confirmed).
-/// MonitorSwapsJob follows a Spark swap until its payment settles; from then on the swap is this job's.
+/// Exits paid Spark swaps on-chain, every SPARK_EXIT_INTERVAL_MINUTES. Paid swaps wait in their Spark wallet until
+/// it is full, then exit together: one cooperative exit per destination wallet, of exactly the leaves their own
+/// payments brought in, to one new address. Exits go strictly one at a time, oldest first, each step saved before the
+/// next (paid, then leaves attributed, then exit sent, then payout confirmed). MonitorSwapsJob follows a Spark swap
+/// until its payment settles; from then on the swap is this job's.
///
[DisallowConcurrentExecution]
public class SparkSwapExitJob : IJob
@@ -58,7 +59,8 @@ public async Task Execute(IJobExecutionContext context)
var ct = context.CancellationToken;
try
{
- var swaps = (await _swapOutRepository.GetAllPending())
+ var pending = await _swapOutRepository.GetAllPending();
+ var swaps = pending
.Where(s => s.Provider == SwapProvider.Spark && s.LightningFeeSats is not null)
.OrderBy(s => s.CreationDatetime)
.ThenBy(s => s.Id)
@@ -70,16 +72,26 @@ public async Task Execute(IJobExecutionContext context)
// Every swap gets its own transfer's leaves before any exit, so no exit takes leaves another swap is due
foreach (var swap in swaps.Where(s => s.SparkTransferId is null && s.TxId is null))
{
- await RunAsync(swap, "attributing its transfer", () => _sparkSwapService.AttributeAsync(swap, ct));
+ await RunAsync($"Spark swap {swap.Id}", "attributing its transfer", () => _sparkSwapService.AttributeAsync(swap, ct));
}
- foreach (var swap in swaps)
+ // Batches already exiting, the swaps sharing a reserved address: their payout is followed, or their exit
+ // retried with exactly them
+ var exiting = swaps
+ .Where(s => s.DestinationAddress is not null)
+ .GroupBy(s => (s.SparkIdentity, s.DestinationWalletId, s.DestinationAddress))
+ .Select(g => (IReadOnlyList)g.ToList())
+ .ToList();
+ foreach (var batch in exiting)
{
- var step = await RunAsync(swap, "exiting", () => _sparkSwapService.ExitAsync(swap, ct));
- if (step == SparkExitStep.Completed)
- {
- await AuditCompletionAsync(swap);
- }
+ await ExitAsync(batch, ct);
+ }
+
+ // New batches: the paid swaps of each Spark wallet that is full
+ var planned = await RunAsync("Spark swaps", "planning their exits", () => _sparkSwapService.PlanExitsAsync(pending, ct));
+ foreach (var batch in planned ?? [])
+ {
+ await ExitAsync(batch, ct);
}
}
catch (Exception e) when (e is not OperationCanceledException)
@@ -91,8 +103,20 @@ public async Task Execute(IJobExecutionContext context)
_logger.LogInformation("{JobName} ended", nameof(SparkSwapExitJob));
}
- /// Runs one swap's step; a failure is logged and leaves the swap for the next run.
- private async Task RunAsync(SwapOut swap, string what, Func> step)
+ private async Task ExitAsync(IReadOnlyList batch, CancellationToken ct)
+ {
+ var label = batch.Count == 1 ? $"Spark swap {batch[0].Id}" : $"Spark swaps {string.Join(", ", batch.Select(s => s.Id))}";
+ var step = await RunAsync(label, "exiting", () => _sparkSwapService.ExitBatchAsync(batch, ct));
+ if (step != SparkExitStep.Completed) return;
+
+ foreach (var swap in batch)
+ {
+ await AuditCompletionAsync(swap);
+ }
+ }
+
+ /// Runs one step; a failure is logged and leaves its swaps for the next run.
+ private async Task RunAsync(string swaps, string what, Func> step)
{
try
{
@@ -100,7 +124,7 @@ public async Task Execute(IJobExecutionContext context)
}
catch (Exception e) when (e is not OperationCanceledException)
{
- _logger.LogError(e, "Spark swap {SwapId}: error {What}; retried on the next run", swap.Id, what);
+ _logger.LogError(e, "{Swaps}: error {What}; retried on the next run", swaps, what);
return default;
}
}
diff --git a/src/Migrations/20261016144038_AddPerProviderSwapLimitsAndSparkExitBatches.Designer.cs b/src/Migrations/20261016144038_AddPerProviderSwapLimitsAndSparkExitBatches.Designer.cs
new file mode 100644
index 00000000..aa7557f6
--- /dev/null
+++ b/src/Migrations/20261016144038_AddPerProviderSwapLimitsAndSparkExitBatches.Designer.cs
@@ -0,0 +1,2024 @@
+//
+using System;
+using System.Collections.Generic;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using NodeGuard.Data;
+using NodeGuard.Helpers;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+
+#nullable disable
+
+namespace NodeGuard.Migrations
+{
+ [DbContext(typeof(ApplicationDbContext))]
+ [Migration("20261016144038_AddPerProviderSwapLimitsAndSparkExitBatches")]
+ partial class AddPerProviderSwapLimitsAndSparkExitBatches
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "10.0.1")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("ApplicationUserNode", b =>
+ {
+ b.Property("NodesId")
+ .HasColumnType("integer");
+
+ b.Property("UsersId")
+ .HasColumnType("text");
+
+ b.HasKey("NodesId", "UsersId");
+
+ b.HasIndex("UsersId");
+
+ b.ToTable("ApplicationUserNode");
+ });
+
+ modelBuilder.Entity("ChannelOperationRequestFMUTXO", b =>
+ {
+ b.Property("ChannelOperationRequestsId")
+ .HasColumnType("integer");
+
+ b.Property("UtxosId")
+ .HasColumnType("integer");
+
+ b.HasKey("ChannelOperationRequestsId", "UtxosId");
+
+ b.HasIndex("UtxosId");
+
+ b.ToTable("ChannelOperationRequestFMUTXO");
+ });
+
+ modelBuilder.Entity("FMUTXOWalletWithdrawalRequest", b =>
+ {
+ b.Property("UTXOsId")
+ .HasColumnType("integer");
+
+ b.Property("WalletWithdrawalRequestsId")
+ .HasColumnType("integer");
+
+ b.HasKey("UTXOsId", "WalletWithdrawalRequestsId");
+
+ b.HasIndex("WalletWithdrawalRequestsId");
+
+ b.ToTable("FMUTXOWalletWithdrawalRequest");
+ });
+
+ modelBuilder.Entity("KeyWallet", b =>
+ {
+ b.Property("KeysId")
+ .HasColumnType("integer");
+
+ b.Property("WalletsId")
+ .HasColumnType("integer");
+
+ b.HasKey("KeysId", "WalletsId");
+
+ b.HasIndex("WalletsId");
+
+ b.ToTable("KeyWallet");
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("text");
+
+ b.Property("ConcurrencyStamp")
+ .IsConcurrencyToken()
+ .HasColumnType("text");
+
+ b.Property("Name")
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)");
+
+ b.Property("NormalizedName")
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("NormalizedName")
+ .IsUnique()
+ .HasDatabaseName("RoleNameIndex");
+
+ b.ToTable("AspNetRoles", (string)null);
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("ClaimType")
+ .HasColumnType("text");
+
+ b.Property("ClaimValue")
+ .HasColumnType("text");
+
+ b.Property("RoleId")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.HasKey("Id");
+
+ b.HasIndex("RoleId");
+
+ b.ToTable("AspNetRoleClaims", (string)null);
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUser", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("text");
+
+ b.Property("AccessFailedCount")
+ .HasColumnType("integer");
+
+ b.Property("ConcurrencyStamp")
+ .IsConcurrencyToken()
+ .HasColumnType("text");
+
+ b.Property("Discriminator")
+ .IsRequired()
+ .HasMaxLength(21)
+ .HasColumnType("character varying(21)");
+
+ b.Property("Email")
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)");
+
+ b.Property("EmailConfirmed")
+ .HasColumnType("boolean");
+
+ b.Property("LockoutEnabled")
+ .HasColumnType("boolean");
+
+ b.Property("LockoutEnd")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("NormalizedEmail")
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)");
+
+ b.Property("NormalizedUserName")
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)");
+
+ b.Property("PasswordHash")
+ .HasColumnType("text");
+
+ b.Property("PhoneNumber")
+ .HasColumnType("text");
+
+ b.Property("PhoneNumberConfirmed")
+ .HasColumnType("boolean");
+
+ b.Property("SecurityStamp")
+ .HasColumnType("text");
+
+ b.Property("TwoFactorEnabled")
+ .HasColumnType("boolean");
+
+ b.Property("UserName")
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("NormalizedEmail")
+ .HasDatabaseName("EmailIndex");
+
+ b.HasIndex("NormalizedUserName")
+ .IsUnique()
+ .HasDatabaseName("UserNameIndex");
+
+ b.ToTable("AspNetUsers", (string)null);
+
+ b.HasDiscriminator().HasValue("IdentityUser");
+
+ b.UseTphMappingStrategy();
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("ClaimType")
+ .HasColumnType("text");
+
+ b.Property("ClaimValue")
+ .HasColumnType("text");
+
+ b.Property("UserId")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId");
+
+ b.ToTable("AspNetUserClaims", (string)null);
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b =>
+ {
+ b.Property("LoginProvider")
+ .HasMaxLength(128)
+ .HasColumnType("character varying(128)");
+
+ b.Property("ProviderKey")
+ .HasMaxLength(128)
+ .HasColumnType("character varying(128)");
+
+ b.Property("ProviderDisplayName")
+ .HasColumnType("text");
+
+ b.Property("UserId")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.HasKey("LoginProvider", "ProviderKey");
+
+ b.HasIndex("UserId");
+
+ b.ToTable("AspNetUserLogins", (string)null);
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b =>
+ {
+ b.Property("UserId")
+ .HasColumnType("text");
+
+ b.Property("RoleId")
+ .HasColumnType("text");
+
+ b.HasKey("UserId", "RoleId");
+
+ b.HasIndex("RoleId");
+
+ b.ToTable("AspNetUserRoles", (string)null);
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b =>
+ {
+ b.Property("UserId")
+ .HasColumnType("text");
+
+ b.Property("LoginProvider")
+ .HasMaxLength(128)
+ .HasColumnType("character varying(128)");
+
+ b.Property("Name")
+ .HasMaxLength(128)
+ .HasColumnType("character varying(128)");
+
+ b.Property("Value")
+ .HasColumnType("text");
+
+ b.HasKey("UserId", "LoginProvider", "Name");
+
+ b.ToTable("AspNetUserTokens", (string)null);
+ });
+
+ modelBuilder.Entity("NodeGuard.Data.Models.APIToken", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("CreationDatetime")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("CreatorId")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("ExpirationDate")
+ .HasColumnType("timestamp without time zone");
+
+ b.Property("IsBlocked")
+ .HasColumnType("boolean");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("TokenHash")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("UpdateDatetime")
+ .HasColumnType("timestamp with time zone");
+
+ b.HasKey("Id");
+
+ b.HasIndex("CreatorId");
+
+ b.ToTable("ApiTokens");
+ });
+
+ modelBuilder.Entity("NodeGuard.Data.Models.AuditLog", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("ActionType")
+ .HasColumnType("integer");
+
+ b.Property("Details")
+ .HasColumnType("text");
+
+ b.Property("EventType")
+ .HasColumnType("integer");
+
+ b.Property("IpAddress")
+ .HasMaxLength(45)
+ .HasColumnType("character varying(45)");
+
+ b.Property("ObjectAffected")
+ .HasColumnType("integer");
+
+ b.Property("ObjectId")
+ .HasMaxLength(450)
+ .HasColumnType("character varying(450)");
+
+ b.Property("Timestamp")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("UserId")
+ .HasMaxLength(450)
+ .HasColumnType("character varying(450)");
+
+ b.Property("Username")
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)");
+
+ b.HasKey("Id");
+
+ b.ToTable("AuditLogs");
+ });
+
+ modelBuilder.Entity("NodeGuard.Data.Models.Channel", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("BtcCloseAddress")
+ .HasColumnType("text");
+
+ b.Property("ChanId")
+ .HasColumnType("numeric(20,0)");
+
+ b.Property("ClosedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("CreatedByNodeGuard")
+ .HasColumnType("boolean");
+
+ b.Property("CreationDatetime")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("DestinationNodeId")
+ .HasColumnType("integer");
+
+ b.Property("FundingTx")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("FundingTxOutputIndex")
+ .HasColumnType("bigint");
+
+ b.Property("IsAutoRebalanceEnabled")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("boolean")
+ .HasDefaultValue(false);
+
+ b.Property("IsAutomatedLiquidityEnabled")
+ .HasColumnType("boolean");
+
+ b.Property("IsDynamicFeeEnabled")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("boolean")
+ .HasDefaultValue(false);
+
+ b.Property("IsPrivate")
+ .HasColumnType("boolean");
+
+ b.Property("SatsAmount")
+ .HasColumnType("bigint");
+
+ b.Property("SourceNodeId")
+ .HasColumnType("integer");
+
+ b.Property("Status")
+ .HasColumnType("integer");
+
+ b.Property("UpdateDatetime")
+ .HasColumnType("timestamp with time zone");
+
+ b.HasKey("Id");
+
+ b.HasIndex("DestinationNodeId");
+
+ b.HasIndex("SourceNodeId");
+
+ b.ToTable("Channels");
+ });
+
+ modelBuilder.Entity("NodeGuard.Data.Models.ChannelFeeState", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("ChannelId")
+ .HasColumnType("integer");
+
+ b.Property("CreationDatetime")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("LastAppliedInboundBaseMsat")
+ .HasColumnType("integer");
+
+ b.Property("LastAppliedInboundPpm")
+ .HasColumnType("integer");
+
+ b.Property("LastAppliedOutboundBaseFeeMsat")
+ .HasColumnType("bigint");
+
+ b.Property("LastAppliedOutboundPpm")
+ .HasColumnType("bigint");
+
+ b.Property("LastComputedTarget")
+ .HasColumnType("double precision");
+
+ b.Property("LastFeeUpdateAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("LastObservedRatio")
+ .HasColumnType("double precision");
+
+ b.Property("ManagedNodePubKey")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("UpdateDatetime")
+ .HasColumnType("timestamp with time zone");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ChannelId", "ManagedNodePubKey")
+ .IsUnique();
+
+ b.ToTable("ChannelFeeStates");
+ });
+
+ modelBuilder.Entity("NodeGuard.Data.Models.ChannelOperationRequest", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("AmountCryptoUnit")
+ .HasColumnType("integer");
+
+ b.Property("Changeless")
+ .HasColumnType("boolean");
+
+ b.Property("ChannelId")
+ .HasColumnType("integer");
+
+ b.Property("ClosingReason")
+ .HasColumnType("text");
+
+ b.Property("CreationDatetime")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Description")
+ .HasColumnType("text");
+
+ b.Property("DestNodeId")
+ .HasColumnType("integer");
+
+ b.Property("FeeRate")
+ .HasColumnType("numeric");
+
+ b.Property("InitialChannelBaseFeeMsat")
+ .HasColumnType("bigint");
+
+ b.Property("InitialChannelFeeRatePpm")
+ .HasColumnType("bigint");
+
+ b.Property("IsChannelPrivate")
+ .HasColumnType("boolean");
+
+ b.Property("MempoolRecommendedFeesType")
+ .HasColumnType("integer");
+
+ b.Property("RequestType")
+ .HasColumnType("integer");
+
+ b.Property("SatsAmount")
+ .HasColumnType("bigint");
+
+ b.Property("SourceNodeId")
+ .HasColumnType("integer");
+
+ b.Property("Status")
+ .HasColumnType("integer");
+
+ b.Property>("StatusLogs")
+ .HasColumnType("jsonb");
+
+ b.Property("TxId")
+ .HasColumnType("text");
+
+ b.Property("UpdateDatetime")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("UserId")
+ .HasColumnType("text");
+
+ b.Property("WalletId")
+ .HasColumnType("integer");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ChannelId");
+
+ b.HasIndex("DestNodeId");
+
+ b.HasIndex("SourceNodeId");
+
+ b.HasIndex("UserId");
+
+ b.HasIndex("WalletId");
+
+ b.ToTable("ChannelOperationRequests");
+ });
+
+ modelBuilder.Entity("NodeGuard.Data.Models.ChannelOperationRequestPSBT", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("ChannelOperationRequestId")
+ .HasColumnType("integer");
+
+ b.Property("CreationDatetime")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("IsFinalisedPSBT")
+ .HasColumnType("boolean");
+
+ b.Property("IsInternalWalletPSBT")
+ .HasColumnType("boolean");
+
+ b.Property("IsTemplatePSBT")
+ .HasColumnType("boolean");
+
+ b.Property("PSBT")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("UpdateDatetime")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("UserSignerId")
+ .HasColumnType("text");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ChannelOperationRequestId");
+
+ b.HasIndex("UserSignerId");
+
+ b.HasIndex(new[] { "ChannelOperationRequestId" }, "IX_ChannelOperationRequestPSBTs_Template")
+ .IsUnique()
+ .HasFilter("\"IsTemplatePSBT\"");
+
+ b.ToTable("ChannelOperationRequestPSBTs");
+ });
+
+ modelBuilder.Entity("NodeGuard.Data.Models.ChannelRoutingState", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("AgeBlocks")
+ .HasColumnType("bigint");
+
+ b.Property("ChanIdLnd")
+ .HasColumnType("numeric(20,0)");
+
+ b.Property("ChannelId")
+ .HasColumnType("integer");
+
+ b.Property("ConsecutiveCategoryCyclesInNewState")
+ .HasColumnType("bigint");
+
+ b.Property("CreationDatetime")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("EmaLocalRatio")
+ .HasColumnType("double precision");
+
+ b.Property("FundingBlockHeight")
+ .HasColumnType("bigint");
+
+ b.Property("LastCategorizedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("LastEvaluatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("LastKnownLifetime")
+ .HasColumnType("bigint");
+
+ b.Property("LastKnownNumUpdates")
+ .HasColumnType("bigint");
+
+ b.Property("LastKnownUptime")
+ .HasColumnType("bigint");
+
+ b.Property("ManagedNodePubKey")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("NetFlowRatio")
+ .HasColumnType("double precision");
+
+ b.Property("PeerFlowCategory")
+ .HasColumnType("integer");
+
+ b.Property("PeerInitiated")
+ .HasColumnType("boolean");
+
+ b.Property("PendingCategory")
+ .HasColumnType("integer");
+
+ b.Property("PullMsatWindow")
+ .HasColumnType("bigint");
+
+ b.Property("PushMsatWindow")
+ .HasColumnType("bigint");
+
+ b.Property("TargetLocalRatio")
+ .HasColumnType("double precision");
+
+ b.Property("UpdateDatetime")
+ .HasColumnType("timestamp with time zone");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ChannelId", "ManagedNodePubKey")
+ .IsUnique();
+
+ b.ToTable("ChannelRoutingStates");
+ });
+
+ modelBuilder.Entity("NodeGuard.Data.Models.FMUTXO", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("CreationDatetime")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("OutputIndex")
+ .HasColumnType("bigint");
+
+ b.Property("SatsAmount")
+ .HasColumnType("bigint");
+
+ b.Property("TxId")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("UpdateDatetime")
+ .HasColumnType("timestamp with time zone");
+
+ b.HasKey("Id");
+
+ b.ToTable("FMUTXOs");
+ });
+
+ modelBuilder.Entity("NodeGuard.Data.Models.ForwardingHtlcEvent", b =>
+ {
+ b.Property("ManagedNodePubKey")
+ .HasColumnType("text");
+
+ b.Property("IncomingChannelId")
+ .HasColumnType("numeric(20,0)");
+
+ b.Property("OutgoingChannelId")
+ .HasColumnType("numeric(20,0)");
+
+ b.Property("IncomingHtlcId")
+ .HasColumnType("numeric(20,0)");
+
+ b.Property("OutgoingHtlcId")
+ .HasColumnType("numeric(20,0)");
+
+ b.Property("CreationDatetime")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("EventCase")
+ .HasColumnType("integer");
+
+ b.Property("EventTimestamp")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("EventType")
+ .HasColumnType("integer");
+
+ b.Property("FailureDetail")
+ .HasColumnType("integer");
+
+ b.Property("FailureString")
+ .HasMaxLength(2048)
+ .HasColumnType("character varying(2048)");
+
+ b.Property("FeeMsat")
+ .HasColumnType("bigint");
+
+ b.Property("GrossFeeMsat")
+ .HasColumnType("bigint");
+
+ b.Property("InboundFeeMsat")
+ .HasColumnType("bigint");
+
+ b.Property("InboundFeePpm")
+ .HasColumnType("bigint");
+
+ b.Property("IncomingAmountMsat")
+ .HasColumnType("numeric(20,0)");
+
+ b.Property("IncomingPeerAlias")
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)");
+
+ b.Property("IncomingTimelock")
+ .HasColumnType("bigint");
+
+ b.Property("ManagedNodeName")
+ .IsRequired()
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)");
+
+ b.Property("Outcome")
+ .HasColumnType("integer");
+
+ b.Property("OutgoingAmountMsat")
+ .HasColumnType("numeric(20,0)");
+
+ b.Property("OutgoingPeerAlias")
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)");
+
+ b.Property("OutgoingTimelock")
+ .HasColumnType("bigint");
+
+ b.Property("RoutingFeePpm")
+ .HasColumnType("bigint");
+
+ b.Property("UpdateDatetime")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("WireFailureCode")
+ .HasColumnType("integer");
+
+ b.HasKey("ManagedNodePubKey", "IncomingChannelId", "OutgoingChannelId", "IncomingHtlcId", "OutgoingHtlcId");
+
+ b.HasIndex("CreationDatetime");
+
+ b.HasIndex("EventTimestamp");
+
+ b.ToTable("ForwardingHtlcEvents");
+ });
+
+ modelBuilder.Entity("NodeGuard.Data.Models.InternalWallet", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("CreationDatetime")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("DerivationPath")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("MasterFingerprint")
+ .HasColumnType("text");
+
+ b.Property("MnemonicString")
+ .HasColumnType("text");
+
+ b.Property("UpdateDatetime")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("XPUB")
+ .HasColumnType("text");
+
+ b.HasKey("Id");
+
+ b.ToTable("InternalWallets");
+ });
+
+ modelBuilder.Entity("NodeGuard.Data.Models.Key", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("CreationDatetime")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Description")
+ .HasColumnType("text");
+
+ b.Property("InternalWalletId")
+ .HasColumnType("integer");
+
+ b.Property("IsArchived")
+ .HasColumnType("boolean");
+
+ b.Property("IsBIP39ImportedKey")
+ .HasColumnType("boolean");
+
+ b.Property("IsCompromised")
+ .HasColumnType("boolean");
+
+ b.Property("MasterFingerprint")
+ .HasColumnType("text");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("Path")
+ .HasColumnType("text");
+
+ b.Property("UpdateDatetime")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("UserId")
+ .HasColumnType("text");
+
+ b.Property("XPUB")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.HasKey("Id");
+
+ b.HasIndex("InternalWalletId");
+
+ b.HasIndex("UserId");
+
+ b.ToTable("Keys");
+ });
+
+ modelBuilder.Entity("NodeGuard.Data.Models.LiquidityRule", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("ChannelId")
+ .HasColumnType("integer");
+
+ b.Property("CreationDatetime")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("IsReverseSwapWalletRule")
+ .HasColumnType("boolean");
+
+ b.Property("MinimumLocalBalance")
+ .HasColumnType("numeric");
+
+ b.Property("MinimumRemoteBalance")
+ .HasColumnType("numeric");
+
+ b.Property("NodeId")
+ .HasColumnType("integer");
+
+ b.Property("RebalanceTarget")
+ .HasColumnType("numeric");
+
+ b.Property("ReverseSwapAddress")
+ .HasColumnType("text");
+
+ b.Property("ReverseSwapWalletId")
+ .HasColumnType("integer");
+
+ b.Property("SwapWalletId")
+ .HasColumnType("integer");
+
+ b.Property("UpdateDatetime")
+ .HasColumnType("timestamp with time zone");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ChannelId")
+ .IsUnique();
+
+ b.HasIndex("NodeId");
+
+ b.HasIndex("ReverseSwapWalletId");
+
+ b.HasIndex("SwapWalletId");
+
+ b.ToTable("LiquidityRules");
+ });
+
+ modelBuilder.Entity("NodeGuard.Data.Models.Node", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("AllowPositiveInboundFees")
+ .HasColumnType("boolean");
+
+ b.Property("AutoLiquidityManagementEnabled")
+ .HasColumnType("boolean");
+
+ b.Property("AutoRebalanceEnabled")
+ .HasColumnType("boolean");
+
+ b.Property("AutosweepEnabled")
+ .HasColumnType("boolean");
+
+ b.Property("ChannelAdminMacaroon")
+ .HasColumnType("text");
+
+ b.Property("CreationDatetime")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property