From 94eb98bcc12208ab90e38a743ef73aff3d49f2ec Mon Sep 17 00:00:00 2001 From: Bernd Date: Tue, 24 Feb 2026 17:54:59 +0100 Subject: [PATCH] feat: centralize fee logic and add Bitcoin deposit forwarding Move getSendFeeRate() into BitcoinBasedFeeService using template method pattern, eliminating duplicate fee multiplier logic across Bitcoin and Firo services. Add dedicated Bitcoin deposit forwarding with explicit UTXO selection and subtract_fee_from_outputs for accurate fee handling. --- .../bitcoin/node/bitcoin-based-client.ts | 9 ++++++- .../blockchain/bitcoin/node/node-client.ts | 5 ++++ .../services/bitcoin-based-fee.service.ts | 17 ++++++++++++ .../bitcoin/services/bitcoin-fee.service.ts | 7 ++++- .../blockchain/firo/firo-client.ts | 14 +++++----- .../firo/services/firo-fee.service.ts | 11 +++----- .../services/payment-balance.service.ts | 26 ++++++++++++++++++- .../dex/services/dex-bitcoin.service.ts | 2 +- .../dex/services/dex-firo.service.ts | 6 +---- .../payin/services/payin-bitcoin.service.ts | 2 +- .../payin/services/payin-firo.service.ts | 2 +- .../payout/services/payout-bitcoin.service.ts | 9 +------ 12 files changed, 75 insertions(+), 35 deletions(-) diff --git a/src/integration/blockchain/bitcoin/node/bitcoin-based-client.ts b/src/integration/blockchain/bitcoin/node/bitcoin-based-client.ts index ba3a7bdb28..2e43b9c4a9 100644 --- a/src/integration/blockchain/bitcoin/node/bitcoin-based-client.ts +++ b/src/integration/blockchain/bitcoin/node/bitcoin-based-client.ts @@ -53,13 +53,20 @@ export abstract class BitcoinBasedClient extends NodeClient implements CoinOnly return { outTxId: result?.txid ?? '', feeAmount }; } - async sendMany(payload: { addressTo: string; amount: number }[], feeRate: number): Promise { + async sendMany( + payload: { addressTo: string; amount: number }[], + feeRate: number, + inputs?: Array<{ txid: string; vout: number }>, + subtractFeeFromOutputs?: number[], + ): Promise { const outputs = payload.map((p) => ({ [p.addressTo]: p.amount })); const options = { replaceable: true, change_address: this.walletAddress, ...(this.nodeConfig.allowUnconfirmedUtxos && { include_unsafe: true }), + ...(inputs && { inputs, add_inputs: false }), + ...(subtractFeeFromOutputs && { subtract_fee_from_outputs: subtractFeeFromOutputs }), }; const result = await this.callNode(() => this.rpc.send(outputs, null, null, feeRate, options), true); diff --git a/src/integration/blockchain/bitcoin/node/node-client.ts b/src/integration/blockchain/bitcoin/node/node-client.ts index 65c5bd0a48..34912e3378 100644 --- a/src/integration/blockchain/bitcoin/node/node-client.ts +++ b/src/integration/blockchain/bitcoin/node/node-client.ts @@ -158,6 +158,11 @@ export abstract class NodeClient extends BlockchainClient { return this.callNode(() => this.rpc.listUnspent(minConf), true); } + async getUtxoForAddresses(addresses: string[], includeUnconfirmed = false): Promise { + const minConf = includeUnconfirmed ? 0 : 1; + return this.callNode(() => this.rpc.listUnspent(minConf, 9999999, addresses), true); + } + async getBalance(): Promise { // Include unconfirmed UTXOs when configured // Bitcoin Core's getbalances returns: trusted (confirmed + own unconfirmed), untrusted_pending (others' unconfirmed), immature (coinbase) 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 a4a9775b7d..9d0d95952c 100644 --- a/src/integration/blockchain/bitcoin/services/bitcoin-based-fee.service.ts +++ b/src/integration/blockchain/bitcoin/services/bitcoin-based-fee.service.ts @@ -9,6 +9,12 @@ export interface TxFeeRateResult { feeRate?: number; } +export interface FeeConfig { + allowUnconfirmedUtxos: boolean; + cpfpFeeMultiplier: number; + defaultFeeMultiplier: number; +} + export abstract class BitcoinBasedFeeService { private readonly logger = new DfxLogger(BitcoinBasedFeeService); @@ -17,6 +23,8 @@ export abstract class BitcoinBasedFeeService { constructor(protected readonly client: NodeClient) {} + protected abstract get feeConfig(): FeeConfig; + async getRecommendedFeeRate(): Promise { return this.feeRateCache.get( 'fastestFee', @@ -74,4 +82,13 @@ export abstract class BitcoinBasedFeeService { return results; } + + async getSendFeeRate(): Promise { + const baseRate = await this.getRecommendedFeeRate(); + + const { allowUnconfirmedUtxos, cpfpFeeMultiplier, defaultFeeMultiplier } = this.feeConfig; + const multiplier = allowUnconfirmedUtxos ? cpfpFeeMultiplier : defaultFeeMultiplier; + + return baseRate * multiplier; + } } diff --git a/src/integration/blockchain/bitcoin/services/bitcoin-fee.service.ts b/src/integration/blockchain/bitcoin/services/bitcoin-fee.service.ts index ac6eeb95d4..08620eff47 100644 --- a/src/integration/blockchain/bitcoin/services/bitcoin-fee.service.ts +++ b/src/integration/blockchain/bitcoin/services/bitcoin-fee.service.ts @@ -1,5 +1,6 @@ import { Injectable } from '@nestjs/common'; -import { BitcoinBasedFeeService } from './bitcoin-based-fee.service'; +import { Config } from 'src/config/config'; +import { BitcoinBasedFeeService, FeeConfig } from './bitcoin-based-fee.service'; import { BitcoinNodeType, BitcoinService } from './bitcoin.service'; export { TxFeeRateResult, TxFeeRateStatus } from './bitcoin-based-fee.service'; @@ -9,4 +10,8 @@ export class BitcoinFeeService extends BitcoinBasedFeeService { constructor(bitcoinService: BitcoinService) { super(bitcoinService.getDefaultClient(BitcoinNodeType.BTC_INPUT)); } + + protected get feeConfig(): FeeConfig { + return Config.blockchain.default; + } } diff --git a/src/integration/blockchain/firo/firo-client.ts b/src/integration/blockchain/firo/firo-client.ts index ca23ddc31c..9da3b5b498 100644 --- a/src/integration/blockchain/firo/firo-client.ts +++ b/src/integration/blockchain/firo/firo-client.ts @@ -50,10 +50,9 @@ export class FiroClient extends BitcoinBasedClient { // Firo's account-based getbalance with '' returns only the default account, which can be negative. // Use listunspent filtered to the liquidity and payment addresses for an accurate spendable balance. async getBalance(): Promise { - const minConf = this.nodeConfig.allowUnconfirmedUtxos ? 0 : 1; - const utxos = await this.callNode( - () => this.rpc.listUnspent(minConf, 9999999, [this.walletAddress, this.paymentAddress]), - true, + const utxos = await this.getUtxoForAddresses( + [this.walletAddress, this.paymentAddress], + this.nodeConfig.allowUnconfirmedUtxos, ); return utxos?.reduce((sum, u) => sum + u.amount, 0) ?? 0; @@ -113,10 +112,9 @@ export class FiroClient extends BitcoinBasedClient { const outputTotal = payload.reduce((sum, p) => sum + p.amount, 0); // Get UTXOs from liquidity and payment addresses (excludes deposit address UTXOs) - const minConf = this.nodeConfig.allowUnconfirmedUtxos ? 0 : 1; - const utxos = await this.callNode( - () => this.rpc.listUnspent(minConf, 9999999, [this.walletAddress, this.paymentAddress]), - true, + const utxos = await this.getUtxoForAddresses( + [this.walletAddress, this.paymentAddress], + this.nodeConfig.allowUnconfirmedUtxos, ); if (!utxos || utxos.length === 0) { diff --git a/src/integration/blockchain/firo/services/firo-fee.service.ts b/src/integration/blockchain/firo/services/firo-fee.service.ts index d7d08e3d96..2d985ec1a2 100644 --- a/src/integration/blockchain/firo/services/firo-fee.service.ts +++ b/src/integration/blockchain/firo/services/firo-fee.service.ts @@ -1,6 +1,6 @@ import { Injectable } from '@nestjs/common'; import { Config } from 'src/config/config'; -import { BitcoinBasedFeeService } from '../../bitcoin/services/bitcoin-based-fee.service'; +import { BitcoinBasedFeeService, FeeConfig } from '../../bitcoin/services/bitcoin-based-fee.service'; import { FiroService } from './firo.service'; @Injectable() @@ -9,12 +9,7 @@ export class FiroFeeService extends BitcoinBasedFeeService { super(firoService.getDefaultClient()); } - async getSendFeeRate(): Promise { - const baseRate = await this.getRecommendedFeeRate(); - - const { allowUnconfirmedUtxos, cpfpFeeMultiplier, defaultFeeMultiplier } = Config.blockchain.firo; - const multiplier = allowUnconfirmedUtxos ? cpfpFeeMultiplier : defaultFeeMultiplier; - - return baseRate * multiplier; + protected get feeConfig(): FeeConfig { + return Config.blockchain.firo; } } diff --git a/src/subdomains/core/payment-link/services/payment-balance.service.ts b/src/subdomains/core/payment-link/services/payment-balance.service.ts index 31cfebdd75..e648f43e89 100644 --- a/src/subdomains/core/payment-link/services/payment-balance.service.ts +++ b/src/subdomains/core/payment-link/services/payment-balance.service.ts @@ -1,5 +1,7 @@ import { Injectable, OnModuleInit } from '@nestjs/common'; import { Config } from 'src/config/config'; +import { BitcoinFeeService } from 'src/integration/blockchain/bitcoin/services/bitcoin-fee.service'; +import { BitcoinNodeType } from 'src/integration/blockchain/bitcoin/services/bitcoin.service'; import { CardanoUtil } from 'src/integration/blockchain/cardano/cardano.util'; import { BlockchainTokenBalance } from 'src/integration/blockchain/shared/dto/blockchain-token-balance.dto'; import { Blockchain } from 'src/integration/blockchain/shared/enums/blockchain.enum'; @@ -43,6 +45,7 @@ export class PaymentBalanceService implements OnModuleInit { constructor( private readonly assetService: AssetService, private readonly blockchainRegistryService: BlockchainRegistryService, + private readonly bitcoinFeeService: BitcoinFeeService, ) {} onModuleInit() { @@ -151,7 +154,7 @@ export class PaymentBalanceService implements OnModuleInit { } async forwardDeposits() { - const chainsWithoutForwarding = [Blockchain.BITCOIN, Blockchain.FIRO, ...this.chainsWithoutPaymentBalance]; + const chainsWithoutForwarding = [Blockchain.FIRO, ...this.chainsWithoutPaymentBalance]; const paymentAssets = await this.assetService .getPaymentAssets() @@ -171,6 +174,10 @@ export class PaymentBalanceService implements OnModuleInit { } private async forwardDeposit(asset: Asset, balance: number): Promise { + if (asset.blockchain === Blockchain.BITCOIN) { + return this.forwardBitcoinDeposit(); + } + const account = this.getPaymentAccount(asset.blockchain); const client = this.blockchainRegistryService.getClient(asset.blockchain) as EvmClient | SolanaClient | TronClient; @@ -179,6 +186,23 @@ export class PaymentBalanceService implements OnModuleInit { : client.sendTokenFromAccount(account, client.walletAddress, asset, balance); } + private async forwardBitcoinDeposit(): Promise { + const client = this.blockchainRegistryService.getBitcoinClient(Blockchain.BITCOIN, BitcoinNodeType.BTC_INPUT); + const paymentAddress = Config.payment.bitcoinAddress; + const outputAddress = Config.blockchain.default.btcOutput.address; + const feeRate = await this.bitcoinFeeService.getSendFeeRate(); + + // only use UTXOs from the payment address (not deposit UTXOs on the same wallet) + const utxos = await client.getUtxoForAddresses([paymentAddress], true); + if (!utxos.length) return ''; + + const inputs = utxos.map((u) => ({ txid: u.txid, vout: u.vout })); + const utxoBalance = utxos.reduce((sum, u) => sum + u.amount, 0); + + // sweep all UTXOs: send full balance and let Bitcoin Core subtract the fee from the output + return client.sendMany([{ addressTo: outputAddress, amount: utxoBalance }], feeRate, inputs, [0]); + } + private getPaymentAccount(chain: Blockchain): WalletAccount { switch (chain) { case Blockchain.ETHEREUM: diff --git a/src/subdomains/supporting/dex/services/dex-bitcoin.service.ts b/src/subdomains/supporting/dex/services/dex-bitcoin.service.ts index 11c9dbfd2a..528d30c19b 100644 --- a/src/subdomains/supporting/dex/services/dex-bitcoin.service.ts +++ b/src/subdomains/supporting/dex/services/dex-bitcoin.service.ts @@ -21,7 +21,7 @@ export class DexBitcoinService { } async sendUtxoToMany(payout: { addressTo: string; amount: number }[]): Promise { - const feeRate = await this.feeService.getRecommendedFeeRate(); + const feeRate = await this.feeService.getSendFeeRate(); return this.client.sendMany(payout, feeRate); } diff --git a/src/subdomains/supporting/dex/services/dex-firo.service.ts b/src/subdomains/supporting/dex/services/dex-firo.service.ts index ebf9e01fad..2c56279957 100644 --- a/src/subdomains/supporting/dex/services/dex-firo.service.ts +++ b/src/subdomains/supporting/dex/services/dex-firo.service.ts @@ -21,7 +21,7 @@ export class DexFiroService { } async sendUtxoToMany(payout: { addressTo: string; amount: number }[]): Promise { - const feeRate = await this.getFeeRate(); + const feeRate = await this.feeService.getSendFeeRate(); return this.client.sendMany(payout, feeRate); } @@ -44,10 +44,6 @@ export class DexFiroService { //*** HELPER METHODS ***// - private async getFeeRate(): Promise { - return this.feeService.getSendFeeRate(); - } - private async getPendingAmount(): Promise { const pendingOrders = await this.liquidityOrderRepo.findBy({ isComplete: false, diff --git a/src/subdomains/supporting/payin/services/payin-bitcoin.service.ts b/src/subdomains/supporting/payin/services/payin-bitcoin.service.ts index 7a393d8a40..c84885f8d4 100644 --- a/src/subdomains/supporting/payin/services/payin-bitcoin.service.ts +++ b/src/subdomains/supporting/payin/services/payin-bitcoin.service.ts @@ -99,7 +99,7 @@ export class PayInBitcoinService extends PayInBitcoinBasedService { input.inTxId, input.sendingAmount, input.txSequence, - await this.feeService.getRecommendedFeeRate(), + await this.feeService.getSendFeeRate(), ); } diff --git a/src/subdomains/supporting/payin/services/payin-firo.service.ts b/src/subdomains/supporting/payin/services/payin-firo.service.ts index 55ba2f270e..8d84d260fc 100644 --- a/src/subdomains/supporting/payin/services/payin-firo.service.ts +++ b/src/subdomains/supporting/payin/services/payin-firo.service.ts @@ -90,7 +90,7 @@ export class PayInFiroService extends PayInBitcoinBasedService { } async sendTransfer(input: CryptoInput): Promise<{ outTxId: string; feeAmount: number }> { - const feeRate = await this.feeService.getRecommendedFeeRate(); + const feeRate = await this.feeService.getSendFeeRate(); return this.client.send( input.destinationAddress.address, input.inTxId, diff --git a/src/subdomains/supporting/payout/services/payout-bitcoin.service.ts b/src/subdomains/supporting/payout/services/payout-bitcoin.service.ts index cb946dc201..900b0c5f93 100644 --- a/src/subdomains/supporting/payout/services/payout-bitcoin.service.ts +++ b/src/subdomains/supporting/payout/services/payout-bitcoin.service.ts @@ -1,5 +1,4 @@ import { Injectable } from '@nestjs/common'; -import { Config } from 'src/config/config'; import { BitcoinClient } from 'src/integration/blockchain/bitcoin/node/bitcoin-client'; import { BitcoinFeeService } from 'src/integration/blockchain/bitcoin/services/bitcoin-fee.service'; import { BitcoinNodeType, BitcoinService } from 'src/integration/blockchain/bitcoin/services/bitcoin.service'; @@ -44,12 +43,6 @@ export class PayoutBitcoinService extends PayoutBitcoinBasedService { } async getCurrentFeeRate(): Promise { - const baseRate = await this.feeService.getRecommendedFeeRate(); - - // Use higher multiplier when unconfirmed UTXOs are enabled (CPFP effect) - const { allowUnconfirmedUtxos, cpfpFeeMultiplier, defaultFeeMultiplier } = Config.blockchain.default; - const multiplier = allowUnconfirmedUtxos ? cpfpFeeMultiplier : defaultFeeMultiplier; - - return baseRate * multiplier; + return this.feeService.getSendFeeRate(); } }