diff --git a/src/config/config.ts b/src/config/config.ts index 2ac230df19..a066380874 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -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, diff --git a/src/integration/blockchain/bitcoin-testnet4/bitcoin-testnet4-client.ts b/src/integration/blockchain/bitcoin-testnet4/bitcoin-testnet4-client.ts new file mode 100644 index 0000000000..4a9d09151a --- /dev/null +++ b/src/integration/blockchain/bitcoin-testnet4/bitcoin-testnet4-client.ts @@ -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; + } +} diff --git a/src/integration/blockchain/bitcoin-testnet4/bitcoin-testnet4.module.ts b/src/integration/blockchain/bitcoin-testnet4/bitcoin-testnet4.module.ts new file mode 100644 index 0000000000..efc29781a7 --- /dev/null +++ b/src/integration/blockchain/bitcoin-testnet4/bitcoin-testnet4.module.ts @@ -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 {} diff --git a/src/integration/blockchain/bitcoin-testnet4/bitcoin-testnet4.service.ts b/src/integration/blockchain/bitcoin-testnet4/bitcoin-testnet4.service.ts new file mode 100644 index 0000000000..1bf4a170ff --- /dev/null +++ b/src/integration/blockchain/bitcoin-testnet4/bitcoin-testnet4.service.ts @@ -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 = 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 { + return Promise.all(Object.values(BitcoinTestnet4NodeType).map((type) => this.checkNode(type))).then((errors) => + errors.reduce((prev, curr) => prev.concat(curr), []), + ); + } + + // --- PUBLIC API --- // + + getNodeFromPool(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 { + 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, + }; + } +} diff --git a/src/integration/blockchain/bitcoin-testnet4/services/bitcoin-testnet4-fee.service.ts b/src/integration/blockchain/bitcoin-testnet4/services/bitcoin-testnet4-fee.service.ts new file mode 100644 index 0000000000..8373b72869 --- /dev/null +++ b/src/integration/blockchain/bitcoin-testnet4/services/bitcoin-testnet4-fee.service.ts @@ -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(CacheItemResetPeriod.EVERY_30_SECONDS); + private readonly txFeeRateCache = new AsyncCache(CacheItemResetPeriod.EVERY_30_SECONDS); + + constructor(bitcoinTestnet4Service: BitcoinTestnet4Service) { + this.client = bitcoinTestnet4Service.getDefaultClient(BitcoinTestnet4NodeType.BTC_TESTNET4_OUTPUT); + } + + async getRecommendedFeeRate(): Promise { + 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 { + 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> { + const results = new Map(); + + // 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; + } +} diff --git a/src/integration/blockchain/bitcoin/node/__tests__/bitcoin-client.spec.ts b/src/integration/blockchain/bitcoin/node/__tests__/bitcoin-client.spec.ts index 27940737d0..eb855dec69 100644 --- a/src/integration/blockchain/bitcoin/node/__tests__/bitcoin-client.spec.ts +++ b/src/integration/blockchain/bitcoin/node/__tests__/bitcoin-client.spec.ts @@ -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', @@ -22,8 +22,12 @@ jest.mock('src/config/config', () => ({ }, }, }, - }, -})); + }; + return { + Config: mockConfig, + GetConfig: () => mockConfig, + }; +}); describe('BitcoinClient', () => { let client: BitcoinClient; @@ -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'); }); }); }); diff --git a/src/integration/blockchain/bitcoin/node/__tests__/node-client.spec.ts b/src/integration/blockchain/bitcoin/node/__tests__/node-client.spec.ts index 95c38904b2..d8688aa5f2 100644 --- a/src/integration/blockchain/bitcoin/node/__tests__/node-client.spec.ts +++ b/src/integration/blockchain/bitcoin/node/__tests__/node-client.spec.ts @@ -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'; @@ -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', @@ -74,8 +86,12 @@ jest.mock('src/config/config', () => ({ allowUnconfirmedUtxos: true, }, }, - }, -})); + }; + return { + Config: mockBlockchainConfig, + GetConfig: () => mockBlockchainConfig, + }; +}); describe('NodeClient', () => { let mockHttpService: jest.Mocked; diff --git a/src/integration/blockchain/bitcoin/node/bitcoin-based-client.ts b/src/integration/blockchain/bitcoin/node/bitcoin-based-client.ts new file mode 100644 index 0000000000..83644b00c7 --- /dev/null +++ b/src/integration/blockchain/bitcoin/node/bitcoin-based-client.ts @@ -0,0 +1,150 @@ +import { Currency } from '@uniswap/sdk-core'; +import { Asset } from 'src/shared/models/asset/asset.entity'; +import { HttpService } from 'src/shared/services/http.service'; +import { BlockchainTokenBalance } from '../../shared/dto/blockchain-token-balance.dto'; +import { BlockchainSignedTransactionResponse } from '../../shared/dto/signed-transaction-reponse.dto'; +import { NodeClient, NodeClientConfig } from './node-client'; + +export interface TransactionHistory { + address: string; + category: string; + blocktime: number; + txid: string; + confirmations: number; + amount: number; +} + +export interface TestMempoolResult { + txid: string; + allowed: boolean; + vsize: number; + fees: { + base: number; + }; + 'reject-reason': string; +} + +export abstract class BitcoinBasedClient extends NodeClient { + constructor(http: HttpService, url: string, config: NodeClientConfig) { + super(http, url, config); + } + + abstract get walletAddress(): string; + + async send( + addressTo: string, + txId: string, + amount: number, + vout: number, + feeRate: number, + ): Promise<{ outTxId: string; feeAmount: number }> { + // 135 vByte for a single-input single-output TX + const feeAmount = (feeRate * 135) / Math.pow(10, 8); + + const outputs = [{ [addressTo]: this.roundAmount(amount - feeAmount) }]; + const options = { + inputs: [{ txid: txId, vout }], + replaceable: true, + }; + + const result = await this.callNode(() => this.rpc.send(outputs, null, null, feeRate, options), true); + + return { outTxId: result?.txid ?? '', feeAmount }; + } + + async sendMany(payload: { addressTo: string; amount: number }[], feeRate: 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 }), + }; + + const result = await this.callNode(() => this.rpc.send(outputs, null, null, feeRate, options), true); + + return result?.txid ?? ''; + } + + async testMempoolAccept(hex: string): Promise { + const result = await this.callNode(() => this.rpc.testMempoolAccept([hex]), true); + + if (!result || !Array.isArray(result)) { + return [{ txid: '', allowed: false, vsize: 0, fees: { base: 0 }, 'reject-reason': 'RPC call failed' }]; + } + + return result.map((r) => ({ + txid: r.txid ?? '', + allowed: r.allowed ?? false, + vsize: r.vsize ?? 0, + fees: { base: r.fees?.base ?? 0 }, + 'reject-reason': r['reject-reason'] ?? '', + })); + } + + async sendSignedTransaction(hex: string): Promise { + try { + const txid = await this.callNode(() => this.rpc.sendRawTransaction(hex), true); + return { hash: txid ?? '' }; + } catch (e) { + return { + error: { + code: e.code ?? -1, + message: e.message ?? 'Unknown error', + }, + }; + } + } + + async getRecentHistory(txCount = 100): Promise { + const result = await this.callNode(() => this.rpc.listTransactions('*', txCount), true); + return result.map((tx) => ({ + address: tx.address, + category: tx.category, + blocktime: tx.blocktime ?? 0, + txid: tx.txid, + confirmations: tx.confirmations, + amount: tx.amount, + })); + } + + async isTxComplete(txId: string, minConfirmations?: number): Promise { + const transaction = await this.getRawTx(txId); + return ( + transaction !== null && + transaction.blockhash !== undefined && + (transaction.confirmations ?? 0) > (minConfirmations ?? 0) + ); + } + + async getNativeCoinBalance(): Promise { + return this.getBalance(); + } + + async getNativeCoinBalanceForAddress(address: string): Promise { + const groupings = await this.callNode(() => this.rpc.listAddressGroupings(), true); + + for (const outer of groupings) { + for (const inner of outer) { + if (inner[0] === address) { + return inner[1] as number; + } + } + } + + return 0; + } + + // --- UNIMPLEMENTED METHODS (Bitcoin-based chains have no tokens) --- // + async getToken(_: Asset): Promise { + throw new Error('Bitcoin chain has no token'); + } + + async getTokenBalance(_: Asset, __?: string): Promise { + throw new Error('Bitcoin chain has no token'); + } + + async getTokenBalances(_: Asset[], __?: string): Promise { + throw new Error('Bitcoin chain has no token'); + } +} diff --git a/src/integration/blockchain/bitcoin/node/bitcoin-client.ts b/src/integration/blockchain/bitcoin/node/bitcoin-client.ts index 7434f9e1c4..ce61496afe 100644 --- a/src/integration/blockchain/bitcoin/node/bitcoin-client.ts +++ b/src/integration/blockchain/bitcoin/node/bitcoin-client.ts @@ -1,148 +1,23 @@ -import { Currency } from '@uniswap/sdk-core'; -import { Config } from 'src/config/config'; -import { Asset } from 'src/shared/models/asset/asset.entity'; -import { BlockchainTokenBalance } from '../../shared/dto/blockchain-token-balance.dto'; -import { BlockchainSignedTransactionResponse } from '../../shared/dto/signed-transaction-reponse.dto'; -import { NodeClient } from './node-client'; - -export interface TransactionHistory { - address: string; - category: string; - blocktime: number; - txid: string; - confirmations: number; - amount: number; -} - -export interface TestMempoolResult { - txid: string; - allowed: boolean; - vsize: number; - fees: { - base: number; - }; - 'reject-reason': string; -} - -export class BitcoinClient extends NodeClient { - get walletAddress(): string { - return Config.blockchain.default.btcOutput.address; - } - - async send( - addressTo: string, - txId: string, - amount: number, - vout: number, - feeRate: number, - ): Promise<{ outTxId: string; feeAmount: number }> { - // 135 vByte for a single-input single-output TX - const feeAmount = (feeRate * 135) / Math.pow(10, 8); - - const outputs = [{ [addressTo]: this.roundAmount(amount - feeAmount) }]; - const options = { - inputs: [{ txid: txId, vout }], - replaceable: true, +import { Config, GetConfig } from 'src/config/config'; +import { HttpService } from 'src/shared/services/http.service'; +import { BitcoinBasedClient } from './bitcoin-based-client'; +import { NodeClientConfig } from './node-client'; + +export class BitcoinClient extends BitcoinBasedClient { + constructor(http: HttpService, url: string) { + const defaultConfig = GetConfig().blockchain.default; + + const config: NodeClientConfig = { + user: defaultConfig.user, + password: defaultConfig.password, + walletPassword: defaultConfig.walletPassword, + allowUnconfirmedUtxos: defaultConfig.allowUnconfirmedUtxos, }; - const result = await this.callNode(() => this.rpc.send(outputs, null, null, feeRate, options), true); - - return { outTxId: result?.txid ?? '', feeAmount }; - } - - async sendMany(payload: { addressTo: string; amount: number }[], feeRate: number): Promise { - const outputs = payload.map((p) => ({ [p.addressTo]: p.amount })); - - const options = { - replaceable: true, - change_address: Config.blockchain.default.btcOutput.address, - ...(Config.blockchain.default.allowUnconfirmedUtxos && { include_unsafe: true }), - }; - - const result = await this.callNode(() => this.rpc.send(outputs, null, null, feeRate, options), true); - - return result?.txid ?? ''; + super(http, url, config); } - async testMempoolAccept(hex: string): Promise { - const result = await this.callNode(() => this.rpc.testMempoolAccept([hex]), true); - - if (!result || !Array.isArray(result)) { - return [{ txid: '', allowed: false, vsize: 0, fees: { base: 0 }, 'reject-reason': 'RPC call failed' }]; - } - - return result.map((r) => ({ - txid: r.txid ?? '', - allowed: r.allowed ?? false, - vsize: r.vsize ?? 0, - fees: { base: r.fees?.base ?? 0 }, - 'reject-reason': r['reject-reason'] ?? '', - })); - } - - async sendSignedTransaction(hex: string): Promise { - try { - const txid = await this.callNode(() => this.rpc.sendRawTransaction(hex), true); - return { hash: txid ?? '' }; - } catch (e) { - return { - error: { - code: e.code ?? -1, - message: e.message ?? 'Unknown error', - }, - }; - } - } - - async getRecentHistory(txCount = 100): Promise { - const result = await this.callNode(() => this.rpc.listTransactions('*', txCount), true); - return result.map((tx) => ({ - address: tx.address, - category: tx.category, - blocktime: tx.blocktime ?? 0, - txid: tx.txid, - confirmations: tx.confirmations, - amount: tx.amount, - })); - } - - async isTxComplete(txId: string, minConfirmations?: number): Promise { - const transaction = await this.getRawTx(txId); - return ( - transaction !== null && - transaction.blockhash !== undefined && - (transaction.confirmations ?? 0) > (minConfirmations ?? 0) - ); - } - - async getNativeCoinBalance(): Promise { - return this.getBalance(); - } - - async getNativeCoinBalanceForAddress(address: string): Promise { - const groupings = await this.callNode(() => this.rpc.listAddressGroupings(), true); - - for (const outer of groupings) { - for (const inner of outer) { - if (inner[0] === address) { - return inner[1] as number; - } - } - } - - return 0; - } - - // --- UNIMPLEMENTED METHODS --- // - async getToken(_: Asset): Promise { - throw new Error('Bitcoin has no token'); - } - - async getTokenBalance(_: Asset, __?: string): Promise { - throw new Error('Bitcoin has no token'); - } - - async getTokenBalances(_: Asset[], __?: string): Promise { - throw new Error('Bitcoin has no token'); + get walletAddress(): string { + return Config.blockchain.default.btcOutput.address; } } diff --git a/src/integration/blockchain/bitcoin/node/bitcoin.service.ts b/src/integration/blockchain/bitcoin/node/bitcoin.service.ts index c620416b60..ac84744b8e 100644 --- a/src/integration/blockchain/bitcoin/node/bitcoin.service.ts +++ b/src/integration/blockchain/bitcoin/node/bitcoin.service.ts @@ -1,10 +1,10 @@ import { BadRequestException, Injectable } from '@nestjs/common'; -import { BlockchainInfo } from './node-client'; import { Config } from 'src/config/config'; import { HttpService } from 'src/shared/services/http.service'; import { Util } from 'src/shared/utils/util'; import { BlockchainService } from '../../shared/util/blockchain.service'; import { BitcoinClient } from './bitcoin-client'; +import { BlockchainInfo } from './rpc'; export enum BitcoinNodeType { BTC_INPUT = 'btc-inp', diff --git a/src/integration/blockchain/bitcoin/node/node-client.ts b/src/integration/blockchain/bitcoin/node/node-client.ts index 2cdc96e916..65c5bd0a48 100644 --- a/src/integration/blockchain/bitcoin/node/node-client.ts +++ b/src/integration/blockchain/bitcoin/node/node-client.ts @@ -1,5 +1,4 @@ import { ServiceUnavailableException } from '@nestjs/common'; -import { Config } from 'src/config/config'; import { DfxLogger } from 'src/shared/services/dfx-logger'; import { HttpService } from 'src/shared/services/http.service'; import { QueueHandler } from 'src/shared/utils/queue-handler'; @@ -10,6 +9,13 @@ import { BitcoinRpcClient, BitcoinRpcConfig, BlockchainInfo, RawTransaction } fr export type AddressType = 'legacy' | 'p2sh-segwit' | 'bech32'; +export interface NodeClientConfig { + user: string; + password: string; + walletPassword: string; + allowUnconfirmedUtxos?: boolean; +} + export interface InWalletTransaction { txid: string; blockhash?: string; @@ -27,8 +33,6 @@ export interface Block { tx: string[]; } -export { BlockchainInfo }; - export enum NodeCommand { UNLOCK = 'walletpassphrase', SEND_UTXO = 'sendutxosfrom', @@ -43,16 +47,20 @@ export abstract class NodeClient extends BlockchainClient { protected readonly rpc: BitcoinRpcClient; private readonly queue: QueueHandler; + protected readonly nodeConfig: NodeClientConfig; - constructor(http: HttpService, url: string) { + constructor(http: HttpService, url: string, config: NodeClientConfig) { super(); - const config: BitcoinRpcConfig = { + this.nodeConfig = config; + + const rpcConfig: BitcoinRpcConfig = { url, - username: Config.blockchain.default.user, - password: Config.blockchain.default.password, + username: this.nodeConfig.user, + password: this.nodeConfig.password, }; - this.rpc = new BitcoinRpcClient(http, config); + + this.rpc = new BitcoinRpcClient(http, rpcConfig); this.queue = new QueueHandler(180000, 60000); } @@ -159,7 +167,7 @@ export abstract class NodeClient extends BlockchainClient { } // Return confirmed + unconfirmed balances when allowUnconfirmedUtxos is enabled const baseBalance = balances.mine.trusted; - const unconfirmedBalance = Config.blockchain.default.allowUnconfirmedUtxos ? balances.mine.untrusted_pending : 0; + const unconfirmedBalance = this.nodeConfig.allowUnconfirmedUtxos ? balances.mine.untrusted_pending : 0; return baseBalance + unconfirmedBalance; } @@ -254,7 +262,7 @@ export abstract class NodeClient extends BlockchainClient { private async unlock(timeout = 60): Promise { try { - await this.rpc.walletPassphrase(Config.blockchain.default.walletPassword, timeout); + await this.rpc.walletPassphrase(this.nodeConfig.walletPassword, timeout); } catch (e) { this.logger.verbose('Wallet unlock attempt:', e.message); } diff --git a/src/integration/blockchain/blockchain.module.ts b/src/integration/blockchain/blockchain.module.ts index f8eb2ba78d..23c96b721e 100644 --- a/src/integration/blockchain/blockchain.module.ts +++ b/src/integration/blockchain/blockchain.module.ts @@ -1,5 +1,6 @@ import { Module } from '@nestjs/common'; import { BitcoinModule } from 'src/integration/blockchain/bitcoin/bitcoin.module'; +import { BitcoinTestnet4Module } from 'src/integration/blockchain/bitcoin-testnet4/bitcoin-testnet4.module'; import { SharedModule } from 'src/shared/shared.module'; import { LightningModule } from '../lightning/lightning.module'; import { RailgunModule } from '../railgun/railgun.module'; @@ -38,6 +39,7 @@ import { ZanoModule } from './zano/zano.module'; imports: [ SharedModule, BitcoinModule, + BitcoinTestnet4Module, BscModule, EthereumModule, SepoliaModule, @@ -68,6 +70,7 @@ import { ZanoModule } from './zano/zano.module'; ], exports: [ BitcoinModule, + BitcoinTestnet4Module, BscModule, EthereumModule, SepoliaModule, diff --git a/src/integration/blockchain/shared/enums/blockchain.enum.ts b/src/integration/blockchain/shared/enums/blockchain.enum.ts index 4bedf2b868..ebdb2209ff 100644 --- a/src/integration/blockchain/shared/enums/blockchain.enum.ts +++ b/src/integration/blockchain/shared/enums/blockchain.enum.ts @@ -22,6 +22,7 @@ export enum Blockchain { TRON = 'Tron', CITREA = 'Citrea', CITREA_TESTNET = 'CitreaTestnet', + BITCOIN_TESTNET4 = 'BitcoinTestnet4', // Payment Provider BINANCE_PAY = 'BinancePay', diff --git a/src/integration/blockchain/shared/services/blockchain-registry.service.ts b/src/integration/blockchain/shared/services/blockchain-registry.service.ts index 18b7a53cd0..30b8cf8c22 100644 --- a/src/integration/blockchain/shared/services/blockchain-registry.service.ts +++ b/src/integration/blockchain/shared/services/blockchain-registry.service.ts @@ -3,6 +3,8 @@ import { ArbitrumService } from '../../arbitrum/arbitrum.service'; import { BaseService } from '../../base/base.service'; import { BitcoinClient } from '../../bitcoin/node/bitcoin-client'; import { BitcoinNodeType, BitcoinService } from '../../bitcoin/node/bitcoin.service'; +import { BitcoinTestnet4Client } from '../../bitcoin-testnet4/bitcoin-testnet4-client'; +import { BitcoinTestnet4NodeType, BitcoinTestnet4Service } from '../../bitcoin-testnet4/bitcoin-testnet4.service'; import { BscService } from '../../bsc/bsc.service'; import { CardanoClient } from '../../cardano/cardano-client'; import { CardanoService } from '../../cardano/services/cardano.service'; @@ -31,6 +33,7 @@ import { L2BridgeEvmClient } from '../evm/interfaces'; type BlockchainClientType = | EvmClient | BitcoinClient + | BitcoinTestnet4Client | MoneroClient | SparkClient | ZanoClient @@ -40,6 +43,7 @@ type BlockchainClientType = type BlockchainServiceType = | EvmService | BitcoinService + | BitcoinTestnet4Service | MoneroService | SparkService | ZanoService @@ -67,6 +71,7 @@ export class BlockchainRegistryService { private readonly cardanoService: CardanoService, private readonly citreaService: CitreaService, private readonly citreaTestnetService: CitreaTestnetService, + private readonly bitcoinTestnet4Service: BitcoinTestnet4Service, ) {} getClient(blockchain: Blockchain): BlockchainClientType { @@ -86,6 +91,13 @@ export class BlockchainRegistryService { return blockchainService.getDefaultClient(type); } + getBitcoinTestnet4Client(blockchain: Blockchain, type: BitcoinTestnet4NodeType): BitcoinTestnet4Client { + const blockchainService = this.getService(blockchain); + if (!(blockchainService instanceof BitcoinTestnet4Service)) + throw new Error(`No bitcoin testnet4 client found for blockchain ${blockchain}`); + return blockchainService.getDefaultClient(type); + } + getService(blockchain: Blockchain): BlockchainServiceType { switch (blockchain) { case Blockchain.ETHEREUM: @@ -122,6 +134,8 @@ export class BlockchainRegistryService { return this.citreaService; case Blockchain.CITREA_TESTNET: return this.citreaTestnetService; + case Blockchain.BITCOIN_TESTNET4: + return this.bitcoinTestnet4Service; default: throw new Error(`No service found for blockchain ${blockchain}`); diff --git a/src/integration/blockchain/shared/util/blockchain.util.ts b/src/integration/blockchain/shared/util/blockchain.util.ts index 7a3f46e4d1..e3d01c3a7d 100644 --- a/src/integration/blockchain/shared/util/blockchain.util.ts +++ b/src/integration/blockchain/shared/util/blockchain.util.ts @@ -20,7 +20,7 @@ export const EvmBlockchains = [ export const TestBlockchains = GetConfig().environment === Environment.PRD - ? [Blockchain.SEPOLIA, Blockchain.CITREA_TESTNET, Blockchain.HAQQ, Blockchain.ARWEAVE] + ? [Blockchain.SEPOLIA, Blockchain.CITREA_TESTNET, Blockchain.BITCOIN_TESTNET4, Blockchain.HAQQ, Blockchain.ARWEAVE] : []; export const PaymentLinkBlockchains = [ @@ -82,6 +82,7 @@ const BlockchainExplorerUrls: { [b in Blockchain]: string } = { [Blockchain.TRON]: 'https://tronscan.org/#', [Blockchain.CITREA]: 'https://citreascan.com', [Blockchain.CITREA_TESTNET]: 'https://testnet.citreascan.com', + [Blockchain.BITCOIN_TESTNET4]: 'https://mempool.space/testnet4', [Blockchain.HAQQ]: 'https://explorer.haqq.network', [Blockchain.LIQUID]: 'https://blockstream.info/liquid', [Blockchain.ARWEAVE]: 'https://arscan.io', @@ -119,6 +120,7 @@ const TxPaths: { [b in Blockchain]: string } = { [Blockchain.TRON]: 'transaction', [Blockchain.CITREA]: 'tx', [Blockchain.CITREA_TESTNET]: 'tx', + [Blockchain.BITCOIN_TESTNET4]: 'tx', [Blockchain.HAQQ]: 'tx', [Blockchain.LIQUID]: 'tx', [Blockchain.ARWEAVE]: 'tx', @@ -143,6 +145,7 @@ function assetPaths(asset: Asset): string | undefined { return `tokens/${asset.name}`; case Blockchain.BITCOIN: + case Blockchain.BITCOIN_TESTNET4: case Blockchain.LIGHTNING: case Blockchain.MONERO: return undefined; @@ -178,6 +181,7 @@ function addressPaths(blockchain: Blockchain): string | undefined { case Blockchain.DEFICHAIN: case Blockchain.BITCOIN: + case Blockchain.BITCOIN_TESTNET4: case Blockchain.ETHEREUM: case Blockchain.BINANCE_SMART_CHAIN: case Blockchain.OPTIMISM: diff --git a/src/integration/exchange/services/__tests__/exchange.test.ts b/src/integration/exchange/services/__tests__/exchange.test.ts index 5a06c52011..d59f9bd670 100644 --- a/src/integration/exchange/services/__tests__/exchange.test.ts +++ b/src/integration/exchange/services/__tests__/exchange.test.ts @@ -38,6 +38,7 @@ export class TestExchangeService extends ExchangeService { Tron: undefined, Citrea: undefined, CitreaTestnet: undefined, + BitcoinTestnet4: undefined, Kraken: undefined, Binance: undefined, XT: undefined, diff --git a/src/integration/exchange/services/binance.service.ts b/src/integration/exchange/services/binance.service.ts index 9aeb37ea7b..673fd1fc03 100644 --- a/src/integration/exchange/services/binance.service.ts +++ b/src/integration/exchange/services/binance.service.ts @@ -35,6 +35,7 @@ export class BinanceService extends ExchangeService { Tron: 'TRX', Citrea: undefined, CitreaTestnet: undefined, + BitcoinTestnet4: undefined, Kraken: undefined, Binance: undefined, XT: undefined, diff --git a/src/integration/exchange/services/bitstamp.service.ts b/src/integration/exchange/services/bitstamp.service.ts index 514d32b55b..a9517a956e 100644 --- a/src/integration/exchange/services/bitstamp.service.ts +++ b/src/integration/exchange/services/bitstamp.service.ts @@ -35,6 +35,7 @@ export class BitstampService extends ExchangeService { Tron: undefined, Citrea: undefined, CitreaTestnet: undefined, + BitcoinTestnet4: undefined, Kraken: undefined, Binance: undefined, XT: undefined, diff --git a/src/integration/exchange/services/kraken.service.ts b/src/integration/exchange/services/kraken.service.ts index 97c08470a6..e57b76044b 100644 --- a/src/integration/exchange/services/kraken.service.ts +++ b/src/integration/exchange/services/kraken.service.ts @@ -42,6 +42,7 @@ export class KrakenService extends ExchangeService { Tron: undefined, Citrea: undefined, CitreaTestnet: undefined, + BitcoinTestnet4: undefined, Kraken: undefined, Binance: undefined, XT: undefined, diff --git a/src/integration/exchange/services/kucoin.service.ts b/src/integration/exchange/services/kucoin.service.ts index ce720a22e0..81641ac49b 100644 --- a/src/integration/exchange/services/kucoin.service.ts +++ b/src/integration/exchange/services/kucoin.service.ts @@ -35,6 +35,7 @@ export class KucoinService extends ExchangeService { Tron: undefined, Citrea: undefined, CitreaTestnet: undefined, + BitcoinTestnet4: undefined, Kraken: undefined, Binance: undefined, XT: undefined, diff --git a/src/integration/exchange/services/mexc.service.ts b/src/integration/exchange/services/mexc.service.ts index d9662dcf83..2ff8c49e57 100644 --- a/src/integration/exchange/services/mexc.service.ts +++ b/src/integration/exchange/services/mexc.service.ts @@ -51,6 +51,7 @@ export class MexcService extends ExchangeService { Tron: 'TRX', Citrea: undefined, CitreaTestnet: undefined, + BitcoinTestnet4: undefined, Kraken: undefined, Binance: undefined, XT: undefined, diff --git a/src/integration/exchange/services/xt.service.ts b/src/integration/exchange/services/xt.service.ts index ea1bf5f47e..04897946de 100644 --- a/src/integration/exchange/services/xt.service.ts +++ b/src/integration/exchange/services/xt.service.ts @@ -35,6 +35,7 @@ export class XtService extends ExchangeService { Tron: undefined, Citrea: undefined, CitreaTestnet: undefined, + BitcoinTestnet4: undefined, Kraken: undefined, Binance: undefined, XT: undefined, diff --git a/src/shared/models/asset/asset.service.ts b/src/shared/models/asset/asset.service.ts index 5a36cc9aae..f6f11772ac 100644 --- a/src/shared/models/asset/asset.service.ts +++ b/src/shared/models/asset/asset.service.ts @@ -276,4 +276,12 @@ export class AssetService { type: AssetType.COIN, }); } + + async getBitcoinTestnet4Coin(): Promise { + return this.getAssetByQuery({ + name: 'BTC', + blockchain: Blockchain.BITCOIN_TESTNET4, + type: AssetType.COIN, + }); + } } diff --git a/src/subdomains/core/referral/reward/services/ref-reward.service.ts b/src/subdomains/core/referral/reward/services/ref-reward.service.ts index 79c816d167..98357b957e 100644 --- a/src/subdomains/core/referral/reward/services/ref-reward.service.ts +++ b/src/subdomains/core/referral/reward/services/ref-reward.service.ts @@ -47,6 +47,7 @@ const PayoutLimits: { [k in Blockchain]: number } = { [Blockchain.TRON]: undefined, [Blockchain.CITREA]: undefined, [Blockchain.CITREA_TESTNET]: undefined, + [Blockchain.BITCOIN_TESTNET4]: undefined, [Blockchain.KRAKEN]: undefined, [Blockchain.BINANCE]: undefined, [Blockchain.XT]: undefined, diff --git a/src/subdomains/generic/kyc/dto/kyc-error.enum.ts b/src/subdomains/generic/kyc/dto/kyc-error.enum.ts index 8f1eea01f7..35852c0a52 100644 --- a/src/subdomains/generic/kyc/dto/kyc-error.enum.ts +++ b/src/subdomains/generic/kyc/dto/kyc-error.enum.ts @@ -5,6 +5,7 @@ export enum KycError { RESTARTED_STEP = 'RestartedStep', BLOCKED = 'Blocked', RELEASED = 'Released', + EXPIRED_STEP = 'ExpiredStep', // Ident errors USER_DATA_MERGED = 'UserDataMerged', @@ -89,6 +90,7 @@ export const KycErrorMap: Record = { [KycError.BANK_RECALL_FEE_NOT_PAID]: 'Recall fee not paid', [KycError.INCORRECT_INFO]: 'Incorrect response', [KycError.RESIDENCE_PERMIT_CHECK_REQUIRED]: undefined, + [KycError.EXPIRED_STEP]: 'Your documents are expired', }; export const KycReasonMap: { [e in KycError]?: KycStepReason } = { diff --git a/src/subdomains/supporting/dex/dex.module.ts b/src/subdomains/supporting/dex/dex.module.ts index 6dc7f92104..7d3b9c6751 100644 --- a/src/subdomains/supporting/dex/dex.module.ts +++ b/src/subdomains/supporting/dex/dex.module.ts @@ -10,6 +10,7 @@ import { LiquidityOrderRepository } from './repositories/liquidity-order.reposit import { DexArbitrumService } from './services/dex-arbitrum.service'; import { DexBaseService } from './services/dex-base.service'; import { DexBitcoinService } from './services/dex-bitcoin.service'; +import { DexBitcoinTestnet4Service } from './services/dex-bitcoin-testnet4.service'; import { DexBscService } from './services/dex-bsc.service'; import { DexCardanoService } from './services/dex-cardano.service'; import { DexCitreaService } from './services/dex-citrea.service'; @@ -31,6 +32,7 @@ import { BaseCoinStrategy as BaseCoinStrategyCL } from './strategies/check-liqui import { BaseTokenStrategy as BaseTokenStrategyCL } from './strategies/check-liquidity/impl/base-token.strategy'; import { CheckLiquidityStrategyRegistry } from './strategies/check-liquidity/impl/base/check-liquidity.strategy-registry'; import { BitcoinStrategy as BitcoinStrategyCL } from './strategies/check-liquidity/impl/bitcoin.strategy'; +import { BitcoinTestnet4Strategy as BitcoinTestnet4StrategyCL } from './strategies/check-liquidity/impl/bitcoin-testnet4.strategy'; import { BscCoinStrategy as BscCoinStrategyCL } from './strategies/check-liquidity/impl/bsc-coin.strategy'; import { BscTokenStrategy as BscTokenStrategyCL } from './strategies/check-liquidity/impl/bsc-token.strategy'; import { CardanoCoinStrategy as CardanoCoinStrategyCL } from './strategies/check-liquidity/impl/cardano-coin.strategy'; @@ -63,6 +65,7 @@ import { BaseCoinStrategy as BaseCoinStrategyPL } from './strategies/purchase-li import { BaseTokenStrategy as BaseTokenStrategyPL } from './strategies/purchase-liquidity/impl/base-token.strategy'; import { PurchaseLiquidityStrategyRegistry } from './strategies/purchase-liquidity/impl/base/purchase-liquidity.strategy-registry'; import { BitcoinStrategy as BitcoinStrategyPL } from './strategies/purchase-liquidity/impl/bitcoin.strategy'; +import { BitcoinTestnet4Strategy as BitcoinTestnet4StrategyPL } from './strategies/purchase-liquidity/impl/bitcoin-testnet4.strategy'; import { BscCoinStrategy as BscCoinStrategyPL } from './strategies/purchase-liquidity/impl/bsc-coin.strategy'; import { BscTokenStrategy as BscTokenStrategyPL } from './strategies/purchase-liquidity/impl/bsc-token.strategy'; import { CardanoCoinStrategy as CardanoCoinStrategyPL } from './strategies/purchase-liquidity/impl/cardano-coin.strategy'; @@ -94,6 +97,7 @@ import { BaseCoinStrategy as BaseCoinStrategySL } from './strategies/sell-liquid import { BaseTokenStrategy as BaseTokenStrategySL } from './strategies/sell-liquidity/impl/base-token.strategy'; import { SellLiquidityStrategyRegistry } from './strategies/sell-liquidity/impl/base/sell-liquidity.strategy-registry'; import { BitcoinStrategy as BitcoinStrategySL } from './strategies/sell-liquidity/impl/bitcoin.strategy'; +import { BitcoinTestnet4Strategy as BitcoinTestnet4StrategySL } from './strategies/sell-liquidity/impl/bitcoin-testnet4.strategy'; import { BscCoinStrategy as BscCoinStrategySL } from './strategies/sell-liquidity/impl/bsc-coin.strategy'; import { BscTokenStrategy as BscTokenStrategySL } from './strategies/sell-liquidity/impl/bsc-token.strategy'; import { CardanoCoinStrategy as CardanoCoinStrategySL } from './strategies/sell-liquidity/impl/cardano-coin.strategy'; @@ -123,6 +127,7 @@ import { ArbitrumStrategy as ArbitrumStrategyS } from './strategies/supplementar import { BaseStrategy as BaseStrategyS } from './strategies/supplementary/impl/base.strategy'; import { SupplementaryStrategyRegistry } from './strategies/supplementary/impl/base/supplementary.strategy-registry'; import { BitcoinStrategy as BitcoinStrategyS } from './strategies/supplementary/impl/bitcoin.strategy'; +import { BitcoinTestnet4Strategy as BitcoinTestnet4StrategyS } from './strategies/supplementary/impl/bitcoin-testnet4.strategy'; import { BscStrategy as BscStrategyS } from './strategies/supplementary/impl/bsc.strategy'; import { CardanoStrategy as CardanoStrategyS } from './strategies/supplementary/impl/cardano.strategy'; import { CitreaStrategy as CitreaStrategyS } from './strategies/supplementary/impl/citrea.strategy'; @@ -153,6 +158,7 @@ import { ZanoStrategy as ZanoStrategyS } from './strategies/supplementary/impl/z DexGnosisService, DexBscService, DexBitcoinService, + DexBitcoinTestnet4Service, DexCitreaService, DexCitreaTestnetService, DexLightningService, @@ -170,6 +176,7 @@ import { ZanoStrategy as ZanoStrategyS } from './strategies/supplementary/impl/z EthereumCoinStrategyCL, BscCoinStrategyCL, BitcoinStrategyCL, + BitcoinTestnet4StrategyCL, LightningStrategyCL, MoneroStrategyCL, ZanoCoinStrategyCL, @@ -199,6 +206,7 @@ import { ZanoStrategy as ZanoStrategyS } from './strategies/supplementary/impl/z EthereumCoinStrategyPL, BscCoinStrategyPL, BitcoinStrategyPL, + BitcoinTestnet4StrategyPL, MoneroStrategyPL, ZanoCoinStrategyPL, ZanoTokenStrategyPL, @@ -227,6 +235,7 @@ import { ZanoStrategy as ZanoStrategyS } from './strategies/supplementary/impl/z CardanoCoinStrategyPL, CardanoTokenStrategyPL, BitcoinStrategySL, + BitcoinTestnet4StrategySL, MoneroStrategySL, ZanoCoinStrategySL, ZanoTokenStrategySL, @@ -258,6 +267,7 @@ import { ZanoStrategy as ZanoStrategyS } from './strategies/supplementary/impl/z CardanoTokenStrategySL, ArbitrumStrategyS, BitcoinStrategyS, + BitcoinTestnet4StrategyS, MoneroStrategyS, ZanoStrategyS, BscStrategyS, diff --git a/src/subdomains/supporting/dex/services/dex-bitcoin-testnet4.service.ts b/src/subdomains/supporting/dex/services/dex-bitcoin-testnet4.service.ts new file mode 100644 index 0000000000..f489c7c354 --- /dev/null +++ b/src/subdomains/supporting/dex/services/dex-bitcoin-testnet4.service.ts @@ -0,0 +1,62 @@ +import { Injectable } from '@nestjs/common'; +import { TransactionHistory } from 'src/integration/blockchain/bitcoin/node/bitcoin-based-client'; +import { BitcoinTestnet4Client } from 'src/integration/blockchain/bitcoin-testnet4/bitcoin-testnet4-client'; +import { + BitcoinTestnet4NodeType, + BitcoinTestnet4Service, +} from 'src/integration/blockchain/bitcoin-testnet4/bitcoin-testnet4.service'; +import { BitcoinTestnet4FeeService } from 'src/integration/blockchain/bitcoin-testnet4/services/bitcoin-testnet4-fee.service'; +import { Blockchain } from 'src/integration/blockchain/shared/enums/blockchain.enum'; +import { Util } from 'src/shared/utils/util'; +import { LiquidityOrder } from '../entities/liquidity-order.entity'; +import { LiquidityOrderRepository } from '../repositories/liquidity-order.repository'; + +@Injectable() +export class DexBitcoinTestnet4Service { + private readonly client: BitcoinTestnet4Client; + + constructor( + private readonly liquidityOrderRepo: LiquidityOrderRepository, + private readonly feeService: BitcoinTestnet4FeeService, + readonly bitcoinTestnet4Service: BitcoinTestnet4Service, + ) { + this.client = bitcoinTestnet4Service.getDefaultClient(BitcoinTestnet4NodeType.BTC_TESTNET4_OUTPUT); + } + + async sendUtxoToMany(payout: { addressTo: string; amount: number }[]): Promise { + const feeRate = await this.feeService.getRecommendedFeeRate(); + return this.client.sendMany(payout, feeRate); + } + + async checkAvailableTargetLiquidity(inputAmount: number): Promise<[number, number]> { + const pendingAmount = await this.getPendingAmount(); + const availableAmount = await this.client.getBalance(); + + return [inputAmount, availableAmount - pendingAmount]; + } + + async checkTransferCompletion(transferTxId: string): Promise { + const transaction = await this.client.getTx(transferTxId); + + return transaction != null; + } + + async getRecentHistory(txCount: number): Promise { + return this.client.getRecentHistory(txCount); + } + + protected getClient(): BitcoinTestnet4Client { + return this.client; + } + + //*** HELPER METHODS ***// + + private async getPendingAmount(): Promise { + const pendingOrders = await this.liquidityOrderRepo.findBy({ + isComplete: false, + targetAsset: { dexName: 'BTC', blockchain: Blockchain.BITCOIN_TESTNET4 }, + }); + + return Util.sumObjValue(pendingOrders, 'estimatedTargetAmount'); + } +} diff --git a/src/subdomains/supporting/dex/services/dex-bitcoin.service.ts b/src/subdomains/supporting/dex/services/dex-bitcoin.service.ts index 50e428ac3c..3627dc640f 100644 --- a/src/subdomains/supporting/dex/services/dex-bitcoin.service.ts +++ b/src/subdomains/supporting/dex/services/dex-bitcoin.service.ts @@ -1,5 +1,6 @@ import { Injectable } from '@nestjs/common'; -import { BitcoinClient, TransactionHistory } from 'src/integration/blockchain/bitcoin/node/bitcoin-client'; +import { TransactionHistory } from 'src/integration/blockchain/bitcoin/node/bitcoin-based-client'; +import { BitcoinClient } from 'src/integration/blockchain/bitcoin/node/bitcoin-client'; import { BitcoinNodeType, BitcoinService } from 'src/integration/blockchain/bitcoin/node/bitcoin.service'; import { BitcoinFeeService } from 'src/integration/blockchain/bitcoin/services/bitcoin-fee.service'; import { Blockchain } from 'src/integration/blockchain/shared/enums/blockchain.enum'; diff --git a/src/subdomains/supporting/dex/strategies/check-liquidity/impl/bitcoin-testnet4.strategy.ts b/src/subdomains/supporting/dex/strategies/check-liquidity/impl/bitcoin-testnet4.strategy.ts new file mode 100644 index 0000000000..77b0ab8019 --- /dev/null +++ b/src/subdomains/supporting/dex/strategies/check-liquidity/impl/bitcoin-testnet4.strategy.ts @@ -0,0 +1,55 @@ +import { Injectable } from '@nestjs/common'; +import { Blockchain } from 'src/integration/blockchain/shared/enums/blockchain.enum'; +import { Asset, AssetCategory, AssetType } from 'src/shared/models/asset/asset.entity'; +import { AssetService } from 'src/shared/models/asset/asset.service'; +import { CheckLiquidityRequest, CheckLiquidityResult } from '../../../interfaces'; +import { DexBitcoinTestnet4Service } from '../../../services/dex-bitcoin-testnet4.service'; +import { CheckLiquidityUtil } from '../utils/check-liquidity.util'; +import { CheckLiquidityStrategy } from './base/check-liquidity.strategy'; + +@Injectable() +export class BitcoinTestnet4Strategy extends CheckLiquidityStrategy { + constructor( + protected readonly assetService: AssetService, + private readonly dexBitcoinTestnet4Service: DexBitcoinTestnet4Service, + ) { + super(); + } + + get blockchain(): Blockchain { + return Blockchain.BITCOIN_TESTNET4; + } + + get assetType(): AssetType { + return undefined; + } + + get assetCategory(): AssetCategory { + return undefined; + } + + async checkLiquidity(request: CheckLiquidityRequest): Promise { + const { context, correlationId, referenceAsset, referenceAmount: bitcoinAmount } = request; + + if (referenceAsset.dexName === 'BTC') { + const [targetAmount, availableAmount] = + await this.dexBitcoinTestnet4Service.checkAvailableTargetLiquidity(bitcoinAmount); + + return CheckLiquidityUtil.createNonPurchasableCheckLiquidityResult( + request, + targetAmount, + availableAmount, + await this.feeAsset(), + ); + } + + // only native coin is enabled as a referenceAsset + throw new Error( + `Only native coin reference is supported by Bitcoin Testnet4 CheckLiquidity strategy. Provided reference asset: ${referenceAsset.dexName} Context: ${context}. CorrelationID: ${correlationId}`, + ); + } + + protected getFeeAsset(): Promise { + return this.assetService.getBitcoinTestnet4Coin(); + } +} diff --git a/src/subdomains/supporting/dex/strategies/purchase-liquidity/impl/bitcoin-testnet4.strategy.ts b/src/subdomains/supporting/dex/strategies/purchase-liquidity/impl/bitcoin-testnet4.strategy.ts new file mode 100644 index 0000000000..6dbf1609b0 --- /dev/null +++ b/src/subdomains/supporting/dex/strategies/purchase-liquidity/impl/bitcoin-testnet4.strategy.ts @@ -0,0 +1,30 @@ +import { Injectable } from '@nestjs/common'; +import { Blockchain } from 'src/integration/blockchain/shared/enums/blockchain.enum'; +import { Asset, AssetCategory, AssetType } from 'src/shared/models/asset/asset.entity'; +import { DfxLogger } from 'src/shared/services/dfx-logger'; +import { NoPurchaseStrategy } from './base/no-purchase.strategy'; + +@Injectable() +export class BitcoinTestnet4Strategy extends NoPurchaseStrategy { + protected readonly logger = new DfxLogger(BitcoinTestnet4Strategy); + + get blockchain(): Blockchain { + return Blockchain.BITCOIN_TESTNET4; + } + + get assetType(): AssetType { + return undefined; + } + + get assetCategory(): AssetCategory { + return undefined; + } + + get dexName(): string { + return undefined; + } + + protected getFeeAsset(): Promise { + return this.assetService.getBitcoinTestnet4Coin(); + } +} diff --git a/src/subdomains/supporting/dex/strategies/sell-liquidity/impl/bitcoin-testnet4.strategy.ts b/src/subdomains/supporting/dex/strategies/sell-liquidity/impl/bitcoin-testnet4.strategy.ts new file mode 100644 index 0000000000..e7b216775a --- /dev/null +++ b/src/subdomains/supporting/dex/strategies/sell-liquidity/impl/bitcoin-testnet4.strategy.ts @@ -0,0 +1,35 @@ +import { Injectable } from '@nestjs/common'; +import { Blockchain } from 'src/integration/blockchain/shared/enums/blockchain.enum'; +import { Asset, AssetType } from 'src/shared/models/asset/asset.entity'; +import { AssetService } from 'src/shared/models/asset/asset.service'; +import { DfxLogger } from 'src/shared/services/dfx-logger'; +import { SellLiquidityStrategy } from './base/sell-liquidity.strategy'; + +@Injectable() +export class BitcoinTestnet4Strategy extends SellLiquidityStrategy { + protected readonly logger = new DfxLogger(BitcoinTestnet4Strategy); + + constructor(protected readonly assetService: AssetService) { + super(); + } + + get blockchain(): Blockchain { + return Blockchain.BITCOIN_TESTNET4; + } + + get assetType(): AssetType { + return undefined; + } + + sellLiquidity(): Promise { + throw new Error('Selling liquidity on DEX is not supported for Bitcoin Testnet4'); + } + + addSellData(): Promise { + throw new Error('Selling liquidity on DEX is not supported for Bitcoin Testnet4'); + } + + protected getFeeAsset(): Promise { + return this.assetService.getBitcoinTestnet4Coin(); + } +} diff --git a/src/subdomains/supporting/dex/strategies/supplementary/impl/bitcoin-testnet4.strategy.ts b/src/subdomains/supporting/dex/strategies/supplementary/impl/bitcoin-testnet4.strategy.ts new file mode 100644 index 0000000000..c0f0b8bea7 --- /dev/null +++ b/src/subdomains/supporting/dex/strategies/supplementary/impl/bitcoin-testnet4.strategy.ts @@ -0,0 +1,51 @@ +import { Injectable } from '@nestjs/common'; +import { TransactionHistory } from 'src/integration/blockchain/bitcoin/node/bitcoin-based-client'; +import { Blockchain } from 'src/integration/blockchain/shared/enums/blockchain.enum'; +import { Asset } from 'src/shared/models/asset/asset.entity'; +import { Util } from 'src/shared/utils/util'; +import { TransactionQuery, TransactionResult, TransferRequest } from '../../../interfaces'; +import { DexBitcoinTestnet4Service } from '../../../services/dex-bitcoin-testnet4.service'; +import { SupplementaryStrategy } from './base/supplementary.strategy'; + +@Injectable() +export class BitcoinTestnet4Strategy extends SupplementaryStrategy { + constructor(protected readonly dexBitcoinTestnet4Service: DexBitcoinTestnet4Service) { + super(); + } + + get blockchain(): Blockchain { + return Blockchain.BITCOIN_TESTNET4; + } + + async transferLiquidity(request: TransferRequest): Promise { + const { destinationAddress, amount } = request; + + return this.dexBitcoinTestnet4Service.sendUtxoToMany([{ addressTo: destinationAddress, amount }]); + } + + async checkTransferCompletion(transferTxId: string): Promise { + return this.dexBitcoinTestnet4Service.checkTransferCompletion(transferTxId); + } + + async findTransaction(query: TransactionQuery): Promise { + const { amount, since } = query; + + const allHistory = await this.dexBitcoinTestnet4Service.getRecentHistory(100); + const relevantHistory = this.filterRelevantHistory(allHistory, since); + const targetEntry = relevantHistory.find((e) => e.amount === amount); + + if (!targetEntry) return { isComplete: false }; + + return { isComplete: true, txId: targetEntry.txid }; + } + + async getTargetAmount(_a: number, _f: Asset, _t: Asset): Promise { + throw new Error(`Swapping is not implemented on ${this.blockchain}`); + } + + //*** HELPER METHODS ***// + + private filterRelevantHistory(allHistory: TransactionHistory[], since: Date): TransactionHistory[] { + return allHistory.filter((h) => Util.round(h.blocktime * 1000, 0) > since.getTime()); + } +} diff --git a/src/subdomains/supporting/dex/strategies/supplementary/impl/bitcoin.strategy.ts b/src/subdomains/supporting/dex/strategies/supplementary/impl/bitcoin.strategy.ts index de8b2567ec..89ce50834e 100644 --- a/src/subdomains/supporting/dex/strategies/supplementary/impl/bitcoin.strategy.ts +++ b/src/subdomains/supporting/dex/strategies/supplementary/impl/bitcoin.strategy.ts @@ -1,5 +1,5 @@ import { Injectable } from '@nestjs/common'; -import { TransactionHistory } from 'src/integration/blockchain/bitcoin/node/bitcoin-client'; +import { TransactionHistory } from 'src/integration/blockchain/bitcoin/node/bitcoin-based-client'; import { Blockchain } from 'src/integration/blockchain/shared/enums/blockchain.enum'; import { Asset } from 'src/shared/models/asset/asset.entity'; import { Util } from 'src/shared/utils/util'; diff --git a/src/subdomains/supporting/fiat-output/fiat-output-job.service.ts b/src/subdomains/supporting/fiat-output/fiat-output-job.service.ts index 585f67f1ec..ed721e274c 100644 --- a/src/subdomains/supporting/fiat-output/fiat-output-job.service.ts +++ b/src/subdomains/supporting/fiat-output/fiat-output-job.service.ts @@ -219,10 +219,18 @@ export class FiatOutputJobService { return a.bankAmount - b.bankAmount; }); - const pendingFiatOutputs = accountIbanGroup.filter( - (tx) => - tx.isReadyDate && !tx.bankTx && (!tx.bank || tx.bank.name !== IbanBankName.YAPEAL || !tx.isTransmittedDate), - ); + const pendingFiatOutputs = accountIbanGroup.filter((tx) => { + if (!tx.isReadyDate) return false; + + switch (tx.bank?.name) { + case IbanBankName.YAPEAL: + return !tx.isTransmittedDate; + case IbanBankName.OLKY: + return !tx.bankTx || tx.bankTx.created > Util.minutesBefore(5); + default: + return !tx.bankTx; + } + }); const pendingBalance = Util.sumObjValue(pendingFiatOutputs, 'bankAmount'); for (const entity of sortedEntities.filter((e) => !e.isReadyDate)) { diff --git a/src/subdomains/supporting/payout/payout.module.ts b/src/subdomains/supporting/payout/payout.module.ts index 00bb511d71..4c7e00168e 100644 --- a/src/subdomains/supporting/payout/payout.module.ts +++ b/src/subdomains/supporting/payout/payout.module.ts @@ -28,6 +28,7 @@ import { PayoutSolanaService } from './services/payout-solana.service'; import { PayoutTronService } from './services/payout-tron.service'; import { PayoutZanoService } from './services/payout-zano.service'; import { PayoutSparkService } from './services/payout-spark.service'; +import { PayoutBitcoinTestnet4Service } from './services/payout-bitcoin-testnet4.service'; import { PayoutService } from './services/payout.service'; import { ArbitrumCoinStrategy as ArbitrumCoinStrategyPO } from './strategies/payout/impl/arbitrum-coin.strategy'; import { ArbitrumTokenStrategy as ArbitrumTokenStrategyPO } from './strategies/payout/impl/arbitrum-token.strategy'; @@ -62,6 +63,7 @@ import { TronTokenStrategy as TronTokenStrategyPO } from './strategies/payout/im import { ZanoCoinStrategy as ZanoCoinStrategyPO } from './strategies/payout/impl/zano-coin.strategy'; import { ZanoTokenStrategy as ZanoTokenStrategyPO } from './strategies/payout/impl/zano-token.strategy'; import { SparkStrategy as SparkStrategyPO } from './strategies/payout/impl/spark.strategy'; +import { BitcoinTestnet4Strategy as BitcoinTestnet4StrategyPO } from './strategies/payout/impl/bitcoin-testnet4.strategy'; import { ArbitrumStrategy as ArbitrumStrategyPR } from './strategies/prepare/impl/arbitrum.strategy'; import { BaseStrategy as BaseStrategyPR } from './strategies/prepare/impl/base.strategy'; import { PrepareStrategyRegistry } from './strategies/prepare/impl/base/prepare.strategy-registry'; @@ -81,6 +83,7 @@ import { SolanaStrategy as SolanaStrategyPR } from './strategies/prepare/impl/so import { TronStrategy as TronStrategyPR } from './strategies/prepare/impl/tron.strategy'; import { ZanoStrategy as ZanoStrategyPR } from './strategies/prepare/impl/zano.strategy'; import { SparkStrategy as SparkStrategyPR } from './strategies/prepare/impl/spark.strategy'; +import { BitcoinTestnet4Strategy as BitcoinTestnet4StrategyPR } from './strategies/prepare/impl/bitcoin-testnet4.strategy'; @Module({ imports: [ @@ -115,6 +118,7 @@ import { SparkStrategy as SparkStrategyPR } from './strategies/prepare/impl/spar PayoutCardanoService, PayoutCitreaService, PayoutCitreaTestnetService, + PayoutBitcoinTestnet4Service, PayoutStrategyRegistry, PrepareStrategyRegistry, BitcoinStrategyPR, @@ -166,6 +170,8 @@ import { SparkStrategy as SparkStrategyPR } from './strategies/prepare/impl/spar CitreaTestnetStrategyPR, CitreaTestnetCoinStrategyPO, CitreaTestnetTokenStrategyPO, + BitcoinTestnet4StrategyPR, + BitcoinTestnet4StrategyPO, SparkStrategyPR, ], exports: [ diff --git a/src/subdomains/supporting/payout/services/payout-bitcoin-testnet4.service.ts b/src/subdomains/supporting/payout/services/payout-bitcoin-testnet4.service.ts new file mode 100644 index 0000000000..bf4a57a661 --- /dev/null +++ b/src/subdomains/supporting/payout/services/payout-bitcoin-testnet4.service.ts @@ -0,0 +1,54 @@ +import { Injectable } from '@nestjs/common'; +import { Config } from 'src/config/config'; +import { BitcoinTestnet4Client } from 'src/integration/blockchain/bitcoin-testnet4/bitcoin-testnet4-client'; +import { + BitcoinTestnet4NodeType, + BitcoinTestnet4Service, +} from 'src/integration/blockchain/bitcoin-testnet4/bitcoin-testnet4.service'; +import { PayoutOrderContext } from '../entities/payout-order.entity'; +import { PayoutBitcoinBasedService, PayoutGroup } from './base/payout-bitcoin-based.service'; + +@Injectable() +export class PayoutBitcoinTestnet4Service extends PayoutBitcoinBasedService { + private readonly client: BitcoinTestnet4Client; + + constructor(readonly bitcoinTestnet4Service: BitcoinTestnet4Service) { + super(); + + this.client = bitcoinTestnet4Service.getDefaultClient(BitcoinTestnet4NodeType.BTC_TESTNET4_OUTPUT); + } + + async isHealthy(): Promise { + try { + return !!(await this.client?.getInfo()); + } catch { + return false; + } + } + + async sendUtxoToMany(_context: PayoutOrderContext, payout: PayoutGroup): Promise { + const feeRate = await this.getCurrentFeeRate(); + return this.client.sendMany(payout, feeRate); + } + + async getPayoutCompletionData(_context: PayoutOrderContext, payoutTxId: string): Promise<[boolean, number]> { + const transaction = await this.client.getTx(payoutTxId); + + const isComplete = transaction && transaction.blockhash && transaction.confirmations > 0; + const payoutFee = isComplete ? -(transaction.fee ?? 0) : 0; + + return [isComplete, payoutFee]; + } + + async getCurrentFeeRate(): Promise { + const estimatedRate = await this.client?.estimateSmartFee(1); + + // Testnet4 may have low activity, use minimum fee rate if estimation fails + const baseRate = estimatedRate ?? 1; + + const { minTxAmount } = Config.blockchain.bitcoinTestnet4; + const multiplier = minTxAmount ? 1.5 : 1; + + return baseRate * multiplier; + } +} diff --git a/src/subdomains/supporting/payout/strategies/payout/impl/bitcoin-testnet4.strategy.ts b/src/subdomains/supporting/payout/strategies/payout/impl/bitcoin-testnet4.strategy.ts new file mode 100644 index 0000000000..1b958147bb --- /dev/null +++ b/src/subdomains/supporting/payout/strategies/payout/impl/bitcoin-testnet4.strategy.ts @@ -0,0 +1,81 @@ +import { Injectable } from '@nestjs/common'; +import { Blockchain } from 'src/integration/blockchain/shared/enums/blockchain.enum'; +import { Asset, AssetType } from 'src/shared/models/asset/asset.entity'; +import { AssetService } from 'src/shared/models/asset/asset.service'; +import { DfxLogger } from 'src/shared/services/dfx-logger'; +import { Util } from 'src/shared/utils/util'; +import { NotificationService } from 'src/subdomains/supporting/notification/services/notification.service'; +import { PayoutOrder, PayoutOrderContext } from '../../../entities/payout-order.entity'; +import { FeeResult } from '../../../interfaces'; +import { PayoutOrderRepository } from '../../../repositories/payout-order.repository'; +import { PayoutGroup } from '../../../services/base/payout-bitcoin-based.service'; +import { PayoutBitcoinTestnet4Service } from '../../../services/payout-bitcoin-testnet4.service'; +import { BitcoinBasedStrategy } from './base/bitcoin-based.strategy'; + +@Injectable() +export class BitcoinTestnet4Strategy extends BitcoinBasedStrategy { + protected readonly logger = new DfxLogger(BitcoinTestnet4Strategy); + + private readonly averageTransactionSize = 140; // vBytes + + constructor( + notificationService: NotificationService, + protected readonly bitcoinTestnet4Service: PayoutBitcoinTestnet4Service, + protected readonly payoutOrderRepo: PayoutOrderRepository, + protected readonly assetService: AssetService, + ) { + super(notificationService, payoutOrderRepo, bitcoinTestnet4Service); + } + + get blockchain(): Blockchain { + return Blockchain.BITCOIN_TESTNET4; + } + + get assetType(): AssetType { + return undefined; + } + + async estimateFee(): Promise { + const feeRate = await this.bitcoinTestnet4Service.getCurrentFeeRate(); + const satoshiFeeAmount = this.averageTransactionSize * feeRate; + const btcFeeAmount = Util.round(satoshiFeeAmount / 100000000, 8); + + return { asset: await this.feeAsset(), amount: btcFeeAmount }; + } + + protected async doPayoutForContext(context: PayoutOrderContext, orders: PayoutOrder[]): Promise { + const payoutGroups = this.createPayoutGroups(orders, 100); + + for (const group of payoutGroups) { + try { + if (group.length === 0) { + continue; + } + + this.logger.verbose( + `Paying out ${group.length} BTC Testnet4 orders(s). Order ID(s): ${group.map((o) => o.id)}`, + ); + + await this.sendBTC(context, group); + } catch (e) { + this.logger.error( + `Error in paying out a group of ${group.length} BTC Testnet4 orders(s). Order ID(s): ${group.map((o) => o.id)}`, + e, + ); + continue; + } + } + } + + protected dispatchPayout(context: PayoutOrderContext, payout: PayoutGroup): Promise { + return this.bitcoinTestnet4Service.sendUtxoToMany(context, payout); + } + + protected getFeeAsset(): Promise { + return this.assetService.getBitcoinTestnet4Coin(); + } + + private async sendBTC(context: PayoutOrderContext, orders: PayoutOrder[]): Promise { + await this.send(context, orders); + } +} diff --git a/src/subdomains/supporting/payout/strategies/prepare/impl/bitcoin-testnet4.strategy.ts b/src/subdomains/supporting/payout/strategies/prepare/impl/bitcoin-testnet4.strategy.ts new file mode 100644 index 0000000000..7bf356ffca --- /dev/null +++ b/src/subdomains/supporting/payout/strategies/prepare/impl/bitcoin-testnet4.strategy.ts @@ -0,0 +1,24 @@ +import { Injectable } from '@nestjs/common'; +import { Blockchain } from 'src/integration/blockchain/shared/enums/blockchain.enum'; +import { Asset } from 'src/shared/models/asset/asset.entity'; +import { AssetService } from 'src/shared/models/asset/asset.service'; +import { PayoutOrderRepository } from '../../../repositories/payout-order.repository'; +import { AutoConfirmStrategy } from './base/auto-confirm.strategy'; + +@Injectable() +export class BitcoinTestnet4Strategy extends AutoConfirmStrategy { + constructor( + private readonly assetService: AssetService, + payoutOrderRepo: PayoutOrderRepository, + ) { + super(payoutOrderRepo); + } + + get blockchain(): Blockchain { + return Blockchain.BITCOIN_TESTNET4; + } + + protected getFeeAsset(): Promise { + return this.assetService.getBitcoinTestnet4Coin(); + } +}