Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 26 additions & 2 deletions src/Rpc/NodeGuardService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,30 @@ public override async Task<RequestWithdrawalResponse> 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;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If mempool is down use 1 (?)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1, or 0.1? 🤔

1 => More money wasted, faster mining.
0.1 => Conservative, slower mining.

@RodriFS RodriFS Jul 6, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

mm this kind of breaks this code inside GenerateTemplatePSBT

//We get the mempool.space recommended fees, the custom or use the bitcoin core (nbxplorer) one if not available
var feerate = await _nbXplorerService.GetFeesByType(walletWithdrawalRequest.MempoolRecommendedFeesType)
    ?? walletWithdrawalRequest.CustomFeeRate
    ?? (await LightningHelper.GetFeeRateResult(network, _nbXplorerService)).FeeRate
    .SatoshiPerByte;

if the mempool is not working, then we default to LightningHelper.GetFeeRateResult as a backup

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

but @RodriFS , for what I saw, GetFeeRateResult relies on Mempool for non reg-test, so in this particular case (and rare) where mempool service is not up, it will call it again 🤔

}
else
{
feeRate = recommendedFeeRate.Value;
}
}

withdrawalRequest = new WalletWithdrawalRequest()
{
WalletId = request.WalletId,
Expand All @@ -276,8 +300,8 @@ public override async Task<RequestWithdrawalResponse> RequestWithdrawal(RequestW
: WalletWithdrawalRequestStatus.Pending,
RequestMetadata = request.RequestMetadata,
Changeless = request.Changeless,
MempoolRecommendedFeesType = (MempoolRecommendedFeesType)request.MempoolFeeRate,
CustomFeeRate = request.CustomFeeRate,
MempoolRecommendedFeesType = mempoolFeesType,
CustomFeeRate = feeRate,
ReferenceId = request.ReferenceId
};

Expand Down
118 changes: 118 additions & 0 deletions test/NodeGuard.Tests/Rpc/NodeGuardServiceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -801,6 +801,124 @@ public async Task RequestWithdrawal_NullTemplatePSBT()
await resp.Should().ThrowAsync<RpcException>();
}

[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<CancellationToken>()))
.ReturnsAsync(recommendedFeeRate);

WalletWithdrawalRequest? capturedRequest = null;
walletWithdrawalRequestRepository.Setup(x => x.AddAsync(It.IsAny<WalletWithdrawalRequest>()))
.Callback<WalletWithdrawalRequest>(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<WalletWithdrawalRequest>()))
.Callback<WalletWithdrawalRequest>(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<CancellationToken>()),
Times.Never);
}

private Wallet InitMockRequestWithdrawal(out Mock<IWalletRepository> walletRepository,
out Mock<INBXplorerService> nbxplorerService,
out KeyPathInformation keypath, out PSBT psbt, out UTXOChanges utxoChanges, out PSBTInput input,
Expand Down
Loading