Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
@@ -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<BlockchainRegistryService>;
let payoutBitcoinService: jest.Mocked<PayoutBitcoinService>;
let payoutFiroService: jest.Mocked<PayoutFiroService>;

beforeEach(() => {
blockchainRegistryService = {} as unknown as jest.Mocked<BlockchainRegistryService>;

payoutBitcoinService = {
getCurrentFeeRate: jest.fn().mockResolvedValue(8),
getRecommendedFeeRate: jest.fn().mockResolvedValue(4),
} as unknown as jest.Mocked<PayoutBitcoinService>;

payoutFiroService = {
getCurrentFeeRate: jest.fn().mockResolvedValue(6),
getRecommendedFeeRate: jest.fn().mockResolvedValue(3),
} as unknown as jest.Mocked<PayoutFiroService>;

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);
});
});
});
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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);
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ describe('PayoutFiroService', () => {
mintSpark: mintSparkSpy,
getInfo: jest.fn(),
getTx: jest.fn(),
estimateSmartFee: jest.fn(),
} as unknown as jest.Mocked<FiroClient>;

const mockFiroService = {
Expand Down Expand Up @@ -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);
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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<number> {
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
Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -62,4 +63,15 @@ export class PayoutFiroService extends PayoutBitcoinBasedService {
async getCurrentFeeRate(): Promise<number> {
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<number> {
return (await this.client.estimateSmartFee(1)) ?? MIN_FEE_RATE_SAT_VB;
}
}