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("Description") + .HasColumnType("text"); + + b.Property("DynamicFeeManagementEnabled") + .HasColumnType("boolean"); + + b.Property("Endpoint") + .HasColumnType("text"); + + b.Property("FortySwapEndpoint") + .HasColumnType("text"); + + b.Property("FortySwapMaxAmountSats") + .HasColumnType("bigint"); + + b.Property("FortySwapMaxSwapsInFlight") + .HasColumnType("integer"); + + b.Property("FortySwapMinAmountSats") + .HasColumnType("bigint"); + + b.Property("FortySwapWeight") + .HasColumnType("integer"); + + b.Property("FundsDestinationWalletId") + .HasColumnType("integer"); + + b.Property("IsNodeDisabled") + .HasColumnType("boolean"); + + b.Property("LoopMaxSwapsInFlight") + .HasColumnType("integer"); + + b.Property("LoopSwapMaxAmountSats") + .HasColumnType("bigint"); + + b.Property("LoopSwapMinAmountSats") + .HasColumnType("bigint"); + + b.Property("LoopSwapWeight") + .HasColumnType("integer"); + + b.Property("LoopdCert") + .HasColumnType("text"); + + b.Property("LoopdEndpoint") + .HasColumnType("text"); + + b.Property("LoopdMacaroon") + .HasColumnType("text"); + + b.Property("MaxRebalanceCostToEarnRatio") + .HasColumnType("double precision"); + + b.Property("MaxRebalancesInFlight") + .HasColumnType("integer"); + + b.Property("MaxSwapRoutingFeeRatio") + .HasColumnType("numeric"); + + b.Property("MinimumBalanceThresholdSats") + .HasColumnType("bigint"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("PubKey") + .IsRequired() + .HasColumnType("text"); + + b.Property("RebalanceBudgetRefreshInterval") + .HasColumnType("interval"); + + b.Property("RebalanceBudgetSats") + .HasColumnType("bigint"); + + b.Property("RebalanceBudgetStartDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("RoutingEngineDryRun") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("SparkMaxSwapsInFlight") + .HasColumnType("integer"); + + b.Property("SparkPaymentTimeoutMinutes") + .HasColumnType("integer"); + + b.Property("SparkSwapMaxAmountSats") + .HasColumnType("bigint"); + + b.Property("SparkSwapMinAmountSats") + .HasColumnType("bigint"); + + b.Property("SparkSwapWeight") + .HasColumnType("integer"); + + b.Property("SparkWalletId") + .HasColumnType("integer"); + + b.Property("SwapBudgetRefreshInterval") + .HasColumnType("interval"); + + b.Property("SwapBudgetSats") + .HasColumnType("bigint"); + + b.Property("SwapBudgetStartDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdateDatetime") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("FundsDestinationWalletId"); + + b.HasIndex("PubKey") + .IsUnique(); + + b.HasIndex("SparkWalletId"); + + b.ToTable("Nodes"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.Rebalance", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AmountBackoffRatio") + .HasColumnType("double precision"); + + b.Property("AttemptNumber") + .HasColumnType("integer"); + + b.Property("CreationDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("FeePaidMsat") + .HasColumnType("bigint"); + + b.Property("FeePaidSats") + .HasColumnType("bigint"); + + b.Property("IsManual") + .HasColumnType("boolean"); + + b.Property("MaxAttempts") + .HasColumnType("integer"); + + b.Property("MaxFeePct") + .HasColumnType("double precision"); + + b.Property("NodeId") + .HasColumnType("integer"); + + b.Property("PaymentHashHex") + .HasColumnType("text"); + + b.Property("PaymentRequest") + .HasColumnType("text"); + + b.Property("PreimageHex") + .HasColumnType("text"); + + b.Property("RequestedAmountSats") + .HasColumnType("bigint"); + + b.Property("RetryMaxFeePct") + .HasColumnType("double precision"); + + b.Property("SatsAmount") + .HasColumnType("bigint"); + + b.Property("SourceChanIdLnd") + .HasColumnType("numeric(20,0)"); + + b.Property("SourceChannelId") + .HasColumnType("integer"); + + b.Property("SourceNodePubKey") + .HasColumnType("text"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("TargetPubkey") + .HasColumnType("text"); + + b.Property("TimeoutSeconds") + .HasColumnType("integer"); + + b.Property("UpdateDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("UserRequestorId") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("NodeId"); + + b.HasIndex("SourceChannelId"); + + b.HasIndex("UserRequestorId"); + + b.ToTable("Rebalances"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.SwapOut", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreationDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("DestinationAddress") + .HasColumnType("text"); + + b.Property("DestinationWalletId") + .HasColumnType("integer"); + + b.Property("ErrorDetails") + .HasColumnType("text"); + + b.Property("IsManual") + .HasColumnType("boolean"); + + b.Property("LightningFeeSats") + .HasColumnType("bigint"); + + b.Property("NodeId") + .HasColumnType("integer"); + + b.Property("OnChainFeeSats") + .HasColumnType("bigint"); + + b.Property("PaymentHash") + .HasColumnType("text"); + + b.Property("PayoutSats") + .HasColumnType("bigint"); + + b.Property("Provider") + .HasColumnType("integer"); + + b.Property("ProviderId") + .HasColumnType("text"); + + b.Property("ReferenceId") + .HasColumnType("text"); + + b.Property("SatsAmount") + .HasColumnType("bigint"); + + b.Property("ServiceFeeSats") + .HasColumnType("bigint"); + + b.Property("SparkExitDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("SparkIdentity") + .HasColumnType("text"); + + b.Property("SparkLeafIds") + .HasColumnType("text"); + + b.Property("SparkReceivedSats") + .HasColumnType("bigint"); + + b.Property("SparkTransferId") + .HasColumnType("text"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("TxId") + .HasColumnType("text"); + + b.Property("UpdateDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("UserRequestorId") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("DestinationWalletId"); + + b.HasIndex("NodeId"); + + b.HasIndex("ReferenceId") + .IsUnique() + .HasFilter("\"ReferenceId\" IS NOT NULL"); + + b.HasIndex("UserRequestorId"); + + b.ToTable("SwapOuts"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.UTXOTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreationDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("Key") + .IsRequired() + .HasColumnType("text"); + + b.Property("Outpoint") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdateDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Key", "Outpoint") + .IsUnique(); + + b.ToTable("UTXOTags"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.Wallet", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("BIP39Seedphrase") + .HasColumnType("text"); + + b.Property("CreationDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("ImportedOutputDescriptor") + .HasColumnType("text"); + + b.Property("InternalWalletId") + .HasColumnType("integer"); + + b.Property("InternalWalletMasterFingerprint") + .HasColumnType("text"); + + b.Property("InternalWalletSubDerivationPath") + .HasColumnType("text"); + + b.Property("IsArchived") + .HasColumnType("boolean"); + + b.Property("IsBIP39Imported") + .HasColumnType("boolean"); + + b.Property("IsCompromised") + .HasColumnType("boolean"); + + b.Property("IsFinalised") + .HasColumnType("boolean"); + + b.Property("IsHotWallet") + .HasColumnType("boolean"); + + b.Property("IsUnSortedMultiSig") + .HasColumnType("boolean"); + + b.Property("Kind") + .HasColumnType("integer"); + + b.Property("MofN") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("ReferenceId") + .HasColumnType("text"); + + b.Property("SparkAccount") + .HasColumnType("integer"); + + b.Property("SparkEncryptedMnemonic") + .HasColumnType("text"); + + b.Property("SparkIdentityPublicKey") + .HasColumnType("text"); + + b.Property("SparkMaxBalanceSats") + .HasColumnType("bigint"); + + b.Property("UpdateDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("WalletAddressType") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("InternalWalletId"); + + b.HasIndex("InternalWalletSubDerivationPath", "InternalWalletMasterFingerprint") + .IsUnique(); + + b.ToTable("Wallets"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.WalletWithdrawalRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("BumpingWalletWithdrawalRequestId") + .HasColumnType("integer"); + + b.Property("Changeless") + .HasColumnType("boolean"); + + b.Property("CoinSelectionStrategy") + .HasColumnType("integer"); + + b.Property("CreationDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("CustomFeeRate") + .HasColumnType("numeric"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("IsRbfCancellation") + .HasColumnType("boolean"); + + b.Property("MempoolRecommendedFeesType") + .HasColumnType("integer"); + + b.Property("ReferenceId") + .HasColumnType("text"); + + b.Property("RejectCancelDescription") + .HasColumnType("text"); + + b.Property("RequestMetadata") + .HasColumnType("text"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("TxId") + .HasColumnType("text"); + + b.Property("UpdateDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("UserRequestorId") + .HasColumnType("text"); + + b.Property("WalletId") + .HasColumnType("integer"); + + b.Property("WithdrawAllFunds") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.HasIndex("BumpingWalletWithdrawalRequestId"); + + b.HasIndex("UserRequestorId"); + + b.HasIndex("WalletId"); + + b.ToTable("WalletWithdrawalRequests"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.WalletWithdrawalRequestDestination", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Address") + .IsRequired() + .HasColumnType("text"); + + b.Property("Amount") + .HasColumnType("numeric"); + + b.Property("CreationDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdateDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("WalletWithdrawalRequestId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("WalletWithdrawalRequestId"); + + b.ToTable("WalletWithdrawalRequestDestinations"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.WalletWithdrawalRequestPSBT", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + 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("SignerId") + .HasColumnType("text"); + + b.Property("UpdateDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("WalletWithdrawalRequestId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("SignerId"); + + b.HasIndex("WalletWithdrawalRequestId"); + + b.HasIndex(new[] { "WalletWithdrawalRequestId" }, "IX_WalletWithdrawalRequestPSBTs_Template") + .IsUnique() + .HasFilter("\"IsTemplatePSBT\""); + + b.ToTable("WalletWithdrawalRequestPSBTs"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.ApplicationUser", b => + { + b.HasBaseType("Microsoft.AspNetCore.Identity.IdentityUser"); + + b.HasDiscriminator().HasValue("ApplicationUser"); + }); + + modelBuilder.Entity("ApplicationUserNode", b => + { + b.HasOne("NodeGuard.Data.Models.Node", null) + .WithMany() + .HasForeignKey("NodesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("NodeGuard.Data.Models.ApplicationUser", null) + .WithMany() + .HasForeignKey("UsersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChannelOperationRequestFMUTXO", b => + { + b.HasOne("NodeGuard.Data.Models.ChannelOperationRequest", null) + .WithMany() + .HasForeignKey("ChannelOperationRequestsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("NodeGuard.Data.Models.FMUTXO", null) + .WithMany() + .HasForeignKey("UtxosId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("FMUTXOWalletWithdrawalRequest", b => + { + b.HasOne("NodeGuard.Data.Models.FMUTXO", null) + .WithMany() + .HasForeignKey("UTXOsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("NodeGuard.Data.Models.WalletWithdrawalRequest", null) + .WithMany() + .HasForeignKey("WalletWithdrawalRequestsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("KeyWallet", b => + { + b.HasOne("NodeGuard.Data.Models.Key", null) + .WithMany() + .HasForeignKey("KeysId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("NodeGuard.Data.Models.Wallet", null) + .WithMany() + .HasForeignKey("WalletsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.APIToken", b => + { + b.HasOne("NodeGuard.Data.Models.ApplicationUser", "Creator") + .WithMany() + .HasForeignKey("CreatorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Creator"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.Channel", b => + { + b.HasOne("NodeGuard.Data.Models.Node", "DestinationNode") + .WithMany() + .HasForeignKey("DestinationNodeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("NodeGuard.Data.Models.Node", "SourceNode") + .WithMany() + .HasForeignKey("SourceNodeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("DestinationNode"); + + b.Navigation("SourceNode"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.ChannelFeeState", b => + { + b.HasOne("NodeGuard.Data.Models.Channel", "Channel") + .WithMany() + .HasForeignKey("ChannelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Channel"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.ChannelOperationRequest", b => + { + b.HasOne("NodeGuard.Data.Models.Channel", "Channel") + .WithMany("ChannelOperationRequests") + .HasForeignKey("ChannelId"); + + b.HasOne("NodeGuard.Data.Models.Node", "DestNode") + .WithMany("ChannelOperationRequestsAsDestination") + .HasForeignKey("DestNodeId"); + + b.HasOne("NodeGuard.Data.Models.Node", "SourceNode") + .WithMany("ChannelOperationRequestsAsSource") + .HasForeignKey("SourceNodeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("NodeGuard.Data.Models.ApplicationUser", "User") + .WithMany("ChannelOperationRequests") + .HasForeignKey("UserId"); + + b.HasOne("NodeGuard.Data.Models.Wallet", "Wallet") + .WithMany("ChannelOperationRequestsAsSource") + .HasForeignKey("WalletId"); + + b.Navigation("Channel"); + + b.Navigation("DestNode"); + + b.Navigation("SourceNode"); + + b.Navigation("User"); + + b.Navigation("Wallet"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.ChannelOperationRequestPSBT", b => + { + b.HasOne("NodeGuard.Data.Models.ChannelOperationRequest", "ChannelOperationRequest") + .WithMany("ChannelOperationRequestPsbts") + .HasForeignKey("ChannelOperationRequestId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("NodeGuard.Data.Models.ApplicationUser", "UserSigner") + .WithMany() + .HasForeignKey("UserSignerId"); + + b.Navigation("ChannelOperationRequest"); + + b.Navigation("UserSigner"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.ChannelRoutingState", b => + { + b.HasOne("NodeGuard.Data.Models.Channel", "Channel") + .WithMany() + .HasForeignKey("ChannelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Channel"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.Key", b => + { + b.HasOne("NodeGuard.Data.Models.InternalWallet", "InternalWallet") + .WithMany() + .HasForeignKey("InternalWalletId"); + + b.HasOne("NodeGuard.Data.Models.ApplicationUser", "User") + .WithMany("Keys") + .HasForeignKey("UserId"); + + b.Navigation("InternalWallet"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.LiquidityRule", b => + { + b.HasOne("NodeGuard.Data.Models.Channel", "Channel") + .WithMany("LiquidityRules") + .HasForeignKey("ChannelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("NodeGuard.Data.Models.Node", "Node") + .WithMany() + .HasForeignKey("NodeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("NodeGuard.Data.Models.Wallet", "ReverseSwapWallet") + .WithMany("LiquidityRulesAsReverseSwapWallet") + .HasForeignKey("ReverseSwapWalletId"); + + b.HasOne("NodeGuard.Data.Models.Wallet", "SwapWallet") + .WithMany("LiquidityRulesAsSwapWallet") + .HasForeignKey("SwapWalletId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Channel"); + + b.Navigation("Node"); + + b.Navigation("ReverseSwapWallet"); + + b.Navigation("SwapWallet"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.Node", b => + { + b.HasOne("NodeGuard.Data.Models.Wallet", "FundsDestinationWallet") + .WithMany() + .HasForeignKey("FundsDestinationWalletId"); + + b.HasOne("NodeGuard.Data.Models.Wallet", null) + .WithMany() + .HasForeignKey("SparkWalletId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("FundsDestinationWallet"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.Rebalance", b => + { + b.HasOne("NodeGuard.Data.Models.Node", "Node") + .WithMany() + .HasForeignKey("NodeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("NodeGuard.Data.Models.Channel", "SourceChannel") + .WithMany() + .HasForeignKey("SourceChannelId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("NodeGuard.Data.Models.ApplicationUser", "UserRequestor") + .WithMany() + .HasForeignKey("UserRequestorId"); + + b.Navigation("Node"); + + b.Navigation("SourceChannel"); + + b.Navigation("UserRequestor"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.SwapOut", b => + { + b.HasOne("NodeGuard.Data.Models.Wallet", "DestinationWallet") + .WithMany("SwapOuts") + .HasForeignKey("DestinationWalletId"); + + b.HasOne("NodeGuard.Data.Models.Node", "Node") + .WithMany("SwapOuts") + .HasForeignKey("NodeId"); + + b.HasOne("NodeGuard.Data.Models.ApplicationUser", "UserRequestor") + .WithMany() + .HasForeignKey("UserRequestorId"); + + b.Navigation("DestinationWallet"); + + b.Navigation("Node"); + + b.Navigation("UserRequestor"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.Wallet", b => + { + b.HasOne("NodeGuard.Data.Models.InternalWallet", "InternalWallet") + .WithMany() + .HasForeignKey("InternalWalletId"); + + b.Navigation("InternalWallet"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.WalletWithdrawalRequest", b => + { + b.HasOne("NodeGuard.Data.Models.WalletWithdrawalRequest", "BumpingWalletWithdrawalRequest") + .WithMany() + .HasForeignKey("BumpingWalletWithdrawalRequestId"); + + b.HasOne("NodeGuard.Data.Models.ApplicationUser", "UserRequestor") + .WithMany("WalletWithdrawalRequests") + .HasForeignKey("UserRequestorId"); + + b.HasOne("NodeGuard.Data.Models.Wallet", "Wallet") + .WithMany() + .HasForeignKey("WalletId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("BumpingWalletWithdrawalRequest"); + + b.Navigation("UserRequestor"); + + b.Navigation("Wallet"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.WalletWithdrawalRequestDestination", b => + { + b.HasOne("NodeGuard.Data.Models.WalletWithdrawalRequest", "WalletWithdrawalRequest") + .WithMany("WalletWithdrawalRequestDestinations") + .HasForeignKey("WalletWithdrawalRequestId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("WalletWithdrawalRequest"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.WalletWithdrawalRequestPSBT", b => + { + b.HasOne("NodeGuard.Data.Models.ApplicationUser", "Signer") + .WithMany() + .HasForeignKey("SignerId"); + + b.HasOne("NodeGuard.Data.Models.WalletWithdrawalRequest", "WalletWithdrawalRequest") + .WithMany("WalletWithdrawalRequestPSBTs") + .HasForeignKey("WalletWithdrawalRequestId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Signer"); + + b.Navigation("WalletWithdrawalRequest"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.Channel", b => + { + b.Navigation("ChannelOperationRequests"); + + b.Navigation("LiquidityRules"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.ChannelOperationRequest", b => + { + b.Navigation("ChannelOperationRequestPsbts"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.Node", b => + { + b.Navigation("ChannelOperationRequestsAsDestination"); + + b.Navigation("ChannelOperationRequestsAsSource"); + + b.Navigation("SwapOuts"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.Wallet", b => + { + b.Navigation("ChannelOperationRequestsAsSource"); + + b.Navigation("LiquidityRulesAsReverseSwapWallet"); + + b.Navigation("LiquidityRulesAsSwapWallet"); + + b.Navigation("SwapOuts"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.WalletWithdrawalRequest", b => + { + b.Navigation("WalletWithdrawalRequestDestinations"); + + b.Navigation("WalletWithdrawalRequestPSBTs"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.ApplicationUser", b => + { + b.Navigation("ChannelOperationRequests"); + + b.Navigation("Keys"); + + b.Navigation("WalletWithdrawalRequests"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Migrations/20261016144038_AddPerProviderSwapLimitsAndSparkExitBatches.cs b/src/Migrations/20261016144038_AddPerProviderSwapLimitsAndSparkExitBatches.cs new file mode 100644 index 00000000..b57d626d --- /dev/null +++ b/src/Migrations/20261016144038_AddPerProviderSwapLimitsAndSparkExitBatches.cs @@ -0,0 +1,129 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace NodeGuard.Migrations +{ + /// + public partial class AddPerProviderSwapLimitsAndSparkExitBatches : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.RenameColumn( + name: "SwapMinAmountSats", + table: "Nodes", + newName: "LoopSwapMinAmountSats"); + + migrationBuilder.RenameColumn( + name: "SwapMaxAmountSats", + table: "Nodes", + newName: "LoopSwapMaxAmountSats"); + + migrationBuilder.RenameColumn( + name: "MaxSwapsInFlight", + table: "Nodes", + newName: "LoopMaxSwapsInFlight"); + + migrationBuilder.AddColumn( + name: "SparkExitDatetime", + table: "SwapOuts", + type: "timestamp with time zone", + nullable: true); + + migrationBuilder.AddColumn( + name: "FortySwapMaxAmountSats", + table: "Nodes", + type: "bigint", + nullable: false, + defaultValue: 0L); + + migrationBuilder.AddColumn( + name: "FortySwapMaxSwapsInFlight", + table: "Nodes", + type: "integer", + nullable: false, + defaultValue: 0); + + migrationBuilder.AddColumn( + name: "FortySwapMinAmountSats", + table: "Nodes", + type: "bigint", + nullable: false, + defaultValue: 0L); + + migrationBuilder.AddColumn( + name: "SparkMaxSwapsInFlight", + table: "Nodes", + type: "integer", + nullable: false, + defaultValue: 0); + + migrationBuilder.AddColumn( + name: "SparkSwapMaxAmountSats", + table: "Nodes", + type: "bigint", + nullable: true); + + migrationBuilder.AddColumn( + name: "SparkSwapMinAmountSats", + table: "Nodes", + type: "bigint", + nullable: true); + + // The node's shared swap sizes and max swaps in flight become Loop's (the renames above), and 40swap's + // and Spark's max swaps in flight too; Spark's sizes default to SPARK_SWAP_MIN_SATS / SPARK_SWAP_MAX_SATS + migrationBuilder.Sql( + "UPDATE \"Nodes\" SET \"FortySwapMinAmountSats\" = \"LoopSwapMinAmountSats\", \"FortySwapMaxAmountSats\" = \"LoopSwapMaxAmountSats\", " + + "\"FortySwapMaxSwapsInFlight\" = \"LoopMaxSwapsInFlight\", \"SparkMaxSwapsInFlight\" = \"LoopMaxSwapsInFlight\";"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "SparkExitDatetime", + table: "SwapOuts"); + + migrationBuilder.DropColumn( + name: "FortySwapMaxAmountSats", + table: "Nodes"); + + migrationBuilder.DropColumn( + name: "FortySwapMaxSwapsInFlight", + table: "Nodes"); + + migrationBuilder.DropColumn( + name: "FortySwapMinAmountSats", + table: "Nodes"); + + migrationBuilder.DropColumn( + name: "SparkMaxSwapsInFlight", + table: "Nodes"); + + migrationBuilder.DropColumn( + name: "SparkSwapMaxAmountSats", + table: "Nodes"); + + migrationBuilder.DropColumn( + name: "SparkSwapMinAmountSats", + table: "Nodes"); + + migrationBuilder.RenameColumn( + name: "LoopMaxSwapsInFlight", + table: "Nodes", + newName: "MaxSwapsInFlight"); + + migrationBuilder.RenameColumn( + name: "LoopSwapMinAmountSats", + table: "Nodes", + newName: "SwapMinAmountSats"); + + migrationBuilder.RenameColumn( + name: "LoopSwapMaxAmountSats", + table: "Nodes", + newName: "SwapMaxAmountSats"); + } + } +} diff --git a/src/Migrations/ApplicationDbContextModelSnapshot.cs b/src/Migrations/ApplicationDbContextModelSnapshot.cs index f13ebf08..1dcf6f15 100644 --- a/src/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/src/Migrations/ApplicationDbContextModelSnapshot.cs @@ -1011,6 +1011,15 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("FortySwapEndpoint") .HasColumnType("text"); + b.Property("FortySwapMaxAmountSats") + .HasColumnType("bigint"); + + b.Property("FortySwapMaxSwapsInFlight") + .HasColumnType("integer"); + + b.Property("FortySwapMinAmountSats") + .HasColumnType("bigint"); + b.Property("FortySwapWeight") .HasColumnType("integer"); @@ -1020,6 +1029,15 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("IsNodeDisabled") .HasColumnType("boolean"); + b.Property("LoopMaxSwapsInFlight") + .HasColumnType("integer"); + + b.Property("LoopSwapMaxAmountSats") + .HasColumnType("bigint"); + + b.Property("LoopSwapMinAmountSats") + .HasColumnType("bigint"); + b.Property("LoopSwapWeight") .HasColumnType("integer"); @@ -1041,9 +1059,6 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("MaxSwapRoutingFeeRatio") .HasColumnType("numeric"); - b.Property("MaxSwapsInFlight") - .HasColumnType("integer"); - b.Property("MinimumBalanceThresholdSats") .HasColumnType("bigint"); @@ -1069,9 +1084,18 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("boolean") .HasDefaultValue(false); + b.Property("SparkMaxSwapsInFlight") + .HasColumnType("integer"); + b.Property("SparkPaymentTimeoutMinutes") .HasColumnType("integer"); + b.Property("SparkSwapMaxAmountSats") + .HasColumnType("bigint"); + + b.Property("SparkSwapMinAmountSats") + .HasColumnType("bigint"); + b.Property("SparkSwapWeight") .HasColumnType("integer"); @@ -1087,12 +1111,6 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("SwapBudgetStartDatetime") .HasColumnType("timestamp with time zone"); - b.Property("SwapMaxAmountSats") - .HasColumnType("bigint"); - - b.Property("SwapMinAmountSats") - .HasColumnType("bigint"); - b.Property("UpdateDatetime") .HasColumnType("timestamp with time zone"); @@ -1249,6 +1267,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("ServiceFeeSats") .HasColumnType("bigint"); + b.Property("SparkExitDatetime") + .HasColumnType("timestamp with time zone"); + b.Property("SparkIdentity") .HasColumnType("text"); diff --git a/src/Pages/Nodes.razor b/src/Pages/Nodes.razor index d65091ab..529a4ec1 100644 --- a/src/Pages/Nodes.razor +++ b/src/Pages/Nodes.razor @@ -164,6 +164,29 @@ + + @* Each provider's swap sizes (liquidity config) *@ + + @SwapSizeText(context.LoopSwapMinAmountSats) + + + @SwapSizeText(context.LoopSwapMaxAmountSats) + + + @SwapSizeText(context.FortySwapMinAmountSats) + + + @SwapSizeText(context.FortySwapMaxAmountSats) + + + @SparkSwapSizeText(context.SparkSwapMinAmountSats, SparkSettings.SwapMinSats) + + + @SparkSwapSizeText(context.SparkSwapMaxAmountSats, SparkSettings.SwapMaxSats) + + + + @@ -317,7 +340,7 @@ Spark Weight - Weight for the node's Spark wallet (0-100). Skipped while it is unavailable; Max Swaps In Flight limits Spark swaps too + Weight for the node's Spark wallet (0-100). Skipped while it is unavailable; Spark Max Swaps in Flight limits its swaps + @* Each provider has its own swap sizes and max swaps in flight: auto-liquidity picks a provider below its max, then swaps the most it takes *@ - - Min Swap Size - Minimum amount for each swap (must be ≥ @Constants.MINIMUM_SWAP_OUT_SIZE_BTC BTC) - + Loop Min Swap Size + Minimum amount for each Loop swap (≥ @Constants.MINIMUM_SWAP_OUT_SIZE_BTC BTC) + - Min swap size must be ≥ @Constants.MINIMUM_SWAP_OUT_SIZE_BTC BTC and ≤ max swap size + @@ -399,37 +423,146 @@ - - Max Swap Size - Maximum amount for each swap (must be ≤ @Constants.MAXIMUM_SWAP_OUT_SIZE_BTC BTC) - + Loop Max Swap Size + Maximum amount for each Loop swap (≤ @Constants.MAXIMUM_SWAP_OUT_SIZE_BTC BTC) + - Max swap size must be ≤ @Constants.MAXIMUM_SWAP_OUT_SIZE_BTC BTC and ≥ min swap size + + + + Loop Max Swaps in Flight + Concurrent Loop swaps (0-100), to limit HTLC locking + + + Must be between 0 and 100 + + + + - Max Swaps in Flight - Maximum concurrent swaps (0-100) to limit HTLC locking - + + 40swap Min Swap Size + Minimum amount for each 40swap swap (≥ @Constants.MINIMUM_SWAP_OUT_SIZE_BTC BTC) + + + + + + + + + + + + 40swap Max Swap Size + Maximum amount for each 40swap swap (≤ @Constants.MAXIMUM_SWAP_OUT_SIZE_BTC BTC) + + + + + + + + + + + 40swap Max Swaps in Flight + Concurrent 40swap swaps (0-100), to limit HTLC locking + Must be between 0 and 100 + + @if (SparkSettings.Enabled || _selectedNodeForLiquidity.SparkSwapWeight > 0) + { + + + + + Spark Min Swap Size + Minimum amount for each Spark swap (≥ @SparkLowestMinSwapBtc BTC; empty: @SatsToBtc(SparkSettings.SwapMinSats) BTC, SPARK_SWAP_MIN_SATS) + + + + + + + + + + + + Spark Max Swap Size + Maximum amount for each Spark swap (≤ @Constants.MAXIMUM_SWAP_OUT_SIZE_BTC BTC; empty: @SatsToBtc(SparkSettings.SwapMaxSats) BTC, SPARK_SWAP_MAX_SATS). A Spark swap also fits its wallet's max balance + + + + + + + + + + + Spark Max Swaps in Flight + Spark swaps being paid at once (0-100; 0 still lets one manual swap through). Once paid, a swap waits in the Spark wallet and no longer counts + + + Must be between 0 and 100 + + + + + + } + Max Swap Routing Fee @@ -668,8 +801,26 @@ private Validations? _nodeLiquidityValidationsRef; // BTC conversion fields for easier editing - private decimal _minSwapSizeBtc; - private decimal _maxSwapSizeBtc; + private decimal _loopMinSwapBtc; + private decimal _loopMaxSwapBtc; + private decimal _fortySwapMinSwapBtc; + private decimal _fortySwapMaxSwapBtc; + // Empty: SPARK_SWAP_MIN_SATS / SPARK_SWAP_MAX_SATS + private decimal? _sparkMinSwapBtc; + private decimal? _sparkMaxSwapBtc; + + private decimal SparkMinSwapBtc => _sparkMinSwapBtc ?? SatsToBtc(SparkSettings.SwapMinSats); + private decimal SparkMaxSwapBtc => _sparkMaxSwapBtc ?? SatsToBtc(SparkSettings.SwapMaxSats); + private static decimal SparkLowestMinSwapBtc => SatsToBtc(SparkSettings.LowestSwapMinSats); + + private static decimal SatsToBtc(long sats) => (decimal)new NBitcoin.Money(sats).ToUnit(NBitcoin.MoneyUnit.BTC); + + private static string SwapSizeText(long sats) => $"{SatsToBtc(sats)} BTC"; + + /// A node's Spark swap size: its own, else the setting's, marked as the default + private static string SparkSwapSizeText(long? sats, long defaultSats) => + sats is { } own ? SwapSizeText(own) : $"{SwapSizeText(defaultSats)} (default)"; + private static long BtcToSats(decimal btc) => (long)new NBitcoin.Money(btc, NBitcoin.MoneyUnit.BTC).Satoshi; private decimal _balanceThresholdBtc; private decimal _swapBudgetBtc; private int _budgetRefreshDays; @@ -692,9 +843,15 @@ public static readonly ColumnDefault Disabled = new("Disabled"); public static readonly ColumnDefault Autosweep = new("Autosweep"); public static readonly ColumnDefault AutoLiquidityManagement = new("Auto Liquidity Management"); - public static readonly ColumnDefault SwapMinAmount = new("Min Swap Amount",false); - public static readonly ColumnDefault SwapMaxAmount = new("Max Swap Amount",false); - public static readonly ColumnDefault MaxSwapsInFlight = new("Max Swaps In Flight",false); + public static readonly ColumnDefault LoopSwapMinAmount = new("Loop Min Swap Amount",false); + public static readonly ColumnDefault LoopSwapMaxAmount = new("Loop Max Swap Amount",false); + public static readonly ColumnDefault FortySwapMinAmount = new("40swap Min Swap Amount",false); + public static readonly ColumnDefault FortySwapMaxAmount = new("40swap Max Swap Amount",false); + public static readonly ColumnDefault SparkSwapMinAmount = new("Spark Min Swap Amount",false); + public static readonly ColumnDefault SparkSwapMaxAmount = new("Spark Max Swap Amount",false); + public static readonly ColumnDefault LoopMaxSwapsInFlight = new("Loop Max Swaps In Flight",false); + public static readonly ColumnDefault FortySwapMaxSwapsInFlight = new("40swap Max Swaps In Flight",false); + public static readonly ColumnDefault SparkMaxSwapsInFlight = new("Spark Max Swaps In Flight",false); public static readonly ColumnDefault MaxSwapFeeRatio = new("Max Swap Fee Ratio",false); public static readonly ColumnDefault MinimumBalanceThreshold = new("Minimum Balance Threshold",false); public static readonly ColumnDefault SwapBudget = new("Swap Budget",false); @@ -782,7 +939,10 @@ private async Task LoadColumnLayout() { - NodesColumns = await LocalStorageService.LoadStorage(nameof(NodesColumnName), ColumnHelpers.GetColumnsDictionary()); + // A layout saved before columns were added or renamed: new ones take their default, gone ones are dropped + var defaults = ColumnHelpers.GetColumnsDictionary(); + var saved = await LocalStorageService.LoadStorage(nameof(NodesColumnName), defaults); + NodesColumns = defaults.ToDictionary(c => c.Key, c => saved.TryGetValue(c.Key, out var visible) ? visible : c.Value); columnsLoaded = true; StateHasChanged(); } @@ -1029,8 +1189,12 @@ } // Convert sats to BTC for display - _minSwapSizeBtc = (decimal)new NBitcoin.Money(node.SwapMinAmountSats).ToUnit(NBitcoin.MoneyUnit.BTC); - _maxSwapSizeBtc = (decimal)new NBitcoin.Money(node.SwapMaxAmountSats).ToUnit(NBitcoin.MoneyUnit.BTC); + _loopMinSwapBtc = SatsToBtc(node.LoopSwapMinAmountSats); + _loopMaxSwapBtc = SatsToBtc(node.LoopSwapMaxAmountSats); + _fortySwapMinSwapBtc = SatsToBtc(node.FortySwapMinAmountSats); + _fortySwapMaxSwapBtc = SatsToBtc(node.FortySwapMaxAmountSats); + _sparkMinSwapBtc = node.SparkSwapMinAmountSats is { } sparkMin ? SatsToBtc(sparkMin) : null; + _sparkMaxSwapBtc = node.SparkSwapMaxAmountSats is { } sparkMax ? SatsToBtc(sparkMax) : null; _balanceThresholdBtc = (decimal)new NBitcoin.Money(node.MinimumBalanceThresholdSats).ToUnit(NBitcoin.MoneyUnit.BTC); _swapBudgetBtc = (decimal)new NBitcoin.Money(node.SwapBudgetSats).ToUnit(NBitcoin.MoneyUnit.BTC); _budgetRefreshDays = node.SwapBudgetRefreshInterval.Days; @@ -1078,8 +1242,12 @@ } // Convert BTC back to sats and percent back to ratio (0-1) - _selectedNodeForLiquidity.SwapMinAmountSats = (long)new NBitcoin.Money(_minSwapSizeBtc, NBitcoin.MoneyUnit.BTC).Satoshi; - _selectedNodeForLiquidity.SwapMaxAmountSats = (long)new NBitcoin.Money(_maxSwapSizeBtc, NBitcoin.MoneyUnit.BTC).Satoshi; + _selectedNodeForLiquidity.LoopSwapMinAmountSats = BtcToSats(_loopMinSwapBtc); + _selectedNodeForLiquidity.LoopSwapMaxAmountSats = BtcToSats(_loopMaxSwapBtc); + _selectedNodeForLiquidity.FortySwapMinAmountSats = BtcToSats(_fortySwapMinSwapBtc); + _selectedNodeForLiquidity.FortySwapMaxAmountSats = BtcToSats(_fortySwapMaxSwapBtc); + _selectedNodeForLiquidity.SparkSwapMinAmountSats = _sparkMinSwapBtc is { } sparkMin ? BtcToSats(sparkMin) : null; + _selectedNodeForLiquidity.SparkSwapMaxAmountSats = _sparkMaxSwapBtc is { } sparkMax ? BtcToSats(sparkMax) : null; _selectedNodeForLiquidity.MinimumBalanceThresholdSats = (long)new NBitcoin.Money(_balanceThresholdBtc, NBitcoin.MoneyUnit.BTC).Satoshi; _selectedNodeForLiquidity.SwapBudgetSats = (long)new NBitcoin.Money(_swapBudgetBtc, NBitcoin.MoneyUnit.BTC).Satoshi; _selectedNodeForLiquidity.SwapBudgetRefreshInterval = TimeSpan.FromDays(_budgetRefreshDays); @@ -1129,39 +1297,34 @@ } // Validation methods - private void ValidateMinSwapSize(ValidatorEventArgs arg) + /// + /// One end of a provider's swap sizes: the min at least , the max at most + /// MAXIMUM_SWAP_OUT_SIZE_BTC, and the min no more than the max + /// + private static void ValidateSwapSize(ValidatorEventArgs arg, decimal min, decimal max, decimal lowestMin, bool isMin) { arg.Status = ValidationStatus.Success; - - if (_minSwapSizeBtc < Constants.MINIMUM_SWAP_OUT_SIZE_BTC) + + if (isMin && min < lowestMin) { arg.Status = ValidationStatus.Error; - arg.ErrorText = $"Min swap size must be at least {Constants.MINIMUM_SWAP_OUT_SIZE_BTC} BTC"; + arg.ErrorText = $"Min swap size must be at least {lowestMin} BTC"; return; } - - if (_minSwapSizeBtc > _maxSwapSizeBtc) - { - arg.Status = ValidationStatus.Error; - arg.ErrorText = "Min swap size must be less than or equal to max swap size"; - } - } - private void ValidateMaxSwapSize(ValidatorEventArgs arg) - { - arg.Status = ValidationStatus.Success; - - if (_maxSwapSizeBtc > Constants.MAXIMUM_SWAP_OUT_SIZE_BTC) + if (!isMin && max > Constants.MAXIMUM_SWAP_OUT_SIZE_BTC) { arg.Status = ValidationStatus.Error; arg.ErrorText = $"Max swap size must be at most {Constants.MAXIMUM_SWAP_OUT_SIZE_BTC} BTC"; return; } - - if (_maxSwapSizeBtc < _minSwapSizeBtc) + + if (min > max) { arg.Status = ValidationStatus.Error; - arg.ErrorText = "Max swap size must be greater than or equal to min swap size"; + arg.ErrorText = isMin + ? "Min swap size must be less than or equal to max swap size" + : "Max swap size must be greater than or equal to min swap size"; } } diff --git a/src/Services/Spark/SparkGuardrails.cs b/src/Services/Spark/SparkGuardrails.cs index 08694e12..31becae7 100644 --- a/src/Services/Spark/SparkGuardrails.cs +++ b/src/Services/Spark/SparkGuardrails.cs @@ -90,6 +90,13 @@ public SparkGuardrails(SparkSettings settings, ISparkWalletService spark, ILogge public static long UnpaidSats(IEnumerable pendingSwaps) => PendingSpark(pendingSwaps).Where(s => s.LightningFeeSats is null).Sum(s => s.SatsAmount); + /// + /// How many more sats a Spark wallet can take under its max balance: what it holds and what its pending swaps + /// not paid yet (, the wallet's own) will still bring in count against it. + /// + public static long RoomSats(long maxBalanceSats, SatsBalance balance, IEnumerable walletPendingSwaps) => + maxBalanceSats - balance.Owned - balance.Incoming - UnpaidSats(walletPendingSwaps); + /// /// What the wallet holds for its pending Spark swaps: what each one brought in, or its amount until it is known. /// A swap whose exit was sent still counts: its leaves stay the wallet's until the exit confirms, which at SLOW @@ -174,15 +181,25 @@ public async Task> CheckAsync(IReadOnlyCollection s.LightningFeeSats is not null)) { - var age = now - swap.CreationDatetime; + // Counted from when the swap should have moved on: its transfer is found within minutes of the payment, + // and its exit, once attempted, confirms at the exit speed. A swap waiting for its wallet to fill isn't late + var (since, what) = swap.SparkTransferId is null + ? (swap.CreationDatetime, "it was paid") + // A swap that exited before batching has an address but no exit time: counted from its creation + : (swap.SparkExitDatetime ?? (swap.DestinationAddress is null ? null : swap.CreationDatetime)) is { } exitAttempted + ? (exitAttempted, "its exit was first attempted") + : (default(DateTimeOffset?), ""); + if (since is null) continue; + + var age = now - since.Value; // A slower exit waits longer for a block: the alert waits with it if (age < _settings.ExitOverdueAfter || !_overdueAlerted.Add(swap.Id)) continue; - _logger.LogError("Spark swap {SwapId} was paid {AgeHours} hours ago and its payout is not confirmed yet (exit {TxId})", - swap.Id, (int)age.TotalHours, swap.TxId ?? "not started"); + _logger.LogError("Spark swap {SwapId} has no confirmed payout {AgeHours} hours after {What} (exit {TxId})", + swap.Id, (int)age.TotalHours, what, swap.TxId ?? "not started"); alerts.Add(new SparkAlert(AuditActionType.SparkExitOverdue, AuditObjectType.SwapOut, swap.ProviderId ?? swap.Id.ToString(), - $"Spark swap {swap.Id} has no confirmed payout {(int)age.TotalHours} hours after it was paid", - new { SwapId = swap.Id, swap.NodeId, swap.SatsAmount, swap.TxId, swap.DestinationAddress, swap.CreationDatetime })); + $"Spark swap {swap.Id} has no confirmed payout {(int)age.TotalHours} hours after {what}", + new { SwapId = swap.Id, swap.NodeId, swap.SatsAmount, swap.TxId, swap.DestinationAddress, swap.CreationDatetime, swap.SparkExitDatetime })); } return alerts; diff --git a/src/Services/Spark/SparkSettings.cs b/src/Services/Spark/SparkSettings.cs index f3473c3f..7d38b723 100644 --- a/src/Services/Spark/SparkSettings.cs +++ b/src/Services/Spark/SparkSettings.cs @@ -67,6 +67,15 @@ public sealed class SparkSettings public const int MaxPaymentTimeoutMinutes = 25; public const long DefaultMaxBalanceSats = 10_000_000; + /// A node's smallest Spark swap by default (SPARK_SWAP_MIN_SATS). + public const long DefaultSwapMinSats = 50_000; + + /// A node's largest Spark swap by default (SPARK_SWAP_MAX_SATS). + public const long DefaultSwapMaxSats = 2_000_000; + + /// The smallest Spark min swap a node may set: smaller swaps aren't worth their routing and exit fees. + public const long LowestSwapMinSats = 10_000; + public static readonly SparkSettings Disabled = new(); public bool Enabled { get; init; } @@ -103,7 +112,7 @@ public sealed class SparkSettings /// The Spark identity the remote signer must serve (SPARK_IDENTITY_PUBKEY; remote signer only). public string? IdentityPublicKey { get; init; } - /// Highest fee accepted for the cooperative exit of a swap (SPARK_MAX_EXIT_FEE_SATS). + /// Highest fee accepted for a cooperative exit, of a batch of swaps (SPARK_MAX_EXIT_FEE_SATS). public long MaxExitFeeSats { get; init; } = DefaultMaxExitFeeSats; /// @@ -155,6 +164,12 @@ public sealed class SparkSettings /// public long MaxBalanceSats { get; init; } = DefaultMaxBalanceSats; + /// A node's smallest Spark swap (SPARK_SWAP_MIN_SATS), unless the node has its own. + public long SwapMinSats { get; init; } = DefaultSwapMinSats; + + /// A node's largest Spark swap (SPARK_SWAP_MAX_SATS), unless the node has its own. + public long SwapMaxSats { get; init; } = DefaultSwapMaxSats; + /// The remote signer's Function URL (REMOTE_SIGNER_ENDPOINT), signed with SigV4. public Uri? SignerEndpoint { get; init; } @@ -324,6 +339,21 @@ public static SparkSettings FromEnvironment(Func env, Network n errors.Add("SPARK_MAX_BALANCE_SATS must be a positive number of sats"); } + var largestSwap = (long)(Constants.MAXIMUM_SWAP_OUT_SIZE_BTC * 100_000_000m); + var swapMin = DefaultSwapMinSats; + if (env("SPARK_SWAP_MIN_SATS") is { Length: > 0 } swapMinValue && + (!long.TryParse(swapMinValue, out swapMin) || swapMin < LowestSwapMinSats)) + { + errors.Add($"SPARK_SWAP_MIN_SATS must be a number of sats of at least {LowestSwapMinSats}"); + } + + var swapMax = DefaultSwapMaxSats; + if (env("SPARK_SWAP_MAX_SATS") is { Length: > 0 } swapMaxValue && + (!long.TryParse(swapMaxValue, out swapMax) || swapMax < swapMin || swapMax > largestSwap)) + { + errors.Add($"SPARK_SWAP_MAX_SATS must be a number of sats from SPARK_SWAP_MIN_SATS to {largestSwap} (MAXIMUM_SWAP_OUT_SIZE_BTC)"); + } + Uri? endpoint = null; Uri? rieUrl = null; if (mode == SparkSignerMode.Remote) @@ -364,6 +394,8 @@ public static SparkSettings FromEnvironment(Func env, Network n PaymentTimeoutMinutes = paymentTimeout, ExitSpeed = exitSpeed, MaxBalanceSats = maxBalance, + SwapMinSats = swapMin, + SwapMaxSats = swapMax, SignerEndpoint = endpoint, SignerRieUrl = rieUrl }; diff --git a/src/Services/Spark/SparkSwapService.cs b/src/Services/Spark/SparkSwapService.cs index f08d8d50..68a1dfc3 100644 --- a/src/Services/Spark/SparkSwapService.cs +++ b/src/Services/Spark/SparkSwapService.cs @@ -51,10 +51,11 @@ public enum SparkExitStep /// Spark swaps open at once; /// payment (, MonitorSwapsJob): the payment is followed on the node /// until it settles, which hands the swap to SparkSwapExitJob; -/// exit ( and , SparkSwapExitJob): the inbound -/// transfer that brought the payment in is found and its leaves are recorded on the swap. Exactly those -/// leaves exit on-chain to the swap's own destination address, with no leaf swap, so the remote signer can -/// sign it. The swap completes when that address receives a confirmed payout, which is what it delivered. +/// exit (, and , +/// SparkSwapExitJob): the inbound transfer that brought the payment in is found and its leaves are recorded on the +/// swap. Paid swaps wait in their Spark wallet until it is full, then exit together: one cooperative exit per +/// destination wallet, of exactly their leaves, with no leaf swap, so the remote signer can sign it. Each swap +/// completes when the batch's address receives a confirmed payout, its share of which is what it delivered. /// /// Each step is saved before the next and can be retried after a crash: the swap is recorded before the /// payment, the payment is looked up by its hash, a transfer is given to one swap only, and the payout is @@ -86,16 +87,32 @@ public interface ISparkSwapService Task AttributeAsync(SwapOut swap, CancellationToken ct = default); /// - /// Moves a paid swap's exit one step forward: exits its leaves to its destination address, or completes it - /// once that address has a confirmed payout. Progress is saved on . + /// The batches to exit now, oldest first, out of the attributed swaps not exiting yet among + /// (all pending Spark swaps): those of each Spark wallet that is full (no node + /// using it can add a swap under its max balance) and none of whose swaps is still being paid, one batch per + /// destination wallet, of at most leaves. /// - Task ExitAsync(SwapOut swap, CancellationToken ct = default); + Task>> PlanExitsAsync(IReadOnlyCollection pendingSwaps, CancellationToken ct = default); + + /// + /// Moves a batch's exit one step forward: exits its swaps' leaves together to one new address of their + /// destination wallet, or completes them once that address has a confirmed payout, shared among them in + /// proportion to what each brought in. A batch is either new (from ) or the swaps + /// already sharing a destination address. Progress is saved on its swaps. + /// + Task ExitBatchAsync(IReadOnlyList batch, CancellationToken ct = default); } public sealed class SparkSwapService : ISparkSwapService { public const int InvoiceExpirySeconds = 1800; + /// + /// Most leaves one cooperative exit carries. open-ssp refuses more than 100 (the operators take up to 1000); its fee + /// doesn't grow with them, so a batch packs as many swaps as fit. + /// + public const int MaxLeavesPerExit = 100; + /// /// Spark's fixed fee for a Lightning payment into Spark, in basis points: 15 (0.15%), charged to the sender on the /// Lightning route through the invoice's route hints (https://docs.spark.money/wallets/estimate-fees). It is part of @@ -127,11 +144,13 @@ public sealed class SparkSwapService : ISparkSwapService private readonly ISwapOutRepository _swapOutRepository; private readonly IWalletRepository _walletRepository; private readonly INBXplorerService _nbXplorerService; + private readonly INodeRepository _nodeRepository; + private readonly IServiceScopeFactory _scopes; private readonly TimeProvider _time; public SparkSwapService(ILogger logger, SparkSettings settings, ISparkWalletService spark, ILightningService lightningService, ISwapOutRepository swapOutRepository, IWalletRepository walletRepository, - INBXplorerService nbXplorerService, TimeProvider time) + INBXplorerService nbXplorerService, INodeRepository nodeRepository, IServiceScopeFactory scopes, TimeProvider time) { _logger = logger; _settings = settings; @@ -140,6 +159,8 @@ public SparkSwapService(ILogger logger, SparkSettings settings _swapOutRepository = swapOutRepository; _walletRepository = walletRepository; _nbXplorerService = nbXplorerService; + _nodeRepository = nodeRepository; + _scopes = scopes; _time = time; } @@ -175,23 +196,24 @@ public async Task CreateSwapOutAsync(Node node, SwapOut swapOut await Reservation.WaitAsync(ct); try { - // Each swap exits its own leaves, so several can be in flight: up to the node's Max swaps in flight - var limit = Math.Max(node.MaxSwapsInFlight, 1); + // Each swap exits its own leaves, so several can be in flight: up to the node's Spark max swaps in flight + var limit = Math.Max(node.SparkMaxSwapsInFlight, 1); var pending = await _swapOutRepository.GetAllPending(); - var inFlight = pending.Count(s => s.Provider == SwapProvider.Spark && s.NodeId == node.Id); + // Once paid, a swap waits in the wallet, whose max balance limits it: only swaps being paid are in flight + var inFlight = pending.Count(s => s.Provider == SwapProvider.Spark && s.NodeId == node.Id && s.LightningFeeSats is null); if (inFlight >= limit) { throw new InvalidOperationException( - $"{node.Name} already has {inFlight} Spark swaps in flight, its limit (Max swaps in flight: {limit})."); + $"{node.Name} already has {inFlight} Spark swaps in flight, its limit (Spark max swaps in flight: {limit})."); } // Transit only: refuse a swap that would leave more than the wallet's max balance in it, counting what // its swaps not paid yet will still bring in var balance = (await _spark.GetBalanceAsync(sparkWallet, ct)).SatsBalance; - var unpaid = SparkGuardrails.UnpaidSats(pending.Where(s => + var room = SparkGuardrails.RoomSats(entry.MaxBalanceSats, balance, pending.Where(s => string.Equals(s.SparkIdentity, status.IdentityPublicKey, StringComparison.OrdinalIgnoreCase))); - var held = balance.Owned + balance.Incoming + unpaid; - if (held + request.Amount > entry.MaxBalanceSats) + var held = entry.MaxBalanceSats - room; + if (request.Amount > room) { throw new InvalidOperationException( $"A {request.Amount} sats Spark swap would bring {entry.Name} to {held + request.Amount} sats, " + @@ -228,29 +250,43 @@ public async Task CreateSwapOutAsync(Node node, SwapOut swapOut Reservation.Release(); } - // Paid in the background, for up to the node's payment timeout: the swap shows as pending meanwhile. Off - // the caller's synchronization context, so the payment doesn't depend on the page that created the swap + // Paid in the background, for up to the node's payment timeout: the swap shows as pending meanwhile. Off the + // caller's synchronization context, and in a scope of its own: the request that created the swap ends long + // before the payment does, and its scope (the database's context factory with it) ends with it var maxFeePercent = request.MaxRoutingFeesPercent ?? DefaultMaxRoutingFeePercent; - var feeLimitMsat = (long)(request.Amount * 1000m * maxFeePercent / 100m); - _ = Task.Run(() => PayAsync(node, swapOut.Id, paymentRequest, feeLimitMsat, request.ChannelsOut, - _settings.PaymentTimeoutFor(node) * 60)); + var (swapId, amount, channelsOut, timeoutSeconds) = + (swapOut.Id, request.Amount, request.ChannelsOut, _settings.PaymentTimeoutFor(node) * 60); + _ = Task.Run(async () => + { + try + { + using var scope = _scopes.CreateScope(); + var service = (SparkSwapService)scope.ServiceProvider.GetRequiredService(); + await service.PayAsync(node, swapId, paymentRequest, amount, maxFeePercent, channelsOut, timeoutSeconds); + } + catch (Exception e) + { + _logger.LogError(e, "Spark swap {SwapId}: its payment could not be started; MonitorSwapsJob takes over", swapId); + } + }); _logger.LogInformation("Spark swap {SwapId}: {Node} is paying {Amount} sats into Spark", swapOut.Id, node.Name, request.Amount); return new SwapOutCreation(Response(swapOut), true, null); } /// - /// Pays a recorded swap's invoice and saves the outcome: the routing fee once paid, the failure once it - /// definitely failed. An unknown outcome, or an error here, is left to MonitorSwapsJob, which finds the payment - /// by its hash. Not cancellable: once the payment starts it is followed to its end. + /// Pays a recorded swap's invoice of , with a fee limit of + /// of it, and saves the outcome: the routing fee once paid, the failure once it + /// definitely failed. An unknown outcome, or an error here, is left to MonitorSwapsJob, which finds the payment by + /// its hash. Not cancellable: once the payment starts it is followed to its end. /// - internal async Task PayAsync(Node node, int swapId, string paymentRequest, long feeLimitMsat, ulong[]? channelsOut, - int timeoutSeconds) + internal async Task PayAsync(Node node, int swapId, string paymentRequest, long amountSats, decimal maxFeePercent, + ulong[]? channelsOut, int timeoutSeconds) { try { - var result = await _lightningService.SendPaymentV2Async(node, paymentRequest, 0, feeLimitMsat, channelsOut, null, - timeoutSeconds, CancellationToken.None); + var result = await _lightningService.SendPaymentV2Async(node, paymentRequest, 0, + FeeLimitMsat(amountSats, maxFeePercent), channelsOut, null, timeoutSeconds, CancellationToken.None); await SavePaymentAsync(node, swapId, result); } catch (Exception e) @@ -259,6 +295,8 @@ internal async Task PayAsync(Node node, int swapId, string paymentRequest, long } } + private static long FeeLimitMsat(long amountSats, decimal maxFeePercent) => (long)(amountSats * 1000m * maxFeePercent / 100m); + private async Task SavePaymentAsync(Node node, int swapId, Payment result) { // Read again once the payment ended: the monitor may have resolved the swap meanwhile @@ -356,75 +394,183 @@ public async Task AttributeAsync(SwapOut swap, CancellationToken ct = defa return true; } - public async Task ExitAsync(SwapOut swap, CancellationToken ct = default) + public async Task>> PlanExitsAsync(IReadOnlyCollection pendingSwaps, + CancellationToken ct = default) { - RequirePaid(swap); - var wallet = await SwapWalletAsync(swap, ct); + var spark = pendingSwaps.Where(s => s.Provider == SwapProvider.Spark && s.Status == SwapOutStatus.Pending).ToList(); + var waiting = spark + .Where(s => s.LightningFeeSats is not null && s.SparkTransferId is not null && s.DestinationAddress is null && s.TxId is null) + .OrderBy(s => s.CreationDatetime) + .ThenBy(s => s.Id) + .ToList(); + if (waiting.Count == 0) return []; + + var nodes = await _nodeRepository.GetAllManagedByNodeGuard(withDisabled: false); + var batches = new List>(); + foreach (var walletSwaps in waiting.GroupBy(s => s.SparkIdentity ?? "", StringComparer.OrdinalIgnoreCase)) + { + var ofWallet = spark.Where(s => string.Equals(s.SparkIdentity, walletSwaps.Key, StringComparison.OrdinalIgnoreCase)).ToList(); + + // A swap still being paid joins this batch once paid: a full wallet takes no new swaps, so that is soon + if (ofWallet.Any(s => s.LightningFeeSats is null)) continue; + + if (await _spark.FindByIdentityAsync(walletSwaps.Key, ct) is not { } entry || + !(await _spark.EnsureReadyAsync(entry.Ref, ct)).IsReady) + { + continue; // ExitBatchAsync says why, once the wallet is back + } + + var balance = (await _spark.GetBalanceAsync(entry.Ref, ct)).SatsBalance; + var room = SparkGuardrails.RoomSats(entry.MaxBalanceSats, balance, ofWallet); + var smallestSwap = nodes + .Where(n => _settings.WalletFor(n) == entry.Ref) + .Select(n => (long?)SwapProviderLimits.For(n, SwapProvider.Spark, _settings).MinSats) + .Min(); + if (smallestSwap is { } min && room >= min) + { + _logger.LogDebug("{SparkWallet} ({Name}) has room for another swap ({Room} sats, its nodes' smallest Spark min swap {Min}): its {Count} paid swaps wait", + entry.Ref, entry.Name, room, min, walletSwaps.Count()); + continue; + } + + _logger.LogInformation("{SparkWallet} ({Name}) is full ({Room} sats of room under its max balance): its {Count} paid swaps exit", + entry.Ref, entry.Name, room, walletSwaps.Count()); + foreach (var destinationSwaps in walletSwaps.GroupBy(s => s.DestinationWalletId)) + { + batches.AddRange(Pack(destinationSwaps)); + } + } + + return batches.OrderBy(b => b[0].CreationDatetime).ThenBy(b => b[0].Id).ToList(); + } + + /// + /// Whole swaps, in order, in batches of at most leaves: a swap's leaves never split + /// across exits, so each swap has one exit and one address. A swap with more leaves than that exits alone. + /// + private static IEnumerable> Pack(IEnumerable swaps) + { + var batch = new List(); + var leaves = 0; + foreach (var swap in swaps) + { + var count = LeafIds(swap).Count; + if (batch.Count > 0 && leaves + count > MaxLeavesPerExit) + { + yield return batch; + batch = []; + leaves = 0; + } + + batch.Add(swap); + leaves += count; + } - // Done once the destination address has a confirmed payout. Checked before exiting too: an exit whose - // txid was not saved (a crash) must not be followed by a second one. No address yet means no exit yet - var payout = swap.DestinationAddress is null ? null : await FindPayoutAsync(swap, ct); + if (batch.Count > 0) yield return batch; + } + + public Task ExitBatchAsync(IReadOnlyList batch, CancellationToken ct = default) => + ExitBatchAsync(batch, retryWithoutStuckLeaves: true, ct); + + private async Task ExitBatchAsync(IReadOnlyList batch, bool retryWithoutStuckLeaves, CancellationToken ct) + { + if (batch.Count == 0) throw new ArgumentException("A batch has at least one swap.", nameof(batch)); + foreach (var swap in batch) RequirePaid(swap); + + var first = batch[0]; + if (batch.Any(s => !string.Equals(s.SparkIdentity, first.SparkIdentity, StringComparison.OrdinalIgnoreCase) || + s.DestinationWalletId != first.DestinationWalletId || s.DestinationAddress != first.DestinationAddress)) + { + throw new InvalidOperationException( + $"Spark swaps {Ids(batch)} can't exit together: their Spark wallet, destination wallet or address differ."); + } + + var wallet = await SwapWalletAsync(first, ct); + + // Done once the batch's address has a confirmed payout. Checked before exiting too: an exit whose txid was + // not saved (a crash) must not be followed by a second one. No address yet means no exit yet + var payout = first.DestinationAddress is null ? null : await FindPayoutAsync(first, ct); if (payout is not null) { if (payout.Confirmations > 0) { - Complete(swap, payout); + Complete(batch, payout); return SparkExitStep.Completed; } - if (swap.TxId != payout.TransactionId.ToString()) + var txId = payout.TransactionId.ToString(); + if (batch.Any(s => s.TxId != txId)) { - swap.TxId = payout.TransactionId.ToString(); - Save(swap); + foreach (var swap in batch) swap.TxId = txId; + SaveAll(batch); } return SparkExitStep.ExitSent; } - if (swap.TxId is not null) return SparkExitStep.ExitSent; - if (!await AttributeAsync(swap, ct)) return SparkExitStep.Waiting; + if (batch.Any(s => s.TxId is not null)) return SparkExitStep.ExitSent; + foreach (var swap in batch) + { + if (!await AttributeAsync(swap, ct)) return SparkExitStep.Waiting; + } - // The address is reserved only now, and saved before the exit: the payout is found by it after a crash - if (swap.DestinationAddress is null) + // The address is reserved only now, and saved on every swap of the batch before the exit: the payout is found + // by it after a crash, and the swaps sharing it are the batch from then on + if (first.DestinationAddress is null) { - swap.DestinationAddress = await ReserveAddressAsync(swap, ct); - if (!Save(swap)) + var address = await ReserveAddressAsync(first, ct); + var now = _time.GetUtcNow(); + foreach (var swap in batch) { - swap.DestinationAddress = null; + swap.DestinationAddress = address; + swap.SparkExitDatetime ??= now; + } + + if (!SaveAll(batch)) + { + foreach (var swap in batch) swap.DestinationAddress = null; return SparkExitStep.Waiting; } - _logger.LogInformation("Spark swap {SwapId}: exits to {Address}", swap.Id, swap.DestinationAddress); + _logger.LogInformation("Spark swaps {SwapIds}: exit together to {Address}", Ids(batch), address); } - var leafIds = LeafIds(swap); + var leafIds = batch.SelectMany(LeafIds).ToList(); WithdrawLeavesResult exit; try { - exit = await _spark.WithdrawLeavesAsync(wallet.Ref, leafIds, swap.DestinationAddress!, _settings.MaxExitFeeSats, ct); + exit = await _spark.WithdrawLeavesAsync(wallet.Ref, leafIds, first.DestinationAddress!, _settings.MaxExitFeeSats, ct); } catch (SparkLeavesNotSpendableException e) { - // Only ever the swap's own leaves. A renewal keeps a leaf's id, so they can't have moved under another one: - // either they already exited (a crash before the txid was saved), or they can't move yet (a renewal failed, - // or they are frozen), and the next run tries again - if (await AlreadyExitedAsync(swap, wallet.Ref, leafIds, ct)) + // Only ever the batch's own leaves. A renewal keeps a leaf's id, so they can't have moved under another one: + // either they already exited (a crash before the txid was saved), or some can't move yet (a renewal failed, + // or they are frozen) + if (await AlreadyExitedAsync(batch, wallet.Ref, leafIds, ct)) { - _logger.LogWarning("Spark swap {SwapId}: its leaves already exited; waiting for the payout", swap.Id); + _logger.LogWarning("Spark swaps {SwapIds}: their leaves already exited; waiting for the payout", Ids(batch)); return SparkExitStep.ExitSent; } - _logger.LogWarning("Spark swap {SwapId}: its leaves can't move yet ({Error}); trying again on the next run", - swap.Id, e.Message); - return SparkExitStep.Waiting; + // The swaps whose leaves can't move leave the batch and wait for the next run; the others exit without them + var stuck = batch.Where(s => LeafIds(s).Intersect(e.LeafIds, StringComparer.OrdinalIgnoreCase).Any()).ToList(); + var rest = batch.Except(stuck).ToList(); + _logger.LogWarning("Spark swaps {SwapIds}: their leaves can't move yet ({Error}); trying again on the next run", + stuck.Count > 0 ? Ids(stuck) : Ids(batch), e.Message); + if (stuck.Count == 0 || rest.Count == 0 || !retryWithoutStuckLeaves) return SparkExitStep.Waiting; + + foreach (var swap in stuck) swap.DestinationAddress = null; + if (!SaveAll(stuck)) return SparkExitStep.Waiting; + + return await ExitBatchAsync(rest, retryWithoutStuckLeaves: false, ct); } - swap.TxId = exit.Txid; - SetFees(swap, exit.PayoutSats); // provisional until the payout confirms - Save(swap); + foreach (var swap in batch) swap.TxId = exit.Txid; + SetFees(batch, exit.PayoutSats); // provisional until the payout confirms + SaveAll(batch); - _logger.LogInformation("Spark swap {SwapId}: cooperative exit {TxId} pays {Payout} of its {Received} sats to {Address} (exit fee {Fee} sats)", - swap.Id, exit.Txid, exit.PayoutSats, exit.SentSats, swap.DestinationAddress, exit.FeeSats); + _logger.LogInformation("Spark swaps {SwapIds}: cooperative exit {TxId} pays {Payout} of their {Received} sats to {Address} (exit fee {Fee} sats)", + Ids(batch), exit.Txid, exit.PayoutSats, exit.SentSats, first.DestinationAddress, exit.FeeSats); return SparkExitStep.ExitSent; } @@ -509,39 +655,58 @@ public async Task ExitAsync(SwapOut swap, CancellationToken ct = return transfer; } - /// Whether a cooperative exit the wallet sent since the swap was created moved any of its leaves. - private async Task AlreadyExitedAsync(SwapOut swap, SparkWalletRef wallet, IReadOnlyCollection leafIds, + /// Whether a cooperative exit the wallet sent since the batch's oldest swap was created moved any of its leaves. + private async Task AlreadyExitedAsync(IReadOnlyList batch, SparkWalletRef wallet, IReadOnlyCollection leafIds, CancellationToken ct) { - var sent = await _spark.GetTransfersAsync(wallet, TransferDirection.Sent, swap.CreationDatetime - TransferLookback, ct); + var since = batch.Min(s => s.CreationDatetime) - TransferLookback; + var sent = await _spark.GetTransfersAsync(wallet, TransferDirection.Sent, since, ct); return sent .Where(t => string.Equals(t.Type, ExitTransferType, StringComparison.OrdinalIgnoreCase)) .Any(t => t.Leaves.Any(l => leafIds.Contains(l.Id, StringComparer.OrdinalIgnoreCase))); } - private void Complete(SwapOut swap, Payout payout) + private void Complete(IReadOnlyList batch, Payout payout) { - swap.Status = SwapOutStatus.Completed; - swap.TxId = payout.TransactionId.ToString(); - swap.PayoutSats = payout.Value; - SetFees(swap, payout.Value); - Save(swap); + foreach (var swap in batch) + { + swap.Status = SwapOutStatus.Completed; + swap.TxId = payout.TransactionId.ToString(); + } - _logger.LogInformation("Spark swap {SwapId}: completed, {Payout} sats confirmed at {Address} in {TxId} (on-chain fee {OnChainFee} sats)", - swap.Id, payout.Value, swap.DestinationAddress, swap.TxId, swap.OnChainFeeSats); + SetFees(batch, payout.Value, payoutConfirmed: true); + SaveAll(batch); + + foreach (var swap in batch) + { + _logger.LogInformation("Spark swap {SwapId}: completed, {Payout} of the {BatchPayout} sats confirmed at {Address} in {TxId} (on-chain fee {OnChainFee} sats)", + swap.Id, swap.PayoutSats, payout.Value, swap.DestinationAddress, swap.TxId, swap.OnChainFeeSats); + } } /// - /// A Spark swap's fees from what it delivered on-chain. Spark's own fee is charged on the Lightning route, so it is - /// part of the routing fee (). The on-chain fee is the move from Spark to L1: - /// the SSP's exit fee and the L1 broadcast, what the swap's leaves held minus the payout. The service fee is only - /// what the payment brought in short of the amount, which receiving into Spark normally leaves at 0. + /// The fees of a batch's swaps from what their exit delivered on-chain. Spark's own fee is charged on the Lightning + /// route, so it is part of the routing fee (). The on-chain fee is the move + /// from Spark to L1: the SSP's exit fee and the L1 broadcast, what the batch's leaves held minus the payout, shared + /// in proportion to what each swap brought in (the rounding's remainder to the oldest, the batch's first). Once the + /// payout confirmed, each swap delivered what it brought in minus its share. The service fee is only what the + /// payment brought in short of the amount, which receiving into Spark normally leaves at 0. /// - private static void SetFees(SwapOut swap, long deliveredSats) + private static void SetFees(IReadOnlyList batch, long deliveredSats, bool payoutConfirmed = false) { - var received = swap.SparkReceivedSats ?? swap.SatsAmount; - swap.ServiceFeeSats = Math.Max(0, swap.SatsAmount - received); - swap.OnChainFeeSats = Math.Max(0, received - deliveredSats); + var received = batch.Select(s => s.SparkReceivedSats ?? s.SatsAmount).ToList(); + var total = received.Sum(); + var fee = Math.Max(0, total - deliveredSats); + var shares = received.Select(r => total == 0 ? 0 : (long)((decimal)fee * r / total)).ToList(); + shares[0] += fee - shares.Sum(); + + for (var i = 0; i < batch.Count; i++) + { + var swap = batch[i]; + swap.ServiceFeeSats = Math.Max(0, swap.SatsAmount - received[i]); + swap.OnChainFeeSats = shares[i]; + if (payoutConfirmed) swap.PayoutSats = received[i] - shares[i]; + } } private sealed record Payout(uint256 TransactionId, long Value, long Confirmations); @@ -611,6 +776,22 @@ private static IReadOnlyList LeafIds(SwapOut swap) => private static bool IsClaimed(SparkTransfer transfer) => string.Equals(transfer.Status, ClaimedTransferStatus, StringComparison.OrdinalIgnoreCase); + /// Saves a batch's progress in one transaction; returns whether it was saved. + private bool SaveAll(IReadOnlyCollection batch) + { + if (batch.Count == 1) return Save(batch.First()); + + var (saved, error) = _swapOutRepository.UpdateRange(batch); + if (!saved) + { + _logger.LogError("Spark swaps {SwapIds}: could not save their progress: {Error}", Ids(batch), error); + } + + return saved; + } + + private static string Ids(IEnumerable swaps) => string.Join(", ", swaps.Select(s => s.Id)); + /// Saves the swap's progress; returns whether it was saved. private bool Save(SwapOut swap) { diff --git a/src/Services/Spark/SparkWalletService.cs b/src/Services/Spark/SparkWalletService.cs index ad749342..5c38b32e 100644 --- a/src/Services/Spark/SparkWalletService.cs +++ b/src/Services/Spark/SparkWalletService.cs @@ -69,9 +69,11 @@ public sealed record SparkWalletCreation(Wallet Wallet, string Mnemonic); /// A transfer into or out of a Spark wallet, as its Transactions tab shows it. /// The wallet sent it (an exit, a withdrawal), rather than received it. /// What it was: "Lightning payment in", "Exit to on-chain", "Spark transfer", or the operators' type. -/// The Spark swap it belongs to, if any: the one it paid in, or whose leaves it exited. +/// +/// The Spark swaps it belongs to: the one it paid in, or those whose leaves it exited (a batch exits several at once). +/// public sealed record SparkWalletTransfer(string Id, DateTimeOffset Date, bool IsSent, string Type, long AmountSats, string Status, - int? SwapId); + IReadOnlyList SwapIds); /// /// NodeGuard's Spark wallets: the Spark wallet rows (SPARK_SIGNER=wallet) or the remote signer's single wallet @@ -524,7 +526,7 @@ public async Task> ListTransfersAsync(int wal { var sent = string.Equals(t.SenderIdentityPublicKey, wallet.SparkIdentityPublicKey, StringComparison.OrdinalIgnoreCase); return new SparkWalletTransfer(t.Id, t.CreatedAt, sent, TransferTypeLabel(t.Type), t.TotalValueSats, t.Status, - SwapOf(t, sent, swaps)?.Id); + SwapsOf(t, sent, swaps).Select(s => s.Id).ToList()); }) .ToList(); } @@ -539,17 +541,17 @@ public async Task> ListTransfersAsync(int wal }; /// The swap a transfer belongs to: the one it paid in, or the one whose leaves it exited. - private static SwapOut? SwapOf(SparkTransfer transfer, bool sent, IReadOnlyList swaps) + private static IEnumerable SwapsOf(SparkTransfer transfer, bool sent, IReadOnlyList swaps) { if (!sent) { - return swaps.FirstOrDefault(s => string.Equals(s.SparkTransferId, transfer.Id, StringComparison.OrdinalIgnoreCase)); + return swaps.Where(s => string.Equals(s.SparkTransferId, transfer.Id, StringComparison.OrdinalIgnoreCase)).Take(1); } var leaves = transfer.Leaves.Select(l => l.Id).ToHashSet(StringComparer.OrdinalIgnoreCase); return leaves.Count == 0 - ? null - : swaps.FirstOrDefault(s => (s.SparkLeafIds ?? string.Empty) + ? [] + : swaps.Where(s => (s.SparkLeafIds ?? string.Empty) .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) .Any(leaves.Contains)); } diff --git a/src/Services/SwapProviderLimits.cs b/src/Services/SwapProviderLimits.cs new file mode 100644 index 00000000..46847335 --- /dev/null +++ b/src/Services/SwapProviderLimits.cs @@ -0,0 +1,43 @@ +/* + * NodeGuard + * Copyright (C) 2023 Elenpay + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + * + */ + +using NodeGuard.Data.Models; +using NodeGuard.Services.Spark; + +namespace NodeGuard.Services; + +/// +/// The smallest and largest swap-out a node makes through a provider, and how many of them it runs at once: one +/// provider may run serialized while another runs in parallel. +/// +public readonly record struct SwapProviderLimits(long MinSats, long MaxSats, int MaxInFlight) +{ + /// + /// 's limits for : Loop's and 40swap's are the node's own; + /// Spark's are the node's own, its sizes else SPARK_SWAP_MIN_SATS / SPARK_SWAP_MAX_SATS. + /// + public static SwapProviderLimits For(Node node, SwapProvider provider, SparkSettings spark) => provider switch + { + SwapProvider.Loop => new(node.LoopSwapMinAmountSats, node.LoopSwapMaxAmountSats, node.LoopMaxSwapsInFlight), + SwapProvider.FortySwap => new(node.FortySwapMinAmountSats, node.FortySwapMaxAmountSats, node.FortySwapMaxSwapsInFlight), + SwapProvider.Spark => new(node.SparkSwapMinAmountSats ?? spark.SwapMinSats, node.SparkSwapMaxAmountSats ?? spark.SwapMaxSats, + node.SparkMaxSwapsInFlight), + _ => throw new ArgumentOutOfRangeException(nameof(provider), provider, "Unknown swap provider") + }; +} diff --git a/src/Shared/NewSwapModal.razor b/src/Shared/NewSwapModal.razor index 3e73e695..b85489be 100644 --- a/src/Shared/NewSwapModal.razor +++ b/src/Shared/NewSwapModal.razor @@ -523,7 +523,7 @@ { var maxRoutingFee = new Money(_maxRoutingFeesPercent * _amountBtc / 100, MoneyUnit.BTC); var maxExitFee = new Money(SparkSettings.MaxExitFeeSats); - var maxSwapsInFlight = Math.Max(_selectedNode?.MaxSwapsInFlight ?? 0, 1); + var maxSwapsInFlight = Math.Max(_selectedNode?.SparkMaxSwapsInFlight ?? 0, 1); _confirmationModalBody = "You are about to create a new swap with the following details:

" + middleChunkMessage + @@ -535,10 +535,11 @@ "
" + $"The node starts paying its Spark wallet ({SparkIdentityOf(_selectedNode!)}) as you confirm, and LND keeps " + $"trying routes for up to {SparkSettings.PaymentTimeoutFor(_selectedNode!)} minutes (the node's Spark payment timeout); " + - "the swap shows as pending on the Swaps page meanwhile. Once paid, the swap exits " + - $"on-chain to the destination wallet on its own, within about {SparkSettings.ExitIntervalOrDefault(Constants.IS_DEV_ENVIRONMENT)} minutes, " + + "the swap shows as pending on the Swaps page meanwhile. " + + "Once paid, the swap waits in the Spark wallet until the wallet is full (no room left for another swap under its max balance), " + + "then exits with the wallet's other paid swaps in one on-chain transaction to the destination wallet, sharing its fee, " + $"at exit speed {SparkSettings.ExitSpeedSummary}. " + - $"Up to {maxSwapsInFlight} Spark swaps of {_selectedNode?.Name} (its Max swaps in flight) run at once.

" + + $"Up to {maxSwapsInFlight} Spark swaps of {_selectedNode?.Name} (its Spark max swaps in flight) are paid at once.

" + "Please confirm that you want to proceed."; await _confirmationModal.ShowModal(new Dictionary diff --git a/src/Shared/SparkWalletModals.razor b/src/Shared/SparkWalletModals.razor index 2add5805..d959d314 100644 --- a/src/Shared/SparkWalletModals.razor +++ b/src/Shared/SparkWalletModals.razor @@ -171,7 +171,7 @@ @transfer.Status - @(transfer.SwapId is { } swapId ? $"#{swapId}" : "") + @string.Join(", ", transfer.SwapIds.Select(id => $"#{id}")) @(transfer.Id.Length > 8 ? transfer.Id[..8] + "…" : transfer.Id) diff --git a/test/NodeGuard.Tests/E2E/SparkSwapOutE2ETests.cs b/test/NodeGuard.Tests/E2E/SparkSwapOutE2ETests.cs index 10422ca9..80b361bf 100644 --- a/test/NodeGuard.Tests/E2E/SparkSwapOutE2ETests.cs +++ b/test/NodeGuard.Tests/E2E/SparkSwapOutE2ETests.cs @@ -55,13 +55,14 @@ public SparkE2EFactAttribute() /// /// A Spark swap-out through NodeGuard's gRPC API against the local Spark network (docker/spark): /// -/// a Spark wallet is created over gRPC and made alice's Spark wallet, with Max swaps in flight 2 (node -/// liquidity config, set in the database as the Nodes page would); +/// a Spark wallet is created over gRPC, with a max balance two swaps fill, and made alice's Spark wallet, with +/// Spark max swaps in flight 2 and a Spark min swap no third swap fits (node liquidity config, set in the database as the +/// Nodes page would); /// NodeGuard restarts, so the wallet's seed has to decrypt with the Data Protection key ring in Postgres; /// two swaps of different amounts start at once. For each, alice's LND pays an invoice of the Spark wallet, -/// MonitorSwapsJob follows the payment until it settles, SparkSwapExitJob records the transfer that brought it in -/// and exits exactly its leaves on-chain to the swap's own reserved address, and the swap completes once that -/// payout confirms, recording it; +/// MonitorSwapsJob follows the payment until it settles, and SparkSwapExitJob records the transfer that brought it +/// in. With both paid the wallet is full: their leaves exit together, in one cooperative exit to one reserved +/// address, and both complete once that payout confirms, each recording its share; /// the Spark wallet is empty again, and can't be archived while alice uses it. /// /// A second test ages a swap's leaves to their timelock floor before its exit, with the local operators' test RPC, @@ -84,7 +85,7 @@ public SparkSwapOutE2ETests(ITestOutputHelper output) : base(output) } [SparkE2EFact] - public async Task SwapOuts_ThroughASparkWallet_EachPayOutToTheirOwnAddressAndComplete() + public async Task SwapOuts_ThroughASparkWallet_ExitTogetherOnceItIsFull_AndComplete() { var client = CreateClient(out var headers); var rpc = CreateBitcoindRpc(); @@ -92,11 +93,12 @@ public async Task SwapOuts_ThroughASparkWallet_EachPayOutToTheirOwnAddressAndCom var payoutWalletId = int.Parse(Env("E2E_SPARK_PAYOUT_WALLET_ID", "2")); long[] amounts = [300_000, 200_000]; - // A Spark wallet for alice's swaps, two of which may run at once + // A Spark wallet for alice's swaps, two of which may run at once. The two fill it: 50,000 sats of room are left + // under its max balance, below alice's Spark min swap, so once both are paid they exit together var sparkWallet = await client.CreateSparkWalletAsync( - new CreateSparkWalletRequest { Name = $"e2e transit {Guid.NewGuid():N}"[..20], MaxBalanceSats = 2_000_000 }, headers); + new CreateSparkWalletRequest { Name = $"e2e transit {Guid.NewGuid():N}"[..20], MaxBalanceSats = 550_000 }, headers); _output.WriteLine($"Spark wallet {sparkWallet.WalletId}: identity {sparkWallet.IdentityPublicKey}"); - await SetSparkConfigAsync(alice.Id, sparkWallet.WalletId, maxSwapsInFlight: 2); + await SetSparkConfigAsync(alice.Id, sparkWallet.WalletId, maxSwapsInFlight: 2, sparkMinSwapSats: 100_000); // Its seed must still decrypt after a restart, with the key ring NodeGuard keeps in Postgres await RestartNodeGuardAsync(); @@ -120,8 +122,8 @@ public async Task SwapOuts_ThroughASparkWallet_EachPayOutToTheirOwnAddressAndCom swap.DestinationAddress.Should().BeEmpty("a Spark swap reserves its address when it exits"); } - // MonitorSwapsJob sees the payments settle and SparkSwapExitJob exits each swap's leaves (each every minute in - // a dev environment); mining confirms the exits + // MonitorSwapsJob sees the payments settle and SparkSwapExitJob exits the swaps' leaves together (each every + // minute in a dev environment); mining confirms the exit var done = await PollAsync( async () => { @@ -140,22 +142,23 @@ public async Task SwapOuts_ThroughASparkWallet_EachPayOutToTheirOwnAddressAndCom swap.Status.Should().Be(SWAP_OUT_STATUS.SwapOutCompleted, swap.Error); swap.SwapId.Should().Be(started[i].SwapId); - swap.DestinationAddress.Should().NotBeNullOrEmpty("the swap reserved its address when it exited"); + swap.DestinationAddress.Should().NotBeNullOrEmpty("the batch reserved its address when it exited"); swap.HasPaymentHash.Should().BeTrue(); - - // What landed on-chain is what the swap delivered. The exit from Spark to L1 is the on-chain fee, and any - // shortfall of the payment the service fee (Spark's own 15 bps is in the routing fee) - var destination = BitcoinAddress.Create(swap.DestinationAddress, Network.RegTest); - var payoutTx = await rpc.GetRawTransactionAsync(uint256.Parse(swap.TxId)); - var payout = payoutTx.Outputs.Where(o => o.ScriptPubKey == destination.ScriptPubKey).Sum(o => o.Value.Satoshi); swap.HasPayoutSats.Should().BeTrue(); - swap.PayoutSats.Should().Be(payout); - (swap.OnchainFeeSats + swap.ServiceFeeSats).Should().Be(amounts[i] - payout, + + // Each swap's share of the payout is what it delivered. The exit from Spark to L1 is the on-chain fee, and + // any shortfall of the payment the service fee (Spark's own 15 bps is in the routing fee) + (swap.OnchainFeeSats + swap.ServiceFeeSats).Should().Be(amounts[i] - swap.PayoutSats, "the on-chain and service fees are everything the swap did not deliver"); } - done.Select(s => s.DestinationAddress).Should().OnlyHaveUniqueItems("each swap pays out to its own address"); - done.Select(s => s.TxId).Should().OnlyHaveUniqueItems("each swap exits on its own"); + // One exit for both, to one address: together they delivered what landed there + done.Select(s => s.TxId).Distinct().Should().ContainSingle("the full wallet's swaps exit together"); + done.Select(s => s.DestinationAddress).Distinct().Should().ContainSingle("a batch pays out to one address"); + var destination = BitcoinAddress.Create(done[0].DestinationAddress, Network.RegTest); + var payoutTx = await rpc.GetRawTransactionAsync(uint256.Parse(done[0].TxId)); + var payout = payoutTx.Outputs.Where(o => o.ScriptPubKey == destination.ScriptPubKey).Sum(o => o.Value.Satoshi); + done.Sum(s => s.PayoutSats).Should().Be(payout, "each swap's payout is its share of the exit's"); // Asking again with the same reference_id returns the same swap rather than paying again var again = await client.RequestSwapOutAsync(requests[0], headers); @@ -187,10 +190,11 @@ public async Task SwapOut_LeavesAtTheirTimelockFloor_AreRenewedAndExitedUnderThe var alice = (await WaitForNodesAsync(client, headers)).Single(n => n.Name == "alice"); const long amount = 300_000; + // The one swap fills the wallet, so it exits as soon as it can var sparkWallet = await client.CreateSparkWalletAsync( - new CreateSparkWalletRequest { Name = $"e2e renew {Guid.NewGuid():N}"[..20], MaxBalanceSats = 2_000_000 }, headers); + new CreateSparkWalletRequest { Name = $"e2e renew {Guid.NewGuid():N}"[..20], MaxBalanceSats = 350_000 }, headers); _output.WriteLine($"Spark wallet {sparkWallet.WalletId}: identity {sparkWallet.IdentityPublicKey}"); - await SetSparkConfigAsync(alice.Id, sparkWallet.WalletId, maxSwapsInFlight: 1); + await SetSparkConfigAsync(alice.Id, sparkWallet.WalletId, maxSwapsInFlight: 1, sparkMinSwapSats: 100_000); var request = new RequestSwapOutRequest { @@ -309,16 +313,21 @@ private async Task StartSwapAsync(NodeGuardService.NodeG } } - /// A node's Spark wallet and Max swaps in flight are liquidity config, set on the Nodes page; there is no RPC for them - private static async Task SetSparkConfigAsync(int nodeId, int sparkWalletId, int maxSwapsInFlight) + /// + /// A node's Spark wallet, Spark max swaps in flight and Spark min swap are liquidity config, set on the Nodes page; there is + /// no RPC for them + /// + private static async Task SetSparkConfigAsync(int nodeId, int sparkWalletId, int maxSwapsInFlight, long sparkMinSwapSats) { await using var connection = new NpgsqlConnection(Env("POSTGRES_CONNECTIONSTRING", "Host=localhost;Port=5432;Database=nodeguard;User ID=postgres;")); await connection.OpenAsync(); await using var command = new NpgsqlCommand( - "UPDATE \"Nodes\" SET \"SparkWalletId\" = @wallet, \"MaxSwapsInFlight\" = @maxSwapsInFlight WHERE \"Id\" = @node", connection); + "UPDATE \"Nodes\" SET \"SparkWalletId\" = @wallet, \"SparkMaxSwapsInFlight\" = @maxSwapsInFlight, " + + "\"SparkSwapMinAmountSats\" = @sparkMinSwap WHERE \"Id\" = @node", connection); command.Parameters.AddWithValue("wallet", sparkWalletId); command.Parameters.AddWithValue("maxSwapsInFlight", maxSwapsInFlight); + command.Parameters.AddWithValue("sparkMinSwap", sparkMinSwapSats); command.Parameters.AddWithValue("node", nodeId); (await command.ExecuteNonQueryAsync()).Should().Be(1); } diff --git a/test/NodeGuard.Tests/Jobs/AutoLiquidityManagementJobTests.cs b/test/NodeGuard.Tests/Jobs/AutoLiquidityManagementJobTests.cs index ecbea7d2..eccfbf23 100644 --- a/test/NodeGuard.Tests/Jobs/AutoLiquidityManagementJobTests.cs +++ b/test/NodeGuard.Tests/Jobs/AutoLiquidityManagementJobTests.cs @@ -109,9 +109,16 @@ private Node CreateTestNode( IsNodeDisabled = isDisabled, AutoLiquidityManagementEnabled = autoLiquidityEnabled, FundsDestinationWalletId = destinationWalletId, - SwapMinAmountSats = minSwapSats, - SwapMaxAmountSats = maxSwapSats, - MaxSwapsInFlight = maxSwapsInFlight, + // The same sizes for every provider, unless a test sets its own + LoopSwapMinAmountSats = minSwapSats, + LoopSwapMaxAmountSats = maxSwapSats, + FortySwapMinAmountSats = minSwapSats, + FortySwapMaxAmountSats = maxSwapSats, + SparkSwapMinAmountSats = minSwapSats, + SparkSwapMaxAmountSats = maxSwapSats, + LoopMaxSwapsInFlight = maxSwapsInFlight, + FortySwapMaxSwapsInFlight = maxSwapsInFlight, + SparkMaxSwapsInFlight = maxSwapsInFlight, MaxSwapRoutingFeeRatio = maxSwapFeeRatio, MinimumBalanceThresholdSats = minBalanceThresholdSats, SwapBudgetSats = swapBudgetSats, @@ -687,7 +694,7 @@ public async Task ManageNodeLiquidity_WithSpark_TheRoomLeavesOutWhatUnpaidSwapsW public async Task ManageNodeLiquidity_WithOnlySparkWeight_WhenTheRoomIsBelowTheNodeMinimum_SkipsWithoutReservingAnAddress() { var (node, _) = ArrangeSwappableNode(loopWeight: 0, fortySwapWeight: 0, sparkWeight: 100); - SetSparkBalance(owned: SparkMaxBalanceSats - node.SwapMinAmountSats + 1); + SetSparkBalance(owned: SparkMaxBalanceSats - node.SparkSwapMinAmountSats!.Value + 1); var result = await _autoLiquidityManagementJob.ManageNodeLiquidity(node, CancellationToken.None); @@ -710,6 +717,108 @@ public async Task ManageNodeLiquidity_WhenSparkHasNoRoom_UsesTheOtherWeightedPro It.IsAny(), It.IsAny()), Times.Once); } + [Fact] + public async Task ManageNodeLiquidity_SizesTheSwapWithTheChosenProvidersOwnMax() + { + var (node, _) = ArrangeSwappableNode(loopWeight: 0, fortySwapWeight: 0, sparkWeight: 100); + node.SparkSwapMaxAmountSats = 2_000_000; + + var result = await _autoLiquidityManagementJob.ManageNodeLiquidity(node, CancellationToken.None); + + result.Should().Be(ManageNodeLiquidityResult.Success); + _swapsServiceMock.Verify(x => x.CreateSwapOutAsync(node, + It.Is(s => s.Provider == SwapProvider.Spark && s.SatsAmount == 2_000_000), + It.Is(r => r.Amount == 2_000_000), It.IsAny()), Times.Once); + } + + [Fact] + public async Task ManageNodeLiquidity_WithoutItsOwnSparkSizes_ANodeUsesTheSparkSettings() + { + var (node, _) = ArrangeSwappableNode(loopWeight: 0, fortySwapWeight: 0, sparkWeight: 100); + node.SparkSwapMinAmountSats = null; + node.SparkSwapMaxAmountSats = null; + + await _autoLiquidityManagementJob.ManageNodeLiquidity(node, CancellationToken.None); + + _swapsServiceMock.Verify(x => x.CreateSwapOutAsync(node, + It.Is(s => s.Provider == SwapProvider.Spark && s.SatsAmount == SparkSettings.DefaultSwapMaxSats), + It.IsAny(), It.IsAny()), Times.Once); + } + + [Fact] + public async Task ManageNodeLiquidity_AProviderWhoseMinIsAboveTheExcess_IsLeftOut_AndAnotherSwaps() + { + // 50M sats above the threshold: below Loop's min, within Spark's + var (node, _) = ArrangeSwappableNode(loopWeight: 50, fortySwapWeight: 0, sparkWeight: 50); + node.LoopSwapMinAmountSats = 60_000_000; + node.LoopSwapMaxAmountSats = 80_000_000; + node.SparkSwapMaxAmountSats = 2_000_000; + + var result = await _autoLiquidityManagementJob.ManageNodeLiquidity(node, CancellationToken.None); + + result.Should().Be(ManageNodeLiquidityResult.Success); + _swapsServiceMock.Verify(x => x.CreateSwapOutAsync(node, + It.Is(s => s.Provider == SwapProvider.Spark && s.SatsAmount == 2_000_000), + It.IsAny(), It.IsAny()), Times.Once); + } + + [Fact] + public async Task ManageNodeLiquidity_WhenTheExcessIsBelowEveryWeightedProvidersMin_IsBelowMinimum_WithoutAskingSpark() + { + var (node, _) = ArrangeSwappableNode(loopWeight: 50, fortySwapWeight: 0, sparkWeight: 50); + node.LoopSwapMinAmountSats = 60_000_000; + node.LoopSwapMaxAmountSats = 80_000_000; + node.SparkSwapMinAmountSats = 60_000_000; + node.SparkSwapMaxAmountSats = 80_000_000; + + var result = await _autoLiquidityManagementJob.ManageNodeLiquidity(node, CancellationToken.None); + + result.Should().Be(ManageNodeLiquidityResult.ExcessBalanceBelowMinimum); + _sparkWalletMock.Verify(x => x.GetBalanceAsync(It.IsAny(), It.IsAny()), Times.Never); + _swapsServiceMock.Verify(x => x.CreateSwapOutAsync(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny()), Times.Never); + } + + [Fact] + public async Task ManageNodeLiquidity_AProviderAtItsMaxSwapsInFlight_IsLeftOut_AndAnotherSwaps() + { + // Loop runs one swap at a time and has one in flight; Spark runs up to five and has two + var (node, _) = ArrangeSwappableNode(loopWeight: 50, fortySwapWeight: 0, sparkWeight: 50); + node.LoopMaxSwapsInFlight = 1; + node.SparkMaxSwapsInFlight = 5; + _swapOutRepositoryMock.Setup(x => x.GetInFlightSwapsByNode(node.Id)).ReturnsAsync(new List + { + new() { Id = 1, Provider = SwapProvider.Loop, Status = SwapOutStatus.Pending }, + new() { Id = 2, Provider = SwapProvider.Spark, Status = SwapOutStatus.Pending }, + new() { Id = 3, Provider = SwapProvider.Spark, Status = SwapOutStatus.Pending } + }); + + var result = await _autoLiquidityManagementJob.ManageNodeLiquidity(node, CancellationToken.None); + + result.Should().Be(ManageNodeLiquidityResult.Success); + _swapsServiceMock.Verify(x => x.CreateSwapOutAsync(node, It.Is(s => s.Provider == SwapProvider.Spark), + It.IsAny(), It.IsAny()), Times.Once); + } + + [Fact] + public async Task ManageNodeLiquidity_WithEveryWeightedProviderAtItsMaxSwapsInFlight_SwapsNothing() + { + var (node, _) = ArrangeSwappableNode(loopWeight: 50, fortySwapWeight: 0, sparkWeight: 50); + node.LoopMaxSwapsInFlight = 1; + node.SparkMaxSwapsInFlight = 1; + _swapOutRepositoryMock.Setup(x => x.GetInFlightSwapsByNode(node.Id)).ReturnsAsync(new List + { + new() { Id = 1, Provider = SwapProvider.Loop, Status = SwapOutStatus.Pending }, + new() { Id = 2, Provider = SwapProvider.Spark, Status = SwapOutStatus.Pending } + }); + + var result = await _autoLiquidityManagementJob.ManageNodeLiquidity(node, CancellationToken.None); + + result.Should().Be(ManageNodeLiquidityResult.MaxSwapsInFlightReached); + _swapsServiceMock.Verify(x => x.CreateSwapOutAsync(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny()), Times.Never); + } + [Fact] public async Task ManageNodeLiquidity_WithAllWeightsAtZero_StillDefaultsToLoop() { diff --git a/test/NodeGuard.Tests/Jobs/SparkSwapExitJobTests.cs b/test/NodeGuard.Tests/Jobs/SparkSwapExitJobTests.cs index efd1a6fd..b75a235d 100644 --- a/test/NodeGuard.Tests/Jobs/SparkSwapExitJobTests.cs +++ b/test/NodeGuard.Tests/Jobs/SparkSwapExitJobTests.cs @@ -39,14 +39,15 @@ public SparkSwapExitJobTests() { _context.CancellationToken.Returns(CancellationToken.None); _sparkSwaps.AttributeAsync(Arg.Any(), Arg.Any()).Returns(true); - _sparkSwaps.ExitAsync(Arg.Any(), Arg.Any()).Returns(SparkExitStep.ExitSent); + _sparkSwaps.ExitBatchAsync(Arg.Any>(), Arg.Any()).Returns(SparkExitStep.ExitSent); + Plans(); } private SparkSwapExitJob Job(bool sparkEnabled = true) => new(NullLogger.Instance, _swapOuts, _sparkSwaps, _audit, sparkEnabled ? new SparkSettings { Enabled = true } : SparkSettings.Disabled); private static SwapOut Swap(int id, int minutes, long? lightningFee = 10, string? transferId = null, - SwapProvider provider = SwapProvider.Spark) => new() + SwapProvider provider = SwapProvider.Spark, string? address = null) => new() { Id = id, Provider = provider, @@ -55,61 +56,88 @@ private static SwapOut Swap(int id, int minutes, long? lightningFee = 10, string SatsAmount = 100_000, LightningFeeSats = lightningFee, SparkTransferId = transferId, + SparkIdentity = "02aa", + DestinationWalletId = 3, + DestinationAddress = address, CreationDatetime = DateTimeOffset.UnixEpoch.AddMinutes(minutes) }; + /// The batches the service plans to exit now. + private void Plans(params IReadOnlyList[] batches) => + _sparkSwaps.PlanExitsAsync(Arg.Any>(), Arg.Any()) + .Returns(batches.ToList()); + + private static IReadOnlyList Batch(params SwapOut[] swaps) => + Arg.Is>(b => b.SequenceEqual(swaps)); + [Fact] - public async Task Execute_AttributesEveryPaidSwap_ThenExitsThemOneAtATime_OldestFirst() + public async Task Execute_AttributesEveryPaidSwap_FollowsTheBatchesExiting_ThenExitsThePlannedOnes() { - var newer = Swap(2, minutes: 5); - var older = Swap(1, minutes: 1); - var attributed = Swap(3, minutes: 3, transferId: "transfer-3"); + var exitingOlder = Swap(1, minutes: 1, transferId: "transfer-1", address: "bcrt1-batch"); + var exitingNewer = Swap(3, minutes: 3, transferId: "transfer-3", address: "bcrt1-batch"); + var waiting = Swap(2, minutes: 5); var unpaid = Swap(4, minutes: 0, lightningFee: null); var loop = Swap(5, minutes: 0, provider: SwapProvider.Loop); - _swapOuts.GetAllPending().Returns(new List { newer, unpaid, attributed, loop, older }); + var pending = new List { waiting, unpaid, exitingNewer, loop, exitingOlder }; + _swapOuts.GetAllPending().Returns(pending); + Plans(new List { waiting }); await Job().Execute(_context); Received.InOrder(() => { - _sparkSwaps.AttributeAsync(older, Arg.Any()); - _sparkSwaps.AttributeAsync(newer, Arg.Any()); - _sparkSwaps.ExitAsync(older, Arg.Any()); - _sparkSwaps.ExitAsync(attributed, Arg.Any()); - _sparkSwaps.ExitAsync(newer, Arg.Any()); + _sparkSwaps.AttributeAsync(waiting, Arg.Any()); + _sparkSwaps.ExitBatchAsync(Batch(exitingOlder, exitingNewer), Arg.Any()); + _sparkSwaps.PlanExitsAsync(pending, Arg.Any()); + _sparkSwaps.ExitBatchAsync(Batch(waiting), Arg.Any()); }); - await _sparkSwaps.DidNotReceive().AttributeAsync(attributed, Arg.Any()); - await _sparkSwaps.DidNotReceive().ExitAsync(unpaid, Arg.Any()); - await _sparkSwaps.DidNotReceive().ExitAsync(loop, Arg.Any()); + await _sparkSwaps.DidNotReceive().AttributeAsync(exitingOlder, Arg.Any()); + await _sparkSwaps.Received(2).ExitBatchAsync(Arg.Any>(), Arg.Any()); } [Fact] - public async Task Execute_AuditsACompletedSwap() + public async Task Execute_AuditsEverySwapOfACompletedBatch() { - var swap = Swap(1, minutes: 1, transferId: "transfer-1"); - _swapOuts.GetAllPending().Returns(new List { swap }); - _sparkSwaps.ExitAsync(swap, Arg.Any()).Returns(SparkExitStep.Completed); + var first = Swap(1, minutes: 1, transferId: "transfer-1", address: "bcrt1-batch"); + var second = Swap(2, minutes: 2, transferId: "transfer-2", address: "bcrt1-batch"); + _swapOuts.GetAllPending().Returns(new List { first, second }); + _sparkSwaps.ExitBatchAsync(Batch(first, second), Arg.Any()).Returns(SparkExitStep.Completed); await Job().Execute(_context); await _audit.Received(1).LogSystemAsync(AuditActionType.SwapOutCompleted, AuditEventType.Success, AuditObjectType.SwapOut, "req-1", Arg.Any()); + await _audit.Received(1).LogSystemAsync(AuditActionType.SwapOutCompleted, AuditEventType.Success, AuditObjectType.SwapOut, + "req-2", Arg.Any()); } [Fact] - public async Task Execute_ASwapThatFails_DoesNotStopTheOthers() + public async Task Execute_ABatchThatFails_DoesNotStopTheOthers() { - var failing = Swap(1, minutes: 1, transferId: "transfer-1"); - var next = Swap(2, minutes: 2, transferId: "transfer-2"); + var failing = Swap(1, minutes: 1, transferId: "transfer-1", address: "bcrt1-one"); + var next = Swap(2, minutes: 2, transferId: "transfer-2", address: "bcrt1-two"); _swapOuts.GetAllPending().Returns(new List { failing, next }); - _sparkSwaps.ExitAsync(failing, Arg.Any()).ThrowsAsync(new InvalidOperationException("the SSP is down")); + _sparkSwaps.ExitBatchAsync(Batch(failing), Arg.Any()).ThrowsAsync(new InvalidOperationException("the SSP is down")); await Job().Execute(_context); - await _sparkSwaps.Received(1).ExitAsync(next, Arg.Any()); + await _sparkSwaps.Received(1).ExitBatchAsync(Batch(next), Arg.Any()); await _audit.DidNotReceiveWithAnyArgs().LogSystemAsync(default, default, default, default, default); } + [Fact] + public async Task Execute_WhenPlanningFails_StillFollowsTheBatchesExiting() + { + var exiting = Swap(1, minutes: 1, transferId: "transfer-1", address: "bcrt1-batch"); + _swapOuts.GetAllPending().Returns(new List { exiting }); + _sparkSwaps.PlanExitsAsync(Arg.Any>(), Arg.Any()) + .ThrowsAsync(new InvalidOperationException("the wallet is down")); + + await Job().Execute(_context); + + await _sparkSwaps.Received(1).ExitBatchAsync(Batch(exiting), Arg.Any()); + } + [Fact] public async Task Execute_WithSparkDisabled_DoesNothing() { diff --git a/test/NodeGuard.Tests/Services/Spark/SparkGuardrailsTests.cs b/test/NodeGuard.Tests/Services/Spark/SparkGuardrailsTests.cs index 85f81423..3ab24d44 100644 --- a/test/NodeGuard.Tests/Services/Spark/SparkGuardrailsTests.cs +++ b/test/NodeGuard.Tests/Services/Spark/SparkGuardrailsTests.cs @@ -206,6 +206,34 @@ public async Task APaidSwapWithoutAConfirmedPayout_IsAlertedOnceWhenOverdueForIt (await _guardrails.CheckAsync([swap])).Should().BeEmpty(); } + [Fact] + public async Task ASwapWaitingForItsWalletToFill_IsNotOverdue() + { + var swap = PaidSparkSwap(_time.GetUtcNow(), received: 499_000); + swap.SparkTransferId = "transfer-5"; + Holds(A, 499_000); + + _time.Advance(TimeSpan.FromDays(3)); + + (await _guardrails.CheckAsync([swap])).Should().BeEmpty(); + } + + [Fact] + public async Task AnAttemptedExit_IsOverdueCountingFromItsFirstAttempt() + { + var swap = PaidSparkSwap(_time.GetUtcNow(), received: 499_000); + swap.SparkTransferId = "transfer-5"; + swap.SparkExitDatetime = _time.GetUtcNow().AddDays(2); + Holds(A, 499_000); + + _time.Advance(TimeSpan.FromDays(2) + TimeSpan.FromHours(24) - TimeSpan.FromMinutes(1)); + (await _guardrails.CheckAsync([swap])).Should().BeEmpty(); + + _time.Advance(TimeSpan.FromMinutes(1)); + (await _guardrails.CheckAsync([swap])).Should().ContainSingle() + .Which.Message.Should().Contain("after its exit was first attempted"); + } + [Fact] public async Task AnUnpaidSwap_IsNotAnOverdueExit() { diff --git a/test/NodeGuard.Tests/Services/Spark/SparkSettingsTests.cs b/test/NodeGuard.Tests/Services/Spark/SparkSettingsTests.cs index cfffbc94..b785b381 100644 --- a/test/NodeGuard.Tests/Services/Spark/SparkSettingsTests.cs +++ b/test/NodeGuard.Tests/Services/Spark/SparkSettingsTests.cs @@ -141,6 +141,8 @@ public void OnMainnet_LightsparksOperatorsAndSspAreTheDefaults() settings.ExitSpeed.Should().Be(ExitSpeed.Slow, "the cheapest exit"); settings.ExitOverdueAfter.Should().Be(TimeSpan.FromHours(24)); settings.MaxBalanceSats.Should().Be(SparkSettings.DefaultMaxBalanceSats); + settings.SwapMinSats.Should().Be(SparkSettings.DefaultSwapMinSats); + settings.SwapMaxSats.Should().Be(SparkSettings.DefaultSwapMaxSats); settings.SignerEndpoint.Should().Be(new Uri("https://abc.lambda-url.eu-central-1.on.aws/")); var options = settings.ToSparkOptions(null); @@ -168,6 +170,8 @@ public void OnRegtest_TheConfiguredNetworkIsRead() env["SPARK_PAYMENT_TIMEOUT_MINUTES"] = "15"; env["SPARK_EXIT_SPEED"] = "fast"; env["SPARK_MAX_BALANCE_SATS"] = "2000000"; + env["SPARK_SWAP_MIN_SATS"] = "20000"; + env["SPARK_SWAP_MAX_SATS"] = "1500000"; var settings = Read(env, Network.RegTest); @@ -183,6 +187,8 @@ public void OnRegtest_TheConfiguredNetworkIsRead() settings.ExitSpeed.Should().Be(ExitSpeed.Fast); settings.ExitOverdueAfter.Should().Be(TimeSpan.FromHours(6)); settings.MaxBalanceSats.Should().Be(2_000_000); + settings.SwapMinSats.Should().Be(20_000); + settings.SwapMaxSats.Should().Be(1_500_000); settings.ToSparkOptions(SspIdentity).EffectiveSspIdentityPublicKeyHex.Should().Be(SspIdentity); } @@ -206,6 +212,10 @@ public void OnRegtest_TheConfiguredNetworkIsRead() [InlineData("SPARK_PAYMENT_TIMEOUT_MINUTES", "ten", "SPARK_PAYMENT_TIMEOUT_MINUTES")] [InlineData("SPARK_EXIT_SPEED", "SLOWEST", "SPARK_EXIT_SPEED must be FAST, MEDIUM or SLOW")] [InlineData("SPARK_MAX_BALANCE_SATS", "-5", "SPARK_MAX_BALANCE_SATS")] + [InlineData("SPARK_SWAP_MIN_SATS", "9999", "SPARK_SWAP_MIN_SATS must be a number of sats of at least 10000")] + [InlineData("SPARK_SWAP_MIN_SATS", "lots", "SPARK_SWAP_MIN_SATS")] + [InlineData("SPARK_SWAP_MAX_SATS", "40000", "SPARK_SWAP_MAX_SATS must be a number of sats from SPARK_SWAP_MIN_SATS")] + [InlineData("SPARK_SWAP_MAX_SATS", "50000001", "SPARK_SWAP_MAX_SATS")] public void InvalidSettings_FailTheStartup(string variable, string? value, string expected) { var env = Remote(); diff --git a/test/NodeGuard.Tests/Services/Spark/SparkSwapServiceTests.cs b/test/NodeGuard.Tests/Services/Spark/SparkSwapServiceTests.cs index a207b34f..5540bd4f 100644 --- a/test/NodeGuard.Tests/Services/Spark/SparkSwapServiceTests.cs +++ b/test/NodeGuard.Tests/Services/Spark/SparkSwapServiceTests.cs @@ -19,6 +19,7 @@ using FluentAssertions; using Lnrpc; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging.Abstractions; using NBitcoin; using NBXplorer.Models; @@ -54,6 +55,9 @@ public class SparkSwapServiceTests private readonly ISwapOutRepository _swapOuts = Substitute.For(); private readonly IWalletRepository _wallets = Substitute.For(); private readonly INBXplorerService _nbXplorer = Substitute.For(); + private readonly INodeRepository _nodes = Substitute.For(); + private readonly IServiceScopeFactory _scopes = Substitute.For(); + private readonly IServiceScope _paymentScope = Substitute.For(); private readonly ManualTime _time = new(); private readonly Node _node = new() { Id = 1, Name = "alice", PubKey = "02aa", Endpoint = "alice:10009", SparkWalletId = 5 }; private readonly Wallet _wallet; @@ -79,12 +83,20 @@ public SparkSwapServiceTests() _wallet.Id = 3; _wallets.GetById(3).Returns(_wallet); Payouts(); + _nodes.GetById(1, Arg.Any()).Returns(_node); + + // The background payment runs in a scope of its own, with a SparkSwapService of that scope + var provider = Substitute.For(); + provider.GetService(typeof(ISparkSwapService)).Returns(_ => Service()); + _paymentScope.ServiceProvider.Returns(provider); + _scopes.CreateScope().Returns(_paymentScope); } private SparkSwapService Service() => new(NullLogger.Instance, Settings, _spark, _lightning, _swapOuts, - _wallets, _nbXplorer, _time); + _wallets, _nbXplorer, _nodes, _scopes, _time); - private Task Pay(int swapId = 7) => Service().PayAsync(_node, swapId, Invoice, 5_000_000, null, 600); + // 500_000 sats at 1%: a fee limit of 5_000_000 msat + private Task Pay(int swapId = 7) => Service().PayAsync(_node, swapId, Invoice, 500_000, 1m, null, 600); /// /// Completes with the payment's timeout once the node starts paying in the background. The payment itself never @@ -233,6 +245,33 @@ public async Task Create_RecordsTheSwap_ThenStartsItsPayment_WithoutWaitingForIt response.Status.Should().Be(SwapOutStatus.Pending); } + [Fact] + public async Task Create_APaidSwapWaitingInTheWallet_IsNotInFlight() + { + InvoiceIssued(); + _node.SparkMaxSwapsInFlight = 1; + _swapOuts.GetAllPending().Returns(new List { Pending(lightningFee: 12, leafIds: "l1", address: null) }); + Balance(available: 0); + + var (_, saved, _) = await Service().CreateSwapOutAsync(_node, Template(), Request()); + + saved.Should().BeTrue(); + } + + [Fact] + public async Task Create_PaysInAScopeOfItsOwn_WhichOutlivesTheCaller() + { + InvoiceIssued(); + var started = PaymentStarted(); + + await Service().CreateSwapOutAsync(_node, Template(), Request()); + await started; + + // The caller's scope may end as soon as the swap is created: the payment saves its outcome through its own + _scopes.Received(1).CreateScope(); + _paymentScope.DidNotReceive().Dispose(); + } + [Fact] public async Task Create_PaysWithTheNodesOwnPaymentTimeout() { @@ -335,8 +374,9 @@ public async Task Pay_WhenPayingThrows_LeavesItToTheMonitor() [InlineData(2, 2)] public async Task Create_AtTheNodesMaxSwapsInFlight_IsRefused(int maxSwapsInFlight, int inFlight) { - _node.MaxSwapsInFlight = maxSwapsInFlight; - var pending = Enumerable.Range(10, inFlight).Select(id => Pending(id: id)).ToList(); + _node.SparkMaxSwapsInFlight = maxSwapsInFlight; + // Swaps being paid: a paid one waits in the wallet and is no longer in flight + var pending = Enumerable.Range(10, inFlight).Select(id => Pending(id: id, lightningFee: null)).ToList(); pending.Add(new SwapOut { Id = 30, NodeId = 2, Provider = SwapProvider.Spark, Status = SwapOutStatus.Pending }); pending.Add(new SwapOut { Id = 31, NodeId = 1, Provider = SwapProvider.Loop, Status = SwapOutStatus.Pending }); _swapOuts.GetAllPending().Returns(pending); @@ -350,7 +390,7 @@ public async Task Create_AtTheNodesMaxSwapsInFlight_IsRefused(int maxSwapsInFlig [Fact] public async Task Create_BelowTheNodesMaxSwapsInFlight_IsAllowed() { - _node.MaxSwapsInFlight = 2; + _node.SparkMaxSwapsInFlight = 2; _swapOuts.GetAllPending().Returns(new List { Pending() }); InvoiceIssued(); NodePays(Payment.Types.PaymentStatus.Succeeded); @@ -397,7 +437,7 @@ public async Task Create_UsesTheWalletsOwnMaxBalance() public async Task Create_CountsWhatUnpaidSwapsWillBringIn_AgainstTheCap() { // 9M held + an unpaid 0.6M swap of the wallet still to come + 0.5M > the wallet's 10M max balance - _node.MaxSwapsInFlight = 2; + _node.SparkMaxSwapsInFlight = 2; _spark.GetBalanceAsync(W, Arg.Any()) .Returns(new WalletBalance(new SatsBalance(9_000_000, 9_000_000, 0), [], [])); var unpaid = Pending(lightningFee: null); @@ -620,6 +660,179 @@ public async Task Attribute_AClaimThatFailed_IsRetriedOnTheNextRun_AndTheTransfe // ── Exit ───────────────────────────────────────────────────────────────────────────── + private Task Exit(params SwapOut[] batch) => Service().ExitBatchAsync(batch); + + /// A second paid, attributed swap of the same wallet and destination: 300,000 sats in leaf l3. + private static SwapOut Second(string? address = Address, string? txId = null) + { + var swap = Pending(id: 8, leafIds: "l3", address: address, txId: txId); + swap.SatsAmount = 300_000; + swap.SparkReceivedSats = 300_000; + swap.CreationDatetime = DateTimeOffset.UnixEpoch.AddMinutes(1); + return swap; + } + + [Fact] + public async Task Exit_ABatch_ExitsAllItsSwapsLeaves_ToOneAddress_SavedOnEverySwapFirst() + { + var first = Pending(leafIds: "l1,l2", address: null); + var second = Second(address: null); + AddressReserved(Address); + _swapOuts.UpdateRange(Arg.Any>()).Returns((true, null)); + _spark.WithdrawLeavesAsync(W, Arg.Any>(), Address, 20_000, Arg.Any()) + .Returns(new WithdrawLeavesResult("exit-tx", 799_000, 789_000)); + + (await Exit(first, second)).Should().Be(SparkExitStep.ExitSent); + + NSubstitute.Received.InOrder(() => + { + _swapOuts.UpdateRange(Arg.Is>(b => b.All(sw => sw.DestinationAddress == Address))); + _spark.WithdrawLeavesAsync(W, Arg.Is>(ids => ids.SequenceEqual(new[] { "l1", "l2", "l3" })), + Address, 20_000, Arg.Any()); + _swapOuts.UpdateRange(Arg.Is>(b => b.All(sw => sw.TxId == "exit-tx"))); + }); + await _nbXplorer.Received(1).GetUnusedAsync(Arg.Any(), + NBXplorer.DerivationStrategy.DerivationFeature.Deposit, 0, true, Arg.Any()); + new[] { first, second }.Should().AllSatisfy(sw => + { + sw.TxId.Should().Be("exit-tx"); + sw.DestinationAddress.Should().Be(Address); + sw.SparkExitDatetime.Should().Be(_time.GetUtcNow()); + }); + // 10,000 sats of exit fee, shared by what each brought in: 499/799 and 300/799, the rounding's sat to the oldest + first.OnChainFeeSats.Should().Be(6_246); + second.OnChainFeeSats.Should().Be(3_754); + } + + [Fact] + public async Task Exit_ABatchsConfirmedPayout_CompletesEverySwap_WithItsShare() + { + var first = Pending(leafIds: "l1,l2", txId: "exit-tx"); + var second = Second(txId: "exit-tx"); + _swapOuts.UpdateRange(Arg.Any>()).Returns((true, null)); + Payouts((7, 789_000, 1)); + + (await Exit(first, second)).Should().Be(SparkExitStep.Completed); + + first.Should().BeEquivalentTo(new { Status = SwapOutStatus.Completed, PayoutSats = (long?)492_754, OnChainFeeSats = (long?)6_246 }); + second.Should().BeEquivalentTo(new { Status = SwapOutStatus.Completed, PayoutSats = (long?)296_246, OnChainFeeSats = (long?)3_754 }); + (first.PayoutSats + second.PayoutSats).Should().Be(789_000, "together they delivered the exit's payout"); + _swapOuts.Received(1).UpdateRange(Arg.Is>(b => b.Count == 2)); + } + + [Fact] + public async Task Exit_SwapsWhoseLeavesCannotMove_LeaveTheBatch_AndTheOthersExit() + { + var first = Pending(leafIds: "l1,l2", address: null); + var stuck = Second(address: null); + AddressReserved(Address); + _swapOuts.UpdateRange(Arg.Any>()).Returns((true, null)); + _spark.WithdrawLeavesAsync(W, Arg.Is>(ids => ids.Count == 3), Address, 20_000, Arg.Any()) + .ThrowsAsync(new SparkLeavesNotSpendableException("withdraw_leaves", ["l3"])); + _spark.WithdrawLeavesAsync(W, Arg.Is>(ids => ids.SequenceEqual(new[] { "l1", "l2" })), Address, 20_000, + Arg.Any()) + .Returns(new WithdrawLeavesResult("exit-tx", 499_000, 497_000)); + + (await Exit(first, stuck)).Should().Be(SparkExitStep.ExitSent); + + first.TxId.Should().Be("exit-tx"); + stuck.TxId.Should().BeNull(); + stuck.DestinationAddress.Should().BeNull("it waits for another batch"); + stuck.SparkExitDatetime.Should().NotBeNull("its exit was attempted: the overdue alert counts from then"); + } + + [Fact] + public async Task Exit_SwapsThatCannotShareAnExit_AreRefused() + { + var other = Second(); + other.DestinationWalletId = 4; + + var act = () => Exit(Pending(leafIds: "l1,l2"), other); + + await act.Should().ThrowAsync().WithMessage("*can't exit together*"); + await _spark.DidNotReceiveWithAnyArgs().WithdrawLeavesAsync(default, default!, default!, default, default); + } + + // ── Planning the exits ─────────────────────────────────────────────────────────────── + + /// alice uses W, with a Spark min swap of 100,000 sats; W holds up to 1,000,000. + private void PlanFixture(long ownedSats) + { + _node.SparkSwapMinAmountSats = 100_000; + SparkWallet(maxBalanceSats: 1_000_000); + Balance(available: ownedSats); + _nodes.GetAllManagedByNodeGuard(false).Returns(new List { _node }); + } + + [Fact] + public async Task Plan_AFullWallet_ExitsItsPaidSwaps_OneBatchPerDestinationWallet() + { + PlanFixture(ownedSats: 950_000); // 50,000 sats of room: no 100,000-sat swap fits + var a = Pending(id: 1, leafIds: "a1", address: null); + var b = Pending(id: 2, leafIds: "b1", address: null); + var c = Pending(id: 3, leafIds: "c1", address: null); + c.DestinationWalletId = 4; + + var batches = await Service().PlanExitsAsync([b, c, a]); + + batches.Should().HaveCount(2); + batches[0].Should().Equal(a, b); + batches[1].Should().Equal(c); + } + + [Fact] + public async Task Plan_AWalletWithRoomForAnotherSwap_LetsItsSwapsWait() + { + PlanFixture(ownedSats: 899_999); // 100,001 sats of room + + (await Service().PlanExitsAsync([Pending(leafIds: "l1", address: null)])).Should().BeEmpty(); + } + + [Fact] + public async Task Plan_WhileASwapOfTheWalletIsBeingPaid_ItsSwapsWaitForIt() + { + PlanFixture(ownedSats: 950_000); + + var batches = await Service().PlanExitsAsync([Pending(leafIds: "l1", address: null), Pending(id: 9, lightningFee: null)]); + + batches.Should().BeEmpty(); + } + + [Fact] + public async Task Plan_AWalletNoNodeUses_IsFull() + { + PlanFixture(ownedSats: 0); + _nodes.GetAllManagedByNodeGuard(false).Returns(new List()); + var swap = Pending(leafIds: "l1", address: null); + + (await Service().PlanExitsAsync([swap])).Should().ContainSingle().Which.Should().Equal(swap); + } + + [Fact] + public async Task Plan_ABatchCarriesAtMostOneHundredLeaves_NeverSplittingASwap() + { + PlanFixture(ownedSats: 950_000); + string Leaves(string prefix, int count) => string.Join(',', Enumerable.Range(0, count).Select(i => $"{prefix}{i}")); + var a = Pending(id: 1, leafIds: Leaves("a", 60), address: null); + var b = Pending(id: 2, leafIds: Leaves("b", 30), address: null); + var c = Pending(id: 3, leafIds: Leaves("c", 20), address: null); + + var batches = await Service().PlanExitsAsync([a, b, c]); + + batches.Select(batch => batch.Select(sw => sw.Id)).Should().BeEquivalentTo(new[] { new[] { 1, 2 }, new[] { 3 } }, + o => o.WithStrictOrdering()); + } + + [Fact] + public async Task Plan_LeavesOutSwapsAlreadyExitingOrNotAttributed() + { + PlanFixture(ownedSats: 950_000); + + var batches = await Service().PlanExitsAsync([Pending(leafIds: "l1"), Pending(id: 9, address: null)]); + + batches.Should().BeEmpty(); + } + [Fact] public async Task Exit_ExitsExactlyTheSwapsLeaves_ToItsOwnAddress() { @@ -627,7 +840,7 @@ public async Task Exit_ExitsExactlyTheSwapsLeaves_ToItsOwnAddress() _spark.WithdrawLeavesAsync(W, Arg.Any>(), Address, 20_000, Arg.Any()) .Returns(new WithdrawLeavesResult("exit-tx", 499_000, 497_000)); - var step = await Service().ExitAsync(swap); + var step = await Exit(swap); step.Should().Be(SparkExitStep.ExitSent); swap.TxId.Should().Be("exit-tx"); @@ -648,7 +861,7 @@ public async Task Exit_ReservesItsAddressOnceAttributed_AndSavesItBeforeExiting( _spark.WithdrawLeavesAsync(W, Arg.Any>(), Address, 20_000, Arg.Any()) .Returns(new WithdrawLeavesResult("exit-tx", 499_000, 497_000)); - (await Service().ExitAsync(swap)).Should().Be(SparkExitStep.ExitSent); + (await Exit(swap)).Should().Be(SparkExitStep.ExitSent); swap.DestinationAddress.Should().Be(Address); NSubstitute.Received.InOrder(() => @@ -666,7 +879,7 @@ public async Task Exit_BeforeAttribution_ReservesNoAddress() { SspReports(null); - (await Service().ExitAsync(Pending(address: null))).Should().Be(SparkExitStep.Waiting); + (await Exit(Pending(address: null))).Should().Be(SparkExitStep.Waiting); await _nbXplorer.DidNotReceiveWithAnyArgs().GetUnusedAsync(default!, default, default, default, default); await _nbXplorer.DidNotReceiveWithAnyArgs().GetTransactionsAsync(default!); @@ -679,7 +892,7 @@ public async Task Exit_WhenItsAddressCannotBeSaved_DoesNotExit() AddressReserved(Address); _swapOuts.Update(swap).Returns((false, "the database is down")); - (await Service().ExitAsync(swap)).Should().Be(SparkExitStep.Waiting); + (await Exit(swap)).Should().Be(SparkExitStep.Waiting); swap.DestinationAddress.Should().BeNull(); await _spark.DidNotReceiveWithAnyArgs().WithdrawLeavesAsync(default, default!, default!, default, default); @@ -695,7 +908,7 @@ public async Task Exit_AnUnattributedSwap_IsAttributedFirst() _spark.WithdrawLeavesAsync(W, Arg.Any>(), Address, 20_000, Arg.Any()) .Returns(new WithdrawLeavesResult("exit-tx", 499_000, 497_000)); - (await Service().ExitAsync(swap)).Should().Be(SparkExitStep.ExitSent); + (await Exit(swap)).Should().Be(SparkExitStep.ExitSent); swap.SparkLeafIds.Should().Be("transfer-1-leaf"); swap.TxId.Should().Be("exit-tx"); @@ -706,7 +919,7 @@ public async Task Exit_BeforeTheTransferArrived_Waits() { SspReports(null); - (await Service().ExitAsync(Pending())).Should().Be(SparkExitStep.Waiting); + (await Exit(Pending())).Should().Be(SparkExitStep.Waiting); await _spark.DidNotReceiveWithAnyArgs().WithdrawLeavesAsync(default, default!, default!, default, default); } @@ -718,7 +931,7 @@ public async Task Exit_WhileTheTransferIsNotClaimed_Waits() SspReports("transfer-1"); _spark.GetTransferAsync(W, "transfer-1", Arg.Any()).Returns(Unclaimed(Transfer("transfer-1", 499_000))); - (await Service().ExitAsync(swap)).Should().Be(SparkExitStep.Waiting); + (await Exit(swap)).Should().Be(SparkExitStep.Waiting); swap.SparkLeafIds.Should().BeNull(); await _spark.DidNotReceiveWithAnyArgs().WithdrawLeavesAsync(default, default!, default!, default, default); @@ -732,7 +945,7 @@ public async Task Exit_LeavesThatCannotMoveYet_Wait_WithoutTouchingAnyOtherLeave _spark.WithdrawLeavesAsync(W, Arg.Any>(), Address, 20_000, Arg.Any()) .ThrowsAsync(new SparkLeavesNotSpendableException("withdraw_leaves", ["l1"])); - (await Service().ExitAsync(swap)).Should().Be(SparkExitStep.Waiting); + (await Exit(swap)).Should().Be(SparkExitStep.Waiting); swap.SparkLeafIds.Should().Be("l1,l2"); swap.TxId.Should().BeNull(); @@ -752,7 +965,7 @@ public async Task Exit_LeavesThatAlreadyExited_AreNotExitedAgain() _spark.GetTransfersAsync(W, TransferDirection.Sent, Arg.Any(), Arg.Any()) .Returns(new List { Transfer("exit", 499_000, "CooperativeExit", Leaf("l2", 236_856)) }); - (await Service().ExitAsync(swap)).Should().Be(SparkExitStep.ExitSent); + (await Exit(swap)).Should().Be(SparkExitStep.ExitSent); await _spark.Received(1).WithdrawLeavesAsync(W, Arg.Any>(), Address, 20_000, Arg.Any()); swap.SparkLeafIds.Should().Be("l1,l2"); @@ -765,7 +978,7 @@ public async Task Exit_AConfirmedPayout_CompletesTheSwap_WithWhatLandedOnChain() var swap = Pending(txId: "exit-tx", leafIds: "l1"); swap.SparkReceivedSats = 500_000; // receiving into Spark is free: Spark's fee was on the Lightning route - (await Service().ExitAsync(swap)).Should().Be(SparkExitStep.Completed); + (await Exit(swap)).Should().Be(SparkExitStep.Completed); swap.Status.Should().Be(SwapOutStatus.Completed); swap.TxId.Should().Be(new uint256(Enumerable.Repeat((byte)0xbb, 32).ToArray()).ToString(), "the SSP may fee-bump the exit"); @@ -783,7 +996,7 @@ public async Task Exit_AnUnconfirmedPayout_WaitsForItsConfirmation() Payouts((Seed: 0xbb, Sats: 497_500, Confirmations: 0)); var swap = Pending(txId: "exit-tx", leafIds: "l1"); - (await Service().ExitAsync(swap)).Should().Be(SparkExitStep.ExitSent); + (await Exit(swap)).Should().Be(SparkExitStep.ExitSent); swap.Status.Should().Be(SwapOutStatus.Pending); swap.TxId.Should().Be(new uint256(Enumerable.Repeat((byte)0xbb, 32).ToArray()).ToString()); @@ -795,7 +1008,7 @@ public async Task Exit_APayoutWhoseExitWasNotSaved_IsNotExitedAgain() { Payouts((Seed: 0xcc, Sats: 497_000, Confirmations: 0)); - (await Service().ExitAsync(Pending(txId: null, leafIds: "l1"))).Should().Be(SparkExitStep.ExitSent); + (await Exit(Pending(txId: null, leafIds: "l1"))).Should().Be(SparkExitStep.ExitSent); await _spark.DidNotReceiveWithAnyArgs().WithdrawLeavesAsync(default, default!, default!, default, default); } @@ -803,7 +1016,7 @@ public async Task Exit_APayoutWhoseExitWasNotSaved_IsNotExitedAgain() [Fact] public async Task Exit_ASwapPaidIntoAnotherSparkIdentity_IsRefused() { - var act = () => Service().ExitAsync(Pending(identity: Ssp, leafIds: "l1")); + var act = () => Exit(Pending(identity: Ssp, leafIds: "l1")); await act.Should().ThrowAsync().WithMessage("*resolved by hand*"); await _spark.DidNotReceiveWithAnyArgs().WithdrawLeavesAsync(default, default!, default!, default, default); @@ -818,7 +1031,7 @@ public async Task Exit_GoesThroughTheWalletTheSwapWasPaidInto_WhicheverTheNodeUs _spark.WithdrawLeavesAsync(other, Arg.Any>(), Address, 20_000, Arg.Any()) .Returns(new WithdrawLeavesResult("exit-tx", 499_000, 497_000)); - (await Service().ExitAsync(Pending(identity: Ssp, leafIds: "l1"))).Should().Be(SparkExitStep.ExitSent); + (await Exit(Pending(identity: Ssp, leafIds: "l1"))).Should().Be(SparkExitStep.ExitSent); await _spark.DidNotReceive().WithdrawLeavesAsync(W, Arg.Any>(), Arg.Any(), Arg.Any(), Arg.Any()); @@ -827,7 +1040,7 @@ await _spark.DidNotReceive().WithdrawLeavesAsync(W, Arg.Any Service().ExitAsync(Pending(lightningFee: null)); + var act = () => Exit(Pending(lightningFee: null)); await act.Should().ThrowAsync().WithMessage("*not paid yet*"); } diff --git a/test/NodeGuard.Tests/Services/Spark/SparkWalletManagementTests.cs b/test/NodeGuard.Tests/Services/Spark/SparkWalletManagementTests.cs index a8567f5b..42fe6cd6 100644 --- a/test/NodeGuard.Tests/Services/Spark/SparkWalletManagementTests.cs +++ b/test/NodeGuard.Tests/Services/Spark/SparkWalletManagementTests.cs @@ -313,13 +313,30 @@ public async Task TheTransactions_AreNewestFirst_WithTheirDirectionTypeAndSwap() transfers.Select(t => t.Id).Should().Equal("exit-1", "stray", "odd", "receive-1"); transfers.Should().BeEquivalentTo(new[] { - new { Id = "exit-1", IsSent = true, Type = "Exit to on-chain", SwapId = (int?)41, Date = DateTimeOffset.UnixEpoch.AddMinutes(5) }, - new { Id = "stray", IsSent = false, Type = "Spark transfer", SwapId = (int?)null, Date = DateTimeOffset.UnixEpoch.AddMinutes(3) }, - new { Id = "odd", IsSent = false, Type = "UtxoSwap", SwapId = (int?)null, Date = DateTimeOffset.UnixEpoch.AddMinutes(2) }, - new { Id = "receive-1", IsSent = false, Type = "Lightning payment in", SwapId = (int?)41, Date = DateTimeOffset.UnixEpoch.AddMinutes(1) } + new { Id = "exit-1", IsSent = true, Type = "Exit to on-chain", SwapIds = new[] { 41 }, Date = DateTimeOffset.UnixEpoch.AddMinutes(5) }, + new { Id = "stray", IsSent = false, Type = "Spark transfer", SwapIds = Array.Empty(), Date = DateTimeOffset.UnixEpoch.AddMinutes(3) }, + new { Id = "odd", IsSent = false, Type = "UtxoSwap", SwapIds = Array.Empty(), Date = DateTimeOffset.UnixEpoch.AddMinutes(2) }, + new { Id = "receive-1", IsSent = false, Type = "Lightning payment in", SwapIds = new[] { 41 }, Date = DateTimeOffset.UnixEpoch.AddMinutes(1) } }, o => o.WithStrictOrdering()); } + [Fact] + public async Task TheTransactions_ABatchsExit_ListsEverySwapItPaid() + { + _spark.Configure().GetTransfersAsync(new SparkWalletRef(5), TransferDirection.Both, DateTimeOffset.UnixEpoch, Arg.Any()) + .Returns(new List { Transfer("exit-1", Identity, "CooperativeExit", minutes: 5, "l1", "l3") }); + _swapOuts.GetSparkSwapsByIdentityAsync(Identity).Returns(new List + { + new() { Id = 41, Provider = SwapProvider.Spark, SparkTransferId = "receive-1", SparkLeafIds = "l1,l2" }, + new() { Id = 42, Provider = SwapProvider.Spark, SparkTransferId = "receive-2", SparkLeafIds = "l3" }, + new() { Id = 43, Provider = SwapProvider.Spark, SparkTransferId = "receive-3", SparkLeafIds = "l4" } + }); + + var transfers = await Service().ListTransfersAsync(5); + + transfers.Should().ContainSingle().Which.SwapIds.Should().Equal(41, 42); + } + [Fact] public async Task TheTransactions_KeepOnlyTheLatest() {