From 01b77a0f62b72234eeb35c9f00e32b19aeebd95f Mon Sep 17 00:00:00 2001 From: Marcos Date: Mon, 19 Oct 2026 19:32:20 +0200 Subject: [PATCH 1/9] Keep replaced withdrawals pending until their RBF replacement confirms A withdrawal request's status now only describes its own transaction. Creating, executing or abandoning an RBF replacement never writes the request it replaces: the API derives WITHDRAWAL_BUMPED / _CANCELLED from a broadcast replacement, and the monitor stores Bumped / Cancelled only once a replacement confirms, or settles the request itself when it was mined instead. Bumped is terminal. - MonitorWithdrawalsJob: null-safe, per-row errors, then ReconcileReplacementChainsAsync gives each live RBF-linked row its final status from its chain alone: Failed when what it replaced confirmed or a sibling branch did, Bumped / Cancelled when a replacement of it did - The replaced request is never marked or reverted; creating a replacement is refused while one is live; the replacements of a request come from one repository lookup, not a navigation - GetWithdrawalsRequestStatusByReferenceIds returns the whole chain, and WithdrawalRequest gains replaced_by_request_id - Migration repairs legacy Bumped / RBF-cancelled rows with no confirmed replacement back to pending - Withdrawals page gates bump / cancel on no live replacement and names the replacement next to the stored status - E2E: the original mined instead of its replacement (bump, cancel, bump of a bump) and the by-reference chain --- docker/e2e/README.md | 11 +- src/Automapper/MapperProfile.cs | 4 +- src/Data/Models/WalletWithdrawalRequest.cs | 92 +- .../IWalletWithdrawalRequestRepository.cs | 15 + src/Data/Repositories/Repository.cs | 2 + .../WalletWithdrawalRequestRepository.cs | 79 +- src/Helpers/Constants.cs | 3 +- src/Jobs/MonitorWithdrawalsJob.cs | 70 +- src/Jobs/PerformWithdrawalJob.cs | 35 +- ...019172941_BumpedIsTerminalOnly.Designer.cs | 2000 +++++++++++++++++ .../20261019172941_BumpedIsTerminalOnly.cs | 63 + src/Pages/Withdrawals.razor | 30 +- src/Proto/nodeguard.proto | 20 +- src/Rpc/NodeGuardService.cs | 41 +- src/Services/WithdrawalRequestService.cs | 154 +- .../WalletWithdrawalRequestRepositoryTests.cs | 188 ++ .../E2E/WithdrawalRbfBumpE2ETests.cs | 230 +- .../Jobs/MonitorWithdrawalsJobTests.cs | 188 ++ .../Jobs/PerformWithdrawalJobTests.cs | 140 ++ .../Rpc/NodeGuardServiceTests.cs | 160 ++ .../Services/WithdrawalRequestServiceTests.cs | 335 ++- .../TestHelpers/TestEnvironment.cs | 2 + 22 files changed, 3641 insertions(+), 221 deletions(-) create mode 100644 src/Migrations/20261019172941_BumpedIsTerminalOnly.Designer.cs create mode 100644 src/Migrations/20261019172941_BumpedIsTerminalOnly.cs create mode 100644 test/NodeGuard.Tests/Data/Repositories/WalletWithdrawalRequestRepositoryTests.cs create mode 100644 test/NodeGuard.Tests/Jobs/MonitorWithdrawalsJobTests.cs create mode 100644 test/NodeGuard.Tests/Jobs/PerformWithdrawalJobTests.cs diff --git a/docker/e2e/README.md b/docker/e2e/README.md index 1ba02acd..dd592d4b 100644 --- a/docker/e2e/README.md +++ b/docker/e2e/README.md @@ -70,9 +70,14 @@ tests (`DustUtxoWithdrawalE2ETests`, `WithdrawalRbfBumpE2ETests`, `GetNewWalletA those two in sync between the `nodeguard` and `e2e-runner` services**, or the test asserts against numbers NodeGuard didn't plan with. - **RBF bump scenario** (`WithdrawalRbfBumpE2ETests`): `RequestWithdrawal` on the hot wallet, then `BumpWithdrawal` - over gRPC, asserting bitcoind evicts the original for the higher-fee replacement, NodeGuard marks the original - `WITHDRAWAL_BUMPED`, and the replacement settles once mined. It relies on `MONITOR_WITHDRAWALS_CRON` being fast on - the `nodeguard` service (10s here; prod keeps the every-5-minutes default) and never mines between the two calls. + over gRPC, asserting bitcoind evicts the original for the higher-fee replacement, NodeGuard reports the original + `WITHDRAWAL_BUMPED` while the replacement is in the mempool (its stored status stays pending confirmation), and once + the replacement is mined it settles and the original is stored as `WITHDRAWAL_BUMPED`. It relies on + `MONITOR_WITHDRAWALS_CRON` being fast on the `nodeguard` service (10s here; prod keeps the every-5-minutes default) + and never mines between the two calls. The mined-original theory mines the ORIGINAL with `generateblock` (raw tx, + bypassing the mempool) after a bump or cancel, and asserts NodeGuard settles the original and fails the replacement. + The bump-of-a-bump test does the same with three transactions (A bumped to B, B to C, A mined): A settles, B and C + fail. The by-reference lookup returns the whole chain, each request with its own status. - **Adding an e2e test**: put it in `[Collection("E2E")]` (so it serialises with the others on the one regtest chain — without it the class runs in parallel and they interfere), have it provision the resources it needs and reset its own state, and gate it with `[E2EFact]`. diff --git a/src/Automapper/MapperProfile.cs b/src/Automapper/MapperProfile.cs index 77680116..b652d58b 100644 --- a/src/Automapper/MapperProfile.cs +++ b/src/Automapper/MapperProfile.cs @@ -60,7 +60,9 @@ public MapperProfile() .ForMember(x => x.Wallet, opt => opt.Ignore()) .ForMember(x => x.WalletWithdrawalRequestPSBTs, opt => opt.Ignore()) .ForMember(x => x.UserRequestor, opt => opt.Ignore()) - .ForMember(x => x.UTXOs, opt => opt.Ignore()); + .ForMember(x => x.UTXOs, opt => opt.Ignore()) + // Update attaches the whole mapped graph as Modified: the replaced request must never ride along. + .ForMember(x => x.BumpingWalletWithdrawalRequest, opt => opt.Ignore()); CreateMap() .ForMember(x => x.TxId, opt => opt.MapFrom(x => x.Outpoint.Hash.ToString())) diff --git a/src/Data/Models/WalletWithdrawalRequest.cs b/src/Data/Models/WalletWithdrawalRequest.cs index b3d25332..049a96b9 100644 --- a/src/Data/Models/WalletWithdrawalRequest.cs +++ b/src/Data/Models/WalletWithdrawalRequest.cs @@ -66,7 +66,8 @@ public enum WalletWithdrawalRequestStatus FinalizingPSBT = 7, /// - /// The tx was bumped by Replace by Fee (RBF) method + /// Superseded: an RBF replacement of this request (or of one of its replacements) confirmed. Terminal, written + /// only by the monitor. While a replacement is merely in flight the request keeps its own status. /// Bumped = 8 } @@ -224,10 +225,70 @@ public bool Equals(WalletWithdrawalRequest? other) /// /// True when this request replaces to CANCEL it (RBF): same inputs, - /// a single output back to the wallet, and the replaced request ends up Cancelled instead of Bumped. + /// a single output back to the wallet, and once this one confirms the replaced request ends up Cancelled + /// instead of Bumped. /// public bool IsRbfCancellation { get; set; } + /// + /// Statuses in which a request can still put (or has put) a transaction in the mempool. A replacement in any of + /// them blocks a second replacement of the same request. + /// + public static readonly WalletWithdrawalRequestStatus[] LiveStatuses = + { + WalletWithdrawalRequestStatus.Pending, + WalletWithdrawalRequestStatus.PSBTSignaturesPending, + WalletWithdrawalRequestStatus.FinalizingPSBT, + WalletWithdrawalRequestStatus.OnChainConfirmationPending, + }; + + [NotMapped] + public bool IsLive => LiveStatuses.Contains(Status); + + [NotMapped] + public bool IsAbandoned => Status is WalletWithdrawalRequestStatus.Failed + or WalletWithdrawalRequestStatus.Cancelled + or WalletWithdrawalRequestStatus.Rejected; + + public static string RbfCancellationReason(int replacementId, string? txId) => + WithTx($"Cancelled by RBF replacement request {replacementId}", txId); + + public static string ReplacedReason(int replacementId, string? txId) => + WithTx($"Replaced by request {replacementId}", txId); + + public static string MinedInsteadReason(int originalId, string? txId) => + WithTx($"Withdrawal request {originalId} was mined instead", txId); + + private static string WithTx(string reason, string? txId) => + string.IsNullOrEmpty(txId) ? reason : $"{reason} (tx {txId})"; + + /// + /// What a request superseded by becomes: Cancelled when a cancellation sits + /// on the way to it, otherwise Bumped. + /// + public static (WalletWithdrawalRequestStatus Status, string Reason) SupersededBy(WalletWithdrawalRequest replacement, bool cancellation) => + cancellation + ? (WalletWithdrawalRequestStatus.Cancelled, RbfCancellationReason(replacement.Id, replacement.TxId)) + : (WalletWithdrawalRequestStatus.Bumped, ReplacedReason(replacement.Id, replacement.TxId)); + + /// + /// The status the API reports, given the requests that replace this one. The stored one describes this + /// request's own transaction only: while a replacement's transaction is in the mempool and this one has no + /// confirmations, the request is reported with the status it will be stored with once that replacement settles. + /// + public (WalletWithdrawalRequestStatus Status, string? Reason, int? ReplacedById) EffectiveStatus(ulong ownConfirmations, + RbfReplacements replacements) + { + if (Status == WalletWithdrawalRequestStatus.OnChainConfirmationPending && ownConfirmations == 0 + && replacements.Broadcast is { } broadcast) + { + var (status, reason) = SupersededBy(broadcast, broadcast.IsRbfCancellation); + return (status, reason, broadcast.Id); + } + + return (Status, RejectCancelDescription, replacements.Newest?.Id); + } + public List WalletWithdrawalRequestPSBTs { get; set; } public List UTXOs { get; set; } @@ -259,4 +320,31 @@ public override int GetHashCode() return !Equals(left, right); } } + + /// + /// The requests that replace one request through RBF (fee bumps and cancellations), as the repository's + /// GetReplacementsAsync returns them. Which of them is live or confirmed is what makes a request "replaced": its + /// own status only ever describes its own transaction. + /// + public sealed class RbfReplacements + { + public static readonly RbfReplacements None = new(Enumerable.Empty()); + + private readonly List _requests; + + public RbfReplacements(IEnumerable requests) + { + _requests = requests.ToList(); + } + + /// The one still in flight, if any; creating a replacement is refused while one exists. + public WalletWithdrawalRequest? Live => _requests.FirstOrDefault(r => r.IsLive); + + /// The one whose transaction competes in the mempool, if any. + public WalletWithdrawalRequest? Broadcast => + _requests.FirstOrDefault(r => r.Status == WalletWithdrawalRequestStatus.OnChainConfirmationPending); + + /// The newest one that was not abandoned: the next hop when following a chain by id. + public WalletWithdrawalRequest? Newest => _requests.Where(r => !r.IsAbandoned).MaxBy(r => r.Id); + } } \ No newline at end of file diff --git a/src/Data/Repositories/Interfaces/IWalletWithdrawalRequestRepository.cs b/src/Data/Repositories/Interfaces/IWalletWithdrawalRequestRepository.cs index 7990ff73..176feb8d 100644 --- a/src/Data/Repositories/Interfaces/IWalletWithdrawalRequestRepository.cs +++ b/src/Data/Repositories/Interfaces/IWalletWithdrawalRequestRepository.cs @@ -58,4 +58,19 @@ public interface IWalletWithdrawalRequestRepository : IBitcoinRequestRepository Task> GetPendingRequests(); Task> GetOnChainPendingWithdrawals(); + + /// + /// Every request connected to this one through RBF links, in both directions, the request itself included. + /// Untracked, no navigations loaded. + /// + Task> GetRbfChainAsync(int requestId); + + /// Ids of the live requests that replace another request or are replaced by one. + Task> GetLiveRbfLinkedRequestIdsAsync(); + + /// + /// The RBF replacements of each of , keyed by the request they replace. A request + /// with none is absent from the lookup. Untracked. + /// + Task> GetReplacementsAsync(IEnumerable requestIds); } \ No newline at end of file diff --git a/src/Data/Repositories/Repository.cs b/src/Data/Repositories/Repository.cs index 3bec0c5b..c95add58 100644 --- a/src/Data/Repositories/Repository.cs +++ b/src/Data/Repositories/Repository.cs @@ -60,6 +60,8 @@ public async Task> GetAll(ApplicationDbContext applicationDbContext) catch (Exception e) { _logger.LogError(e, "Error on repository"); + // The innermost message is the database's own (e.g. the constraint a unique index rejected). + return (false, e.GetBaseException().Message); } return (rowsChanged, null); diff --git a/src/Data/Repositories/WalletWithdrawalRequestRepository.cs b/src/Data/Repositories/WalletWithdrawalRequestRepository.cs index 9f2a2079..a3a0cafa 100644 --- a/src/Data/Repositories/WalletWithdrawalRequestRepository.cs +++ b/src/Data/Repositories/WalletWithdrawalRequestRepository.cs @@ -82,16 +82,12 @@ public async Task> GetByReferenceIds(List { await using var applicationDbContext = await _dbContextFactory.CreateDbContextAsync(); - // RBF replacements inherit the reference id of the request they replace, so a reference id can match a whole - // chain of requests. Only the latest one of each chain reflects the current status of the withdrawal. - var requests = await applicationDbContext.WalletWithdrawalRequests + // RBF replacements inherit the reference id of the request they replace, so a reference id matches a whole + // chain. Every request of it is returned, oldest first, each reporting its own (derived) status. + return await applicationDbContext.WalletWithdrawalRequests .Where(wr => !string.IsNullOrEmpty(wr.ReferenceId) && referenceIds.Contains(wr.ReferenceId)) + .OrderBy(wr => wr.Id) .ToListAsync(); - - return requests - .GroupBy(wr => wr.ReferenceId!) - .Select(g => g.OrderByDescending(wr => wr.Id).First()) - .ToList(); } public async Task GetByTxHash(string txHash) @@ -382,6 +378,73 @@ public async Task> GetPendingRequests() return walletWithdrawalRequests; } + public async Task> GetRbfChainAsync(int requestId) + { + await using var applicationDbContext = await _dbContextFactory.CreateDbContextAsync(); + var requests = applicationDbContext.WalletWithdrawalRequests.AsNoTracking(); + + // Up to the first request of the chain, then down through every replacement, level by level. One query + // per hop; chains are a handful of rows. + var root = await requests.SingleOrDefaultAsync(x => x.Id == requestId); + if (root == null) return new List(); + + var chain = new Dictionary { [root.Id] = root }; + while (root.BumpingWalletWithdrawalRequestId is { } replacedId && !chain.ContainsKey(replacedId)) + { + var replaced = await requests.SingleOrDefaultAsync(x => x.Id == replacedId); + if (replaced == null) break; + + chain[replaced.Id] = replaced; + root = replaced; + } + + var expanded = new HashSet { root.Id }; + var frontier = new List { root.Id }; + while (frontier.Count > 0) + { + var parents = frontier; + var children = await requests + .Where(x => x.BumpingWalletWithdrawalRequestId != null && parents.Contains(x.BumpingWalletWithdrawalRequestId.Value)) + .ToListAsync(); + + frontier = new List(); + foreach (var child in children) + { + chain.TryAdd(child.Id, child); + if (expanded.Add(child.Id)) frontier.Add(child.Id); + } + } + + return chain.Values.OrderBy(x => x.Id).ToList(); + } + + public async Task> GetLiveRbfLinkedRequestIdsAsync() + { + await using var applicationDbContext = await _dbContextFactory.CreateDbContextAsync(); + + var live = WalletWithdrawalRequest.LiveStatuses; + var requests = applicationDbContext.WalletWithdrawalRequests; + return await requests + .Where(x => live.Contains(x.Status) + && (x.BumpingWalletWithdrawalRequestId != null + || requests.Any(r => r.BumpingWalletWithdrawalRequestId == x.Id))) + .Select(x => x.Id) + .ToListAsync(); + } + + public async Task> GetReplacementsAsync(IEnumerable requestIds) + { + await using var applicationDbContext = await _dbContextFactory.CreateDbContextAsync(); + + var ids = requestIds.Distinct().ToList(); + var replacements = await applicationDbContext.WalletWithdrawalRequests + .AsNoTracking() + .Where(x => x.BumpingWalletWithdrawalRequestId != null && ids.Contains(x.BumpingWalletWithdrawalRequestId.Value)) + .ToListAsync(); + + return replacements.ToLookup(x => x.BumpingWalletWithdrawalRequestId!.Value); + } + public async Task> GetOnChainPendingWithdrawals() { await using var applicationDbContext = await _dbContextFactory.CreateDbContextAsync(); diff --git a/src/Helpers/Constants.cs b/src/Helpers/Constants.cs index 91344a98..6f6efb89 100644 --- a/src/Helpers/Constants.cs +++ b/src/Helpers/Constants.cs @@ -525,8 +525,9 @@ static Constants() var environmentVariableMax = Environment.GetEnvironmentVariable("MAXIMUM_WITHDRAWAL_BTC_AMOUNT"); if (environmentVariableMax != null) MAXIMUM_WITHDRAWAL_BTC_AMOUNT = decimal.Parse(environmentVariableMax, NumberStyles.AllowDecimalPoint, CultureInfo.InvariantCulture); + // At least one: a transaction evicted from the mempool sits at 0 confirmations and must never count as mined. var transactionConfBlocks = GetEnvironmentalVariableOrThrowIfNotTesting("TRANSACTION_CONFIRMATION_MINIMUM_BLOCKS"); - if (transactionConfBlocks != null) TRANSACTION_CONFIRMATION_MINIMUM_BLOCKS = int.Parse(transactionConfBlocks); + TRANSACTION_CONFIRMATION_MINIMUM_BLOCKS = Math.Max(1, transactionConfBlocks != null ? int.Parse(transactionConfBlocks) : 1); var defaultChannelFeePolicyTimelockDeltaBlocks = Environment.GetEnvironmentVariable("DEFAULT_CHANNEL_FEE_POLICY_TIMELOCK_DELTA_BLOCKS"); if (defaultChannelFeePolicyTimelockDeltaBlocks != null) DEFAULT_CHANNEL_FEE_POLICY_TIMELOCK_DELTA_BLOCKS = int.Parse(defaultChannelFeePolicyTimelockDeltaBlocks); diff --git a/src/Jobs/MonitorWithdrawalsJob.cs b/src/Jobs/MonitorWithdrawalsJob.cs index c1ecf2d4..35d7a898 100644 --- a/src/Jobs/MonitorWithdrawalsJob.cs +++ b/src/Jobs/MonitorWithdrawalsJob.cs @@ -26,7 +26,9 @@ namespace NodeGuard.Jobs; /// -/// Monitors that withdrawals on-chain txs are confirmed +/// Settles withdrawals whose transaction reached the confirmation depth, then gives every request linked by RBF its +/// final status from what its chain holds (a replaced request that was mined instead, a replacement that confirmed). +/// Each row is written on its own, so a failing one is retried on the next run without touching the rest. /// [DisallowConcurrentExecution] public class MonitorWithdrawalsJob : IJob @@ -34,15 +36,18 @@ public class MonitorWithdrawalsJob : IJob private readonly ILogger _logger; private readonly IWalletWithdrawalRequestRepository _walletWithdrawalRequestRepository; private readonly INBXplorerService _nbXplorerService; + private readonly IWithdrawalRequestService _withdrawalRequestService; public MonitorWithdrawalsJob(ILogger logger, IWalletWithdrawalRequestRepository walletWithdrawalRequestRepository, - INBXplorerService nbXplorerService + INBXplorerService nbXplorerService, + IWithdrawalRequestService withdrawalRequestService ) { _logger = logger; _walletWithdrawalRequestRepository = walletWithdrawalRequestRepository; _nbXplorerService = nbXplorerService; + _withdrawalRequestService = withdrawalRequestService; } public async Task Execute(IJobExecutionContext context) @@ -52,41 +57,48 @@ public async Task Execute(IJobExecutionContext context) foreach (var walletWithdrawalRequest in withdrawalsPending) { - if (!string.IsNullOrEmpty(walletWithdrawalRequest.TxId)) + if (string.IsNullOrEmpty(walletWithdrawalRequest.TxId)) continue; + + try { - try + var getTxResult = await _nbXplorerService.GetTransactionAsync(uint256.Parse(walletWithdrawalRequest.TxId), default); + if (getTxResult == null) { - //Let's check if the minimum amount of confirmations are established - - var getTxResult = await _nbXplorerService.GetTransactionAsync(uint256.Parse(walletWithdrawalRequest.TxId), default); - - if (getTxResult.Confirmations >= Constants.TRANSACTION_CONFIRMATION_MINIMUM_BLOCKS) - { - walletWithdrawalRequest.Status = WalletWithdrawalRequestStatus.OnChainConfirmed; + _logger.LogWarning("Transaction {TxId} of withdrawal request {RequestId} is unknown to NBXplorer", + walletWithdrawalRequest.TxId, walletWithdrawalRequest.Id); + continue; + } - var updateResult = _walletWithdrawalRequestRepository.Update(walletWithdrawalRequest); + if (getTxResult.Confirmations < Constants.TRANSACTION_CONFIRMATION_MINIMUM_BLOCKS) continue; - if (!updateResult.Item1) - { - _logger.LogError( - "Error while updating wallet withdrawal: {RequestId}, status: {RequestStatus}", - walletWithdrawalRequest.Id, - walletWithdrawalRequest.Status); - } - else - { - _logger.LogInformation("Updating wallet withdrawal: {RequestId} to status: {RequestStatus}", - walletWithdrawalRequest.Id, walletWithdrawalRequest.Status); - } - } + walletWithdrawalRequest.Status = WalletWithdrawalRequestStatus.OnChainConfirmed; + var (updated, _) = _walletWithdrawalRequestRepository.Update(walletWithdrawalRequest); + if (updated) + { + _logger.LogInformation("Updating wallet withdrawal: {RequestId} to status: {RequestStatus}", + walletWithdrawalRequest.Id, walletWithdrawalRequest.Status); } - catch (Exception e) + else { - _logger.LogError(e, "Error while monitoring TxId: {TxId}", walletWithdrawalRequest.TxId); - - throw new JobExecutionException(e, false); + _logger.LogError("Error while updating wallet withdrawal: {RequestId}, status: {RequestStatus}", + walletWithdrawalRequest.Id, walletWithdrawalRequest.Status); } } + catch (Exception e) + { + // One request must not stop the rest of the batch. + _logger.LogError(e, "Error while monitoring TxId: {TxId}", walletWithdrawalRequest.TxId); + } + } + + try + { + // After settling, so a request confirmed in this run already drives its RBF chain in this run. + await _withdrawalRequestService.ReconcileReplacementChainsAsync(); + } + catch (Exception e) + { + _logger.LogError(e, "Error while reconciling RBF replacement chains"); } _logger.LogInformation($"Job {nameof(MonitorWithdrawalsJob)} ended"); diff --git a/src/Jobs/PerformWithdrawalJob.cs b/src/Jobs/PerformWithdrawalJob.cs index ba016d3c..e6d5e04b 100644 --- a/src/Jobs/PerformWithdrawalJob.cs +++ b/src/Jobs/PerformWithdrawalJob.cs @@ -37,8 +37,8 @@ public class PerformWithdrawalJob : IJob private readonly IBitcoinService _bitcoinService; private readonly IWalletWithdrawalRequestRepository _walletWithdrawalRequestRepository; - - public PerformWithdrawalJob(ILogger logger, IBitcoinService bitcoinService, IWalletWithdrawalRequestRepository walletWithdrawalRequestRepository) + public PerformWithdrawalJob(ILogger logger, IBitcoinService bitcoinService, + IWalletWithdrawalRequestRepository walletWithdrawalRequestRepository) { _logger = logger; _bitcoinService = bitcoinService; @@ -58,34 +58,15 @@ public async Task Execute(IJobExecutionContext context) catch (Exception e) { var request = await _walletWithdrawalRequestRepository.GetById(withdrawalRequestId); - - // If an error occurs and the wd was bumping another one, we need to update the status of the withdrawal request to previous status - if (request != null && request!.BumpingWalletWithdrawalRequestId.HasValue) + if (request != null) { - var bumped = await _walletWithdrawalRequestRepository.GetById(request.BumpingWalletWithdrawalRequestId.Value); - if (bumped == null) - { - _logger.LogError("Failed to find withdrawal request with ID {WithdrawalRequestId}", withdrawalRequestId); - return; - } - - bumped.Status = WalletWithdrawalRequestStatus.OnChainConfirmationPending; - var (ok, _) = _walletWithdrawalRequestRepository.Update(bumped); - if (!ok) + // A failed RBF replacement needs nothing else: the request it replaced never left its own status. + request.Status = WalletWithdrawalRequestStatus.Failed; + var (updated, _) = _walletWithdrawalRequestRepository.Update(request); + if (!updated) { - _logger.LogError("Failed to update withdrawal request status to OnChainConfirmationPending for ID {WithdrawalRequestId}", withdrawalRequestId); + _logger.LogError("Failed to update withdrawal request status to failed"); } - else - { - _logger.LogInformation("Updated withdrawal request status to OnChainConfirmationPending for ID {WithdrawalRequestId}", withdrawalRequestId); - } - } - - request!.Status = WalletWithdrawalRequestStatus.Failed; - var (updated, _) = _walletWithdrawalRequestRepository.Update(request); - if (!updated) - { - _logger.LogError("Failed to update withdrawal request status to failed"); } _logger.LogError(e, "Error on {JobName}", nameof(PerformWithdrawalJob)); diff --git a/src/Migrations/20261019172941_BumpedIsTerminalOnly.Designer.cs b/src/Migrations/20261019172941_BumpedIsTerminalOnly.Designer.cs new file mode 100644 index 00000000..52d9d6f6 --- /dev/null +++ b/src/Migrations/20261019172941_BumpedIsTerminalOnly.Designer.cs @@ -0,0 +1,2000 @@ +// +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("20261019172941_BumpedIsTerminalOnly")] + partial class BumpedIsTerminalOnly + { + /// + 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("FortySwapWeight") + .HasColumnType("integer"); + + b.Property("FundsDestinationWalletId") + .HasColumnType("integer"); + + b.Property("IsNodeDisabled") + .HasColumnType("boolean"); + + 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("MaxSwapsInFlight") + .HasColumnType("integer"); + + 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("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("SwapMaxAmountSats") + .HasColumnType("bigint"); + + b.Property("SwapMinAmountSats") + .HasColumnType("bigint"); + + 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("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/20261019172941_BumpedIsTerminalOnly.cs b/src/Migrations/20261019172941_BumpedIsTerminalOnly.cs new file mode 100644 index 00000000..69ff5976 --- /dev/null +++ b/src/Migrations/20261019172941_BumpedIsTerminalOnly.cs @@ -0,0 +1,63 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace NodeGuard.Migrations +{ + /// + /// Bumped becomes terminal: a withdrawal request's status describes only its own transaction. + /// + /// Until now a request was marked Bumped (or Cancelled with a "Cancelled by RBF replacement request N" reason) as + /// soon as its RBF replacement was executed, and reverted if the replacement was abandoned. From now on nothing + /// writes the replaced request while the replacement is in flight: the API derives what it reports from the + /// replacement, and the monitor stores Bumped / Cancelled only once a replacement confirms, or settles the + /// request itself when it was mined instead. + /// + /// + /// Existing rows are repaired here, in SQL. A Bumped or RBF-cancelled request none of whose replacements (direct + /// or through further replacements) confirmed goes back to OnChainConfirmationPending: its own transaction is + /// still the live candidate, so the monitor polls it again and the UI offers to replace it again. Rows whose + /// replacement confirmed are already in their final status and are left alone. No schema change. + /// + /// + public partial class BumpedIsTerminalOnly : Migration + { + // WalletWithdrawalRequestStatus: Cancelled = 1, OnChainConfirmationPending = 4, OnChainConfirmed = 5, Bumped = 8 + // A Cancelled request with an RBF-cancellation replacement was cancelled by it: humans can only cancel a + // request that was never broadcast, and only a broadcast one gets replacements. + private const string Repair = @" +WITH RECURSIVE roots AS ( + SELECT w.""Id"" FROM ""WalletWithdrawalRequests"" w + WHERE w.""Status"" = 8 + OR (w.""Status"" = 1 AND EXISTS ( + SELECT 1 FROM ""WalletWithdrawalRequests"" r + WHERE r.""BumpingWalletWithdrawalRequestId"" = w.""Id"" AND r.""IsRbfCancellation"")) +), descendants AS ( + SELECT r.""Id"" AS root_id, r.""Id"" AS node_id FROM roots r + UNION + SELECT d.root_id, c.""Id"" FROM descendants d + JOIN ""WalletWithdrawalRequests"" c ON c.""BumpingWalletWithdrawalRequestId"" = d.node_id +), settled AS ( + SELECT DISTINCT d.root_id FROM descendants d + JOIN ""WalletWithdrawalRequests"" n ON n.""Id"" = d.node_id + WHERE n.""Id"" <> d.root_id AND n.""Status"" = 5 +) +UPDATE ""WalletWithdrawalRequests"" w +SET ""Status"" = 4, + ""RejectCancelDescription"" = CASE WHEN w.""Status"" = 1 THEN NULL ELSE w.""RejectCancelDescription"" END, + ""UpdateDatetime"" = now() +WHERE w.""Id"" IN (SELECT ""Id"" FROM roots) AND w.""Id"" NOT IN (SELECT root_id FROM settled);"; + + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql(Repair); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + // The data repair is not reversed: the revived rows are pending confirmation, which is what they are. + } + } +} diff --git a/src/Pages/Withdrawals.razor b/src/Pages/Withdrawals.razor index 23ae6b6a..a0eb5a53 100644 --- a/src/Pages/Withdrawals.razor +++ b/src/Pages/Withdrawals.razor @@ -416,8 +416,9 @@ + @* The stored status is the row's own transaction; the replacement, if any, is named next to it. *@ - @context?.Status.Humanize() + @context.Status.Humanize()@(ReplacementsOf(context.Id).Newest is { } replacement ? $" · replaced by #{replacement.Id}" : "") @@ -441,7 +442,7 @@ - @if (_isFinanceManager && context.Status == WalletWithdrawalRequestStatus.OnChainConfirmationPending) + @if (_isFinanceManager && context.Status == WalletWithdrawalRequestStatus.OnChainConfirmationPending && ReplacementsOf(context.Id).Live == null) { @@ -534,6 +535,7 @@ private List _availableWallets = new(); private List _availableUsers = new(); private List _withdrawalRequests = new(); + private Dictionary _replacements = new(); private int _totalWithdrawalRequests; private int _currentPage = 1; private int _pageSize = 25; @@ -652,6 +654,7 @@ userId: _userFilter, fromDate: _fromDate.HasValue ? new DateTimeOffset(_fromDate.Value, TimeSpan.Zero) : null, toDate: _toDate.HasValue ? new DateTimeOffset(_toDate.Value.AddDays(1).AddTicks(-1), TimeSpan.Zero) : null); + var replacements = await WalletWithdrawalRequestRepository.GetReplacementsAsync(requests.Select(r => r.Id)); if (cancellationToken.IsCancellationRequested) { @@ -659,11 +662,15 @@ } _withdrawalRequests = requests; + _replacements = replacements.ToDictionary(group => group.Key, group => new RbfReplacements(group)); _totalWithdrawalRequests = totalCount; _currentPage = pageNumber; _pageSize = pageSize; } + /// The RBF replacements of a request on the current page. + private RbfReplacements ReplacementsOf(int requestId) => _replacements.GetValueOrDefault(requestId, RbfReplacements.None); + private async Task OnAllWithdrawalsReadData(DataGridReadDataEventArgs e) { await LoadAllWithdrawalRequestsAsync(e.Page, e.PageSize, e.CancellationToken); @@ -985,7 +992,15 @@ await LogWithdrawalAuditAsync(AuditActionType.BumpFee, AuditEventType.Failure, request, "Bump requested for withdrawal not pending on-chain confirmation."); return; } - + + var replacements = await WalletWithdrawalRequestRepository.GetReplacementsAsync(new[] { request.Id }); + if (new RbfReplacements(replacements[request.Id]).Live is { } liveReplacement) + { + ToastService.ShowError($"This withdrawal is already being replaced by request {liveReplacement.Id}. Bump or cancel that request instead."); + await LogWithdrawalAuditAsync(AuditActionType.BumpFee, AuditEventType.Failure, request, $"Bump requested for withdrawal already replaced by request {liveReplacement.Id}."); + return; + } + ulong confirmations = 0; if (!string.IsNullOrEmpty(request.TxId)) { @@ -1073,10 +1088,6 @@ if (_selectedRequest != null) { await CreateJob(); - if (_selectedRequest?.BumpingWalletWithdrawalRequestId != null) - { - await WithdrawalRequestService.MarkOriginalAsReplacedAsync(_selectedRequest); - } } } catch (Exception ex) @@ -1189,9 +1200,6 @@ await LogWithdrawalAuditAsync(successAction, AuditEventType.Success, _selectedRequest, successMessage); } - // In case that is a bump, we return the old request status to pending confirmation. - await WithdrawalRequestService.RevertOriginalAsync(_selectedRequest); - await HideRejectCancelModal(); } } @@ -1337,7 +1345,7 @@ await LogWithdrawalAuditAsync(AuditActionType.Create, AuditEventType.Success, _selectedRequest, "Withdrawal request created."); // Moves the request to PSBTSignaturesPending (the status PerformWithdrawal requires), generates the template - // PSBT, marks a replaced request as Bumped and schedules PerformWithdrawalJob to sign and broadcast. + // PSBT and schedules PerformWithdrawalJob to sign and broadcast. await WithdrawalRequestService.ScheduleHotWalletWithdrawalAsync(_selectedRequest.Id); ToastService.ShowSuccess("Withdrawal scheduled for signing and broadcast"); diff --git a/src/Proto/nodeguard.proto b/src/Proto/nodeguard.proto index b34e7ff9..6cfe4587 100644 --- a/src/Proto/nodeguard.proto +++ b/src/Proto/nodeguard.proto @@ -24,8 +24,12 @@ service NodeGuardService { /* Replaces a withdrawal that is still waiting for on-chain confirmation with a higher-fee version of the same transaction (RBF): same inputs, same destinations, the extra fee is taken from the change. Cold wallets get a new - request that waits for approvals like RequestWithdrawal; hot wallets sign and broadcast it asynchronously and the - replaced request moves to WITHDRAWAL_BUMPED. + request that waits for approvals like RequestWithdrawal; hot wallets sign and broadcast it asynchronously. + The replaced request keeps its own status. While the replacement's transaction is in the mempool it is reported as + WITHDRAWAL_BUMPED with replaced_by_request_id set, and that becomes its stored status once the replacement settles. + If the replaced transaction is mined instead, it settles and the replacement fails. If the replacement is abandoned + (cancelled, rejected, failed) the replaced request reports WITHDRAWAL_PENDING_CONFIRMATION again. + Fails with FAILED_PRECONDITION when the request is not pending confirmation or already has a live replacement. */ rpc BumpWithdrawal(BumpWithdrawalRequest) returns (BumpWithdrawalResponse); @@ -33,7 +37,8 @@ service NodeGuardService { Cancels a withdrawal that is still waiting for on-chain confirmation by replacing its transaction (RBF) with one that spends the same inputs and returns the funds, minus a higher fee, to a fresh address of the wallet, so the original payment never confirms. Cold wallets get a new request that waits for approvals like RequestWithdrawal; - hot wallets sign and broadcast it asynchronously and the replaced request moves to WITHDRAWAL_CANCELLED. + hot wallets sign and broadcast it asynchronously. Same status rules as BumpWithdrawal, with the replaced request + reported and then stored as WITHDRAWAL_CANCELLED instead of WITHDRAWAL_BUMPED. */ rpc CancelWithdrawal(CancelWithdrawalRequest) returns (CancelWithdrawalResponse); /* @@ -92,7 +97,9 @@ service NodeGuardService { rpc GetWithdrawalsRequestStatus(GetWithdrawalsRequestStatusRequest) returns (GetWithdrawalsRequestStatusResponse); /* - Gets the status for the provided withdrawals request reference ids + Gets the status of every withdrawal request carrying one of the provided reference ids, oldest first. RBF + replacements inherit the reference id of the request they replace, so a reference id returns the whole chain: the + original and each of its bumps or cancellations, each with its own status and replaced_by_request_id. */ rpc GetWithdrawalsRequestStatusByReferenceIds(GetWithdrawalsRequestStatusByReferenceIdsRequest) returns (GetWithdrawalsRequestStatusResponse); @@ -518,6 +525,8 @@ enum WITHDRAWAL_REQUEST_STATUS { WITHDRAWAL_REJECTED = 3; WITHDRAWAL_PENDING_CONFIRMATION = 4; WITHDRAWAL_FAILED = 5; + // Superseded by an RBF replacement (see BumpWithdrawal): reported while the replacement's transaction is in the + // mempool, stored once it settles. The payment happened through the request in replaced_by_request_id. WITHDRAWAL_BUMPED = 6; } @@ -534,6 +543,9 @@ message WithdrawalRequest { optional string tx_id = 6; // in JSON format optional string request_metadata = 7; + // Newest RBF replacement of this request that was not abandoned, set as soon as it exists, even while it is still + // awaiting approval. Follow it to find the transaction that superseded (or will supersede) this one. + optional int32 replaced_by_request_id = 8; } message GetWithdrawalsRequestStatusResponse { diff --git a/src/Rpc/NodeGuardService.cs b/src/Rpc/NodeGuardService.cs index ed79302b..d3b0c225 100644 --- a/src/Rpc/NodeGuardService.cs +++ b/src/Rpc/NodeGuardService.cs @@ -1339,27 +1339,30 @@ private WITHDRAWAL_REQUEST_STATUS GetStatus(WalletWithdrawalRequestStatus status private async Task GetWithdrawalsRequestStatusResponse(List withdrawalRequests) { - var withdrawalsResponses = new List(); - foreach (var withdrawalRequest in withdrawalRequests) - { - ulong confirmations = 0; - if (withdrawalRequest.TxId != null) - { - var nbxplorerStatus = await _nbXplorerService.GetTransactionAsync(uint256.Parse(withdrawalRequest.TxId)); - confirmations = (ulong)(nbxplorerStatus?.Confirmations ?? 0); - } + var replacements = await _walletWithdrawalRequestRepository.GetReplacementsAsync(withdrawalRequests.Select(r => r.Id)); + + // A reference id resolves to its whole RBF chain: one NBXplorer round trip for all of them, not one each. + var confirmations = await Task.WhenAll(withdrawalRequests.Select(GetConfirmationsAsync)); - withdrawalsResponses.Add(new WithdrawalRequest + var withdrawalsResponses = withdrawalRequests.Zip(confirmations, (withdrawalRequest, confirmed) => + { + // A replaced request keeps its own stored status; what the client sees is derived from its replacements. + var (status, reason, replacedById) = + withdrawalRequest.EffectiveStatus(confirmed, new RbfReplacements(replacements[withdrawalRequest.Id])); + var response = new WithdrawalRequest { RequestId = withdrawalRequest.Id, - Status = GetStatus(withdrawalRequest.Status), - RejectOrCancelReason = withdrawalRequest.RejectCancelDescription ?? "", + Status = GetStatus(status), + RejectOrCancelReason = reason ?? "", ReferenceId = withdrawalRequest.ReferenceId ?? "", - Confirmations = confirmations, + Confirmations = confirmed, TxId = withdrawalRequest.TxId ?? "", RequestMetadata = withdrawalRequest.RequestMetadata ?? "", - }); - } + }; + if (replacedById is { } id) response.ReplacedByRequestId = id; + + return response; + }); return new GetWithdrawalsRequestStatusResponse() { @@ -1367,6 +1370,14 @@ private async Task GetWithdrawalsRequestSta }; } + private async Task GetConfirmationsAsync(WalletWithdrawalRequest withdrawalRequest) + { + if (withdrawalRequest.TxId == null) return 0; + + var nbxplorerStatus = await _nbXplorerService.GetTransactionAsync(uint256.Parse(withdrawalRequest.TxId)); + return (ulong)(nbxplorerStatus?.Confirmations ?? 0); + } + public override async Task GetWithdrawalsRequestStatus(GetWithdrawalsRequestStatusRequest request, ServerCallContext context) { var withdrawalRequests = await _walletWithdrawalRequestRepository.GetByIds(request.RequestIds.ToList()); diff --git a/src/Services/WithdrawalRequestService.cs b/src/Services/WithdrawalRequestService.cs index 686c9673..b348163f 100644 --- a/src/Services/WithdrawalRequestService.cs +++ b/src/Services/WithdrawalRequestService.cs @@ -34,6 +34,11 @@ namespace NodeGuard.Services; /// Keeping the orchestration here, rather than in BumpfeeModal.razor / Withdrawals.razor, lets the gRPC API expose it and the /// E2E suite exercise the same code the pages run. Hot-wallet requests are moved to PSBTSignaturesPending explicitly: it is /// the status PerformWithdrawal requires, and a hot wallet has no approvers whose signatures would get it there. +/// +/// RBF rule: a request's status only ever describes its own transaction. Creating, executing or abandoning a +/// replacement never writes the request it replaces; "replaced" is derived from the replacement +/// (), and the replaced request gets its final status +/// (Bumped, Cancelled, or OnChainConfirmed when it won the race) from . /// public interface IWithdrawalRequestService { @@ -50,34 +55,29 @@ Task CreateBumpRequestAsync(int originalRequestId, Memp /// /// Validates and creates the RBF CANCELLATION of a withdrawal that is waiting for on-chain confirmation: the same /// locked UTXOs, a higher fee rate, and a single output that returns the funds to a fresh address of the wallet, so - /// the original payment never confirms. Same status rules as ; once executed the - /// replaced request ends up Cancelled instead of Bumped. + /// the original payment never confirms. Same status rules as ; once the + /// cancellation confirms the replaced request ends up Cancelled instead of Bumped. /// Task CreateCancelRequestAsync(int originalRequestId, MempoolRecommendedFeesType feeType, decimal? customFeeRate); /// - /// Executes a hot-wallet request (fresh or bump): moves it to PSBTSignaturesPending, generates and persists the - /// template PSBT, marks the request it replaces (if any) as Bumped and schedules , - /// which signs and broadcasts. On failure the replaced request is put back to OnChainConfirmationPending and the - /// exception is rethrown; the caller decides what happens to the request itself. + /// Executes a hot-wallet request (fresh or replacement): moves it to PSBTSignaturesPending, generates and persists + /// the template PSBT and schedules , which signs and broadcasts. On failure the + /// exception is rethrown and the caller decides what happens to the request; the request it replaces, if any, is + /// never touched. /// /// The template PSBT; its global transaction hash is the txid that will be broadcast. Task ScheduleHotWalletWithdrawalAsync(int requestId); /// - /// Marks the request replaced by as Bumped (fee bump) or Cancelled (RBF cancellation). - /// Cold-wallet flow: called when the last human signature lands and the withdrawal job is scheduled. No-op when the - /// request is not a replacement. + /// Gives every live request linked by RBF its final status once its chain holds a confirmed transaction, from the + /// database alone. A request whose original (direct or through earlier replacements) confirmed fails: RBF does + /// not guarantee the replacement wins. A request one of whose replacements confirmed becomes Bumped, or + /// Cancelled when the confirmed replacement cancels it. Each request writes only its own row, so a failed write + /// is simply retried on the next run. Run by after it settles confirmations. /// - Task MarkOriginalAsReplacedAsync(WalletWithdrawalRequest replacement); - - /// - /// Puts the request replaced by back to OnChainConfirmationPending when the replacement - /// is abandoned (cancelled, rejected or failed) and the original had already been marked Bumped / Cancelled by it. - /// No-op otherwise. - /// - Task RevertOriginalAsync(WalletWithdrawalRequest replacement); + Task ReconcileReplacementChainsAsync(); } public class WithdrawalRequestService : IWithdrawalRequestService @@ -136,6 +136,14 @@ private async Task CreateReplacementAsync(int originalR BumpingErrorReason.InvalidState); } + var replacements = await _walletWithdrawalRequestRepository.GetReplacementsAsync(new[] { original.Id }); + if (new RbfReplacements(replacements[original.Id]).Live is { } live) + { + throw new BumpingException( + $"Withdrawal request {original.Id} is already being replaced by request {live.Id}; replace that request instead.", + BumpingErrorReason.InvalidState); + } + if (string.IsNullOrEmpty(original.TxId)) { throw new BumpingException("The withdrawal request has no transaction to replace.", @@ -328,11 +336,6 @@ public async Task ScheduleHotWalletWithdrawalAsync(int requestId) { var templatePsbt = await _bitcoinService.GenerateTemplatePSBT(request); - if (request.BumpingWalletWithdrawalRequestId != null) - { - await MarkOriginalAsReplacedAsync(request); - } - var scheduler = await _schedulerFactory.GetScheduler(); var map = new JobDataMap(); map.Put("withdrawalRequestId", request.Id); @@ -344,58 +347,93 @@ public async Task ScheduleHotWalletWithdrawalAsync(int requestId) catch (Exception e) { _logger.LogError(e, "Error executing withdrawal request {RequestId}", request.Id); - await RevertOriginalAsync(request); throw; } } - public async Task MarkOriginalAsReplacedAsync(WalletWithdrawalRequest replacement) + public async Task ReconcileReplacementChainsAsync() { - if (replacement.BumpingWalletWithdrawalRequestId is not { } originalId) return; + var liveIds = await _walletWithdrawalRequestRepository.GetLiveRbfLinkedRequestIdsAsync(); + var chains = new Dictionary>(); - var original = await _walletWithdrawalRequestRepository.GetById(originalId) - ?? throw new BumpingException("Could not find the replaced withdrawal request", - BumpingErrorReason.RequestNotFound); + foreach (var id in liveIds) + { + try + { + if (!chains.TryGetValue(id, out var chain)) + { + chain = (await _walletWithdrawalRequestRepository.GetRbfChainAsync(id)).ToDictionary(member => member.Id); + foreach (var memberId in chain.Keys) chains[memberId] = chain; + } - var targetStatus = replacement.IsRbfCancellation - ? WalletWithdrawalRequestStatus.Cancelled - : WalletWithdrawalRequestStatus.Bumped; - if (original.Status == targetStatus) return; + // The chain is the fresher read; a row that moved on in between is left to the next run. So is a + // FinalizingPSBT one: PerformWithdrawal writes that row whole when it broadcasts. + if (!chain.TryGetValue(id, out var request) || !request.IsLive + || request.Status == WalletWithdrawalRequestStatus.FinalizingPSBT) continue; - original.Status = targetStatus; - if (replacement.IsRbfCancellation) - { - original.RejectCancelDescription = CancellationReason(replacement.Id); - } + if (FinalStatusFromChain(request, chain) is not var (status, reason)) continue; - var (updated, error) = _walletWithdrawalRequestRepository.Update(original); - if (!updated) - { - throw new BumpingException($"Could not mark withdrawal request {originalId} as {targetStatus}: {error}", - BumpingErrorReason.PersistenceError); + request.Status = status; + request.RejectCancelDescription = reason; + var (updated, error) = _walletWithdrawalRequestRepository.Update(request); + if (updated) + { + _logger.LogInformation("Withdrawal request {RequestId} is {Status}: {Reason}", request.Id, status, reason); + } + else + { + _logger.LogError("Could not mark withdrawal request {RequestId} as {Status}: {Error}", request.Id, status, error); + } + } + catch (Exception e) + { + _logger.LogError(e, "Error reconciling withdrawal request {RequestId} against its RBF chain", id); + } } } - public async Task RevertOriginalAsync(WalletWithdrawalRequest replacement) + /// + /// What a live request becomes once its chain holds a confirmed transaction, or null while it does not. Every + /// member of a chain spends the original's inputs, so one confirming means no other can. A confirmed + /// descendant supersedes this request; a confirmed ancestor wins over it (only a reorg confirms both, and the + /// payment happened); any other confirmed member, a sibling branch, just means this one lost. + /// + private static (WalletWithdrawalRequestStatus Status, string Reason)? FinalStatusFromChain( + WalletWithdrawalRequest request, Dictionary chain) { - if (replacement.BumpingWalletWithdrawalRequestId is not { } originalId) return; + var confirmed = chain.Values.Where(member => member.Status == WalletWithdrawalRequestStatus.OnChainConfirmed).ToList(); + if (confirmed.Count == 0) return null; - var original = await _walletWithdrawalRequestRepository.GetById(originalId); - if (original == null) return; + if (Ancestors(request, chain).FirstOrDefault(a => a.Status == WalletWithdrawalRequestStatus.OnChainConfirmed) is { } minedBefore) + { + return (WalletWithdrawalRequestStatus.Failed, WalletWithdrawalRequest.MinedInsteadReason(minedBefore.Id, minedBefore.TxId)); + } - var markedByThisReplacement = original.Status == WalletWithdrawalRequestStatus.Bumped - || (replacement.IsRbfCancellation - && original.Status == WalletWithdrawalRequestStatus.Cancelled - && original.RejectCancelDescription == CancellationReason(replacement.Id)); - if (!markedByThisReplacement) return; + foreach (var descendant in confirmed) + { + var lineage = Ancestors(descendant, chain).ToList(); + var index = lineage.FindIndex(a => a.Id == request.Id); + if (index < 0) continue; + + // Cancelled iff a cancellation sits on the way: a bump of a cancellation still returns the funds, while a + // cancellation of a bump still means this request's destinations were never paid. + var cancellation = descendant.IsRbfCancellation || lineage.Take(index).Any(a => a.IsRbfCancellation); + return WalletWithdrawalRequest.SupersededBy(descendant, cancellation); + } - original.Status = WalletWithdrawalRequestStatus.OnChainConfirmationPending; - original.RejectCancelDescription = null; - var (updated, error) = _walletWithdrawalRequestRepository.Update(original); - if (!updated) + var sibling = confirmed[0]; + return (WalletWithdrawalRequestStatus.Failed, WalletWithdrawalRequest.MinedInsteadReason(sibling.Id, sibling.TxId)); + } + + /// What replaces, nearest first, following the link while it stays in the chain. + private static IEnumerable Ancestors(WalletWithdrawalRequest request, + Dictionary chain) + { + var visited = new HashSet { request.Id }; + var current = request; + while (current.BumpingWalletWithdrawalRequestId is { } id && visited.Add(id) && chain.TryGetValue(id, out current)) { - _logger.LogError("Could not put withdrawal request {RequestId} back to OnChainConfirmationPending: {Error}", - originalId, error); + yield return current; } } @@ -422,8 +460,6 @@ private async Task ResolveFeeRateAsync(MempoolRecommendedFeesType feeTy return recommended.Value; } - private static string CancellationReason(int replacementId) => $"Cancelled by RBF replacement request {replacementId}"; - /// /// Replacements of replacements keep a single "Bump of request N:" / "Cancel of request N:" prefix, pointing at the /// request being replaced. diff --git a/test/NodeGuard.Tests/Data/Repositories/WalletWithdrawalRequestRepositoryTests.cs b/test/NodeGuard.Tests/Data/Repositories/WalletWithdrawalRequestRepositoryTests.cs new file mode 100644 index 00000000..5f3f2883 --- /dev/null +++ b/test/NodeGuard.Tests/Data/Repositories/WalletWithdrawalRequestRepositoryTests.cs @@ -0,0 +1,188 @@ +/* + * 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 AutoMapper; +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging.Abstractions; +using NodeGuard.Data.Models; +using NodeGuard.Data.Repositories.Interfaces; +using NodeGuard.Services; + +namespace NodeGuard.Data.Repositories; + +/// +/// RBF replacements point at the request they replace through BumpingWalletWithdrawalRequestId, and a request's status +/// only describes its own transaction. These reads answer the questions built on that link: every request a reference +/// id maps to (the whole chain, each reporting its own status), the replacements of a request, the chain a request +/// belongs to, and which rows the monitor's reconciliation has to look at. +/// +public class WalletWithdrawalRequestRepositoryTests +{ + private const string Reference = "store-settlement-1"; + + private readonly Random _random = new(); + + private Mock> SetupDbContextFactory() + { + var dbContextFactory = new Mock>(); + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: "WalletWithdrawalRequestRepositoryTests" + _random.Next()) + .Options; + var context = () => new ApplicationDbContext(options); + dbContextFactory.Setup(x => x.CreateDbContext()).Returns(context); + dbContextFactory.Setup(x => x.CreateDbContextAsync(default)).ReturnsAsync(context); + return dbContextFactory; + } + + // NotificationService is not used by the reads under test. + private static WalletWithdrawalRequestRepository CreateRepository(Mock> factory) + => new(new Mock>().Object, + NullLogger.Instance, + factory.Object, + new Mock().Object, + null!, + new Mock().Object); + + private async Task SeedAsync(params WalletWithdrawalRequest[] requests) + { + var factory = SetupDbContextFactory(); + await using var context = await factory.Object.CreateDbContextAsync(); + await context.WalletWithdrawalRequests.AddRangeAsync(requests); + await context.SaveChangesAsync(); + return CreateRepository(factory); + } + + private static WalletWithdrawalRequest Request(int id, WalletWithdrawalRequestStatus status, int? replaces = null, + string reference = Reference) => new() + { + Id = id, + Description = "reference id test", + ReferenceId = reference, + Status = status, + BumpingWalletWithdrawalRequestId = replaces, + }; + + // ---- GetByReferenceIds --------------------------------------------------------------------------------------- + + [Fact] + public async Task GetByReferenceIds_ReturnsEveryRequestOfTheChainOldestFirst() + { + var repository = await SeedAsync( + Request(3, WalletWithdrawalRequestStatus.Failed, replaces: 2), + Request(1, WalletWithdrawalRequestStatus.OnChainConfirmed), + Request(2, WalletWithdrawalRequestStatus.Failed, replaces: 1), + Request(4, WalletWithdrawalRequestStatus.OnChainConfirmationPending, reference: "other")); + + var result = await repository.GetByReferenceIds(new List { Reference }); + + result.Select(r => r.Id).Should().Equal(new[] { 1, 2, 3 }, + "the original and each of its replacements carry the reference id; the caller sees what happened to each"); + } + + [Fact] + public async Task GetByReferenceIds_SeveralReferences_ReturnsAllOfEach() + { + var repository = await SeedAsync( + Request(1, WalletWithdrawalRequestStatus.OnChainConfirmed, reference: "a"), + Request(2, WalletWithdrawalRequestStatus.Failed, replaces: 1, reference: "a"), + Request(3, WalletWithdrawalRequestStatus.OnChainConfirmationPending, reference: "b"), + Request(4, WalletWithdrawalRequestStatus.Pending, reference: "c")); + + var result = await repository.GetByReferenceIds(new List { "a", "b" }); + + result.Select(r => r.Id).Should().Equal(new[] { 1, 2, 3 }); + } + + [Fact] + public async Task GetByReferenceIds_UnknownReference_ReturnsNothing() + { + var repository = await SeedAsync(Request(1, WalletWithdrawalRequestStatus.OnChainConfirmed)); + + var result = await repository.GetByReferenceIds(new List { "missing" }); + + result.Should().BeEmpty(); + } + + // ---- replacement lookups -------------------------------------------------------------------------------------- + + [Fact] + public async Task GetReplacementsAsync_GroupsTheReplacementsByTheRequestTheyReplace() + { + var repository = await SeedAsync( + Request(1, WalletWithdrawalRequestStatus.OnChainConfirmationPending), + Request(2, WalletWithdrawalRequestStatus.Failed, replaces: 1), + Request(3, WalletWithdrawalRequestStatus.OnChainConfirmationPending, replaces: 1), + Request(4, WalletWithdrawalRequestStatus.OnChainConfirmationPending), + Request(5, WalletWithdrawalRequestStatus.OnChainConfirmationPending, replaces: 4), + Request(6, WalletWithdrawalRequestStatus.OnChainConfirmationPending)); + + var replacements = await repository.GetReplacementsAsync(new[] { 1, 4, 6, 7 }); + + replacements[1].Select(r => r.Id).Should().BeEquivalentTo(new[] { 2, 3 }, "abandoned replacements are returned too"); + replacements[4].Select(r => r.Id).Should().Equal(new[] { 5 }); + replacements[6].Should().BeEmpty("a request with no replacements is simply absent"); + replacements.Select(group => group.Key).Should().BeEquivalentTo(new[] { 1, 4 }); + } + + [Fact] + public async Task GetRbfChainAsync_ReturnsTheWholeChainFromAnyMember() + { + var repository = await SeedAsync( + Request(1, WalletWithdrawalRequestStatus.OnChainConfirmationPending), + Request(2, WalletWithdrawalRequestStatus.OnChainConfirmationPending, replaces: 1), + Request(3, WalletWithdrawalRequestStatus.OnChainConfirmationPending, replaces: 2), + Request(4, WalletWithdrawalRequestStatus.Failed, replaces: 1), + Request(5, WalletWithdrawalRequestStatus.OnChainConfirmationPending)); + + var chain = await repository.GetRbfChainAsync(2); + + chain.Select(r => r.Id).Should().Equal(new[] { 1, 2, 3, 4 }, + "walking up finds what 2 replaced, walking down finds every replacement, including the abandoned sibling"); + } + + [Fact] + public async Task GetRbfChainAsync_UnlinkedRequest_ReturnsItself() + { + var repository = await SeedAsync( + Request(1, WalletWithdrawalRequestStatus.OnChainConfirmationPending), + Request(2, WalletWithdrawalRequestStatus.OnChainConfirmationPending)); + + var chain = await repository.GetRbfChainAsync(1); + + chain.Select(r => r.Id).Should().Equal(new[] { 1 }); + } + + [Fact] + public async Task GetLiveRbfLinkedRequestIdsAsync_ReturnsTheLiveRowsWithALinkOrReplacements() + { + var repository = await SeedAsync( + Request(1, WalletWithdrawalRequestStatus.OnChainConfirmationPending), + Request(2, WalletWithdrawalRequestStatus.FinalizingPSBT, replaces: 1), + Request(3, WalletWithdrawalRequestStatus.Pending), + Request(4, WalletWithdrawalRequestStatus.Failed, replaces: 1), + Request(5, WalletWithdrawalRequestStatus.OnChainConfirmed), + Request(6, WalletWithdrawalRequestStatus.PSBTSignaturesPending, replaces: 5)); + + var ids = await repository.GetLiveRbfLinkedRequestIdsAsync(); + + ids.Should().BeEquivalentTo(new[] { 1, 2, 6 }, + "1 is replaced, 2 and 6 replace; 3 is unlinked, 4 and 5 are not live"); + } +} diff --git a/test/NodeGuard.Tests/E2E/WithdrawalRbfBumpE2ETests.cs b/test/NodeGuard.Tests/E2E/WithdrawalRbfBumpE2ETests.cs index 8a9cd721..7ba32b58 100644 --- a/test/NodeGuard.Tests/E2E/WithdrawalRbfBumpE2ETests.cs +++ b/test/NodeGuard.Tests/E2E/WithdrawalRbfBumpE2ETests.cs @@ -29,10 +29,13 @@ namespace NodeGuard.Tests.E2E; /// /// End-to-end coverage for the RBF replacements of a hot-wallet withdrawal against a LIVE NodeGuard + bitcoind, driven /// over gRPC. Fee bump: RequestWithdrawal broadcasts an RBF-signalling transaction, BumpWithdrawal replaces it with a -/// higher-fee version of the same payment, bitcoind evicts the original, NodeGuard marks it WITHDRAWAL_BUMPED and, once -/// mined, settles the replacement. Cancellation: CancelWithdrawal replaces it with a transaction that returns the funds to -/// the wallet, the destination is never paid and the original ends up WITHDRAWAL_CANCELLED. The UI and the RPCs share -/// WithdrawalRequestService, so these scenarios exercise the same orchestration the Withdrawals page runs. +/// higher-fee version of the same payment, bitcoind evicts the original, NodeGuard reports it WITHDRAWAL_BUMPED while +/// the replacement is in the mempool and stores that once the replacement is mined and settled. Cancellation: +/// CancelWithdrawal replaces it with a transaction that returns the funds to the wallet, the destination is never paid +/// and the original ends up WITHDRAWAL_CANCELLED. Mined original: either replacement loses the race to the original, +/// which NodeGuard must then settle while failing every replacement, also when the original was bumped twice. The UI +/// and the RPCs share WithdrawalRequestService, so these scenarios exercise the same orchestration the Withdrawals +/// page runs. /// /// Shared plumbing in ; gated by . Env: see /// . Never mine between the withdrawal and its replacement — the original must @@ -46,6 +49,7 @@ public class WithdrawalRbfBumpE2ETests : E2ETestBase private const long WithdrawalAmountSats = 1_000_000; private const int OriginalFeeRateSatPerVb = 2; private const int BumpedFeeRateSatPerVb = 10; + private const int SecondBumpFeeRateSatPerVb = 20; private const long ProbeAmountSats = 2_000_000; public WithdrawalRbfBumpE2ETests(ITestOutputHelper output) : base(output) @@ -111,6 +115,7 @@ await PollAsync(() => GetStatusesAsync(client, headers, withdrawal.RequestId), && s[withdrawal.RequestId].Status == WITHDRAWAL_REQUEST_STATUS.WithdrawalBumped, attempts: 30, delay: TimeSpan.FromSeconds(2), what: "bump pending confirmation, original bumped"); statuses[bump.RequestId].TxId.Should().Be(bump.Txid); + statuses[withdrawal.RequestId].ReplacedByRequestId.Should().Be(bump.RequestId, "clients follow the chain by id"); // 4. Guards: a bumped request cannot be bumped again, a bump must raise the fee rate, and an unknown txid is not found. var bumpAgain = () => client.BumpWithdrawalAsync(new BumpWithdrawalRequest @@ -120,7 +125,7 @@ await PollAsync(() => GetStatusesAsync(client, headers, withdrawal.RequestId), CustomFeeRate = BumpedFeeRateSatPerVb + 5, }, headers).ResponseAsync; (await bumpAgain.Should().ThrowAsync()).Which.StatusCode.Should().Be(StatusCode.FailedPrecondition, - "the original is WITHDRAWAL_BUMPED, not pending confirmation"); + "the original already has a live replacement"); var bumpLower = () => client.BumpWithdrawalAsync(new BumpWithdrawalRequest { @@ -152,7 +157,8 @@ await PollAsync(() => GetStatusesAsync(client, headers, withdrawal.RequestId), s => s[bump.RequestId].Status == WITHDRAWAL_REQUEST_STATUS.WithdrawalSettled, attempts: 40, delay: TimeSpan.FromSeconds(5), what: "bump settled by MonitorWithdrawalsJob"); settled[withdrawal.RequestId].Status.Should().Be(WITHDRAWAL_REQUEST_STATUS.WithdrawalBumped, - "the replaced request stays bumped"); + "once the replacement settled, Bumped is the replaced request's stored, terminal status"); + settled[withdrawal.RequestId].RejectOrCancelReason.Should().Contain(bump.RequestId.ToString()); } [E2EFact] @@ -239,8 +245,191 @@ await RetryAsync(async () => }, attempts: 30, delay: TimeSpan.FromSeconds(4), what: "returned funds spendable again"); } + /// + /// A replacement is not guaranteed to win: a miner that never saw it can still mine the original. The original's + /// payment is then the one that happened, so NodeGuard must settle the original and fail the replacement, instead of + /// leaving the original Bumped / Cancelled while its transaction is confirmed on-chain. + /// + [E2ETheory] + [InlineData(false)] + [InlineData(true)] + public async Task Replacement_WhenTheOriginalIsMinedInstead_SettlesTheOriginalAndFailsTheReplacement(bool cancellation) + { + var client = CreateClient(out var headers); + var rpc = CreateBitcoindRpc(); + var walletId = await EnsureHotWalletReadyAsync(client, headers); + + // 1. A hot-wallet withdrawal at a low fee rate. Its signed transaction is kept: it is what the other miner mines. + var destination = await rpc.GetNewAddressAsync(); + var referenceId = $"e2e-rbf-mined-original-{Guid.NewGuid():N}"; + var withdrawal = await RequestHotWithdrawalAsync(client, headers, walletId, destination, + $"E2E RBF {(cancellation ? "cancel" : "bump")} mined-original test", referenceId); + var originalTxId = uint256.Parse(withdrawal.Txid); + var originalTx = await WaitForBroadcastAsync(rpc, originalTxId, "original withdrawal broadcast"); + + await PollAsync(() => GetStatusesAsync(client, headers, withdrawal.RequestId), + s => s[withdrawal.RequestId].Status == WITHDRAWAL_REQUEST_STATUS.WithdrawalPendingConfirmation, + attempts: 30, delay: TimeSpan.FromSeconds(2), what: "original pending confirmation"); + + // 2. Replace it: bitcoind evicts the original and NodeGuard marks it bumped / cancelled. + var (replacementId, replacementTxId) = await ReplaceAsync(client, headers, withdrawal.Txid, cancellation); + var replacedStatus = cancellation + ? WITHDRAWAL_REQUEST_STATUS.WithdrawalCancelled + : WITHDRAWAL_REQUEST_STATUS.WithdrawalBumped; + + await WaitForBroadcastAsync(rpc, replacementTxId, "replacement broadcast"); + await WaitUntilEvictedAsync(rpc, originalTxId); + await PollAsync(() => GetStatusesAsync(client, headers, withdrawal.RequestId, replacementId), + s => s[replacementId].Status == WITHDRAWAL_REQUEST_STATUS.WithdrawalPendingConfirmation + && s[withdrawal.RequestId].Status == replacedStatus, + attempts: 30, delay: TimeSpan.FromSeconds(2), what: $"replacement pending confirmation, original {replacedStatus}"); + + // 3. The original is mined instead, which evicts the replacement as a double spend. + await MineBypassingTheMempoolAsync(rpc, originalTx); + await MineAsync(rpc, 5); + + var originalInfo = await rpc.GetRawTransactionInfoAsync(originalTxId); + ((uint?)originalInfo.Confirmations ?? 0).Should().BeGreaterThan(0u, "the original was mined"); + (await rpc.GetRawTransactionAsync(replacementTxId, throwIfNotFound: false)).Should().BeNull( + "the replacement double-spends a confirmed transaction and can never confirm"); + + // 4. NodeGuard's view must follow the chain: the original settled, the replacement failed. + var settled = await PollAsync(() => GetStatusesAsync(client, headers, withdrawal.RequestId, replacementId), + s => + { + _output.WriteLine($"original {s[withdrawal.RequestId].Status}, replacement {s[replacementId].Status}"); + return s[withdrawal.RequestId].Status == WITHDRAWAL_REQUEST_STATUS.WithdrawalSettled + && s[replacementId].Status == WITHDRAWAL_REQUEST_STATUS.WithdrawalFailed; + }, + attempts: 40, delay: TimeSpan.FromSeconds(5), what: "original settled, replacement failed"); + + settled[withdrawal.RequestId].TxId.Should().Be(withdrawal.Txid, "the original's own transaction is the one that confirmed"); + settled[withdrawal.RequestId].Confirmations.Should().BeGreaterThan(0UL); + if (cancellation) + { + settled[withdrawal.RequestId].RejectOrCancelReason.Should().BeEmpty( + "the cancellation never took effect: the destination was paid"); + } + + await AssertReferenceReturnsTheChainAsync(client, headers, referenceId, withdrawal.RequestId, withdrawal.RequestId, replacementId); + } + + /// + /// The same race one bump deeper: A is bumped to B, B is bumped to C, and A is mined. NodeGuard must settle A and fail + /// both B and C: each double-spends the confirmed transaction and can never confirm. + /// + [E2EFact] + public async Task BumpOfABump_WhenTheFirstTransactionIsMined_SettlesItAndFailsBothReplacements() + { + var client = CreateClient(out var headers); + var rpc = CreateBitcoindRpc(); + var walletId = await EnsureHotWalletReadyAsync(client, headers); + + // 1. A: a hot-wallet withdrawal at a low fee rate. Its signed transaction is kept: it is what gets mined. + var destination = await rpc.GetNewAddressAsync(); + var referenceId = $"e2e-rbf-bump-of-a-bump-{Guid.NewGuid():N}"; + var first = await RequestHotWithdrawalAsync(client, headers, walletId, destination, + "E2E RBF bump-of-a-bump mined-first test", referenceId); + var firstTxId = uint256.Parse(first.Txid); + var firstTx = await WaitForBroadcastAsync(rpc, firstTxId, "first withdrawal broadcast"); + + await PollAsync(() => GetStatusesAsync(client, headers, first.RequestId), + s => s[first.RequestId].Status == WITHDRAWAL_REQUEST_STATUS.WithdrawalPendingConfirmation, + attempts: 30, delay: TimeSpan.FromSeconds(2), what: "first pending confirmation"); + + // 2. B bumps A. + var (secondId, secondTxId) = await ReplaceAsync(client, headers, first.Txid, cancellation: false); + await WaitForBroadcastAsync(rpc, secondTxId, "first bump broadcast"); + await WaitUntilEvictedAsync(rpc, firstTxId); + + await PollAsync(() => GetStatusesAsync(client, headers, first.RequestId, secondId), + s => s[secondId].Status == WITHDRAWAL_REQUEST_STATUS.WithdrawalPendingConfirmation + && s[first.RequestId].Status == WITHDRAWAL_REQUEST_STATUS.WithdrawalBumped, + attempts: 30, delay: TimeSpan.FromSeconds(2), what: "first bump pending confirmation, first bumped"); + + // 3. C bumps B at a higher fee rate again: three transactions over the same inputs, only C in the mempool. + var (thirdId, thirdTxId) = await ReplaceAsync(client, headers, secondTxId.ToString(), cancellation: false, + SecondBumpFeeRateSatPerVb); + var thirdTx = await WaitForBroadcastAsync(rpc, thirdTxId, "second bump broadcast"); + await WaitUntilEvictedAsync(rpc, secondTxId); + + thirdTx.Inputs.Select(i => i.PrevOut).Should().BeEquivalentTo(firstTx.Inputs.Select(i => i.PrevOut), + "every replacement spends the inputs of the first transaction"); + await PollAsync(() => GetStatusesAsync(client, headers, first.RequestId, secondId, thirdId), + s => s[thirdId].Status == WITHDRAWAL_REQUEST_STATUS.WithdrawalPendingConfirmation + && s[secondId].Status == WITHDRAWAL_REQUEST_STATUS.WithdrawalBumped + && s[first.RequestId].Status == WITHDRAWAL_REQUEST_STATUS.WithdrawalBumped, + attempts: 30, delay: TimeSpan.FromSeconds(2), what: "second bump pending confirmation, first and first bump bumped"); + + // 4. The oldest transaction, A, is mined, which evicts C as a double spend. + await MineBypassingTheMempoolAsync(rpc, firstTx); + await MineAsync(rpc, 5); + + var firstInfo = await rpc.GetRawTransactionInfoAsync(firstTxId); + ((uint?)firstInfo.Confirmations ?? 0).Should().BeGreaterThan(0u, "the first transaction was mined"); + (await rpc.GetRawTransactionAsync(secondTxId, throwIfNotFound: false)).Should().BeNull("B can never confirm"); + (await rpc.GetRawTransactionAsync(thirdTxId, throwIfNotFound: false)).Should().BeNull("C can never confirm"); + + // 5. NodeGuard's view must follow the chain: A settled, B and C failed. + var settled = await PollAsync(() => GetStatusesAsync(client, headers, first.RequestId, secondId, thirdId), + s => + { + _output.WriteLine($"A {s[first.RequestId].Status}, B {s[secondId].Status}, C {s[thirdId].Status}"); + return s[first.RequestId].Status == WITHDRAWAL_REQUEST_STATUS.WithdrawalSettled + && s[secondId].Status == WITHDRAWAL_REQUEST_STATUS.WithdrawalFailed + && s[thirdId].Status == WITHDRAWAL_REQUEST_STATUS.WithdrawalFailed; + }, + attempts: 40, delay: TimeSpan.FromSeconds(5), what: "first settled, both bumps failed"); + + settled[first.RequestId].TxId.Should().Be(first.Txid, "A's own transaction is the one that confirmed"); + settled[first.RequestId].Confirmations.Should().BeGreaterThan(0UL); + settled[secondId].RejectOrCancelReason.Should().Contain(first.RequestId.ToString(), + "B double-spends the confirmed transaction; the failure names the request that was mined instead"); + settled[thirdId].RejectOrCancelReason.Should().Contain(first.RequestId.ToString(), + "the failure names the request that was mined instead"); + + await AssertReferenceReturnsTheChainAsync(client, headers, referenceId, first.RequestId, first.RequestId, secondId, thirdId); + } + // ---- plumbing ------------------------------------------------------------------------------------------------ + /// Bumps or cancels at sat/vB. + private async Task<(int RequestId, uint256 TxId)> ReplaceAsync(NodeGuardService.NodeGuardServiceClient client, + Metadata headers, string txId, bool cancellation, int feeRate = BumpedFeeRateSatPerVb) + { + if (cancellation) + { + var cancel = await client.CancelWithdrawalAsync(new CancelWithdrawalRequest + { + TxId = txId, + MempoolFeeRate = FEES_TYPE.CustomFee, + CustomFeeRate = feeRate, + }, headers); + _output.WriteLine($"cancel request {cancel.RequestId} → txid {cancel.Txid}"); + return (cancel.RequestId, uint256.Parse(cancel.Txid)); + } + + var bump = await client.BumpWithdrawalAsync(new BumpWithdrawalRequest + { + TxId = txId, + MempoolFeeRate = FEES_TYPE.CustomFee, + CustomFeeRate = feeRate, + }, headers); + _output.WriteLine($"bump request {bump.RequestId} → txid {bump.Txid}"); + return (bump.RequestId, uint256.Parse(bump.Txid)); + } + + /// + /// Mines as a miner that never saw its replacements would: generateblock takes the raw + /// transaction, bypassing the mempool the replacement sits in. + /// + private async Task MineBypassingTheMempoolAsync(RPCClient rpc, Transaction tx) + { + var minerAddress = await rpc.GetNewAddressAsync(); + var block = await rpc.SendCommandAsync("generateblock", minerAddress.ToString(), new[] { tx.ToHex() }); + _output.WriteLine($"{tx.GetHash()} mined in block {block.Result["hash"]}"); + } + /// NodeGuard up and the dev hot wallet spendable (DbInitializer funds it; another e2e may hold it briefly). private async Task EnsureHotWalletReadyAsync(NodeGuardService.NodeGuardServiceClient client, Metadata headers) { @@ -266,16 +455,19 @@ await RetryAsync(async () => } private async Task RequestHotWithdrawalAsync(NodeGuardService.NodeGuardServiceClient client, - Metadata headers, int walletId, BitcoinAddress destination, string description) + Metadata headers, int walletId, BitcoinAddress destination, string description, string? referenceId = null) { - var withdrawal = await client.RequestWithdrawalAsync(new RequestWithdrawalRequest + var request = new RequestWithdrawalRequest { WalletId = walletId, Description = description, Destinations = { new Destination { Address = destination.ToString(), AmountSats = WithdrawalAmountSats } }, MempoolFeeRate = FEES_TYPE.CustomFee, CustomFeeRate = OriginalFeeRateSatPerVb, - }, headers); + }; + if (referenceId != null) request.ReferenceId = referenceId; + + var withdrawal = await client.RequestWithdrawalAsync(request, headers); _output.WriteLine($"withdrawal request {withdrawal.RequestId} → txid {withdrawal.Txid}"); withdrawal.IsHotWallet.Should().BeTrue(); return withdrawal; @@ -299,6 +491,26 @@ await RetryAsync(async () => }, attempts: 15, delay: TimeSpan.FromSeconds(2), what: "original evicted by the replacement"); } + /// + /// What a caller tracking the withdrawal by reference id sees: every request of the chain, oldest first, each with + /// its own status. The one whose transaction confirmed reads as settled, and every replacement of it reads as + /// failed, so a paid withdrawal never reads as failed and the caller can tell which transaction paid it. + /// + private static async Task AssertReferenceReturnsTheChainAsync(NodeGuardService.NodeGuardServiceClient client, + Metadata headers, string referenceId, int settledRequestId, params int[] chainRequestIds) + { + var response = await client.GetWithdrawalsRequestStatusByReferenceIdsAsync( + new GetWithdrawalsRequestStatusByReferenceIdsRequest { ReferenceIds = { referenceId } }, headers); + + response.WithdrawalRequests.Select(r => r.RequestId).Should().Equal(chainRequestIds, + "every request carrying the reference id is returned, oldest first"); + response.WithdrawalRequests.Single(r => r.RequestId == settledRequestId).Status + .Should().Be(WITHDRAWAL_REQUEST_STATUS.WithdrawalSettled, "the request whose transaction confirmed is the payment that happened"); + response.WithdrawalRequests.Where(r => r.RequestId != settledRequestId) + .Should().OnlyContain(r => r.Status == WITHDRAWAL_REQUEST_STATUS.WithdrawalFailed, + "every replacement double-spends the confirmed transaction"); + } + private static async Task> GetStatusesAsync( NodeGuardService.NodeGuardServiceClient client, Metadata headers, params int[] requestIds) { diff --git a/test/NodeGuard.Tests/Jobs/MonitorWithdrawalsJobTests.cs b/test/NodeGuard.Tests/Jobs/MonitorWithdrawalsJobTests.cs new file mode 100644 index 00000000..25c0855b --- /dev/null +++ b/test/NodeGuard.Tests/Jobs/MonitorWithdrawalsJobTests.cs @@ -0,0 +1,188 @@ +/* + * 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 FluentAssertions; +using Microsoft.Extensions.Logging.Abstractions; +using NBitcoin; +using NBXplorer.Models; +using NodeGuard.Data.Models; +using NodeGuard.Data.Repositories.Interfaces; +using NodeGuard.Helpers; +using NodeGuard.Services; +using Quartz; + +namespace NodeGuard.Jobs; + +/// +/// MonitorWithdrawalsJob settles withdrawals whose transaction reached the settlement depth, one row at a time so a +/// bad row never stops the batch, and then hands over to WithdrawalRequestService.ReconcileReplacementChainsAsync, +/// which gives RBF-linked requests their final status from the database. The order matters: a request settled in this +/// run must drive its chain in this run. +/// +public class MonitorWithdrawalsJobTests +{ + private readonly Mock _requests = new(); + private readonly Mock _nbXplorer = new(); + private readonly Mock _withdrawalRequestService = new(); + private readonly List _events = new(); + + public MonitorWithdrawalsJobTests() + { + _requests.Setup(x => x.Update(It.IsAny())) + .Callback(r => _events.Add($"settle:{r.Id}")) + .Returns((true, (string?)null)); + _withdrawalRequestService.Setup(x => x.ReconcileReplacementChainsAsync()) + .Callback(() => _events.Add("reconcile")) + .Returns(Task.CompletedTask); + } + + private Task ExecuteAsync(params WalletWithdrawalRequest[] pending) + { + _requests.Setup(x => x.GetOnChainPendingWithdrawals()).ReturnsAsync(pending.ToList()); + + var job = new MonitorWithdrawalsJob(NullLogger.Instance, _requests.Object, + _nbXplorer.Object, _withdrawalRequestService.Object); + return job.Execute(new Mock().Object); + } + + /// A broadcast request waiting for confirmation; NBXplorer answers for it. + private WalletWithdrawalRequest Pending(int id, Func transaction) + { + var request = new WalletWithdrawalRequest + { + Id = id, + Status = WalletWithdrawalRequestStatus.OnChainConfirmationPending, + TxId = new uint256((ulong)id).ToString(), + }; + _nbXplorer.Setup(x => x.GetTransactionAsync(uint256.Parse(request.TxId), It.IsAny())) + .ReturnsAsync(transaction); + return request; + } + + private WalletWithdrawalRequest Pending(int id, int confirmations) + => Pending(id, () => new TransactionResult { Confirmations = confirmations }); + + [Fact] + public async Task Execute_WithdrawalAtTheSettlementDepth_IsSettled() + { + var request = Pending(1, Constants.TRANSACTION_CONFIRMATION_MINIMUM_BLOCKS); + + await ExecuteAsync(request); + + request.Status.Should().Be(WalletWithdrawalRequestStatus.OnChainConfirmed); + _requests.Verify(x => x.Update(request), Times.Once); + } + + [Fact] + public async Task Execute_ReplacementAtTheSettlementDepth_IsSettled() + { + var replacement = Pending(2, Constants.TRANSACTION_CONFIRMATION_MINIMUM_BLOCKS); + replacement.BumpingWalletWithdrawalRequestId = 1; + + await ExecuteAsync(replacement); + + replacement.Status.Should().Be(WalletWithdrawalRequestStatus.OnChainConfirmed, + "what that means for the request it replaced is the reconciliation's job"); + } + + [Theory] + [InlineData(0)] + [InlineData(1)] + public async Task Execute_WithdrawalBelowTheSettlementDepth_IsLeftPending(int confirmations) + { + var request = Pending(1, confirmations); + + await ExecuteAsync(request); + + request.Status.Should().Be(WalletWithdrawalRequestStatus.OnChainConfirmationPending); + _requests.Verify(x => x.Update(It.IsAny()), Times.Never); + } + + [Fact] + public async Task Execute_TransactionUnknownToNbxplorer_LeavesTheRequestPendingAndContinues() + { + var unknown = Pending(1, () => null); + var settling = Pending(2, Constants.TRANSACTION_CONFIRMATION_MINIMUM_BLOCKS); + + await ExecuteAsync(unknown, settling); + + unknown.Status.Should().Be(WalletWithdrawalRequestStatus.OnChainConfirmationPending); + settling.Status.Should().Be(WalletWithdrawalRequestStatus.OnChainConfirmed); + } + + [Fact] + public async Task Execute_NbxplorerFailureOnOneRequest_DoesNotAbortTheBatch() + { + var failing = Pending(1, () => throw new HttpRequestException("nbxplorer down")); + var settling = Pending(2, Constants.TRANSACTION_CONFIRMATION_MINIMUM_BLOCKS); + + var act = () => ExecuteAsync(failing, settling); + + await act.Should().NotThrowAsync(); + settling.Status.Should().Be(WalletWithdrawalRequestStatus.OnChainConfirmed); + _events.Should().Contain("reconcile"); + } + + [Fact] + public async Task Execute_RequestWithoutTransaction_IsSkipped() + { + var request = Pending(1, confirmations: 0); + request.TxId = null; + + await ExecuteAsync(request); + + _nbXplorer.Verify(x => x.GetTransactionAsync(It.IsAny(), It.IsAny()), Times.Never); + _requests.Verify(x => x.Update(It.IsAny()), Times.Never); + } + + [Fact] + public async Task Execute_ReconcilesTheRbfChainsAfterSettling() + { + var settling = Pending(1, Constants.TRANSACTION_CONFIRMATION_MINIMUM_BLOCKS); + var inFlight = Pending(2, confirmations: 0); + inFlight.BumpingWalletWithdrawalRequestId = 1; + + await ExecuteAsync(settling, inFlight); + + _events.Should().Equal(new[] { "settle:1", "reconcile" }, + "a request settled in this run must drive its RBF chain in this run"); + inFlight.Status.Should().Be(WalletWithdrawalRequestStatus.OnChainConfirmationPending, + "failing it is the reconciliation's decision, not the job's"); + } + + [Fact] + public async Task Execute_NothingPending_StillReconciles() + { + await ExecuteAsync(); + + _withdrawalRequestService.Verify(x => x.ReconcileReplacementChainsAsync(), Times.Once, + "a cold replacement awaiting approval is not pending confirmation, yet its original may have confirmed"); + } + + [Fact] + public async Task Execute_ReconciliationThrowing_IsLoggedNotRethrown() + { + _withdrawalRequestService.Setup(x => x.ReconcileReplacementChainsAsync()) + .ThrowsAsync(new InvalidOperationException("db down")); + + var act = () => ExecuteAsync(); + + await act.Should().NotThrowAsync(); + } +} diff --git a/test/NodeGuard.Tests/Jobs/PerformWithdrawalJobTests.cs b/test/NodeGuard.Tests/Jobs/PerformWithdrawalJobTests.cs new file mode 100644 index 00000000..0f9103c7 --- /dev/null +++ b/test/NodeGuard.Tests/Jobs/PerformWithdrawalJobTests.cs @@ -0,0 +1,140 @@ +/* + * 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 FluentAssertions; +using Microsoft.Extensions.Logging.Abstractions; +using NodeGuard.Data.Models; +using NodeGuard.Data.Repositories.Interfaces; +using NodeGuard.Services; +using Quartz; + +namespace NodeGuard.Jobs; + +/// +/// When PerformWithdrawalJob cannot sign or broadcast, the request fails and nothing else happens: a request's status +/// only describes its own transaction, so the request an RBF replacement replaced never left pending confirmation and +/// needs no revert. Its inputs are still locked by it, and it is reported as pending again as soon as the replacement +/// is no longer live. +/// +public class PerformWithdrawalJobTests +{ + private const int OriginalId = 10; + private const int ReplacementId = 11; + + private readonly Mock _requests = new(); + private readonly Mock _bitcoin = new(); + + public PerformWithdrawalJobTests() + { + _requests.Setup(x => x.Update(It.IsAny())).Returns((true, (string?)null)); + _bitcoin.Setup(x => x.PerformWithdrawal(It.IsAny())) + .ThrowsAsync(new InvalidOperationException("Failed tx broadcast")); + } + + private Task ExecuteAsync(int requestId) + { + var job = new PerformWithdrawalJob(NullLogger.Instance, _bitcoin.Object, _requests.Object); + + var context = new Mock(); + context.Setup(x => x.JobDetail).Returns(JobBuilder.Create() + .UsingJobData("withdrawalRequestId", requestId) + .Build()); + return job.Execute(context.Object); + } + + private WalletWithdrawalRequest Stored(int id, WalletWithdrawalRequestStatus status, int? replaces = null, + bool cancellation = false) + { + var request = new WalletWithdrawalRequest + { + Id = id, + Description = "perform withdrawal test", + Status = status, + BumpingWalletWithdrawalRequestId = replaces, + IsRbfCancellation = cancellation, + }; + _requests.Setup(x => x.GetById(id)).ReturnsAsync(request); + return request; + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Execute_FailedReplacement_FailsOnlyTheReplacement(bool cancellation) + { + var original = Stored(OriginalId, WalletWithdrawalRequestStatus.OnChainConfirmationPending); + var replacement = Stored(ReplacementId, WalletWithdrawalRequestStatus.FinalizingPSBT, replaces: OriginalId, cancellation); + + await ExecuteAsync(ReplacementId); + + replacement.Status.Should().Be(WalletWithdrawalRequestStatus.Failed); + original.Status.Should().Be(WalletWithdrawalRequestStatus.OnChainConfirmationPending, + "the replacement never went out, so the original transaction is still the one waiting to confirm"); + _requests.Verify(x => x.Update(original), Times.Never, "a replacement never writes the request it replaces"); + _requests.Verify(x => x.Update(It.IsAny()), Times.Once); + } + + [Fact] + public async Task Execute_FailedReplacementOfAnOriginalThatAlreadySettled_LeavesItSettled() + { + var original = Stored(OriginalId, WalletWithdrawalRequestStatus.OnChainConfirmed); + var bump = Stored(ReplacementId, WalletWithdrawalRequestStatus.FinalizingPSBT, replaces: OriginalId); + + await ExecuteAsync(ReplacementId); + + bump.Status.Should().Be(WalletWithdrawalRequestStatus.Failed); + original.Status.Should().Be(WalletWithdrawalRequestStatus.OnChainConfirmed, + "it was mined while the replacement waited for its signatures; that is why the broadcast failed"); + _requests.Verify(x => x.Update(original), Times.Never); + } + + [Fact] + public async Task Execute_FailedWithdrawalThatReplacesNothing_IsMarkedFailed() + { + var request = Stored(OriginalId, WalletWithdrawalRequestStatus.FinalizingPSBT); + + await ExecuteAsync(OriginalId); + + request.Status.Should().Be(WalletWithdrawalRequestStatus.Failed); + _requests.Verify(x => x.Update(It.IsAny()), Times.Once); + } + + [Fact] + public async Task Execute_RequestGoneByTheTimeItFails_DoesNotThrow() + { + var act = () => ExecuteAsync(OriginalId); + + await act.Should().NotThrowAsync(); + _requests.Verify(x => x.Update(It.IsAny()), Times.Never); + } + + [Fact] + public async Task Execute_SuccessfulBroadcast_LeavesTheReplacedRequestAsItWas() + { + _bitcoin.Setup(x => x.PerformWithdrawal(It.IsAny())).Returns(Task.CompletedTask); + var original = Stored(OriginalId, WalletWithdrawalRequestStatus.OnChainConfirmationPending); + Stored(ReplacementId, WalletWithdrawalRequestStatus.FinalizingPSBT, replaces: OriginalId); + + await ExecuteAsync(ReplacementId); + + original.Status.Should().Be(WalletWithdrawalRequestStatus.OnChainConfirmationPending, + "whether it ends up Bumped or settled is for the monitor to decide once a transaction confirms"); + _requests.Verify(x => x.Update(It.IsAny()), Times.Never); + } +} diff --git a/test/NodeGuard.Tests/Rpc/NodeGuardServiceTests.cs b/test/NodeGuard.Tests/Rpc/NodeGuardServiceTests.cs index 26421b2a..d238101e 100644 --- a/test/NodeGuard.Tests/Rpc/NodeGuardServiceTests.cs +++ b/test/NodeGuard.Tests/Rpc/NodeGuardServiceTests.cs @@ -992,6 +992,166 @@ public async Task BumpWithdrawal_RefusedByTheService_MapsTheReasonToAStatusCode( exception.Status.Detail.Should().Be("refused", "the user-facing reason travels to the API caller"); } + // ---- GetWithdrawalsRequestStatus: a replaced request keeps its own status; the API derives what it reports ---- + + private const string OriginalTxId = "5f59c45bc3fa8362d5c38bce5c633bb901a57f268ec75e55548c00d2baec0d65"; + private const string ReplacementTxId = "3a9907ab1b965e1ad0024e7fe4466ddab0d3b1bf3bd15ab6416bf8f0c9032f84"; + + private static WalletWithdrawalRequest Original() => new() + { + Id = 10, + Status = WalletWithdrawalRequestStatus.OnChainConfirmationPending, + TxId = OriginalTxId, + ReferenceId = "store-1", + Description = "pay rent", + }; + + private static WalletWithdrawalRequest Replacement(WalletWithdrawalRequestStatus status, bool cancellation = false) => new() + { + Id = 11, + Status = status, + TxId = ReplacementTxId, + ReferenceId = "store-1", + Description = "bump", + BumpingWalletWithdrawalRequestId = 10, + IsRbfCancellation = cancellation, + }; + + /// A repository holding exactly , answering the status lookups from them. + private static Mock Repository(params WalletWithdrawalRequest[] requests) + { + var repository = new Mock(); + repository.Setup(x => x.GetByIds(It.IsAny>())) + .ReturnsAsync((List ids) => requests.Where(r => ids.Contains(r.Id)).ToList()); + repository.Setup(x => x.GetByReferenceIds(It.IsAny>())) + .ReturnsAsync((List references) => requests.Where(r => references.Contains(r.ReferenceId!)).OrderBy(r => r.Id).ToList()); + repository.Setup(x => x.GetReplacementsAsync(It.IsAny>())) + .ReturnsAsync((IEnumerable ids) => requests + .Where(r => r.BumpingWalletWithdrawalRequestId is { } replacedId && ids.Contains(replacedId)) + .ToLookup(r => r.BumpingWalletWithdrawalRequestId!.Value)); + return repository; + } + + private NodeGuardService StatusService(Mock walletWithdrawalRequestRepository, int confirmations) + { + var nbxplorerService = new Mock(); + nbxplorerService.Setup(x => x.GetTransactionAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new TransactionResult { Confirmations = confirmations }); + + return CreateNodeGuardService( + walletWithdrawalRequestRepository: walletWithdrawalRequestRepository.Object, + nbXplorerService: nbxplorerService.Object); + } + + /// The status of the original (id 10) as reported by id, with in the repository. + private async Task GetStatusAsync(int confirmations, params WalletWithdrawalRequest[] requests) + { + var response = await StatusService(Repository(requests), confirmations).GetWithdrawalsRequestStatus( + new GetWithdrawalsRequestStatusRequest { RequestIds = { 10 } }, TestServerCallContext.Create()); + return response.WithdrawalRequests.Single(); + } + + [Fact] + public async Task GetWithdrawalsRequestStatus_OriginalWithABroadcastReplacement_ReportsBumpedAndTheReplacementId() + { + var status = await GetStatusAsync(0, Original(), Replacement(WalletWithdrawalRequestStatus.OnChainConfirmationPending)); + + status.Status.Should().Be(WITHDRAWAL_REQUEST_STATUS.WithdrawalBumped, + "the replacement's transaction competes with its own in the mempool"); + status.ReplacedByRequestId.Should().Be(11); + status.RejectOrCancelReason.Should().Be($"Replaced by request 11 (tx {ReplacementTxId})", + "the same text it will be stored with once the replacement settles"); + } + + [Fact] + public async Task GetWithdrawalsRequestStatus_OriginalWithABroadcastCancellation_ReportsCancelledWithTheReason() + { + var status = await GetStatusAsync(0, Original(), Replacement(WalletWithdrawalRequestStatus.OnChainConfirmationPending, cancellation: true)); + + status.Status.Should().Be(WITHDRAWAL_REQUEST_STATUS.WithdrawalCancelled); + status.RejectOrCancelReason.Should().Be($"Cancelled by RBF replacement request 11 (tx {ReplacementTxId})"); + status.ReplacedByRequestId.Should().Be(11); + } + + [Theory] + [InlineData(WalletWithdrawalRequestStatus.Pending)] + [InlineData(WalletWithdrawalRequestStatus.PSBTSignaturesPending)] + public async Task GetWithdrawalsRequestStatus_OriginalWithAReplacementAwaitingApproval_ReportsPendingConfirmation( + WalletWithdrawalRequestStatus replacementStatus) + { + var status = await GetStatusAsync(0, Original(), Replacement(replacementStatus)); + + status.Status.Should().Be(WITHDRAWAL_REQUEST_STATUS.WithdrawalPendingConfirmation, + "its own transaction is still the only candidate in the mempool and may well confirm"); + status.ReplacedByRequestId.Should().Be(11, "clients can already see the replacement being prepared"); + } + + [Theory] + [InlineData(WalletWithdrawalRequestStatus.Failed)] + [InlineData(WalletWithdrawalRequestStatus.Cancelled)] + [InlineData(WalletWithdrawalRequestStatus.Rejected)] + public async Task GetWithdrawalsRequestStatus_OriginalWithAnAbandonedReplacement_ReportsPendingConfirmation( + WalletWithdrawalRequestStatus replacementStatus) + { + var status = await GetStatusAsync(0, Original(), Replacement(replacementStatus)); + + status.Status.Should().Be(WITHDRAWAL_REQUEST_STATUS.WithdrawalPendingConfirmation); + status.HasReplacedByRequestId.Should().BeFalse(); + } + + [Fact] + public async Task GetWithdrawalsRequestStatus_OriginalWithOwnConfirmations_IsNotReportedAsBumped() + { + var status = await GetStatusAsync(1, Original(), Replacement(WalletWithdrawalRequestStatus.OnChainConfirmationPending)); + + status.Status.Should().Be(WITHDRAWAL_REQUEST_STATUS.WithdrawalPendingConfirmation, + "it was mined: the replacement is the one that can never confirm"); + status.Confirmations.Should().Be(1UL); + } + + [Fact] + public async Task GetWithdrawalsRequestStatus_StoredBumpedRow_ReportsBumped() + { + var original = Original(); + original.Status = WalletWithdrawalRequestStatus.Bumped; + original.RejectCancelDescription = WalletWithdrawalRequest.ReplacedReason(11, ReplacementTxId); + + var status = await GetStatusAsync(0, original, Replacement(WalletWithdrawalRequestStatus.OnChainConfirmed)); + + status.Status.Should().Be(WITHDRAWAL_REQUEST_STATUS.WithdrawalBumped); + status.RejectOrCancelReason.Should().Be($"Replaced by request 11 (tx {ReplacementTxId})"); + status.ReplacedByRequestId.Should().Be(11); + } + + [Fact] + public async Task GetWithdrawalsRequestStatusByReferenceIds_ReturnsEveryRequestOfTheChain() + { + var repository = Repository(Original(), Replacement(WalletWithdrawalRequestStatus.OnChainConfirmationPending)); + + var response = await StatusService(repository, confirmations: 0).GetWithdrawalsRequestStatusByReferenceIds( + new GetWithdrawalsRequestStatusByReferenceIdsRequest { ReferenceIds = { "store-1" } }, TestServerCallContext.Create()); + + response.WithdrawalRequests.Select(r => (r.RequestId, r.Status)).Should().Equal( + (10, WITHDRAWAL_REQUEST_STATUS.WithdrawalBumped), + (11, WITHDRAWAL_REQUEST_STATUS.WithdrawalPendingConfirmation)); + response.WithdrawalRequests[0].ReplacedByRequestId.Should().Be(11, "the caller can follow the chain"); + response.WithdrawalRequests[1].HasReplacedByRequestId.Should().BeFalse(); + } + + [Fact] + public void WalletWithdrawalRequestSelfMap_DropsTheReplacedRequestNavigation() + { + var original = Original(); + original.BumpingWalletWithdrawalRequestId = 9; + original.BumpingWalletWithdrawalRequest = new WalletWithdrawalRequest { Id = 9, Description = "older" }; + + var copy = _mockMapper.Map(original); + + copy.BumpingWalletWithdrawalRequest.Should().BeNull("Repository.Update attaches the mapped graph; the replaced request must never ride along"); + copy.BumpingWalletWithdrawalRequestId.Should().Be(9); + copy.Status.Should().Be(original.Status); + } + [Fact] public async Task BumpWithdrawal_SchedulingFails_CancelsTheBumpRequest() { diff --git a/test/NodeGuard.Tests/Services/WithdrawalRequestServiceTests.cs b/test/NodeGuard.Tests/Services/WithdrawalRequestServiceTests.cs index 80448162..8f6f3954 100644 --- a/test/NodeGuard.Tests/Services/WithdrawalRequestServiceTests.cs +++ b/test/NodeGuard.Tests/Services/WithdrawalRequestServiceTests.cs @@ -71,6 +71,7 @@ public WithdrawalRequestServiceTests() .ReturnsAsync((true, (string?)null)); _requests.Setup(x => x.GetById(BumpId)).ReturnsAsync(() => _storedBump); _requests.Setup(x => x.Update(It.IsAny())).Returns((true, (string?)null)); + ReplacementsOf(); _coinSelection.Setup(x => x.GetUTXOsByOutpointAsync(It.IsAny(), It.IsAny>())) .ReturnsAsync((DerivationStrategyBase _, List outpoints) => @@ -395,48 +396,25 @@ public async Task ScheduleHotWalletWithdrawalAsync_RequestAlreadyPsbtSignaturesP _scheduler.Verify(x => x.ScheduleJob(It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); } - [Fact] - public async Task ScheduleHotWalletWithdrawalAsync_Bump_MarksTheReplacedRequestAsBumped() - { - var original = SetupOriginal(HotWallet()); - HotRequest(WalletWithdrawalRequestStatus.PSBTSignaturesPending, bumping: OriginalId); - - await CreateService().ScheduleHotWalletWithdrawalAsync(BumpId); - - original.Status.Should().Be(WalletWithdrawalRequestStatus.Bumped); - _requests.Verify(x => x.Update(It.Is(r => r.Id == OriginalId - && r.Status == WalletWithdrawalRequestStatus.Bumped)), Times.Once); - } - - [Fact] - public async Task ScheduleHotWalletWithdrawalAsync_Cancellation_MarksTheReplacedRequestCancelledWithAReason() + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task ScheduleHotWalletWithdrawalAsync_Replacement_NeverWritesTheReplacedRequest(bool cancellation) { var original = SetupOriginal(HotWallet()); - HotRequest(WalletWithdrawalRequestStatus.PSBTSignaturesPending, bumping: OriginalId, cancellation: true); + HotRequest(WalletWithdrawalRequestStatus.PSBTSignaturesPending, bumping: OriginalId, cancellation); await CreateService().ScheduleHotWalletWithdrawalAsync(BumpId); - original.Status.Should().Be(WalletWithdrawalRequestStatus.Cancelled, "the payment is not happening, it is not merely re-fed"); - original.RejectCancelDescription.Should().Contain("RBF").And.Contain(BumpId.ToString()); - } - - [Fact] - public async Task ScheduleHotWalletWithdrawalAsync_CancellationSchedulingFails_RestoresTheReplacedRequest() - { - var original = SetupOriginal(HotWallet()); - HotRequest(WalletWithdrawalRequestStatus.PSBTSignaturesPending, bumping: OriginalId, cancellation: true); - _scheduler.Setup(x => x.ScheduleJob(It.IsAny(), It.IsAny(), It.IsAny())) - .ThrowsAsync(new InvalidOperationException("quartz down")); - - var act = () => CreateService().ScheduleHotWalletWithdrawalAsync(BumpId); - - await act.Should().ThrowAsync(); - original.Status.Should().Be(WalletWithdrawalRequestStatus.OnChainConfirmationPending); + original.Status.Should().Be(WalletWithdrawalRequestStatus.OnChainConfirmationPending, + "its own transaction is still in the mempool; Bumped / Cancelled is derived from the replacement until it confirms"); original.RejectCancelDescription.Should().BeNull(); + _requests.Verify(x => x.Update(It.Is(r => r.Id == OriginalId)), Times.Never); + _scheduler.Verify(x => x.ScheduleJob(It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); } [Fact] - public async Task ScheduleHotWalletWithdrawalAsync_SchedulingFails_RevertsTheReplacedRequestAndRethrows() + public async Task ScheduleHotWalletWithdrawalAsync_SchedulingFails_RethrowsWithoutTouchingTheReplacedRequest() { var original = SetupOriginal(HotWallet()); HotRequest(WalletWithdrawalRequestStatus.PSBTSignaturesPending, bumping: OriginalId); @@ -446,8 +424,9 @@ public async Task ScheduleHotWalletWithdrawalAsync_SchedulingFails_RevertsTheRep var act = () => CreateService().ScheduleHotWalletWithdrawalAsync(BumpId); await act.Should().ThrowAsync().WithMessage("quartz down"); - original.Status.Should().Be(WalletWithdrawalRequestStatus.OnChainConfirmationPending, - "an abandoned bump must give the replaced request back its pending-confirmation status"); + original.Status.Should().Be(WalletWithdrawalRequestStatus.OnChainConfirmationPending); + _requests.Verify(x => x.Update(It.Is(r => r.Id == OriginalId)), Times.Never, + "nothing was written to it, so there is nothing to revert"); } [Fact] @@ -490,46 +469,298 @@ public async Task ScheduleHotWalletWithdrawalAsync_WrongStatus_IsRefused(WalletW await act.Should().ThrowAsync(); } - // ---- MarkOriginalAsBumpedAsync / RevertOriginalAsync ------------------------------------------------------- + // ---- one live replacement per request -------------------------------------------------------------------------- + + [Theory] + [InlineData(WalletWithdrawalRequestStatus.Pending)] + [InlineData(WalletWithdrawalRequestStatus.PSBTSignaturesPending)] + [InlineData(WalletWithdrawalRequestStatus.FinalizingPSBT)] + [InlineData(WalletWithdrawalRequestStatus.OnChainConfirmationPending)] + public async Task CreateBumpRequestAsync_OriginalWithALiveReplacement_IsRefused(WalletWithdrawalRequestStatus replacementStatus) + { + SetupOriginal(HotWallet()); + ReplacementsOf(Row(12, replacementStatus, replaces: OriginalId)); + + await AssertRefusedAsync(BumpingErrorReason.InvalidState, MempoolRecommendedFeesType.CustomFee, 10m); + } + + [Fact] + public async Task CreateCancelRequestAsync_OriginalWithALiveReplacement_IsRefused() + { + SetupOriginal(HotWallet()); + ReplacementsOf(Row(12, WalletWithdrawalRequestStatus.OnChainConfirmationPending, replaces: OriginalId)); + + await AssertRefusedAsync(BumpingErrorReason.InvalidState, MempoolRecommendedFeesType.CustomFee, 10m, cancellation: true); + } + + [Theory] + [InlineData(WalletWithdrawalRequestStatus.Failed)] + [InlineData(WalletWithdrawalRequestStatus.Cancelled)] + [InlineData(WalletWithdrawalRequestStatus.Rejected)] + public async Task CreateBumpRequestAsync_OriginalWithOnlyAbandonedReplacements_IsAllowed(WalletWithdrawalRequestStatus replacementStatus) + { + SetupOriginal(HotWallet()); + ReplacementsOf(Row(12, replacementStatus, replaces: OriginalId)); + + var bump = await CreateService().CreateBumpRequestAsync(OriginalId, MempoolRecommendedFeesType.CustomFee, 10m); + + bump.BumpingWalletWithdrawalRequestId.Should().Be(OriginalId, + "an abandoned replacement left the original's transaction in the mempool, so it can be replaced again"); + } + + [Fact] + public async Task CreateBumpRequestAsync_InsertFails_IsAPersistenceError() + { + SetupOriginal(HotWallet()); + _requests.Setup(x => x.AddAsync(It.IsAny())).ReturnsAsync((false, "db down")); + + var act = () => CreateService().CreateBumpRequestAsync(OriginalId, MempoolRecommendedFeesType.CustomFee, 10m); + + (await act.Should().ThrowAsync()).Which.Reason.Should().Be(BumpingErrorReason.PersistenceError); + _coinSelection.Verify(x => x.LockUTXOs(It.IsAny>(), It.IsAny(), It.IsAny()), Times.Never); + } + + // ---- ReconcileReplacementChainsAsync --------------------------------------------------------------------------- [Fact] - public async Task RevertOriginalAsync_OnlyRevertsARequestThatIsBumped() + public async Task ReconcileReplacementChainsAsync_ReplacementConfirmed_MarksTheOriginalBumped() { - var original = SetupOriginal(HotWallet(), status: WalletWithdrawalRequestStatus.OnChainConfirmed); - var bump = HotRequest(WalletWithdrawalRequestStatus.Cancelled, bumping: OriginalId); + var original = Row(10, WalletWithdrawalRequestStatus.OnChainConfirmationPending); + var bump = Row(11, WalletWithdrawalRequestStatus.OnChainConfirmed, replaces: 10); + Chain(original, bump); - await CreateService().RevertOriginalAsync(bump); + await CreateService().ReconcileReplacementChainsAsync(); - original.Status.Should().Be(WalletWithdrawalRequestStatus.OnChainConfirmed, - "a request that was never marked Bumped (or already confirmed) must not be pushed back to pending"); - _requests.Verify(x => x.Update(It.IsAny()), Times.Never); + original.Status.Should().Be(WalletWithdrawalRequestStatus.Bumped); + original.RejectCancelDescription.Should().Be($"Replaced by request 11 (tx {bump.TxId})"); + _requests.Verify(x => x.Update(original), Times.Once); + _requests.Verify(x => x.Update(It.IsAny()), Times.Once, "the confirmed replacement is left as it is"); + } + + [Fact] + public async Task ReconcileReplacementChainsAsync_CancellationConfirmed_MarksTheOriginalCancelledWithTheReason() + { + var original = Row(10, WalletWithdrawalRequestStatus.OnChainConfirmationPending); + var cancel = Row(11, WalletWithdrawalRequestStatus.OnChainConfirmed, replaces: 10, cancellation: true); + Chain(original, cancel); + + await CreateService().ReconcileReplacementChainsAsync(); + + original.Status.Should().Be(WalletWithdrawalRequestStatus.Cancelled, "the destination was never paid"); + original.RejectCancelDescription.Should().Be($"Cancelled by RBF replacement request 11 (tx {cancel.TxId})"); + } + + [Fact] + public async Task ReconcileReplacementChainsAsync_BumpOfABumpConfirmed_MarksBothAncestorsBumped() + { + var first = Row(10, WalletWithdrawalRequestStatus.OnChainConfirmationPending); + var second = Row(11, WalletWithdrawalRequestStatus.OnChainConfirmationPending, replaces: 10); + var third = Row(12, WalletWithdrawalRequestStatus.OnChainConfirmed, replaces: 11); + Chain(first, second, third); + + await CreateService().ReconcileReplacementChainsAsync(); + + first.Status.Should().Be(WalletWithdrawalRequestStatus.Bumped); + first.RejectCancelDescription.Should().Contain("12", "the reason names the transaction that confirmed, not the next hop"); + second.Status.Should().Be(WalletWithdrawalRequestStatus.Bumped); + _requests.Verify(x => x.Update(third), Times.Never); + } + + [Fact] + public async Task ReconcileReplacementChainsAsync_BumpOfACancellationConfirmed_MarksTheFirstOriginalCancelled() + { + var first = Row(10, WalletWithdrawalRequestStatus.OnChainConfirmationPending); + var cancel = Row(11, WalletWithdrawalRequestStatus.OnChainConfirmationPending, replaces: 10, cancellation: true); + var bumpOfCancel = Row(12, WalletWithdrawalRequestStatus.OnChainConfirmed, replaces: 11); + Chain(first, cancel, bumpOfCancel); + + await CreateService().ReconcileReplacementChainsAsync(); + + first.Status.Should().Be(WalletWithdrawalRequestStatus.Cancelled, + "a bump of a cancellation still returns the funds: the first destination was never paid"); + first.RejectCancelDescription.Should().Contain("12"); + cancel.Status.Should().Be(WalletWithdrawalRequestStatus.Bumped, "its own transaction was merely re-fed"); + } + + [Fact] + public async Task ReconcileReplacementChainsAsync_CancellationOfABumpConfirmed_CancelsBoth() + { + var first = Row(10, WalletWithdrawalRequestStatus.OnChainConfirmationPending); + var bump = Row(11, WalletWithdrawalRequestStatus.OnChainConfirmationPending, replaces: 10); + var cancelOfBump = Row(12, WalletWithdrawalRequestStatus.OnChainConfirmed, replaces: 11, cancellation: true); + Chain(first, bump, cancelOfBump); + + await CreateService().ReconcileReplacementChainsAsync(); + + first.Status.Should().Be(WalletWithdrawalRequestStatus.Cancelled); + bump.Status.Should().Be(WalletWithdrawalRequestStatus.Cancelled); + } + + [Fact] + public async Task ReconcileReplacementChainsAsync_OriginalConfirmed_FailsEveryLiveDescendant() + { + var original = Row(10, WalletWithdrawalRequestStatus.OnChainConfirmed); + var broadcast = Row(11, WalletWithdrawalRequestStatus.OnChainConfirmationPending, replaces: 10); + var awaitingApproval = Row(12, WalletWithdrawalRequestStatus.Pending, replaces: 11); + Chain(original, broadcast, awaitingApproval); + + await CreateService().ReconcileReplacementChainsAsync(); + + broadcast.Status.Should().Be(WalletWithdrawalRequestStatus.Failed, "it double-spends a confirmed transaction"); + broadcast.RejectCancelDescription.Should().Be($"Withdrawal request 10 was mined instead (tx {original.TxId})"); + awaitingApproval.Status.Should().Be(WalletWithdrawalRequestStatus.Failed, + "a cold replacement awaiting approval can never be broadcast either, and must leave the approvers' grid"); + awaitingApproval.RejectCancelDescription.Should().Contain("10"); + _requests.Verify(x => x.Update(original), Times.Never); } [Fact] - public async Task RevertOriginalAsync_CancellationByAnotherReplacement_IsLeftAlone() + public async Task ReconcileReplacementChainsAsync_OriginalConfirmed_SkipsAFinalizingDescendant() { - var original = SetupOriginal(HotWallet(), status: WalletWithdrawalRequestStatus.Cancelled); - original.RejectCancelDescription = "Cancelled by RBF replacement request 99"; - var cancel = HotRequest(WalletWithdrawalRequestStatus.Failed, bumping: OriginalId, cancellation: true); + var original = Row(10, WalletWithdrawalRequestStatus.OnChainConfirmed); + var finalizing = Row(11, WalletWithdrawalRequestStatus.FinalizingPSBT, replaces: 10); + Chain(original, finalizing); - await CreateService().RevertOriginalAsync(cancel); + await CreateService().ReconcileReplacementChainsAsync(); - original.Status.Should().Be(WalletWithdrawalRequestStatus.Cancelled); + finalizing.Status.Should().Be(WalletWithdrawalRequestStatus.FinalizingPSBT, + "PerformWithdrawal writes that row whole when it broadcasts; the next run decides"); _requests.Verify(x => x.Update(It.IsAny()), Times.Never); } [Fact] - public async Task MarkOriginalAsReplacedAsync_NotAReplacement_IsANoOp() + public async Task ReconcileReplacementChainsAsync_BothDirectionsConfirmed_ThePaymentWins() + { + var original = Row(10, WalletWithdrawalRequestStatus.OnChainConfirmed); + var middle = Row(11, WalletWithdrawalRequestStatus.OnChainConfirmationPending, replaces: 10); + var last = Row(12, WalletWithdrawalRequestStatus.OnChainConfirmed, replaces: 11); + Chain(original, middle, last); + + await CreateService().ReconcileReplacementChainsAsync(); + + middle.Status.Should().Be(WalletWithdrawalRequestStatus.Failed, "a confirmed ancestor means the payment happened"); + middle.RejectCancelDescription.Should().Contain("10"); + } + + [Fact] + public async Task ReconcileReplacementChainsAsync_NothingConfirmed_WritesNothing() { - var request = HotRequest(WalletWithdrawalRequestStatus.PSBTSignaturesPending); + var original = Row(10, WalletWithdrawalRequestStatus.OnChainConfirmationPending); + var bump = Row(11, WalletWithdrawalRequestStatus.OnChainConfirmationPending, replaces: 10); + Chain(original, bump); - await CreateService().MarkOriginalAsReplacedAsync(request); + await CreateService().ReconcileReplacementChainsAsync(); + original.Status.Should().Be(WalletWithdrawalRequestStatus.OnChainConfirmationPending); + bump.Status.Should().Be(WalletWithdrawalRequestStatus.OnChainConfirmationPending); _requests.Verify(x => x.Update(It.IsAny()), Times.Never); } + [Fact] + public async Task ReconcileReplacementChainsAsync_SiblingBranchConfirmed_FailsTheLosingBranch() + { + // Two replacements of the same request got through (a race): 11 and its bump 13, against 12. + var original = Row(10, WalletWithdrawalRequestStatus.OnChainConfirmationPending); + var loser = Row(11, WalletWithdrawalRequestStatus.OnChainConfirmationPending, replaces: 10); + var loserBump = Row(13, WalletWithdrawalRequestStatus.OnChainConfirmationPending, replaces: 11); + var winner = Row(12, WalletWithdrawalRequestStatus.OnChainConfirmed, replaces: 10); + Chain(original, loser, loserBump, winner); + + await CreateService().ReconcileReplacementChainsAsync(); + + original.Status.Should().Be(WalletWithdrawalRequestStatus.Bumped, "12 replaced it and confirmed"); + loser.Status.Should().Be(WalletWithdrawalRequestStatus.Failed, "it spends the inputs 12 spent"); + loser.RejectCancelDescription.Should().Contain("12"); + loserBump.Status.Should().Be(WalletWithdrawalRequestStatus.Failed, "so does everything built on it"); + _requests.Verify(x => x.Update(winner), Times.Never); + } + + [Fact] + public async Task ReconcileReplacementChainsAsync_AbandonedSiblingReplacement_IsIgnored() + { + var original = Row(10, WalletWithdrawalRequestStatus.OnChainConfirmationPending); + var abandoned = Row(11, WalletWithdrawalRequestStatus.Failed, replaces: 10); + var confirmed = Row(12, WalletWithdrawalRequestStatus.OnChainConfirmed, replaces: 10); + Chain(original, abandoned, confirmed); + + await CreateService().ReconcileReplacementChainsAsync(); + + original.Status.Should().Be(WalletWithdrawalRequestStatus.Bumped); + original.RejectCancelDescription.Should().Contain("12"); + _requests.Verify(x => x.Update(abandoned), Times.Never); + } + + [Fact] + public async Task ReconcileReplacementChainsAsync_OneChainThrowing_DoesNotStopTheOthers() + { + var broken = Row(10, WalletWithdrawalRequestStatus.OnChainConfirmationPending, replaces: 9); + Live(broken.Id); + _requests.Setup(x => x.GetRbfChainAsync(10)).ThrowsAsync(new InvalidOperationException("db down")); + var original = Row(20, WalletWithdrawalRequestStatus.OnChainConfirmationPending); + var bump = Row(21, WalletWithdrawalRequestStatus.OnChainConfirmed, replaces: 20); + Chain(original, bump); + + var act = () => CreateService().ReconcileReplacementChainsAsync(); + + await act.Should().NotThrowAsync(); + original.Status.Should().Be(WalletWithdrawalRequestStatus.Bumped); + } + + [Fact] + public async Task ReconcileReplacementChainsAsync_UpdateFails_IsOnlyLogged() + { + var original = Row(10, WalletWithdrawalRequestStatus.OnChainConfirmationPending); + var bump = Row(11, WalletWithdrawalRequestStatus.OnChainConfirmed, replaces: 10); + Chain(original, bump); + _requests.Setup(x => x.Update(It.IsAny())).Returns((false, "db down")); + + var act = () => CreateService().ReconcileReplacementChainsAsync(); + + await act.Should().NotThrowAsync("the next run retries; nothing else was written"); + } + // ---- fixtures --------------------------------------------------------------------------------------------- + private readonly List _liveIds = new(); + + /// What the repository answers for the replacements of the original. + private void ReplacementsOf(params WalletWithdrawalRequest[] replacements) + => _requests.Setup(x => x.GetReplacementsAsync(It.IsAny>())) + .ReturnsAsync(replacements.ToLookup(r => r.BumpingWalletWithdrawalRequestId!.Value)); + + /// A request of an RBF chain; its txid is derived from its id. + private static WalletWithdrawalRequest Row(int id, WalletWithdrawalRequestStatus status, int? replaces = null, + bool cancellation = false) => new() + { + Id = id, + Status = status, + Description = "chain", + TxId = new uint256((ulong)id).ToString(), + BumpingWalletWithdrawalRequestId = replaces, + IsRbfCancellation = cancellation, + }; + + /// + /// Wires a chain the way the repository returns it: every member resolves to the whole chain, and the ids of + /// the live ones are what the reconciliation iterates. + /// + private void Chain(params WalletWithdrawalRequest[] members) + { + var chain = members.ToList(); + foreach (var member in members) + { + _requests.Setup(x => x.GetRbfChainAsync(member.Id)).ReturnsAsync(chain); + } + + Live(members.Where(m => m.IsLive).Select(m => m.Id).ToArray()); + } + + private void Live(params int[] ids) + { + _liveIds.AddRange(ids); + _requests.Setup(x => x.GetLiveRbfLinkedRequestIdsAsync()).ReturnsAsync(_liveIds.ToList()); + } + private async Task AssertRefusedAsync(BumpingErrorReason reason, MempoolRecommendedFeesType feeType, decimal? customFeeRate, bool cancellation = false) { diff --git a/test/NodeGuard.Tests/TestHelpers/TestEnvironment.cs b/test/NodeGuard.Tests/TestHelpers/TestEnvironment.cs index 66d89504..ff989420 100644 --- a/test/NodeGuard.Tests/TestHelpers/TestEnvironment.cs +++ b/test/NodeGuard.Tests/TestHelpers/TestEnvironment.cs @@ -36,6 +36,8 @@ internal static class TestEnvironment internal static void Initialize() { SetIfMissing("NBXPLORER_URI", "http://localhost:32838"); + // The dev / e2e depth: an unset value is 0, which would settle unconfirmed withdrawals. + SetIfMissing("TRANSACTION_CONFIRMATION_MINIMUM_BLOCKS", "6"); } private static void SetIfMissing(string name, string value) From a4e06764e5f42887b560304e2f88d8ec93f21caf Mon Sep 17 00:00:00 2001 From: Marcos Date: Tue, 20 Oct 2026 14:46:42 +0200 Subject: [PATCH 2/9] fix: enhance WalletWithdrawalRequest logic for RBF handling and transaction competition --- src/Data/Models/WalletWithdrawalRequest.cs | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/src/Data/Models/WalletWithdrawalRequest.cs b/src/Data/Models/WalletWithdrawalRequest.cs index 049a96b9..bb57b27d 100644 --- a/src/Data/Models/WalletWithdrawalRequest.cs +++ b/src/Data/Models/WalletWithdrawalRequest.cs @@ -245,10 +245,13 @@ public bool Equals(WalletWithdrawalRequest? other) [NotMapped] public bool IsLive => LiveStatuses.Contains(Status); + /// + /// Never put a transaction in the mempool, or lost to another one. A request cancelled by its own RBF + /// replacement (Cancelled with a txid) was superseded, not abandoned: it is a hop of the chain. + /// [NotMapped] - public bool IsAbandoned => Status is WalletWithdrawalRequestStatus.Failed - or WalletWithdrawalRequestStatus.Cancelled - or WalletWithdrawalRequestStatus.Rejected; + public bool IsAbandoned => Status is WalletWithdrawalRequestStatus.Failed or WalletWithdrawalRequestStatus.Rejected + || (Status == WalletWithdrawalRequestStatus.Cancelled && string.IsNullOrEmpty(TxId)); public static string RbfCancellationReason(int replacementId, string? txId) => WithTx($"Cancelled by RBF replacement request {replacementId}", txId); @@ -337,12 +340,15 @@ public RbfReplacements(IEnumerable requests) _requests = requests.ToList(); } - /// The one still in flight, if any; creating a replacement is refused while one exists. - public WalletWithdrawalRequest? Live => _requests.FirstOrDefault(r => r.IsLive); + /// + /// The one still in flight, if any. Creating a replacement is refused while one exists; should a race let two + /// through, the newest is the one that had to outbid the other to get into the mempool. + /// + public WalletWithdrawalRequest? Live => _requests.Where(r => r.IsLive).MaxBy(r => r.Id); - /// The one whose transaction competes in the mempool, if any. + /// The one whose transaction competes in the mempool, if any; the newest when a race left two. public WalletWithdrawalRequest? Broadcast => - _requests.FirstOrDefault(r => r.Status == WalletWithdrawalRequestStatus.OnChainConfirmationPending); + _requests.Where(r => r.Status == WalletWithdrawalRequestStatus.OnChainConfirmationPending).MaxBy(r => r.Id); /// The newest one that was not abandoned: the next hop when following a chain by id. public WalletWithdrawalRequest? Newest => _requests.Where(r => !r.IsAbandoned).MaxBy(r => r.Id); From ee79f6ff51aa682b784fb0209911ccdc58499f91 Mon Sep 17 00:00:00 2001 From: Marcos Date: Tue, 20 Oct 2026 15:55:54 +0200 Subject: [PATCH 3/9] fix: improve withdrawal request status handling for abandoned and competing replacements --- .../Rpc/NodeGuardServiceTests.cs | 35 +++++++++++++++++-- 1 file changed, 32 insertions(+), 3 deletions(-) diff --git a/test/NodeGuard.Tests/Rpc/NodeGuardServiceTests.cs b/test/NodeGuard.Tests/Rpc/NodeGuardServiceTests.cs index d238101e..3d367740 100644 --- a/test/NodeGuard.Tests/Rpc/NodeGuardServiceTests.cs +++ b/test/NodeGuard.Tests/Rpc/NodeGuardServiceTests.cs @@ -1090,13 +1090,42 @@ public async Task GetWithdrawalsRequestStatus_OriginalWithAReplacementAwaitingAp [InlineData(WalletWithdrawalRequestStatus.Failed)] [InlineData(WalletWithdrawalRequestStatus.Cancelled)] [InlineData(WalletWithdrawalRequestStatus.Rejected)] - public async Task GetWithdrawalsRequestStatus_OriginalWithAnAbandonedReplacement_ReportsPendingConfirmation( + public async Task GetWithdrawalsRequestStatus_OriginalWithAReplacementAbandonedBeforeBroadcast_ReportsPendingConfirmation( WalletWithdrawalRequestStatus replacementStatus) { - var status = await GetStatusAsync(0, Original(), Replacement(replacementStatus)); + var abandoned = Replacement(replacementStatus); + abandoned.TxId = null; + + var status = await GetStatusAsync(0, Original(), abandoned); status.Status.Should().Be(WITHDRAWAL_REQUEST_STATUS.WithdrawalPendingConfirmation); - status.HasReplacedByRequestId.Should().BeFalse(); + status.HasReplacedByRequestId.Should().BeFalse("a replacement that never went out is not a hop of the chain"); + } + + [Fact] + public async Task GetWithdrawalsRequestStatus_OriginalWithAReplacementThatLostTheRace_HasNoNextHop() + { + var original = Original(); + original.Status = WalletWithdrawalRequestStatus.OnChainConfirmed; + + var status = await GetStatusAsync(6, original, Replacement(WalletWithdrawalRequestStatus.Failed)); + + status.Status.Should().Be(WITHDRAWAL_REQUEST_STATUS.WithdrawalSettled); + status.HasReplacedByRequestId.Should().BeFalse("nothing superseded a request that was mined"); + } + + [Fact] + public async Task GetWithdrawalsRequestStatus_ReplacementCancelledByItsOwnReplacement_IsStillTheNextHop() + { + // A bumped by B, B cancelled by C, C confirmed: B is Cancelled with its txid, A is Cancelled too. + var original = Original(); + original.Status = WalletWithdrawalRequestStatus.Cancelled; + original.RejectCancelDescription = WalletWithdrawalRequest.RbfCancellationReason(12, "c0ffee"); + + var status = await GetStatusAsync(0, original, Replacement(WalletWithdrawalRequestStatus.Cancelled)); + + status.Status.Should().Be(WITHDRAWAL_REQUEST_STATUS.WithdrawalCancelled); + status.ReplacedByRequestId.Should().Be(11, "a replacement that was broadcast is a hop of the chain, whatever it ended up as"); } [Fact] From 9e211fcf9fb0106cf7c1900aee00b107f37558e2 Mon Sep 17 00:00:00 2001 From: Marcos Date: Tue, 20 Oct 2026 16:01:54 +0200 Subject: [PATCH 4/9] fix: reload grid and replacement cache after cancelling a withdrawal request --- src/Pages/Withdrawals.razor | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Pages/Withdrawals.razor b/src/Pages/Withdrawals.razor index a0eb5a53..a4318bad 100644 --- a/src/Pages/Withdrawals.razor +++ b/src/Pages/Withdrawals.razor @@ -1280,6 +1280,8 @@ { await LogWithdrawalAuditAsync(AuditActionType.Cancel, AuditEventType.Success, _selectedRequest, "Withdrawal request cancelled by user."); } + // Reload the grid and the replacement cache: a cancelled replacement must give its original its actions back. + await GetData(); StateHasChanged(); } await _confirmationModal.CloseModal(); From 62c1ee4b1dff089e40f95c3dcf572209fb42e4f9 Mon Sep 17 00:00:00 2001 From: Marcos Date: Tue, 20 Oct 2026 16:48:20 +0200 Subject: [PATCH 5/9] fix: add migration to update status of Bumped and RBF-cancelled withdrawal requests --- ...20143744_BumpedIsTerminalOnly.Designer.cs} | 56 ++++++++++++++----- ...=> 20261020143744_BumpedIsTerminalOnly.cs} | 3 +- 2 files changed, 45 insertions(+), 14 deletions(-) rename src/Migrations/{20261019172941_BumpedIsTerminalOnly.Designer.cs => 20261020143744_BumpedIsTerminalOnly.Designer.cs} (98%) rename src/Migrations/{20261019172941_BumpedIsTerminalOnly.cs => 20261020143744_BumpedIsTerminalOnly.cs} (95%) diff --git a/src/Migrations/20261019172941_BumpedIsTerminalOnly.Designer.cs b/src/Migrations/20261020143744_BumpedIsTerminalOnly.Designer.cs similarity index 98% rename from src/Migrations/20261019172941_BumpedIsTerminalOnly.Designer.cs rename to src/Migrations/20261020143744_BumpedIsTerminalOnly.Designer.cs index 52d9d6f6..34d8ff1b 100644 --- a/src/Migrations/20261019172941_BumpedIsTerminalOnly.Designer.cs +++ b/src/Migrations/20261020143744_BumpedIsTerminalOnly.Designer.cs @@ -14,7 +14,7 @@ namespace NodeGuard.Migrations { [DbContext(typeof(ApplicationDbContext))] - [Migration("20261019172941_BumpedIsTerminalOnly")] + [Migration("20261020143744_BumpedIsTerminalOnly")] partial class BumpedIsTerminalOnly { /// @@ -1014,6 +1014,18 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.Property("FortySwapEndpoint") .HasColumnType("text"); + b.Property("FortySwapMaxAmountSats") + .HasColumnType("bigint"); + + b.Property("FortySwapMaxSwapRoutingFeeRatio") + .HasColumnType("numeric"); + + b.Property("FortySwapMaxSwapsInFlight") + .HasColumnType("integer"); + + b.Property("FortySwapMinAmountSats") + .HasColumnType("bigint"); + b.Property("FortySwapWeight") .HasColumnType("integer"); @@ -1023,6 +1035,18 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.Property("IsNodeDisabled") .HasColumnType("boolean"); + b.Property("LoopMaxSwapRoutingFeeRatio") + .HasColumnType("numeric"); + + b.Property("LoopMaxSwapsInFlight") + .HasColumnType("integer"); + + b.Property("LoopSwapMaxAmountSats") + .HasColumnType("bigint"); + + b.Property("LoopSwapMinAmountSats") + .HasColumnType("bigint"); + b.Property("LoopSwapWeight") .HasColumnType("integer"); @@ -1041,12 +1065,6 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.Property("MaxRebalancesInFlight") .HasColumnType("integer"); - b.Property("MaxSwapRoutingFeeRatio") - .HasColumnType("numeric"); - - b.Property("MaxSwapsInFlight") - .HasColumnType("integer"); - b.Property("MinimumBalanceThresholdSats") .HasColumnType("bigint"); @@ -1072,6 +1090,21 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) .HasColumnType("boolean") .HasDefaultValue(false); + b.Property("SparkMaxSwapRoutingFeeRatio") + .HasColumnType("numeric"); + + 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 +1120,6 @@ protected override void BuildTargetModel(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 +1276,9 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.Property("ServiceFeeSats") .HasColumnType("bigint"); + b.Property("SparkExitDatetime") + .HasColumnType("timestamp with time zone"); + b.Property("SparkIdentity") .HasColumnType("text"); diff --git a/src/Migrations/20261019172941_BumpedIsTerminalOnly.cs b/src/Migrations/20261020143744_BumpedIsTerminalOnly.cs similarity index 95% rename from src/Migrations/20261019172941_BumpedIsTerminalOnly.cs rename to src/Migrations/20261020143744_BumpedIsTerminalOnly.cs index 69ff5976..ec3a5b48 100644 --- a/src/Migrations/20261019172941_BumpedIsTerminalOnly.cs +++ b/src/Migrations/20261020143744_BumpedIsTerminalOnly.cs @@ -25,7 +25,8 @@ public partial class BumpedIsTerminalOnly : Migration // WalletWithdrawalRequestStatus: Cancelled = 1, OnChainConfirmationPending = 4, OnChainConfirmed = 5, Bumped = 8 // A Cancelled request with an RBF-cancellation replacement was cancelled by it: humans can only cancel a // request that was never broadcast, and only a broadcast one gets replacements. - private const string Repair = @" + // Internal so BumpedIsTerminalOnlyMigrationE2ETests can run it against the stack's Postgres. + internal const string Repair = @" WITH RECURSIVE roots AS ( SELECT w.""Id"" FROM ""WalletWithdrawalRequests"" w WHERE w.""Status"" = 8 From b68f49b7a51e41b6ffa5c9a5bbcfdb5dfb79bbeb Mon Sep 17 00:00:00 2001 From: Marcos Date: Wed, 21 Oct 2026 17:44:54 +0200 Subject: [PATCH 6/9] fix: remove mention of an unexisting test --- src/Migrations/20261020143744_BumpedIsTerminalOnly.cs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/Migrations/20261020143744_BumpedIsTerminalOnly.cs b/src/Migrations/20261020143744_BumpedIsTerminalOnly.cs index ec3a5b48..69ff5976 100644 --- a/src/Migrations/20261020143744_BumpedIsTerminalOnly.cs +++ b/src/Migrations/20261020143744_BumpedIsTerminalOnly.cs @@ -25,8 +25,7 @@ public partial class BumpedIsTerminalOnly : Migration // WalletWithdrawalRequestStatus: Cancelled = 1, OnChainConfirmationPending = 4, OnChainConfirmed = 5, Bumped = 8 // A Cancelled request with an RBF-cancellation replacement was cancelled by it: humans can only cancel a // request that was never broadcast, and only a broadcast one gets replacements. - // Internal so BumpedIsTerminalOnlyMigrationE2ETests can run it against the stack's Postgres. - internal const string Repair = @" + private const string Repair = @" WITH RECURSIVE roots AS ( SELECT w.""Id"" FROM ""WalletWithdrawalRequests"" w WHERE w.""Status"" = 8 From 412b78523deadedb1e861bf7ec48d4658fb30bb8 Mon Sep 17 00:00:00 2001 From: Marcos Date: Wed, 21 Oct 2026 18:42:58 +0200 Subject: [PATCH 7/9] fix: report the stored withdrawal status only, replaced requests stay pending until a confirmation. --- src/Data/Models/WalletWithdrawalRequest.cs | 37 +++------- src/Proto/nodeguard.proto | 17 +++-- src/Rpc/NodeGuardService.cs | 10 +-- src/Services/WithdrawalRequestService.cs | 6 +- .../E2E/WithdrawalRbfBumpE2ETests.cs | 65 +++++++++-------- .../Rpc/NodeGuardServiceTests.cs | 69 +++++++++++-------- 6 files changed, 103 insertions(+), 101 deletions(-) diff --git a/src/Data/Models/WalletWithdrawalRequest.cs b/src/Data/Models/WalletWithdrawalRequest.cs index bb57b27d..7e01e0f3 100644 --- a/src/Data/Models/WalletWithdrawalRequest.cs +++ b/src/Data/Models/WalletWithdrawalRequest.cs @@ -82,7 +82,7 @@ public class WalletWithdrawalRequest : Entity, IEquatable /// Description by the requestor /// - public string Description { get; set; } + public string? Description { get; set; } /// /// Bool used to mark if the output of the request should the maximum as possible to the destination address. @@ -214,10 +214,10 @@ public bool Equals(WalletWithdrawalRequest? other) /// public string? UserRequestorId { get; set; } - public ApplicationUser UserRequestor { get; set; } + public ApplicationUser? UserRequestor { get; set; } public int WalletId { get; set; } - public Wallet Wallet { get; set; } + public Wallet? Wallet { get; set; } public int? BumpingWalletWithdrawalRequestId { get; set; } @@ -274,27 +274,9 @@ public static (WalletWithdrawalRequestStatus Status, string Reason) SupersededBy ? (WalletWithdrawalRequestStatus.Cancelled, RbfCancellationReason(replacement.Id, replacement.TxId)) : (WalletWithdrawalRequestStatus.Bumped, ReplacedReason(replacement.Id, replacement.TxId)); - /// - /// The status the API reports, given the requests that replace this one. The stored one describes this - /// request's own transaction only: while a replacement's transaction is in the mempool and this one has no - /// confirmations, the request is reported with the status it will be stored with once that replacement settles. - /// - public (WalletWithdrawalRequestStatus Status, string? Reason, int? ReplacedById) EffectiveStatus(ulong ownConfirmations, - RbfReplacements replacements) - { - if (Status == WalletWithdrawalRequestStatus.OnChainConfirmationPending && ownConfirmations == 0 - && replacements.Broadcast is { } broadcast) - { - var (status, reason) = SupersededBy(broadcast, broadcast.IsRbfCancellation); - return (status, reason, broadcast.Id); - } + public List? WalletWithdrawalRequestPSBTs { get; set; } - return (Status, RejectCancelDescription, replacements.Newest?.Id); - } - - public List WalletWithdrawalRequestPSBTs { get; set; } - - public List UTXOs { get; set; } + public List? UTXOs { get; set; } /// /// This is a optional field that you can used to link withdrawals with externally-generated IDs (e.g. a withdrawal/settlement that belongs to an elenpay store) @@ -326,8 +308,9 @@ public override int GetHashCode() /// /// The requests that replace one request through RBF (fee bumps and cancellations), as the repository's - /// GetReplacementsAsync returns them. Which of them is live or confirmed is what makes a request "replaced": its - /// own status only ever describes its own transaction. + /// GetReplacementsAsync returns them. A request's status only ever describes its own transaction and only a + /// confirmation changes it: a live replacement blocks another one, and the newest not abandoned is the next hop + /// the API and the UI point at while the chain is in flight. /// public sealed class RbfReplacements { @@ -346,10 +329,6 @@ public RbfReplacements(IEnumerable requests) /// public WalletWithdrawalRequest? Live => _requests.Where(r => r.IsLive).MaxBy(r => r.Id); - /// The one whose transaction competes in the mempool, if any; the newest when a race left two. - public WalletWithdrawalRequest? Broadcast => - _requests.Where(r => r.Status == WalletWithdrawalRequestStatus.OnChainConfirmationPending).MaxBy(r => r.Id); - /// The newest one that was not abandoned: the next hop when following a chain by id. public WalletWithdrawalRequest? Newest => _requests.Where(r => !r.IsAbandoned).MaxBy(r => r.Id); } diff --git a/src/Proto/nodeguard.proto b/src/Proto/nodeguard.proto index 252bf808..baf89fc6 100644 --- a/src/Proto/nodeguard.proto +++ b/src/Proto/nodeguard.proto @@ -25,10 +25,11 @@ service NodeGuardService { Replaces a withdrawal that is still waiting for on-chain confirmation with a higher-fee version of the same transaction (RBF): same inputs, same destinations, the extra fee is taken from the change. Cold wallets get a new request that waits for approvals like RequestWithdrawal; hot wallets sign and broadcast it asynchronously. - The replaced request keeps its own status. While the replacement's transaction is in the mempool it is reported as - WITHDRAWAL_BUMPED with replaced_by_request_id set, and that becomes its stored status once the replacement settles. - If the replaced transaction is mined instead, it settles and the replacement fails. If the replacement is abandoned - (cancelled, rejected, failed) the replaced request reports WITHDRAWAL_PENDING_CONFIRMATION again. + The replaced request keeps its own status: it stays WITHDRAWAL_PENDING_CONFIRMATION, with replaced_by_request_id + pointing at the replacement, until a transaction of the chain confirms. Only confirmations change an RBF chain: + once the replacement settles the replaced request becomes WITHDRAWAL_BUMPED; if the replaced transaction is mined + instead, it settles and the replacement fails. No status is reported before it is stored, so SETTLED, BUMPED, + CANCELLED and FAILED are final. Follow replaced_by_request_id to find the transaction expected to pay. Fails with FAILED_PRECONDITION when the request is not pending confirmation or already has a live replacement. */ rpc BumpWithdrawal(BumpWithdrawalRequest) returns (BumpWithdrawalResponse); @@ -38,7 +39,8 @@ service NodeGuardService { that spends the same inputs and returns the funds, minus a higher fee, to a fresh address of the wallet, so the original payment never confirms. Cold wallets get a new request that waits for approvals like RequestWithdrawal; hot wallets sign and broadcast it asynchronously. Same status rules as BumpWithdrawal, with the replaced request - reported and then stored as WITHDRAWAL_CANCELLED instead of WITHDRAWAL_BUMPED. + stored as WITHDRAWAL_CANCELLED instead of WITHDRAWAL_BUMPED once the cancellation confirms. Until then it still + reads WITHDRAWAL_PENDING_CONFIRMATION: its own transaction could still be mined instead and pay the destination. */ rpc CancelWithdrawal(CancelWithdrawalRequest) returns (CancelWithdrawalResponse); /* @@ -527,8 +529,9 @@ enum WITHDRAWAL_REQUEST_STATUS { WITHDRAWAL_REJECTED = 3; WITHDRAWAL_PENDING_CONFIRMATION = 4; WITHDRAWAL_FAILED = 5; - // Superseded by an RBF replacement (see BumpWithdrawal): reported while the replacement's transaction is in the - // mempool, stored once it settles. The payment happened through the request in replaced_by_request_id. + // Superseded by an RBF replacement (see BumpWithdrawal) that confirmed. Final: the payment happened through the + // chain starting at replaced_by_request_id. While the replacement is only in the mempool the request still reads + // WITHDRAWAL_PENDING_CONFIRMATION. WITHDRAWAL_BUMPED = 6; } diff --git a/src/Rpc/NodeGuardService.cs b/src/Rpc/NodeGuardService.cs index 653c4d31..9fea5906 100644 --- a/src/Rpc/NodeGuardService.cs +++ b/src/Rpc/NodeGuardService.cs @@ -1346,14 +1346,14 @@ private async Task GetWithdrawalsRequestSta var withdrawalsResponses = withdrawalRequests.Zip(confirmations, (withdrawalRequest, confirmed) => { - // A replaced request keeps its own stored status; what the client sees is derived from its replacements. - var (status, reason, replacedById) = - withdrawalRequest.EffectiveStatus(confirmed, new RbfReplacements(replacements[withdrawalRequest.Id])); + // The stored status, nothing derived: a replaced request reads pending confirmation, with its replacement + // as the next hop, until a confirmation makes it Bumped, Cancelled or Settled. Final statuses never flip. + var replacedById = new RbfReplacements(replacements[withdrawalRequest.Id]).Newest?.Id; var response = new WithdrawalRequest { RequestId = withdrawalRequest.Id, - Status = GetStatus(status), - RejectOrCancelReason = reason ?? "", + Status = GetStatus(withdrawalRequest.Status), + RejectOrCancelReason = withdrawalRequest.RejectCancelDescription ?? "", ReferenceId = withdrawalRequest.ReferenceId ?? "", Confirmations = confirmed, TxId = withdrawalRequest.TxId ?? "", diff --git a/src/Services/WithdrawalRequestService.cs b/src/Services/WithdrawalRequestService.cs index b348163f..6fe0c478 100644 --- a/src/Services/WithdrawalRequestService.cs +++ b/src/Services/WithdrawalRequestService.cs @@ -35,9 +35,9 @@ namespace NodeGuard.Services; /// E2E suite exercise the same code the pages run. Hot-wallet requests are moved to PSBTSignaturesPending explicitly: it is /// the status PerformWithdrawal requires, and a hot wallet has no approvers whose signatures would get it there. /// -/// RBF rule: a request's status only ever describes its own transaction. Creating, executing or abandoning a -/// replacement never writes the request it replaces; "replaced" is derived from the replacement -/// (), and the replaced request gets its final status +/// RBF rule: a request's status only ever describes its own transaction, and only a confirmation changes it. Creating, +/// executing or abandoning a replacement never writes the request it replaces, which stays pending confirmation with +/// the replacement as its next hop (); the replaced request gets its final status /// (Bumped, Cancelled, or OnChainConfirmed when it won the race) from . /// public interface IWithdrawalRequestService diff --git a/test/NodeGuard.Tests/E2E/WithdrawalRbfBumpE2ETests.cs b/test/NodeGuard.Tests/E2E/WithdrawalRbfBumpE2ETests.cs index 7ba32b58..da1f1f57 100644 --- a/test/NodeGuard.Tests/E2E/WithdrawalRbfBumpE2ETests.cs +++ b/test/NodeGuard.Tests/E2E/WithdrawalRbfBumpE2ETests.cs @@ -29,13 +29,13 @@ namespace NodeGuard.Tests.E2E; /// /// End-to-end coverage for the RBF replacements of a hot-wallet withdrawal against a LIVE NodeGuard + bitcoind, driven /// over gRPC. Fee bump: RequestWithdrawal broadcasts an RBF-signalling transaction, BumpWithdrawal replaces it with a -/// higher-fee version of the same payment, bitcoind evicts the original, NodeGuard reports it WITHDRAWAL_BUMPED while -/// the replacement is in the mempool and stores that once the replacement is mined and settled. Cancellation: -/// CancelWithdrawal replaces it with a transaction that returns the funds to the wallet, the destination is never paid -/// and the original ends up WITHDRAWAL_CANCELLED. Mined original: either replacement loses the race to the original, -/// which NodeGuard must then settle while failing every replacement, also when the original was bumped twice. The UI -/// and the RPCs share WithdrawalRequestService, so these scenarios exercise the same orchestration the Withdrawals -/// page runs. +/// higher-fee version of the same payment, bitcoind evicts the original, NodeGuard keeps reporting the original +/// pending confirmation (pointing at the bump) and stores WITHDRAWAL_BUMPED once the replacement is mined and settled. +/// Cancellation: CancelWithdrawal replaces it with a transaction that returns the funds to the wallet, the destination +/// is never paid and the original ends up WITHDRAWAL_CANCELLED. Mined original: either replacement loses the race to +/// the original, which NodeGuard must then settle while failing every replacement, also when the original was bumped +/// twice. Only confirmations change an RBF chain, so no status reported here ever flips once it is final. The UI and +/// the RPCs share WithdrawalRequestService, so these scenarios exercise the same orchestration the Withdrawals page runs. /// /// Shared plumbing in ; gated by . Env: see /// . Never mine between the withdrawal and its replacement — the original must @@ -109,13 +109,15 @@ await PollAsync(() => GetStatusesAsync(client, headers, withdrawal.RequestId), .BeGreaterThanOrEqualTo(BumpedFeeRateSatPerVb * 0.8m, "the replacement targets the requested rate (the fee is estimated on the unsigned size)"); - // NodeGuard's view: the bump is pending confirmation with the replacement txid, the original is bumped. + // NodeGuard's view: the bump is pending confirmation with the replacement txid; the original stays pending + // confirmation too, pointing at the bump, since nothing final is reported before a confirmation. var statuses = await PollAsync(() => GetStatusesAsync(client, headers, withdrawal.RequestId, bump.RequestId), s => s[bump.RequestId].Status == WITHDRAWAL_REQUEST_STATUS.WithdrawalPendingConfirmation - && s[withdrawal.RequestId].Status == WITHDRAWAL_REQUEST_STATUS.WithdrawalBumped, - attempts: 30, delay: TimeSpan.FromSeconds(2), what: "bump pending confirmation, original bumped"); + && s[withdrawal.RequestId].ReplacedByRequestId == bump.RequestId, + attempts: 30, delay: TimeSpan.FromSeconds(2), what: "bump pending confirmation, original pointing at it"); statuses[bump.RequestId].TxId.Should().Be(bump.Txid); - statuses[withdrawal.RequestId].ReplacedByRequestId.Should().Be(bump.RequestId, "clients follow the chain by id"); + statuses[withdrawal.RequestId].Status.Should().Be(WITHDRAWAL_REQUEST_STATUS.WithdrawalPendingConfirmation, + "only a confirmation changes an RBF chain: the original could still be mined instead"); // 4. Guards: a bumped request cannot be bumped again, a bump must raise the fee rate, and an unknown txid is not found. var bumpAgain = () => client.BumpWithdrawalAsync(new BumpWithdrawalRequest @@ -208,13 +210,16 @@ await PollAsync(() => GetStatusesAsync(client, headers, withdrawal.RequestId), (cancelTx.TotalOut + cancelEntry.BaseFee).Should().Be(originalTx.TotalOut + originalEntry.BaseFee, "both transactions spend the same inputs: whatever is not fee comes back to the wallet"); - // NodeGuard's view: the cancellation is pending confirmation, the original is cancelled with a reason. + // NodeGuard's view: the cancellation is pending confirmation; the original stays pending confirmation too, + // pointing at it and with no reason yet. A cancellation is only reported once it can no longer be outrun. var statuses = await PollAsync(() => GetStatusesAsync(client, headers, withdrawal.RequestId, cancel.RequestId), s => s[cancel.RequestId].Status == WITHDRAWAL_REQUEST_STATUS.WithdrawalPendingConfirmation - && s[withdrawal.RequestId].Status == WITHDRAWAL_REQUEST_STATUS.WithdrawalCancelled, - attempts: 30, delay: TimeSpan.FromSeconds(2), what: "cancellation pending confirmation, original cancelled"); + && s[withdrawal.RequestId].ReplacedByRequestId == cancel.RequestId, + attempts: 30, delay: TimeSpan.FromSeconds(2), what: "cancellation pending confirmation, original pointing at it"); statuses[cancel.RequestId].TxId.Should().Be(cancel.Txid); - statuses[withdrawal.RequestId].RejectOrCancelReason.Should().Contain("RBF"); + statuses[withdrawal.RequestId].Status.Should().Be(WITHDRAWAL_REQUEST_STATUS.WithdrawalPendingConfirmation, + "an integrator that refunds on WITHDRAWAL_CANCELLED must never see it before the cancellation confirmed"); + statuses[withdrawal.RequestId].RejectOrCancelReason.Should().BeEmpty(); // 4. Guard: a cancelled request cannot be replaced again. var cancelAgain = () => client.CancelWithdrawalAsync(new CancelWithdrawalRequest @@ -233,7 +238,9 @@ await PollAsync(() => GetStatusesAsync(client, headers, withdrawal.RequestId), var settled = await PollAsync(() => GetStatusesAsync(client, headers, withdrawal.RequestId, cancel.RequestId), s => s[cancel.RequestId].Status == WITHDRAWAL_REQUEST_STATUS.WithdrawalSettled, attempts: 40, delay: TimeSpan.FromSeconds(5), what: "cancellation settled by MonitorWithdrawalsJob"); - settled[withdrawal.RequestId].Status.Should().Be(WITHDRAWAL_REQUEST_STATUS.WithdrawalCancelled); + settled[withdrawal.RequestId].Status.Should().Be(WITHDRAWAL_REQUEST_STATUS.WithdrawalCancelled, + "once the cancellation settled, Cancelled is the replaced request's stored, terminal status"); + settled[withdrawal.RequestId].RejectOrCancelReason.Should().Contain($"request {cancel.RequestId}"); var returnedOutpoint = new OutPoint(cancelTxId, 0).ToString(); await RetryAsync(async () => @@ -271,18 +278,17 @@ await PollAsync(() => GetStatusesAsync(client, headers, withdrawal.RequestId), s => s[withdrawal.RequestId].Status == WITHDRAWAL_REQUEST_STATUS.WithdrawalPendingConfirmation, attempts: 30, delay: TimeSpan.FromSeconds(2), what: "original pending confirmation"); - // 2. Replace it: bitcoind evicts the original and NodeGuard marks it bumped / cancelled. + // 2. Replace it: bitcoind evicts the original; NodeGuard keeps it pending confirmation, pointing at the replacement. var (replacementId, replacementTxId) = await ReplaceAsync(client, headers, withdrawal.Txid, cancellation); - var replacedStatus = cancellation - ? WITHDRAWAL_REQUEST_STATUS.WithdrawalCancelled - : WITHDRAWAL_REQUEST_STATUS.WithdrawalBumped; await WaitForBroadcastAsync(rpc, replacementTxId, "replacement broadcast"); await WaitUntilEvictedAsync(rpc, originalTxId); - await PollAsync(() => GetStatusesAsync(client, headers, withdrawal.RequestId, replacementId), + var inFlight = await PollAsync(() => GetStatusesAsync(client, headers, withdrawal.RequestId, replacementId), s => s[replacementId].Status == WITHDRAWAL_REQUEST_STATUS.WithdrawalPendingConfirmation - && s[withdrawal.RequestId].Status == replacedStatus, - attempts: 30, delay: TimeSpan.FromSeconds(2), what: $"replacement pending confirmation, original {replacedStatus}"); + && s[withdrawal.RequestId].ReplacedByRequestId == replacementId, + attempts: 30, delay: TimeSpan.FromSeconds(2), what: "replacement pending confirmation, original pointing at it"); + inFlight[withdrawal.RequestId].Status.Should().Be(WITHDRAWAL_REQUEST_STATUS.WithdrawalPendingConfirmation, + "nothing final is reported while the original could still be mined, which is exactly what happens next"); // 3. The original is mined instead, which evicts the replacement as a double spend. await MineBypassingTheMempoolAsync(rpc, originalTx); @@ -344,8 +350,8 @@ await PollAsync(() => GetStatusesAsync(client, headers, first.RequestId), await PollAsync(() => GetStatusesAsync(client, headers, first.RequestId, secondId), s => s[secondId].Status == WITHDRAWAL_REQUEST_STATUS.WithdrawalPendingConfirmation - && s[first.RequestId].Status == WITHDRAWAL_REQUEST_STATUS.WithdrawalBumped, - attempts: 30, delay: TimeSpan.FromSeconds(2), what: "first bump pending confirmation, first bumped"); + && s[first.RequestId].ReplacedByRequestId == secondId, + attempts: 30, delay: TimeSpan.FromSeconds(2), what: "first bump pending confirmation, first pointing at it"); // 3. C bumps B at a higher fee rate again: three transactions over the same inputs, only C in the mempool. var (thirdId, thirdTxId) = await ReplaceAsync(client, headers, secondTxId.ToString(), cancellation: false, @@ -355,11 +361,12 @@ await PollAsync(() => GetStatusesAsync(client, headers, first.RequestId, secondI thirdTx.Inputs.Select(i => i.PrevOut).Should().BeEquivalentTo(firstTx.Inputs.Select(i => i.PrevOut), "every replacement spends the inputs of the first transaction"); - await PollAsync(() => GetStatusesAsync(client, headers, first.RequestId, secondId, thirdId), + var inFlight = await PollAsync(() => GetStatusesAsync(client, headers, first.RequestId, secondId, thirdId), s => s[thirdId].Status == WITHDRAWAL_REQUEST_STATUS.WithdrawalPendingConfirmation - && s[secondId].Status == WITHDRAWAL_REQUEST_STATUS.WithdrawalBumped - && s[first.RequestId].Status == WITHDRAWAL_REQUEST_STATUS.WithdrawalBumped, - attempts: 30, delay: TimeSpan.FromSeconds(2), what: "second bump pending confirmation, first and first bump bumped"); + && s[secondId].ReplacedByRequestId == thirdId, + attempts: 30, delay: TimeSpan.FromSeconds(2), what: "second bump pending confirmation, first bump pointing at it"); + inFlight.Values.Select(r => r.Status).Should().OnlyContain(s => s == WITHDRAWAL_REQUEST_STATUS.WithdrawalPendingConfirmation, + "every transaction of the chain is still a candidate until one confirms"); // 4. The oldest transaction, A, is mined, which evicts C as a double spend. await MineBypassingTheMempoolAsync(rpc, firstTx); diff --git a/test/NodeGuard.Tests/Rpc/NodeGuardServiceTests.cs b/test/NodeGuard.Tests/Rpc/NodeGuardServiceTests.cs index d7b2a089..0584ffde 100644 --- a/test/NodeGuard.Tests/Rpc/NodeGuardServiceTests.cs +++ b/test/NodeGuard.Tests/Rpc/NodeGuardServiceTests.cs @@ -1051,26 +1051,18 @@ private async Task GetStatusAsync(int confirmations, params W return response.WithdrawalRequests.Single(); } - [Fact] - public async Task GetWithdrawalsRequestStatus_OriginalWithABroadcastReplacement_ReportsBumpedAndTheReplacementId() - { - var status = await GetStatusAsync(0, Original(), Replacement(WalletWithdrawalRequestStatus.OnChainConfirmationPending)); - - status.Status.Should().Be(WITHDRAWAL_REQUEST_STATUS.WithdrawalBumped, - "the replacement's transaction competes with its own in the mempool"); - status.ReplacedByRequestId.Should().Be(11); - status.RejectOrCancelReason.Should().Be($"Replaced by request 11 (tx {ReplacementTxId})", - "the same text it will be stored with once the replacement settles"); - } - - [Fact] - public async Task GetWithdrawalsRequestStatus_OriginalWithABroadcastCancellation_ReportsCancelledWithTheReason() + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task GetWithdrawalsRequestStatus_OriginalWithABroadcastReplacement_StaysPendingConfirmationWithTheNextHop( + bool cancellation) { - var status = await GetStatusAsync(0, Original(), Replacement(WalletWithdrawalRequestStatus.OnChainConfirmationPending, cancellation: true)); + var status = await GetStatusAsync(0, Original(), Replacement(WalletWithdrawalRequestStatus.OnChainConfirmationPending, cancellation)); - status.Status.Should().Be(WITHDRAWAL_REQUEST_STATUS.WithdrawalCancelled); - status.RejectOrCancelReason.Should().Be($"Cancelled by RBF replacement request 11 (tx {ReplacementTxId})"); - status.ReplacedByRequestId.Should().Be(11); + status.Status.Should().Be(WITHDRAWAL_REQUEST_STATUS.WithdrawalPendingConfirmation, + "nothing final is reported before a confirmation: its own transaction could still be mined instead"); + status.RejectOrCancelReason.Should().BeEmpty("the reason is stored, and reported, once the replacement confirms"); + status.ReplacedByRequestId.Should().Be(11, "clients follow the chain by id"); } [Theory] @@ -1129,12 +1121,12 @@ public async Task GetWithdrawalsRequestStatus_ReplacementCancelledByItsOwnReplac } [Fact] - public async Task GetWithdrawalsRequestStatus_OriginalWithOwnConfirmations_IsNotReportedAsBumped() + public async Task GetWithdrawalsRequestStatus_ReportsTheConfirmationsOfItsOwnTransaction() { var status = await GetStatusAsync(1, Original(), Replacement(WalletWithdrawalRequestStatus.OnChainConfirmationPending)); status.Status.Should().Be(WITHDRAWAL_REQUEST_STATUS.WithdrawalPendingConfirmation, - "it was mined: the replacement is the one that can never confirm"); + "the stored status is reported until the monitor settles it"); status.Confirmations.Should().Be(1UL); } @@ -1161,9 +1153,10 @@ public async Task GetWithdrawalsRequestStatusByReferenceIds_ReturnsEveryRequestO new GetWithdrawalsRequestStatusByReferenceIdsRequest { ReferenceIds = { "store-1" } }, TestServerCallContext.Create()); response.WithdrawalRequests.Select(r => (r.RequestId, r.Status)).Should().Equal( - (10, WITHDRAWAL_REQUEST_STATUS.WithdrawalBumped), + (10, WITHDRAWAL_REQUEST_STATUS.WithdrawalPendingConfirmation), (11, WITHDRAWAL_REQUEST_STATUS.WithdrawalPendingConfirmation)); - response.WithdrawalRequests[0].ReplacedByRequestId.Should().Be(11, "the caller can follow the chain"); + response.WithdrawalRequests[0].ReplacedByRequestId.Should().Be(11, + "both transactions are still candidates; the caller follows the chain to the one expected to pay"); response.WithdrawalRequests[1].HasReplacedByRequestId.Should().BeFalse(); } @@ -2581,8 +2574,14 @@ public async Task RequestSwapOut_WithAKnownReferenceId_ReturnsTheExistingSwapWit var (service, swaps, swapOuts, _) = SwapService(); swapOuts.Setup(x => x.GetByReferenceId("ref-1")).ReturnsAsync(new SwapOut { - Id = 77, NodeId = 4, Provider = SwapProvider.Spark, SatsAmount = 500_000, DestinationWalletId = 3, - ProviderId = "req-1", Status = SwapOutStatus.Completed, DestinationAddress = "bcrt1qdest" + Id = 77, + NodeId = 4, + Provider = SwapProvider.Spark, + SatsAmount = 500_000, + DestinationWalletId = 3, + ProviderId = "req-1", + Status = SwapOutStatus.Completed, + DestinationAddress = "bcrt1qdest" }); var response = await service.RequestSwapOut(SwapRequest(), TestServerCallContext.Create()); @@ -2599,7 +2598,11 @@ public async Task RequestSwapOut_ReusingAReferenceIdForAnotherSwap_IsRefused() var (service, _, swapOuts, _) = SwapService(); swapOuts.Setup(x => x.GetByReferenceId("ref-1")).ReturnsAsync(new SwapOut { - Id = 77, NodeId = 4, Provider = SwapProvider.Spark, SatsAmount = 1_000, DestinationWalletId = 3 + Id = 77, + NodeId = 4, + Provider = SwapProvider.Spark, + SatsAmount = 1_000, + DestinationWalletId = 3 }); var act = () => service.RequestSwapOut(SwapRequest(), TestServerCallContext.Create()); @@ -2736,9 +2739,19 @@ public async Task GetSwapOut_ByReferenceId_ReturnsTheSwap() var (service, _, swapOuts, _) = SwapService(); swapOuts.Setup(x => x.GetByReferenceId("ref-1")).ReturnsAsync(new SwapOut { - Id = 77, Provider = SwapProvider.Spark, ProviderId = "req-1", Status = SwapOutStatus.Completed, SatsAmount = 500_000, - ServiceFeeSats = 2_500, LightningFeeSats = 12, TxId = "payout-tx", DestinationAddress = "bcrt1qdest", - PaymentHash = "abcd", ReferenceId = "ref-1", DestinationWalletId = 3, PayoutSats = 497_500 + Id = 77, + Provider = SwapProvider.Spark, + ProviderId = "req-1", + Status = SwapOutStatus.Completed, + SatsAmount = 500_000, + ServiceFeeSats = 2_500, + LightningFeeSats = 12, + TxId = "payout-tx", + DestinationAddress = "bcrt1qdest", + PaymentHash = "abcd", + ReferenceId = "ref-1", + DestinationWalletId = 3, + PayoutSats = 497_500 }); var response = await service.GetSwapOut(new GetSwapOutRequest { ReferenceId = "ref-1" }, TestServerCallContext.Create()); From 9aa8b0e906a0af7e81f11e83802f68182450eeb3 Mon Sep 17 00:00:00 2001 From: Marcos Date: Wed, 21 Oct 2026 19:00:24 +0200 Subject: [PATCH 8/9] fix: clarify RBF bump scenario in e2e tests documentation --- docker/e2e/README.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/docker/e2e/README.md b/docker/e2e/README.md index dd592d4b..c118420f 100644 --- a/docker/e2e/README.md +++ b/docker/e2e/README.md @@ -70,9 +70,10 @@ tests (`DustUtxoWithdrawalE2ETests`, `WithdrawalRbfBumpE2ETests`, `GetNewWalletA those two in sync between the `nodeguard` and `e2e-runner` services**, or the test asserts against numbers NodeGuard didn't plan with. - **RBF bump scenario** (`WithdrawalRbfBumpE2ETests`): `RequestWithdrawal` on the hot wallet, then `BumpWithdrawal` - over gRPC, asserting bitcoind evicts the original for the higher-fee replacement, NodeGuard reports the original - `WITHDRAWAL_BUMPED` while the replacement is in the mempool (its stored status stays pending confirmation), and once - the replacement is mined it settles and the original is stored as `WITHDRAWAL_BUMPED`. It relies on + over gRPC, asserting bitcoind evicts the original for the higher-fee replacement, NodeGuard keeps reporting the + original `WITHDRAWAL_PENDING_CONFIRMATION` with `replaced_by_request_id` set while the replacement is in the mempool + (only a confirmation changes an RBF chain), and once the replacement is mined it settles and the original is stored + as `WITHDRAWAL_BUMPED`. It relies on `MONITOR_WITHDRAWALS_CRON` being fast on the `nodeguard` service (10s here; prod keeps the every-5-minutes default) and never mines between the two calls. The mined-original theory mines the ORIGINAL with `generateblock` (raw tx, bypassing the mempool) after a bump or cancel, and asserts NodeGuard settles the original and fails the replacement. From 0dcdf54754fc3706a6c20b224c27f00ec67ef808 Mon Sep 17 00:00:00 2001 From: Marcos Date: Wed, 21 Oct 2026 19:22:13 +0200 Subject: [PATCH 9/9] fix: change Description property to non-nullable string in WalletWithdrawalRequest --- src/Data/Models/WalletWithdrawalRequest.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Data/Models/WalletWithdrawalRequest.cs b/src/Data/Models/WalletWithdrawalRequest.cs index 7e01e0f3..502fc337 100644 --- a/src/Data/Models/WalletWithdrawalRequest.cs +++ b/src/Data/Models/WalletWithdrawalRequest.cs @@ -82,7 +82,7 @@ public class WalletWithdrawalRequest : Entity, IEquatable /// Description by the requestor /// - public string? Description { get; set; } + public string Description { get; set; } /// /// Bool used to mark if the output of the request should the maximum as possible to the destination address.