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
11 changes: 11 additions & 0 deletions src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -815,6 +815,17 @@ export class Configuration {
citreaTestnetApiKey: process.env.CITREA_TESTNET_API_KEY,
blockscoutApiUrl: process.env.CITREA_TESTNET_BLOCKSCOUT_API_URL,
},
bitcoinTestnet4: {
btcTestnet4Output: {
active: process.env.NODE_BTC_TESTNET4_OUT_URL_ACTIVE,
passive: process.env.NODE_BTC_TESTNET4_OUT_URL_PASSIVE,
address: process.env.BTC_TESTNET4_OUT_WALLET_ADDRESS,
},
user: process.env.NODE_BTC_TESTNET4_USER,
password: process.env.NODE_BTC_TESTNET4_PASSWORD,
walletPassword: process.env.NODE_BTC_TESTNET4_WALLET_PASSWORD,
minTxAmount: 0.00000297,
},
lightning: {
lnbits: {
apiKey: process.env.LIGHTNING_LNBITS_API_KEY,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { Config, GetConfig } from 'src/config/config';
import { HttpService } from 'src/shared/services/http.service';
import { BitcoinBasedClient } from '../bitcoin/node/bitcoin-based-client';
import { NodeClientConfig } from '../bitcoin/node/node-client';

export class BitcoinTestnet4Client extends BitcoinBasedClient {
constructor(http: HttpService, url: string) {
const testnet4Config = GetConfig().blockchain.bitcoinTestnet4;

const config: NodeClientConfig = {
user: testnet4Config.user,
password: testnet4Config.password,
walletPassword: testnet4Config.walletPassword,
allowUnconfirmedUtxos: true,
};

super(http, url, config);
}

get walletAddress(): string {
return Config.blockchain.bitcoinTestnet4.btcTestnet4Output.address;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { SharedModule } from 'src/shared/shared.module';
import { BitcoinTestnet4Service } from './bitcoin-testnet4.service';
import { BitcoinTestnet4FeeService } from './services/bitcoin-testnet4-fee.service';

@Module({
imports: [SharedModule],
providers: [BitcoinTestnet4Service, BitcoinTestnet4FeeService],
exports: [BitcoinTestnet4Service, BitcoinTestnet4FeeService],
})
export class BitcoinTestnet4Module {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { Config } from 'src/config/config';
import { HttpService } from 'src/shared/services/http.service';
import { BlockchainInfo } from '../bitcoin/node/rpc';
import { BlockchainService } from '../shared/util/blockchain.service';
import { BitcoinTestnet4Client } from './bitcoin-testnet4-client';

export enum BitcoinTestnet4NodeType {
BTC_TESTNET4_OUTPUT = 'btc-testnet4-out',
}

export interface BitcoinTestnet4Error {
message: string;
nodeType: BitcoinTestnet4NodeType;
}

interface BitcoinTestnet4CheckResult {
errors: BitcoinTestnet4Error[];
info: BlockchainInfo | undefined;
}

@Injectable()
export class BitcoinTestnet4Service extends BlockchainService {
private readonly allNodes: Map<BitcoinTestnet4NodeType, BitcoinTestnet4Client> = new Map();

constructor(private readonly http: HttpService) {
super();

this.initAllNodes();
}

getDefaultClient(type = BitcoinTestnet4NodeType.BTC_TESTNET4_OUTPUT): BitcoinTestnet4Client {
return this.allNodes.get(type);
}

// --- HEALTH CHECK API --- //

async checkNodes(): Promise<BitcoinTestnet4Error[]> {
return Promise.all(Object.values(BitcoinTestnet4NodeType).map((type) => this.checkNode(type))).then((errors) =>
errors.reduce((prev, curr) => prev.concat(curr), []),
);
}

// --- PUBLIC API --- //

getNodeFromPool<T extends BitcoinTestnet4NodeType>(type: T): BitcoinTestnet4Client {
const client = this.allNodes.get(type);

if (client) {
return client;
}

throw new BadRequestException(`No node for type '${type}'`);
}

// --- INIT METHODS --- //

private initAllNodes(): void {
this.addNode(BitcoinTestnet4NodeType.BTC_TESTNET4_OUTPUT, Config.blockchain.bitcoinTestnet4.btcTestnet4Output);
}

private addNode(type: BitcoinTestnet4NodeType, config: { active: string }): void {
const client = this.createNodeClient(config.active);
this.allNodes.set(type, client);
}

private createNodeClient(url: string | undefined): BitcoinTestnet4Client | null {
return url ? new BitcoinTestnet4Client(this.http, url) : null;
}

// --- HELPER METHODS --- //

private async checkNode(type: BitcoinTestnet4NodeType): Promise<BitcoinTestnet4CheckResult> {
const client = this.allNodes.get(type);

if (!client) {
return { errors: [], info: undefined };
}

return client
.getInfo()
.then((info) => this.handleNodeCheckSuccess(info, type))
.catch(() => this.handleNodeCheckError(type));
}

private handleNodeCheckSuccess(info: BlockchainInfo, type: BitcoinTestnet4NodeType): BitcoinTestnet4CheckResult {
const result = { errors: [], info };

if (info.blocks < info.headers - 10) {
result.errors.push({
message: `${type} node out of sync (blocks: ${info.blocks}, headers: ${info.headers})`,
nodeType: type,
});
}

return result;
}

private handleNodeCheckError(type: BitcoinTestnet4NodeType): BitcoinTestnet4CheckResult {
return {
errors: [{ message: `Failed to get ${type} node infos`, nodeType: type }],
info: undefined,
};
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { Injectable } from '@nestjs/common';
import { DfxLogger } from 'src/shared/services/dfx-logger';
import { AsyncCache, CacheItemResetPeriod } from 'src/shared/utils/async-cache';
import { BitcoinTestnet4Client } from '../bitcoin-testnet4-client';
import { BitcoinTestnet4NodeType, BitcoinTestnet4Service } from '../bitcoin-testnet4.service';

export type TxFeeRateStatus = 'unconfirmed' | 'confirmed' | 'not_found' | 'error';

export interface TxFeeRateResult {
status: TxFeeRateStatus;
feeRate?: number;
}

@Injectable()
export class BitcoinTestnet4FeeService {
private readonly logger = new DfxLogger(BitcoinTestnet4FeeService);
private readonly client: BitcoinTestnet4Client;

// Thread-safe cache with fallback support
private readonly feeRateCache = new AsyncCache<number>(CacheItemResetPeriod.EVERY_30_SECONDS);
private readonly txFeeRateCache = new AsyncCache<TxFeeRateResult>(CacheItemResetPeriod.EVERY_30_SECONDS);

constructor(bitcoinTestnet4Service: BitcoinTestnet4Service) {
this.client = bitcoinTestnet4Service.getDefaultClient(BitcoinTestnet4NodeType.BTC_TESTNET4_OUTPUT);
}

async getRecommendedFeeRate(): Promise<number> {
return this.feeRateCache.get(
'fastestFee',
async () => {
const feeRate = await this.client.estimateSmartFee(1);

if (feeRate === null) {
this.logger.verbose('Fee estimation returned null, using minimum fee rate of 1 sat/vB');
return 1;
}

return feeRate;
},
undefined,
true, // fallbackToCache on error
);
}

async getTxFeeRate(txid: string): Promise<TxFeeRateResult> {
return this.txFeeRateCache.get(
txid,
async () => {
try {
const entry = await this.client.getMempoolEntry(txid);

if (entry === null) {
// TX not in mempool - either confirmed or doesn't exist
// Check if TX exists in wallet
const tx = await this.client.getTx(txid);
if (tx && tx.confirmations > 0) {
return { status: 'confirmed' as const };
}
return { status: 'not_found' as const };
}

return { status: 'unconfirmed' as const, feeRate: entry.feeRate };
} catch (e) {
this.logger.error(`Failed to get TX fee rate for ${txid}:`, e);
return { status: 'error' as const };
}
},
undefined,
true, // fallbackToCache on error
);
}

async getTxFeeRates(txids: string[]): Promise<Map<string, TxFeeRateResult>> {
const results = new Map<string, TxFeeRateResult>();

// Parallel fetch - errors are handled in getTxFeeRate
const promises = txids.map(async (txid) => {
const result = await this.getTxFeeRate(txid);
return { txid, result };
});

const responses = await Promise.all(promises);

for (const { txid, result } of responses) {
results.set(txid, result);
}

return results;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,9 @@
import { HttpService } from 'src/shared/services/http.service';
import { BitcoinClient } from '../bitcoin-client';

// Mock Config
jest.mock('src/config/config', () => ({
Config: {
// Mock Config and GetConfig
jest.mock('src/config/config', () => {
const mockConfig = {
blockchain: {
default: {
user: 'testuser',
Expand All @@ -22,8 +22,12 @@ jest.mock('src/config/config', () => ({
},
},
},
},
}));
};
return {
Config: mockConfig,
GetConfig: () => mockConfig,
};
});

describe('BitcoinClient', () => {
let client: BitcoinClient;
Expand Down Expand Up @@ -605,16 +609,16 @@ describe('BitcoinClient', () => {
// --- Unimplemented Methods Tests --- //

describe('Unimplemented Token Methods', () => {
it('getToken() should throw "Bitcoin has no token"', async () => {
await expect(client.getToken({} as any)).rejects.toThrow('Bitcoin has no token');
it('getToken() should throw "Bitcoin chain has no token"', async () => {
await expect(client.getToken({} as any)).rejects.toThrow('Bitcoin chain has no token');
});

it('getTokenBalance() should throw "Bitcoin has no token"', async () => {
await expect(client.getTokenBalance({} as any)).rejects.toThrow('Bitcoin has no token');
it('getTokenBalance() should throw "Bitcoin chain has no token"', async () => {
await expect(client.getTokenBalance({} as any)).rejects.toThrow('Bitcoin chain has no token');
});

it('getTokenBalances() should throw "Bitcoin has no token"', async () => {
await expect(client.getTokenBalances([])).rejects.toThrow('Bitcoin has no token');
it('getTokenBalances() should throw "Bitcoin chain has no token"', async () => {
await expect(client.getTokenBalances([])).rejects.toThrow('Bitcoin chain has no token');
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,14 @@ import { HttpService } from 'src/shared/services/http.service';
import { Asset } from 'src/shared/models/asset/asset.entity';
import { BlockchainTokenBalance } from '../../../shared/dto/blockchain-token-balance.dto';
import { BitcoinRpcClient } from '../rpc/bitcoin-rpc-client';
import { NodeClient } from '../node-client';
import { NodeClient, NodeClientConfig } from '../node-client';

// Concrete implementation for testing
class TestNodeClient extends NodeClient {
constructor(http: HttpService, url: string) {
super(http, url, testConfig);
}

// Required abstract implementations
get walletAddress(): string {
return 'bc1qtestwalletaddress';
Expand Down Expand Up @@ -63,9 +67,17 @@ class TestNodeClient extends NodeClient {
}
}

// Mock Config
jest.mock('src/config/config', () => ({
Config: {
// Test config for NodeClient
const testConfig: NodeClientConfig = {
user: 'testuser',
password: 'testpass',
walletPassword: 'walletpass123',
allowUnconfirmedUtxos: true,
};

// Mock Config and GetConfig
jest.mock('src/config/config', () => {
const mockBlockchainConfig = {
blockchain: {
default: {
user: 'testuser',
Expand All @@ -74,8 +86,12 @@ jest.mock('src/config/config', () => ({
allowUnconfirmedUtxos: true,
},
},
},
}));
};
return {
Config: mockBlockchainConfig,
GetConfig: () => mockBlockchainConfig,
};
});

describe('NodeClient', () => {
let mockHttpService: jest.Mocked<HttpService>;
Expand Down
Loading
Loading