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(); } }