From 847a8df7dc764e4d6208ca365cf17fcde058a68e Mon Sep 17 00:00:00 2001 From: Danswar <48102227+Danswar@users.noreply.github.com> Date: Mon, 20 Jul 2026 10:34:20 -0300 Subject: [PATCH] fix(bitcoin): clamp send fee rate to the node's minimum relay fee (#4289) estimateSmartFee can return an estimate below the node's own minimum relay fee during periods of very low mempool activity, since Bitcoin Core's historical-inclusion estimator isn't bounded by the live relay-policy floor. Once the configured safety multiplier is applied, the resulting fee rate could still round to a value under 1 sat/vB, causing the node to reject the broadcast with "Fee rate is lower than the minimum fee rate setting". Clamp the value returned by getSendFeeRate() to a 1 sat/vB floor before it is handed to the send RPC. The unclamped estimate from getRecommendedFeeRate() is left untouched since it's also used for quoting/display. This is fixed in the shared base class, so it applies to both Bitcoin and Firo payouts. --- .../__tests__/bitcoin-fee.service.spec.ts | 46 +++++++++++++++++++ .../services/bitcoin-based-fee.service.ts | 8 +++- 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/src/integration/blockchain/bitcoin/services/__tests__/bitcoin-fee.service.spec.ts b/src/integration/blockchain/bitcoin/services/__tests__/bitcoin-fee.service.spec.ts index dddb323452..2ef1755f0d 100644 --- a/src/integration/blockchain/bitcoin/services/__tests__/bitcoin-fee.service.spec.ts +++ b/src/integration/blockchain/bitcoin/services/__tests__/bitcoin-fee.service.spec.ts @@ -126,6 +126,52 @@ describe('BitcoinFeeService', () => { }); }); + // --- getSendFeeRate() minimum fee rate floor Tests --- // + + describe('getSendFeeRate() minimum fee rate floor', () => { + const feeConfig = { allowUnconfirmedUtxos: false, cpfpFeeMultiplier: 2.0, defaultFeeMultiplier: 1.5 }; + + beforeEach(() => { + Object.defineProperty(service, 'feeConfig', { get: () => feeConfig }); + }); + + it('should leave a fee rate comfortably above the minimum relay fee unaffected', async () => { + mockClient.estimateSmartFee.mockResolvedValueOnce(10); // 10 * defaultFeeMultiplier (1.5) = 15 + + const result = await service.getSendFeeRate(); + + expect(result).toBe(15); + }); + + it('should clamp a below-floor estimate to the minimum relay fee', async () => { + // Reproduces a production incident: estimateSmartFee returned 0.664 sat/vB during a + // period of very low mempool activity. Even after the default safety multiplier + // (0.664 * 1.5 = 0.996), the result still undercut the node's 1 sat/vB minimum relay + // fee, so the broadcast was rejected. The clamp must raise this to exactly 1. + mockClient.estimateSmartFee.mockResolvedValueOnce(0.664); + + const result = await service.getSendFeeRate(); + + expect(result).toBe(1); + }); + + it('should act as a floor and not override values already above the minimum', async () => { + mockClient.estimateSmartFee.mockResolvedValueOnce(0.7); // 0.7 * 1.5 = 1.05 + + const result = await service.getSendFeeRate(); + + expect(result).toBe(1.05); + }); + + it('should not clamp the underlying recommended fee rate, only the send fee rate', async () => { + mockClient.estimateSmartFee.mockResolvedValueOnce(0.664); + + const recommended = await service.getRecommendedFeeRate(); + + expect(recommended).toBe(0.664); + }); + }); + // --- getTxFeeRate() Tests --- // describe('getTxFeeRate()', () => { diff --git a/src/integration/blockchain/bitcoin/services/bitcoin-based-fee.service.ts b/src/integration/blockchain/bitcoin/services/bitcoin-based-fee.service.ts index f6ae0f5750..8bf81c9c79 100644 --- a/src/integration/blockchain/bitcoin/services/bitcoin-based-fee.service.ts +++ b/src/integration/blockchain/bitcoin/services/bitcoin-based-fee.service.ts @@ -16,6 +16,9 @@ export interface FeeConfig { defaultFeeMultiplier: number; } +// Node's own minimum relay fee floor (sat/vB); broadcasts below this are rejected outright. +const MIN_FEE_RATE_SAT_VB = 1; + export abstract class BitcoinBasedFeeService { private readonly logger = new DfxLogger(BitcoinBasedFeeService); @@ -92,6 +95,9 @@ export abstract class BitcoinBasedFeeService { // Bitcoin Core's send RPC parses fee_rate with decimals=3; un-rounded floating-point // products (e.g. 1.935 * 2 = 3.8699999999999997) are rejected with "Invalid amount". - return Util.round(baseRate * multiplier, 3); + // estimateSmartFee can also return an estimate below the node's own minimum relay fee + // during periods of very low mempool activity, so clamp the value that is actually + // broadcast to that floor; the raw estimate from getRecommendedFeeRate() stays unclamped. + return Math.max(Util.round(baseRate * multiplier, 3), MIN_FEE_RATE_SAT_VB); } }