From a4978e098ded67b14c11ea6f77f064b68149fc10 Mon Sep 17 00:00:00 2001 From: joshuakrueger-dfx Date: Wed, 22 Jul 2026 12:34:16 +0200 Subject: [PATCH 1/3] Fix Firo Open CryptoPay minimum fee rejecting Spark payments The Firo customer-facing Open CryptoPay minimum was taken from DFX's own payout fee rate (estimateSmartFee times the CPFP/default margin, ~2.068 sat/vB). Firo Spark transactions carry a protocol-fixed fee at the network relay minimum (~1 sat/vB) that the user cannot raise, so valid Spark payments were rejected. Use Firo's relay floor as the customer minimum (configurable via FIRO_MIN_FEE_RATE, default 1 sat/vB); the payout margin stays on DFX's own payout path. Bitcoin keeps its margin-based minimum because its fees are user-adjustable. --- src/config/config.ts | 1 + .../payment-link-fee.service.spec.ts | 44 +++++++++++++++++++ .../services/payment-link-fee.service.ts | 11 +++-- 3 files changed, 53 insertions(+), 3 deletions(-) create mode 100644 src/subdomains/core/payment-link/services/__tests__/payment-link-fee.service.spec.ts diff --git a/src/config/config.ts b/src/config/config.ts index 4992d6d47d..e0db1d01f0 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -998,6 +998,7 @@ export class Configuration { allowUnconfirmedUtxos: process.env.FIRO_ALLOW_UNCONFIRMED_UTXOS === 'true', cpfpFeeMultiplier: +(process.env.FIRO_CPFP_FEE_MULTIPLIER ?? '2.0'), defaultFeeMultiplier: +(process.env.FIRO_DEFAULT_FEE_MULTIPLIER ?? '1.5'), + minFeeRate: +(process.env.FIRO_MIN_FEE_RATE ?? '1'), // sat/vB — Firo minRelayTxFee (0.00001 FIRO/kB) }, monero: { node: { diff --git a/src/subdomains/core/payment-link/services/__tests__/payment-link-fee.service.spec.ts b/src/subdomains/core/payment-link/services/__tests__/payment-link-fee.service.spec.ts new file mode 100644 index 0000000000..c229a4a51e --- /dev/null +++ b/src/subdomains/core/payment-link/services/__tests__/payment-link-fee.service.spec.ts @@ -0,0 +1,44 @@ +import { GetConfig } from 'src/config/config'; +import { Blockchain } from 'src/integration/blockchain/shared/enums/blockchain.enum'; +import { BlockchainRegistryService } from 'src/integration/blockchain/shared/services/blockchain-registry.service'; +import { PayoutBitcoinService } from 'src/subdomains/supporting/payout/services/payout-bitcoin.service'; +import { PaymentLinkFeeService } from '../payment-link-fee.service'; + +describe('PaymentLinkFeeService', () => { + let service: PaymentLinkFeeService; + let blockchainRegistryService: jest.Mocked; + let payoutBitcoinService: jest.Mocked; + + beforeEach(() => { + blockchainRegistryService = {} as unknown as jest.Mocked; + + payoutBitcoinService = { + getCurrentFeeRate: jest.fn().mockResolvedValue(4), + } as unknown as jest.Mocked; + + service = new PaymentLinkFeeService(blockchainRegistryService, payoutBitcoinService); + }); + + // --- calculateFee() Tests --- // + + describe('calculateFee()', () => { + it('should use the network relay floor (not a margin-multiplied rate) as the Firo customer minimum', async () => { + const fee = await service['calculateFee'](Blockchain.FIRO); + + expect(fee).toBe(GetConfig().blockchain.firo.minFeeRate); + }); + + it('should keep the Firo minimum at or below the relay floor so protocol-fixed Spark payments pass', () => { + // A valid Spark tx pays exactly Firo's minRelayTxFee (~1 sat/vB); the customer minimum must + // never exceed it, or legitimate Spark payments get rejected again. + expect(GetConfig().blockchain.firo.minFeeRate).toBeLessThanOrEqual(1); + }); + + it('should keep the CPFP-multiplied payout rate as the Bitcoin customer minimum', async () => { + const fee = await service['calculateFee'](Blockchain.BITCOIN); + + expect(fee).toBe(4); + expect(payoutBitcoinService.getCurrentFeeRate).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/src/subdomains/core/payment-link/services/payment-link-fee.service.ts b/src/subdomains/core/payment-link/services/payment-link-fee.service.ts index 290fe88ad0..ce2c55fd93 100644 --- a/src/subdomains/core/payment-link/services/payment-link-fee.service.ts +++ b/src/subdomains/core/payment-link/services/payment-link-fee.service.ts @@ -8,7 +8,6 @@ import { Process } from 'src/shared/services/process.service'; import { DfxCron } from 'src/shared/utils/cron'; import { Util } from 'src/shared/utils/util'; import { PayoutBitcoinService } from 'src/subdomains/supporting/payout/services/payout-bitcoin.service'; -import { PayoutFiroService } from 'src/subdomains/supporting/payout/services/payout-firo.service'; import { BlockchainRegistryService } from '../../../../integration/blockchain/shared/services/blockchain-registry.service'; interface FeeCacheData { @@ -27,7 +26,6 @@ export class PaymentLinkFeeService implements OnModuleInit { constructor( private readonly blockchainRegistryService: BlockchainRegistryService, private readonly payoutBitcoinService: PayoutBitcoinService, - private readonly payoutFiroService: PayoutFiroService, ) { this.feeCache = new Map(); } @@ -83,7 +81,14 @@ export class PaymentLinkFeeService implements OnModuleInit { return this.payoutBitcoinService.getCurrentFeeRate(); case Blockchain.FIRO: - return this.payoutFiroService.getCurrentFeeRate(); + // Firo/Spark transactions carry a protocol-fixed fee (Firo's GetMinimumFee, floored at + // minRelayTxFee) the user cannot raise, so a valid Spark payment pays exactly the network + // relay floor. Requiring anything above it — the margin-multiplied payout rate + // (getCurrentFeeRate) or even the raw estimateSmartFee, which sits just above the floor — + // rejects that payment. Use the relay floor as the customer minimum; the payout margin + // stays on DFX's own payout path only. Bitcoin keeps getCurrentFeeRate because its fees + // are user-adjustable, so the margin remains satisfiable there. + return GetConfig().blockchain.firo.minFeeRate; } } From b75a06b06325c5c89271ef45ef07be5160ef2e0a Mon Sep 17 00:00:00 2001 From: joshuakrueger-dfx Date: Wed, 22 Jul 2026 16:23:58 +0200 Subject: [PATCH 2/3] Decouple Bitcoin Open CryptoPay minimum from payout fee margin For consistency with the Firo fix, the customer-facing Open CryptoPay minimum for Bitcoin no longer derives from DFX's payout send rate (which carries a CPFP/default margin meant only for DFX's own outbound spends). Use the network's recommended next-block rate, floored at the relay minimum so the advertised minimum stays relayable. The payout margin remains on DFX's own payout/payin/dex paths, unchanged. --- .../services/bitcoin-based-fee.service.ts | 2 +- .../__tests__/payment-link-fee.service.spec.ts | 16 +++++++++++++--- .../services/payment-link-fee.service.ts | 17 +++++++++++------ .../payout/services/payout-bitcoin.service.ts | 6 ++++++ 4 files changed, 31 insertions(+), 10 deletions(-) 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 8bf81c9c79..a2fc89d9fd 100644 --- a/src/integration/blockchain/bitcoin/services/bitcoin-based-fee.service.ts +++ b/src/integration/blockchain/bitcoin/services/bitcoin-based-fee.service.ts @@ -17,7 +17,7 @@ export interface FeeConfig { } // Node's own minimum relay fee floor (sat/vB); broadcasts below this are rejected outright. -const MIN_FEE_RATE_SAT_VB = 1; +export const MIN_FEE_RATE_SAT_VB = 1; export abstract class BitcoinBasedFeeService { private readonly logger = new DfxLogger(BitcoinBasedFeeService); diff --git a/src/subdomains/core/payment-link/services/__tests__/payment-link-fee.service.spec.ts b/src/subdomains/core/payment-link/services/__tests__/payment-link-fee.service.spec.ts index c229a4a51e..d3e4012c5c 100644 --- a/src/subdomains/core/payment-link/services/__tests__/payment-link-fee.service.spec.ts +++ b/src/subdomains/core/payment-link/services/__tests__/payment-link-fee.service.spec.ts @@ -13,7 +13,8 @@ describe('PaymentLinkFeeService', () => { blockchainRegistryService = {} as unknown as jest.Mocked; payoutBitcoinService = { - getCurrentFeeRate: jest.fn().mockResolvedValue(4), + getCurrentFeeRate: jest.fn().mockResolvedValue(8), + getRecommendedFeeRate: jest.fn().mockResolvedValue(4), } as unknown as jest.Mocked; service = new PaymentLinkFeeService(blockchainRegistryService, payoutBitcoinService); @@ -34,11 +35,20 @@ describe('PaymentLinkFeeService', () => { expect(GetConfig().blockchain.firo.minFeeRate).toBeLessThanOrEqual(1); }); - it('should keep the CPFP-multiplied payout rate as the Bitcoin customer minimum', async () => { + it('should use the recommended rate (not the CPFP-multiplied payout rate) as the Bitcoin customer minimum', async () => { const fee = await service['calculateFee'](Blockchain.BITCOIN); expect(fee).toBe(4); - expect(payoutBitcoinService.getCurrentFeeRate).toHaveBeenCalledTimes(1); + expect(payoutBitcoinService.getRecommendedFeeRate).toHaveBeenCalledTimes(1); + expect(payoutBitcoinService.getCurrentFeeRate).not.toHaveBeenCalled(); + }); + + it('should floor the Bitcoin minimum at the relay minimum when the recommended rate dips below it', async () => { + payoutBitcoinService.getRecommendedFeeRate.mockResolvedValueOnce(0.4); + + const fee = await service['calculateFee'](Blockchain.BITCOIN); + + expect(fee).toBe(1); }); }); }); diff --git a/src/subdomains/core/payment-link/services/payment-link-fee.service.ts b/src/subdomains/core/payment-link/services/payment-link-fee.service.ts index ce2c55fd93..fd36276241 100644 --- a/src/subdomains/core/payment-link/services/payment-link-fee.service.ts +++ b/src/subdomains/core/payment-link/services/payment-link-fee.service.ts @@ -1,6 +1,7 @@ import { Injectable, OnModuleInit } from '@nestjs/common'; import { CronExpression } from '@nestjs/schedule'; import { Environment, GetConfig } from 'src/config/config'; +import { MIN_FEE_RATE_SAT_VB } from 'src/integration/blockchain/bitcoin/services/bitcoin-based-fee.service'; import { Blockchain } from 'src/integration/blockchain/shared/enums/blockchain.enum'; import { PaymentLinkBlockchains } from 'src/integration/blockchain/shared/util/blockchain.util'; import { DfxLogger } from 'src/shared/services/dfx-logger'; @@ -77,17 +78,21 @@ export class PaymentLinkFeeService implements OnModuleInit { return +(await client.getRecommendedGasPrice()); } + // The customer minimum is the network's own minimum for an inbound payment to confirm — it + // must NOT include the CPFP/default margin from getSendFeeRate, which exists only for DFX's + // own outbound spends. The value differs per chain because the chains do, but neither carries + // the payout margin. case Blockchain.BITCOIN: - return this.payoutBitcoinService.getCurrentFeeRate(); + // Bitcoin fees are user-adjustable and the chain can congest, so use the recommended + // (next-block) rate, which adapts to congestion — floored at the relay minimum so the + // advertised minimum is always relayable. + return Math.max(await this.payoutBitcoinService.getRecommendedFeeRate(), MIN_FEE_RATE_SAT_VB); case Blockchain.FIRO: // Firo/Spark transactions carry a protocol-fixed fee (Firo's GetMinimumFee, floored at // minRelayTxFee) the user cannot raise, so a valid Spark payment pays exactly the network - // relay floor. Requiring anything above it — the margin-multiplied payout rate - // (getCurrentFeeRate) or even the raw estimateSmartFee, which sits just above the floor — - // rejects that payment. Use the relay floor as the customer minimum; the payout margin - // stays on DFX's own payout path only. Bitcoin keeps getCurrentFeeRate because its fees - // are user-adjustable, so the margin remains satisfiable there. + // relay floor, and Firo does not congest. Use the relay floor itself (below even the + // recommended rate; configurable via FIRO_MIN_FEE_RATE) — anything above it rejects Spark. return GetConfig().blockchain.firo.minFeeRate; } } diff --git a/src/subdomains/supporting/payout/services/payout-bitcoin.service.ts b/src/subdomains/supporting/payout/services/payout-bitcoin.service.ts index d1929a0708..080a8ca27a 100644 --- a/src/subdomains/supporting/payout/services/payout-bitcoin.service.ts +++ b/src/subdomains/supporting/payout/services/payout-bitcoin.service.ts @@ -63,6 +63,12 @@ export class PayoutBitcoinService extends PayoutBitcoinBasedService { return this.feeService.getSendFeeRate(); } + // Network's recommended (next-block) rate without the payout send margin (see getSendFeeRate). + // Used as the customer-facing minimum for inbound Open CryptoPay payments. + async getRecommendedFeeRate(): Promise { + return this.feeService.getRecommendedFeeRate(); + } + // Quantize each amount to 8 decimals before serializing to the RPC. Even though // BitcoinBasedStrategy.aggregatePayout already rounds once, downstream fee // adjustments and fixRoundingMismatch can re-introduce float artifacts. Reject From 82ba07f22f42a29ae28746e6ba83273df64c0407 Mon Sep 17 00:00:00 2001 From: joshuakrueger-dfx Date: Thu, 23 Jul 2026 10:55:06 +0200 Subject: [PATCH 3/3] Derive Firo Open CryptoPay minimum from node rate instead of hardcoded floor The current OCP Firo deposit address is transparent, so a Stack Wallet payment is a Spark-spend to it whose fee sits at the relay floor and cannot be raised. Mirror the Bitcoin approach: use Firo's own estimatesmartfee(1) without the payout CPFP margin, floored at the relay minimum, instead of a hardcoded FIRO_MIN_FEE_RATE constant. On a quiet Firo node estimatesmartfee returns null (the normal state) and degrades to the relay floor; a genuine node/RPC error propagates so Firo fails closed (drops out of the fee cache) like Bitcoin rather than being advertised at 1. Removes the now-unused FIRO_MIN_FEE_RATE config and adds PayoutFiroService tests for the estimate/null/error branches. --- src/config/config.ts | 1 - .../payment-link-fee.service.spec.ts | 28 +++++++++++++------ .../services/payment-link-fee.service.ts | 15 ++++++---- .../__tests__/payout-firo.service.spec.ts | 24 ++++++++++++++++ .../payout/services/payout-firo.service.ts | 12 ++++++++ 5 files changed, 66 insertions(+), 14 deletions(-) diff --git a/src/config/config.ts b/src/config/config.ts index e0db1d01f0..4992d6d47d 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -998,7 +998,6 @@ export class Configuration { allowUnconfirmedUtxos: process.env.FIRO_ALLOW_UNCONFIRMED_UTXOS === 'true', cpfpFeeMultiplier: +(process.env.FIRO_CPFP_FEE_MULTIPLIER ?? '2.0'), defaultFeeMultiplier: +(process.env.FIRO_DEFAULT_FEE_MULTIPLIER ?? '1.5'), - minFeeRate: +(process.env.FIRO_MIN_FEE_RATE ?? '1'), // sat/vB — Firo minRelayTxFee (0.00001 FIRO/kB) }, monero: { node: { diff --git a/src/subdomains/core/payment-link/services/__tests__/payment-link-fee.service.spec.ts b/src/subdomains/core/payment-link/services/__tests__/payment-link-fee.service.spec.ts index d3e4012c5c..9f2634deb8 100644 --- a/src/subdomains/core/payment-link/services/__tests__/payment-link-fee.service.spec.ts +++ b/src/subdomains/core/payment-link/services/__tests__/payment-link-fee.service.spec.ts @@ -1,13 +1,14 @@ -import { GetConfig } from 'src/config/config'; import { Blockchain } from 'src/integration/blockchain/shared/enums/blockchain.enum'; import { BlockchainRegistryService } from 'src/integration/blockchain/shared/services/blockchain-registry.service'; import { PayoutBitcoinService } from 'src/subdomains/supporting/payout/services/payout-bitcoin.service'; +import { PayoutFiroService } from 'src/subdomains/supporting/payout/services/payout-firo.service'; import { PaymentLinkFeeService } from '../payment-link-fee.service'; describe('PaymentLinkFeeService', () => { let service: PaymentLinkFeeService; let blockchainRegistryService: jest.Mocked; let payoutBitcoinService: jest.Mocked; + let payoutFiroService: jest.Mocked; beforeEach(() => { blockchainRegistryService = {} as unknown as jest.Mocked; @@ -17,22 +18,33 @@ describe('PaymentLinkFeeService', () => { getRecommendedFeeRate: jest.fn().mockResolvedValue(4), } as unknown as jest.Mocked; - service = new PaymentLinkFeeService(blockchainRegistryService, payoutBitcoinService); + payoutFiroService = { + getCurrentFeeRate: jest.fn().mockResolvedValue(6), + getRecommendedFeeRate: jest.fn().mockResolvedValue(3), + } as unknown as jest.Mocked; + + service = new PaymentLinkFeeService(blockchainRegistryService, payoutBitcoinService, payoutFiroService); }); // --- calculateFee() Tests --- // describe('calculateFee()', () => { - it('should use the network relay floor (not a margin-multiplied rate) as the Firo customer minimum', async () => { + it('should use the recommended rate (not the CPFP-multiplied payout rate) as the Firo customer minimum', async () => { const fee = await service['calculateFee'](Blockchain.FIRO); - expect(fee).toBe(GetConfig().blockchain.firo.minFeeRate); + expect(fee).toBe(3); + expect(payoutFiroService.getRecommendedFeeRate).toHaveBeenCalledTimes(1); + expect(payoutFiroService.getCurrentFeeRate).not.toHaveBeenCalled(); }); - it('should keep the Firo minimum at or below the relay floor so protocol-fixed Spark payments pass', () => { - // A valid Spark tx pays exactly Firo's minRelayTxFee (~1 sat/vB); the customer minimum must - // never exceed it, or legitimate Spark payments get rejected again. - expect(GetConfig().blockchain.firo.minFeeRate).toBeLessThanOrEqual(1); + it('should floor the Firo minimum at the relay minimum so protocol-fixed Spark payments pass', async () => { + // On a quiet Firo node estimatesmartfee yields the relay floor (~1 sat/vB); the customer + // minimum must never exceed what a Spark-spend to the transparent deposit address pays. + payoutFiroService.getRecommendedFeeRate.mockResolvedValueOnce(0.4); + + const fee = await service['calculateFee'](Blockchain.FIRO); + + expect(fee).toBe(1); }); it('should use the recommended rate (not the CPFP-multiplied payout rate) as the Bitcoin customer minimum', async () => { diff --git a/src/subdomains/core/payment-link/services/payment-link-fee.service.ts b/src/subdomains/core/payment-link/services/payment-link-fee.service.ts index fd36276241..6e76290ce8 100644 --- a/src/subdomains/core/payment-link/services/payment-link-fee.service.ts +++ b/src/subdomains/core/payment-link/services/payment-link-fee.service.ts @@ -9,6 +9,7 @@ import { Process } from 'src/shared/services/process.service'; import { DfxCron } from 'src/shared/utils/cron'; import { Util } from 'src/shared/utils/util'; import { PayoutBitcoinService } from 'src/subdomains/supporting/payout/services/payout-bitcoin.service'; +import { PayoutFiroService } from 'src/subdomains/supporting/payout/services/payout-firo.service'; import { BlockchainRegistryService } from '../../../../integration/blockchain/shared/services/blockchain-registry.service'; interface FeeCacheData { @@ -27,6 +28,7 @@ export class PaymentLinkFeeService implements OnModuleInit { constructor( private readonly blockchainRegistryService: BlockchainRegistryService, private readonly payoutBitcoinService: PayoutBitcoinService, + private readonly payoutFiroService: PayoutFiroService, ) { this.feeCache = new Map(); } @@ -89,11 +91,14 @@ export class PaymentLinkFeeService implements OnModuleInit { return Math.max(await this.payoutBitcoinService.getRecommendedFeeRate(), MIN_FEE_RATE_SAT_VB); case Blockchain.FIRO: - // Firo/Spark transactions carry a protocol-fixed fee (Firo's GetMinimumFee, floored at - // minRelayTxFee) the user cannot raise, so a valid Spark payment pays exactly the network - // relay floor, and Firo does not congest. Use the relay floor itself (below even the - // recommended rate; configurable via FIRO_MIN_FEE_RATE) — anything above it rejects Spark. - return GetConfig().blockchain.firo.minFeeRate; + // Same principle as Bitcoin: Firo's own next-block rate without the payout margin, floored + // at the relay minimum so it stays relayable. The current OCP deposit address is transparent, + // so a Stack Wallet payment is a Spark-spend to it, whose fee sits at the relay floor and + // cannot be raised; Firo does not congest and its node usually returns no estimate, so this + // resolves to the relay floor in practice — exactly what that Spark-spend pays. A dedicated + // relay-floor cap belongs here only once a Spark `sm1…` deposit address is deployed, whose + // protocol-capped fee cannot follow a congestion-adaptive minimum. + return Math.max(await this.payoutFiroService.getRecommendedFeeRate(), MIN_FEE_RATE_SAT_VB); } } diff --git a/src/subdomains/supporting/payout/services/__tests__/payout-firo.service.spec.ts b/src/subdomains/supporting/payout/services/__tests__/payout-firo.service.spec.ts index 291bf0ff6d..ea59bacf36 100644 --- a/src/subdomains/supporting/payout/services/__tests__/payout-firo.service.spec.ts +++ b/src/subdomains/supporting/payout/services/__tests__/payout-firo.service.spec.ts @@ -32,6 +32,7 @@ describe('PayoutFiroService', () => { mintSpark: mintSparkSpy, getInfo: jest.fn(), getTx: jest.fn(), + estimateSmartFee: jest.fn(), } as unknown as jest.Mocked; const mockFiroService = { @@ -225,4 +226,27 @@ describe('PayoutFiroService', () => { expect(mockFeeService.getSendFeeRate).toHaveBeenCalledTimes(1); }); }); + + describe('getRecommendedFeeRate()', () => { + it('returns the node estimate without the payout margin', async () => { + (mockClient.estimateSmartFee as jest.Mock).mockResolvedValueOnce(3); + + await expect(service.getRecommendedFeeRate()).resolves.toBe(3); + expect(mockClient.estimateSmartFee).toHaveBeenCalledWith(1); + expect(mockFeeService.getSendFeeRate).not.toHaveBeenCalled(); + }); + + it('degrades to the relay floor when the quiet node returns no estimate (null)', async () => { + (mockClient.estimateSmartFee as jest.Mock).mockResolvedValueOnce(null); + + await expect(service.getRecommendedFeeRate()).resolves.toBe(1); + }); + + it('propagates a node/RPC error (fail-closed) instead of masking it as the relay floor', async () => { + const nodeError = new Error('Firo node unreachable'); + (mockClient.estimateSmartFee as jest.Mock).mockRejectedValueOnce(nodeError); + + await expect(service.getRecommendedFeeRate()).rejects.toBe(nodeError); + }); + }); }); diff --git a/src/subdomains/supporting/payout/services/payout-firo.service.ts b/src/subdomains/supporting/payout/services/payout-firo.service.ts index ba0ed01d62..9b7bd68556 100644 --- a/src/subdomains/supporting/payout/services/payout-firo.service.ts +++ b/src/subdomains/supporting/payout/services/payout-firo.service.ts @@ -1,4 +1,5 @@ import { Injectable } from '@nestjs/common'; +import { MIN_FEE_RATE_SAT_VB } from 'src/integration/blockchain/bitcoin/services/bitcoin-based-fee.service'; import { FiroClient } from 'src/integration/blockchain/firo/firo-client'; import { FiroFeeService } from 'src/integration/blockchain/firo/services/firo-fee.service'; import { FiroService } from 'src/integration/blockchain/firo/services/firo.service'; @@ -62,4 +63,15 @@ export class PayoutFiroService extends PayoutBitcoinBasedService { async getCurrentFeeRate(): Promise { return this.feeService.getSendFeeRate(); } + + // Network's recommended (next-block) rate without the payout send margin (see getCurrentFeeRate), + // used as the customer-facing minimum for inbound Open CryptoPay payments. Firo's estimatesmartfee + // returns null on a quiet node (little traffic) — the normal state, where the relay floor is the + // correct customer minimum (a Spark-spend to the transparent deposit address pays exactly that), so + // degrade to it. A genuine node/RPC error still propagates (estimateSmartFee returns null only when + // the node answers without an estimate; callNode rethrows connection errors), so a down node fails + // closed like Bitcoin — the chain drops out of the fee cache rather than being advertised at 1. + async getRecommendedFeeRate(): Promise { + return (await this.client.estimateSmartFee(1)) ?? MIN_FEE_RATE_SAT_VB; + } }