import { Balance, ChainClientParams, Fee, FeeOption, FeeRate, Network, Tx, TxHash, TxHistoryParams, TxParams, TxType, TxsPage, UTXOClient, checkFeeBounds, standardFeeRates, } from '@d11k-ts/client' import { getSeed } from '@d11k-ts/crypto' import { Address, Asset, AssetBTC, Chain, assetAmount, assetToBase } from '@d11k-ts/utils' import * as Bitcoin from 'bitcoinjs-lib' import { BTC_DECIMAL, LOWER_FEE_BOUND, UPPER_FEE_BOUND } from './const' import * as sochain from './sochain-api' import { ClientUrl } from './types/client-types' import * as Utils from './utils' export type BitcoinClientParams = ChainClientParams & { sochainUrl?: string haskoinUrl?: ClientUrl } /** * Custom Bitcoin client */ class BitcoinClient extends UTXOClient { private sochainUrl = '' private haskoinUrl: ClientUrl /** * Constructor * Client is initialised with network type * * @param {BitcoinClientParams} params */ constructor({ network = Network.Mainnet, feeBounds = { lower: LOWER_FEE_BOUND, upper: UPPER_FEE_BOUND, }, sochainUrl = 'https://chain.so/api/v2', haskoinUrl = { [Network.DojTestnet]: 'https://api.haskoin.com/btctest', [Network.Testnet]: 'https://api.haskoin.com/btctest', [Network.Mainnet]: 'https://api.haskoin.com/btc', [Network.Stagenet]: 'https://api.haskoin.com/btc', }, rootDerivationPaths = { [Network.Mainnet]: `84'/0'/0'/0/`, //note this isn't bip44 compliant, but it keeps the wallets generated compatible to pre HD wallets [Network.Testnet]: `84'/1'/0'/0/`, [Network.Stagenet]: `84'/0'/0'/0/`, [Network.DojTestnet]: `84'/1'/0'/0/`, }, phrase = '', }: BitcoinClientParams) { super(Chain.Bitcoin, { network, rootDerivationPaths, phrase, feeBounds }) this.setSochainUrl(sochainUrl) this.haskoinUrl = haskoinUrl } /** * Set/Update the sochain url. * * @param {string} url The new sochain url. * @returns {void} */ setSochainUrl(url: string): void { this.sochainUrl = url } /** * Get the explorer url. * * @returns {string} The explorer url based on the network. */ getExplorerUrl(): string { switch (this.network) { case Network.Mainnet: case Network.Stagenet: return 'https://blockstream.info' case Network.Testnet: case Network.DojTestnet: return 'https://blockstream.info/testnet' } } /** * Get the explorer url for the given address. * * @param {Address} address * @returns {string} The explorer url for the given address based on the network. */ getExplorerAddressUrl(address: string): string { return `${this.getExplorerUrl()}/address/${address}` } /** * Get the explorer url for the given transaction id. * * @param {string} txID The transaction id * @returns {string} The explorer url for the given transaction id based on the network. */ getExplorerTxUrl(txID: string): string { return `${this.getExplorerUrl()}/tx/${txID}` } /** * Get the current address. * * Generates a network-specific key-pair by first converting the buffer to a Wallet-Import-Format (WIF) * The address is then decoded into type P2WPKH and returned. * * @returns {Address} The current address. * * @throws {"Phrase must be provided"} Thrown if phrase has not been set before. * @throws {"Address not defined"} Thrown if failed creating account from phrase. */ getAddress(index = 0): Address { if (index < 0) { throw new Error('index must be greater than zero') } if (this.phrase) { const btcNetwork = Utils.btcNetwork(this.network) const btcKeys = this.getBtcKeys(this.phrase, index) const { address } = Bitcoin.payments.p2wpkh({ pubkey: btcKeys.publicKey, network: btcNetwork, }) if (!address) { throw new Error('Address not defined') } return address } throw new Error('Phrase must be provided') } /** * @private * Get private key. * * Private function to get keyPair from the this.phrase * * @param {string} phrase The phrase to be used for generating privkey * @returns {ECPairInterface} The privkey generated from the given phrase * * @throws {"Could not get private key from phrase"} Throws an error if failed creating BTC keys from the given phrase * */ private getBtcKeys(phrase: string, index = 0): Bitcoin.ECPairInterface { const btcNetwork = Utils.btcNetwork(this.network) const seed = getSeed(phrase) const master = Bitcoin.bip32.fromSeed(seed, btcNetwork).derivePath(this.getFullDerivationPath(index)) if (!master.privateKey) { throw new Error('Could not get private key from phrase') } return Bitcoin.ECPair.fromPrivateKey(master.privateKey, { network: btcNetwork }) } /** * Validate the given address. * * @param {Address} address * @returns {boolean} `true` or `false` */ validateAddress(address: string): boolean { return Utils.validateAddress(address, this.network) } /** * Gets BTC balances of a given address. * * @param {Address} BTC address to get balances from * @param {undefined} Needed for legacy only to be in common with `ChainClient` interface - will be removed by a next version * @param {confirmedOnly} Flag to get balances of confirmed txs only * * @returns {Balance[]} BTC balances */ async getBalance(address: Address, _assets?: Asset[] /* not used */, confirmedOnly?: boolean): Promise { return Utils.getBalance({ params: { sochainUrl: this.sochainUrl, network: this.network, address: address, }, haskoinUrl: this.haskoinUrl[this.network], confirmedOnly: !!confirmedOnly, }) } /** * Get transaction history of a given address with pagination options. * By default it will return the transaction history of the current wallet. * * @param {TxHistoryParams} params The options to get transaction history. (optional) * @returns {TxsPage} The transaction history. */ async getTransactions(params?: TxHistoryParams): Promise { // Sochain API doesn't have pagination parameter const offset = params?.offset ?? 0 const limit = params?.limit || 10 const response = await sochain.getAddress({ address: params?.address + '', sochainUrl: this.sochainUrl, network: this.network, }) const total = response.txs.length const transactions: Tx[] = [] const txs = response.txs.filter((_, index) => offset <= index && index < offset + limit) for (const txItem of txs) { const rawTx = await sochain.getTx({ sochainUrl: this.sochainUrl, network: this.network, hash: txItem.txid, }) const tx: Tx = { asset: AssetBTC, from: rawTx.inputs.map((i) => ({ from: i.address, amount: assetToBase(assetAmount(i.value, BTC_DECIMAL)), })), to: rawTx.outputs .filter((i) => i.type !== 'nulldata') .map((i) => ({ to: i.address, amount: assetToBase(assetAmount(i.value, BTC_DECIMAL)) })), date: new Date(rawTx.time * 1000), type: TxType.Transfer, hash: rawTx.txid, } transactions.push(tx) } const result: TxsPage = { total, txs: transactions, } return result } /** * Get the transaction details of a given transaction id. * * @param {string} txId The transaction id. * @returns {Tx} The transaction details of the given transaction id. */ async getTransactionData(txId: string): Promise { const rawTx = await sochain.getTx({ sochainUrl: this.sochainUrl, network: this.network, hash: txId, }) return { asset: AssetBTC, from: rawTx.inputs.map((i) => ({ from: i.address, amount: assetToBase(assetAmount(i.value, BTC_DECIMAL)), })), to: rawTx.outputs.map((i) => ({ to: i.address, amount: assetToBase(assetAmount(i.value, BTC_DECIMAL)) })), date: new Date(rawTx.time * 1000), type: TxType.Transfer, hash: rawTx.txid, } } protected async getSuggestedFeeRate(): Promise { return await sochain.getSuggestedTxFee() } protected calcFee(feeRate: FeeRate, memo?: string): Fee { return Utils.calcFee(feeRate, memo) } /** * Transfer BTC. * * @param {TxParams&FeeRate} params The transfer options. * @returns {TxHash} The transaction hash. * * @throws {"memo too long"} Thrown if memo longer than 80 chars. */ async transfer(params: TxParams & { feeRate?: FeeRate }): Promise { const fromAddressIndex = params?.walletIndex || 0 // set the default fee rate to `fast` // const feeRate = params.feeRate || (await this.getFeeRates())[FeeOption.Fast] const feeRate = params.feeRate || standardFeeRates(await this.getSuggestedFeeRate())[FeeOption.Fast] checkFeeBounds(this.feeBounds, feeRate) /** * do not spend pending UTXOs when adding a memo */ const spendPendingUTXO = !params.memo const haskoinUrl = this.haskoinUrl[this.network] const { psbt } = await Utils.buildTx({ ...params, feeRate, sender: this.getAddress(fromAddressIndex), sochainUrl: this.sochainUrl, haskoinUrl, network: this.network, spendPendingUTXO, }) const btcKeys = this.getBtcKeys(this.phrase, fromAddressIndex) psbt.signAllInputs(btcKeys) // Sign all inputs psbt.finalizeAllInputs() // Finalise inputs const txHex = psbt.extractTransaction().toHex() // TX extracted and formatted to hex try { return await Utils.sochainbroadcastTx({ sochainUrl: this.sochainUrl, txHex, network: this.network }) } catch (error) { if (error instanceof Error && error.message === 'Request failed') { return await Utils.haskoinbroadcastTx({ txHex, haskoinUrl }) } else { return Promise.reject(error) } } } } export { BitcoinClient }