diff --git a/src/Rpc/NodeGuardService.cs b/src/Rpc/NodeGuardService.cs index 7860291f..34a742f2 100644 --- a/src/Rpc/NodeGuardService.cs +++ b/src/Rpc/NodeGuardService.cs @@ -266,6 +266,30 @@ public override async Task RequestWithdrawal(RequestW Amount = new Money(d.AmountSats, MoneyUnit.Satoshi).ToDecimal(MoneyUnit.BTC), }).ToList(); + var mempoolFeesType = (MempoolRecommendedFeesType)request.MempoolFeeRate; + + // For non-custom fee types, snapshot the recommended fee at request time. + // Otherwise CustomFeeRate would be persisted as the proto + // default (0) and shown as "0 sat/vb", even though the actual fee is resolved live at PSBT build time. + decimal feeRate; + if (mempoolFeesType == MempoolRecommendedFeesType.CustomFee) + { + feeRate = request.CustomFeeRate; + } + else + { + var recommendedFeeRate = await _nbXplorerService.GetFeesByType(mempoolFeesType); + if (recommendedFeeRate == null) + { + _logger.LogWarning("Error getting recommended fee rate for type {feeType}, using 0", mempoolFeesType); + feeRate = 0; + } + else + { + feeRate = recommendedFeeRate.Value; + } + } + withdrawalRequest = new WalletWithdrawalRequest() { WalletId = request.WalletId, @@ -276,8 +300,8 @@ public override async Task RequestWithdrawal(RequestW : WalletWithdrawalRequestStatus.Pending, RequestMetadata = request.RequestMetadata, Changeless = request.Changeless, - MempoolRecommendedFeesType = (MempoolRecommendedFeesType)request.MempoolFeeRate, - CustomFeeRate = request.CustomFeeRate, + MempoolRecommendedFeesType = mempoolFeesType, + CustomFeeRate = feeRate, ReferenceId = request.ReferenceId }; diff --git a/test/NodeGuard.Tests/Rpc/NodeGuardServiceTests.cs b/test/NodeGuard.Tests/Rpc/NodeGuardServiceTests.cs index 70be76e3..822babb6 100644 --- a/test/NodeGuard.Tests/Rpc/NodeGuardServiceTests.cs +++ b/test/NodeGuard.Tests/Rpc/NodeGuardServiceTests.cs @@ -801,6 +801,124 @@ public async Task RequestWithdrawal_NullTemplatePSBT() await resp.Should().ThrowAsync(); } + [Fact] + public async Task RequestWithdrawal_NonCustomFeeType_SnapshotsRecommendedFeeRate() + { + //Arrange + var wallet = InitMockRequestWithdrawal(out var walletRepository, + out var nbxplorerService, + out _, + out _, + out _, + out _, + out _, + out var bitcoinService, + out var walletWithdrawalRequestRepository, out var mockScheduler); + + wallet.IsHotWallet = false; + + // The recommended fee for the selected type is resolved live from mempool.space at request time + const decimal recommendedFeeRate = 42m; + nbxplorerService + .Setup(x => x.GetFeesByType(MempoolRecommendedFeesType.HourFee, It.IsAny())) + .ReturnsAsync(recommendedFeeRate); + + WalletWithdrawalRequest? capturedRequest = null; + walletWithdrawalRequestRepository.Setup(x => x.AddAsync(It.IsAny())) + .Callback(r => capturedRequest = r) + .ReturnsAsync((true, null)); + + var mockNodeGuardService = CreateNodeGuardService( + walletRepository: walletRepository.Object, + walletWithdrawalRequestRepository: walletWithdrawalRequestRepository.Object, + bitcoinService: bitcoinService.Object, + nbXplorerService: nbxplorerService.Object, + schedulerFactory: mockScheduler); + + var requestWithdrawalRequest = new RequestWithdrawalRequest + { + WalletId = wallet.Id, + Description = $"Request Withdrawal Test {DateTime.Now}", + MempoolFeeRate = FEES_TYPE.HourFee, + Destinations = + { + new Destination + { + Address = "bcrt1q590shaxaf5u08ml8jwlzghz99dup3z9592vxal", + AmountSats = new Money(1, MoneyUnit.BTC).Satoshi + } + } + }; + + //Act + await mockNodeGuardService.RequestWithdrawal(requestWithdrawalRequest, TestServerCallContext.Create()); + + //Assert + capturedRequest.Should().NotBeNull(); + capturedRequest!.MempoolRecommendedFeesType.Should().Be(MempoolRecommendedFeesType.HourFee); + // CustomFeeRate must hold the recommended fee snapshot, not the proto default (0) + capturedRequest.CustomFeeRate.Should().Be(recommendedFeeRate); + } + + [Fact] + public async Task RequestWithdrawal_CustomFeeType_KeepsRequestCustomFeeRate() + { + //Arrange + var wallet = InitMockRequestWithdrawal(out var walletRepository, + out var nbxplorerService, + out _, + out _, + out _, + out _, + out _, + out var bitcoinService, + out var walletWithdrawalRequestRepository, out var mockScheduler); + + wallet.IsHotWallet = false; + + const int customFeeRate = 15; + + WalletWithdrawalRequest? capturedRequest = null; + walletWithdrawalRequestRepository.Setup(x => x.AddAsync(It.IsAny())) + .Callback(r => capturedRequest = r) + .ReturnsAsync((true, null)); + + var mockNodeGuardService = CreateNodeGuardService( + walletRepository: walletRepository.Object, + walletWithdrawalRequestRepository: walletWithdrawalRequestRepository.Object, + bitcoinService: bitcoinService.Object, + nbXplorerService: nbxplorerService.Object, + schedulerFactory: mockScheduler); + + var requestWithdrawalRequest = new RequestWithdrawalRequest + { + WalletId = wallet.Id, + Description = $"Request Withdrawal Test {DateTime.Now}", + MempoolFeeRate = FEES_TYPE.CustomFee, + CustomFeeRate = customFeeRate, + Destinations = + { + new Destination + { + Address = "bcrt1q590shaxaf5u08ml8jwlzghz99dup3z9592vxal", + AmountSats = new Money(1, MoneyUnit.BTC).Satoshi + } + } + }; + + //Act + await mockNodeGuardService.RequestWithdrawal(requestWithdrawalRequest, TestServerCallContext.Create()); + + //Assert + capturedRequest.Should().NotBeNull(); + capturedRequest!.MempoolRecommendedFeesType.Should().Be(MempoolRecommendedFeesType.CustomFee); + capturedRequest.CustomFeeRate.Should().Be(customFeeRate); + // For a custom fee we must not override the caller's value with a mempool recommendation + nbxplorerService.Verify( + x => x.GetFeesByType(MempoolRecommendedFeesType.CustomFee, It.IsAny()), + Times.Never); + } + private Wallet InitMockRequestWithdrawal(out Mock walletRepository, out Mock nbxplorerService, out KeyPathInformation keypath, out PSBT psbt, out UTXOChanges utxoChanges, out PSBTInput input,