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 new file mode 100644 index 0000000000..9f2634deb8 --- /dev/null +++ b/src/subdomains/core/payment-link/services/__tests__/payment-link-fee.service.spec.ts @@ -0,0 +1,66 @@ +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; + + payoutBitcoinService = { + getCurrentFeeRate: jest.fn().mockResolvedValue(8), + getRecommendedFeeRate: jest.fn().mockResolvedValue(4), + } as unknown as jest.Mocked; + + 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 recommended rate (not the CPFP-multiplied payout rate) as the Firo customer minimum', async () => { + const fee = await service['calculateFee'](Blockchain.FIRO); + + expect(fee).toBe(3); + expect(payoutFiroService.getRecommendedFeeRate).toHaveBeenCalledTimes(1); + expect(payoutFiroService.getCurrentFeeRate).not.toHaveBeenCalled(); + }); + + 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 () => { + const fee = await service['calculateFee'](Blockchain.BITCOIN); + + expect(fee).toBe(4); + 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 290fe88ad0..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 @@ -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'; @@ -79,11 +80,25 @@ 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: - return this.payoutFiroService.getCurrentFeeRate(); + // 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-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 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; + } }