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 @@ -53,20 +53,45 @@ export abstract class BitcoinBasedClient extends NodeClient implements CoinOnly
return { outTxId: result?.txid ?? '', feeAmount };
}

async sendMany(payload: { addressTo: string; amount: number }[], feeRate: number): Promise<string> {
async sendMany(
payload: { addressTo: string; amount: number }[],
feeRate: number,
inputs?: Array<{ txid: string; vout: number }>,
subtractFeeFromOutputs?: number[],
): Promise<string> {
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);

return result?.txid ?? '';
}

async sendManyFromAddress(
fromAddresses: string[],
payload: { addressTo: string; amount: number }[],
feeRate: number,
subtractFeeFromOutputs?: number[],
): Promise<string> {
const utxos = await this.getUtxoForAddresses(fromAddresses, this.nodeConfig.allowUnconfirmedUtxos);
if (!utxos.length) throw new Error('No UTXOs available');

const inputs = utxos.map((u) => ({ txid: u.txid, vout: u.vout }));
const utxoBalance = utxos.reduce((sum, u) => sum + u.amount, 0);

// resolve zero-amount entries with full UTXO balance (sweep mode)
const resolvedPayload = payload.map((p) => ({ addressTo: p.addressTo, amount: p.amount || utxoBalance }));

return this.sendMany(resolvedPayload, feeRate, inputs, subtractFeeFromOutputs);
}

async testMempoolAccept(hex: string): Promise<TestMempoolResult[]> {
const result = await this.callNode(() => this.rpc.testMempoolAccept([hex]), true);

Expand Down
5 changes: 5 additions & 0 deletions src/integration/blockchain/bitcoin/node/node-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<UTXO[]> {
const minConf = includeUnconfirmed ? 0 : 1;
return this.callNode(() => this.rpc.listUnspent(minConf, 9999999, addresses), true);
}

async getBalance(): Promise<number> {
// Include unconfirmed UTXOs when configured
// Bitcoin Core's getbalances returns: trusted (confirmed + own unconfirmed), untrusted_pending (others' unconfirmed), immature (coinbase)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand All @@ -17,6 +23,8 @@ export abstract class BitcoinBasedFeeService {

constructor(protected readonly client: NodeClient) {}

protected abstract get feeConfig(): FeeConfig;

async getRecommendedFeeRate(): Promise<number> {
return this.feeRateCache.get(
'fastestFee',
Expand Down Expand Up @@ -74,4 +82,13 @@ export abstract class BitcoinBasedFeeService {

return results;
}

async getSendFeeRate(): Promise<number> {
const baseRate = await this.getRecommendedFeeRate();

const { allowUnconfirmedUtxos, cpfpFeeMultiplier, defaultFeeMultiplier } = this.feeConfig;
const multiplier = allowUnconfirmedUtxos ? cpfpFeeMultiplier : defaultFeeMultiplier;

return baseRate * multiplier;
}
}
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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;
}
}
33 changes: 17 additions & 16 deletions src/integration/blockchain/firo/firo-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<number> {
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 this.roundAmount(utxos?.reduce((sum, u) => sum + u.amount, 0) ?? 0);
Expand Down Expand Up @@ -103,24 +102,28 @@ export class FiroClient extends BitcoinBasedClient {
return { outTxId, feeAmount };
}

// Use UTXOs from the liquidity and payment addresses to avoid spending deposit UTXOs.
// Change is sent back to the liquidity address, naturally consolidating funds over time.
// Delegates to sendManyFromAddress using the liquidity and payment addresses.
async sendMany(payload: { addressTo: string; amount: number }[], feeRate: number): Promise<string> {
return this.sendManyFromAddress([this.walletAddress, this.paymentAddress], payload, feeRate);
}

// Use UTXOs from the specified addresses to avoid spending deposit UTXOs.
// Change is sent back to the liquidity address, naturally consolidating funds over time.
async sendManyFromAddress(
fromAddresses: string[],
payload: { addressTo: string; amount: number }[],
feeRate: number,
): Promise<string> {
const outputs = payload.reduce(
(acc, p) => ({ ...acc, [p.addressTo]: this.roundAmount(p.amount) }),
{} as Record<string, number>,
);
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(fromAddresses, this.nodeConfig.allowUnconfirmedUtxos);

if (!utxos || utxos.length === 0) {
throw new Error('No UTXOs available on the liquidity/payment addresses');
throw new Error('No UTXOs available on the specified addresses');
}

// Select UTXOs to cover outputs + estimated fee (225 bytes per input, 34 per output, 10 overhead)
Expand All @@ -143,9 +146,7 @@ export class FiroClient extends BitcoinBasedClient {
const fee = (feeRate * txSize) / 1e8;

if (inputTotal < outputTotal + fee) {
throw new Error(
`Insufficient funds on liquidity/payment addresses: have ${inputTotal}, need ${outputTotal + fee}`,
);
throw new Error(`Insufficient funds on specified addresses: have ${inputTotal}, need ${outputTotal + fee}`);
}

const change = this.roundAmount(inputTotal - outputTotal - fee);
Expand Down
11 changes: 3 additions & 8 deletions src/integration/blockchain/firo/services/firo-fee.service.ts
Original file line number Diff line number Diff line change
@@ -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()
Expand All @@ -9,12 +9,7 @@ export class FiroFeeService extends BitcoinBasedFeeService {
super(firoService.getDefaultClient());
}

async getSendFeeRate(): Promise<number> {
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;
}
}
Original file line number Diff line number Diff line change
@@ -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 { InternetComputerUtil } from 'src/integration/blockchain/icp/icp.util';
import { BlockchainTokenBalance } from 'src/integration/blockchain/shared/dto/blockchain-token-balance.dto';
Expand Down Expand Up @@ -46,6 +48,7 @@ export class PaymentBalanceService implements OnModuleInit {
constructor(
private readonly assetService: AssetService,
private readonly blockchainRegistryService: BlockchainRegistryService,
private readonly bitcoinFeeService: BitcoinFeeService,
) {}

onModuleInit() {
Expand Down Expand Up @@ -161,7 +164,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()
Expand All @@ -181,6 +184,10 @@ export class PaymentBalanceService implements OnModuleInit {
}

private async forwardDeposit(asset: Asset, balance: number): Promise<string> {
if (asset.blockchain === Blockchain.BITCOIN) {
return this.forwardBitcoinDeposit();
}

const account = this.getPaymentAccount(asset.blockchain);
const client = this.blockchainRegistryService.getClient(asset.blockchain) as
| EvmClient
Expand All @@ -193,6 +200,20 @@ export class PaymentBalanceService implements OnModuleInit {
: client.sendTokenFromAccount(account, client.walletAddress, asset, balance);
}

private async forwardBitcoinDeposit(): Promise<string> {
const client = this.blockchainRegistryService.getBitcoinClient(Blockchain.BITCOIN, BitcoinNodeType.BTC_INPUT);
const outputAddress = Config.blockchain.default.btcOutput.address;
const feeRate = await this.bitcoinFeeService.getSendFeeRate();

// sweep all payment UTXOs: amount 0 = use full UTXO balance, fee subtracted from output
return client.sendManyFromAddress(
[Config.payment.bitcoinAddress],
[{ addressTo: outputAddress, amount: 0 }],
feeRate,
[0],
);
}

getPaymentAccount(chain: Blockchain): WalletAccount {
switch (chain) {
case Blockchain.ETHEREUM:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ export class DexBitcoinService {
}

async sendUtxoToMany(payout: { addressTo: string; amount: number }[]): Promise<string> {
const feeRate = await this.feeService.getRecommendedFeeRate();
const feeRate = await this.feeService.getSendFeeRate();
return this.client.sendMany(payout, feeRate);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ export class DexFiroService {
}

async sendUtxoToMany(payout: { addressTo: string; amount: number }[]): Promise<string> {
const feeRate = await this.getFeeRate();
const feeRate = await this.feeService.getSendFeeRate();
return this.client.sendMany(payout, feeRate);
}

Expand All @@ -44,10 +44,6 @@ export class DexFiroService {

//*** HELPER METHODS ***//

private async getFeeRate(): Promise<number> {
return this.feeService.getSendFeeRate();
}

private async getPendingAmount(): Promise<number> {
const pendingOrders = await this.liquidityOrderRepo.findBy({
isComplete: false,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ export class PayInBitcoinService extends PayInBitcoinBasedService {
input.inTxId,
input.sendingAmount,
input.txSequence,
await this.feeService.getRecommendedFeeRate(),
await this.feeService.getSendFeeRate(),
);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -44,12 +43,6 @@ export class PayoutBitcoinService extends PayoutBitcoinBasedService {
}

async getCurrentFeeRate(): Promise<number> {
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();
}
}
Loading