UNPKG

@atomiqlabs/sdk

Version:

atomiq labs SDK for cross-chain swaps between smart chains and bitcoin

440 lines (396 loc) 17.5 kB
import {coinSelect, maxSendable, CoinselectAddressTypes, CoinselectTxInput} from "../coinselect2"; import {BTC_NETWORK, NETWORK, TEST_NETWORK} from "@scure/btc-signer/utils" import {p2wpkh, OutScript, Transaction, p2tr, Address} from "@scure/btc-signer"; import {BitcoinWalletUtxo, BitcoinWalletUtxoBase, IBitcoinWallet} from "./IBitcoinWallet"; import {Buffer} from "buffer"; import {randomBytes} from "../../utils/Utils"; import {getDummyOutputScript, toCoinselectAddressType, toOutputScript} from "../../utils/BitcoinUtils"; import {TransactionInputUpdate} from "@scure/btc-signer/psbt"; import {getLogger} from "../../utils/Logger"; import {BitcoinNetwork, BitcoinRpcWithAddressIndex} from "@atomiqlabs/base"; import {utils} from "../coinselect2/utils"; /** * Identifies the address type of a Bitcoin address * * @category Bitcoin */ export function identifyAddressType(address: string, network: BTC_NETWORK): CoinselectAddressTypes { switch(Address(network).decode(address).type) { case "pkh": return "p2pkh"; case "wpkh": return "p2wpkh"; case "tr": return "p2tr"; case "sh": return "p2sh-p2wpkh"; case "wsh": return "p2wsh"; default: throw new Error("Unknown address type of "+address); } } const btcNetworkMapping = { [BitcoinNetwork.MAINNET]: NETWORK, [BitcoinNetwork.TESTNET]: TEST_NETWORK, [BitcoinNetwork.TESTNET4]: TEST_NETWORK, [BitcoinNetwork.REGTEST]: { ...TEST_NETWORK, bech32: "bcrt" } } const logger = getLogger("BitcoinWallet: "); /** * Abstract base class for Bitcoin wallet implementations, using bitcoin rpc with address index * as a backend for fetching balances, UTXOs, etc. * * @category Bitcoin */ export abstract class BitcoinWallet implements IBitcoinWallet { protected readonly rpc: BitcoinRpcWithAddressIndex<any>; protected readonly network: BTC_NETWORK; protected feeMultiplier: number; protected feeOverride?: number; constructor( mempoolApi: BitcoinRpcWithAddressIndex<any>, network: BitcoinNetwork | BTC_NETWORK, feeMultiplier: number = 1.25, feeOverride?: number ) { this.rpc = mempoolApi; this.network = typeof(network)==="object" ? network : BitcoinWallet.bitcoinNetworkToObject(network); this.feeMultiplier = feeMultiplier; this.feeOverride = feeOverride; } /** * @inheritDoc */ async getFeeRate(): Promise<number> { if(this.feeOverride!=null) { return this.feeOverride; } return Math.floor((await this.rpc.getFeeRate())*this.feeMultiplier); } /** * Internal helper function for sending a raw transaction through the underlying RPC * * @param rawHex Serialized bitcoin transaction in hexadecimal format * @returns txId Transaction ID of the submitted bitcoin transaction * * @protected */ protected _sendTransaction(rawHex: string): Promise<string> { return this.rpc.sendRawTransaction(rawHex); } /** * Internal helper function for fetching the balance of the wallet given a specific bitcoin wallet address * * @param address * @protected */ protected _getBalance(address: string): Promise<{ confirmedBalance: bigint; unconfirmedBalance: bigint }> { return this.rpc.getAddressBalances(address); } /** * Internal helper function for fetching the UTXO set of a given wallet address * * @param sendingAddress * @param sendingAddressType * @protected */ protected async _getUtxoPool( sendingAddress: string, sendingAddressType: CoinselectAddressTypes ): Promise<BitcoinWalletUtxo[]> { const utxos = await this.rpc.getAddressUTXOs(sendingAddress); let totalSpendable = 0; const outputScript = toOutputScript(this.network, sendingAddress); const utxoPool: BitcoinWalletUtxo[] = []; for(let utxo of utxos) { const value = Number(utxo.value); totalSpendable += value; utxoPool.push({ vout: utxo.vout, txId: utxo.txid, value: value, type: sendingAddressType, outputScript: outputScript, address: sendingAddress, cpfp: !utxo.confirmed ? await this.rpc.getCPFPData(utxo.txid).then((result) => { if(result==null) return; return { txVsize: result.adjustedVsize, txEffectiveFeeRate: result.effectiveFeePerVsize } }) : undefined, confirmed: utxo.confirmed }) } logger.debug("_getUtxoPool(): Total spendable value: "+totalSpendable+" num utxos: "+utxoPool.length); return utxoPool; } /** * * @param sendingAccounts * @param recipient * @param amount * @param feeRate * @protected */ protected async _getPsbt( sendingAccounts: { pubkey: string, address: string, addressType: CoinselectAddressTypes, }[], recipient: string, amount: number, feeRate?: number ): Promise<{ fee: number, psbt?: Transaction, inputAddressIndexes?: {[address: string]: number[]} }> { const psbt = new Transaction({PSBTVersion: 0}); psbt.addOutput({ amount: BigInt(amount), script: toOutputScript(this.network, recipient) }); return this._fundPsbt(sendingAccounts, psbt, feeRate); } protected async _fundPsbt( sendingAccounts: { pubkey: string, address: string, addressType: CoinselectAddressTypes, }[], psbt: Transaction, _feeRate?: number, utxos?: BitcoinWalletUtxo[], spendFully?: boolean ): Promise<{ fee: number, psbt?: Transaction, inputAddressIndexes?: {[address: string]: number[]} }> { const feeRate = _feeRate ?? await this.getFeeRate(); const utxoPool: BitcoinWalletUtxo[] = utxos ?? (await Promise.all(sendingAccounts.map(acc => this._getUtxoPool(acc.address, acc.addressType)))).flat(); if(spendFully && utxoPool==null) throw new Error("Cannot fully spend when no utxos are passed!"); logger.debug("_fundPsbt(): fee rate: "+feeRate+" utxo pool: ", utxoPool); const accountPubkeys: Record<string, string> = {}; sendingAccounts.forEach(acc => accountPubkeys[acc.address] = acc.pubkey); const requiredInputs: CoinselectTxInput[] = []; for(let i=0;i<psbt.inputsLength;i++) { const input = psbt.getInput(i); if(input.index==null || input.txid==null) throw new Error("Inputs need txid & index!"); let amount: bigint; let script: Uint8Array; if(input.witnessUtxo!=null) { amount = input.witnessUtxo.amount as bigint; script = input.witnessUtxo.script as Uint8Array; } else if(input.nonWitnessUtxo!=null) { amount = input.nonWitnessUtxo.outputs[input.index].amount; script = input.nonWitnessUtxo.outputs[input.index].script; } else throw new Error("Either witnessUtxo or nonWitnessUtxo has to be defined!"); requiredInputs.push({ txId: Buffer.from(input.txid).toString('hex'), vout: input.index, value: Number(amount), type: toCoinselectAddressType(script) }) } const targets: {value: number, script: Buffer}[] = []; for(let i=0;i<psbt.outputsLength;i++) { const output = psbt.getOutput(i); if(output.amount==null || output.script==null) throw new Error("Outputs need amount & script defined!"); targets.push({ value: Number(output.amount), script: Buffer.from(output.script) }) } logger.debug("_fundPsbt(): Coinselect targets: ", targets); let coinselectResult = spendFully ? utils.finalize(requiredInputs.concat(utxoPool.filter(utxo => !utils.isDetrimentalInput(feeRate, utxo))), targets, feeRate, null) : coinSelect(utxoPool, targets, feeRate, sendingAccounts[0].addressType, requiredInputs); logger.debug("_fundPsbt(): Coinselect result: ", coinselectResult); if(coinselectResult.inputs==null || coinselectResult.outputs==null || coinselectResult.effectiveFeeRate==null) { return { fee: coinselectResult.fee }; } if(spendFully && feeRate!=null) { const maximumAllowedFeeRate = (1.5*feeRate) + 10; if(coinselectResult.effectiveFeeRate > maximumAllowedFeeRate) throw new Error(`Effective fee rate too high, feeRate: ${coinselectResult.effectiveFeeRate} sats/vB, maximum: ${maximumAllowedFeeRate} sats/vB!`); const minimumAllowedFeeRate = 0.9*feeRate; if(coinselectResult.effectiveFeeRate < minimumAllowedFeeRate) throw new Error(`Effective fee rate too low, feeRate: ${coinselectResult.effectiveFeeRate} sats/vB, minimum: ${minimumAllowedFeeRate} sats/vB!`); } // Remove in/outs that are already in the PSBT coinselectResult.inputs.splice(0, psbt.inputsLength); coinselectResult.outputs.splice(0, psbt.outputsLength); const inputAddressIndexes: {[address: string]: number[]} = {}; coinselectResult.inputs.forEach((input, index) => { inputAddressIndexes[input.address!] ??= []; inputAddressIndexes[input.address!].push(index); }); const formattedInputs: TransactionInputUpdate[] = await Promise.all<TransactionInputUpdate>(coinselectResult.inputs.map(async (input) => { switch(input.type) { case "p2tr": const parsed = p2tr(Buffer.from(accountPubkeys[input.address!], "hex")); return { txid: input.txId, index: input.vout, witnessUtxo: { script: input.outputScript!, amount: BigInt(input.value) }, tapInternalKey: parsed.tapInternalKey, tapMerkleRoot: parsed.tapMerkleRoot, tapLeafScript: parsed.tapLeafScript }; case "p2wpkh": return { txid: input.txId, index: input.vout, witnessUtxo: { script: input.outputScript!, amount: BigInt(input.value) }, sighashType: 0x01 }; case "p2sh-p2wpkh": return { txid: input.txId, index: input.vout, witnessUtxo: { script: input.outputScript!, amount: BigInt(input.value) }, redeemScript: p2wpkh(Buffer.from(accountPubkeys[input.address!], "hex"), this.network).script, sighashType: 0x01 }; case "p2pkh": const tx = await this.rpc.getTransaction(input.txId); if(tx==null) throw new Error("Cannot fetch existing tx "+input.txId); return { txid: input.txId, index: input.vout, nonWitnessUtxo: tx.raw, sighashType: 0x01 }; default: throw new Error("Invalid input type: "+input.type); } })); formattedInputs.forEach(input => psbt.addInput(input)); coinselectResult.outputs.forEach(output => { if(output.script==null && output.address==null) { //Change output psbt.addOutput({ script: toOutputScript(this.network, sendingAccounts[0].address), amount: BigInt(Math.floor(output.value)) }); } else { psbt.addOutput({ script: output.script ?? toOutputScript(this.network, output.address!), amount: BigInt(output.value) }); } }); return { psbt, fee: coinselectResult.fee, inputAddressIndexes }; } protected async _getSpendableBalance( sendingAccounts: { address: string, addressType: CoinselectAddressTypes, }[], psbt?: Transaction, feeRate?: number, outputAddressType?: CoinselectAddressTypes, utxoPool?: BitcoinWalletUtxoBase[] ): Promise<{ balance: bigint, feeRate: number, totalFee: number }> { feeRate ??= await this.getFeeRate(); utxoPool ??= (await Promise.all(sendingAccounts.map(acc => this._getUtxoPool(acc.address, acc.addressType)))).flat(); return { ...BitcoinWallet.getSpendableBalance( utxoPool ?? (await Promise.all(sendingAccounts.map(acc => this._getUtxoPool(acc.address, acc.addressType)))).flat(), feeRate ?? await this.getFeeRate(), psbt, outputAddressType ), feeRate }; } abstract sendTransaction(address: string, amount: bigint, feeRate?: number): Promise<string>; abstract fundPsbt(psbt: Transaction, feeRate?: number, utxos?: BitcoinWalletUtxo[], spendFully?: boolean): Promise<Transaction>; abstract signPsbt(psbt: Transaction, signInputs: number[]): Promise<Transaction>; abstract getTransactionFee(address: string, amount: bigint, feeRate?: number): Promise<number>; abstract getFundedPsbtFee(psbt: Transaction, feeRate?: number): Promise<number>; abstract getReceiveAddress(): string; abstract getBalance(): Promise<{ confirmedBalance: bigint, unconfirmedBalance: bigint }>; abstract getSpendableBalance(psbt?: Transaction, feeRate?: number): Promise<{ balance: bigint, feeRate: number, totalFee: number }>; static bitcoinNetworkToObject(network: BitcoinNetwork): BTC_NETWORK { return btcNetworkMapping[network]; } static getSpendableBalance( utxoPool: BitcoinWalletUtxoBase[], feeRate: number, psbt?: Transaction, outputAddressType?: CoinselectAddressTypes ): { balance: bigint, totalFee: number } { const requiredInputs: CoinselectTxInput[] = []; if(psbt!=null) for(let i=0;i<psbt.inputsLength;i++) { const input = psbt.getInput(i); if(input.index==null || input.txid==null) throw new Error("Inputs need txid & index!"); let amount: bigint; let script: Uint8Array; if(input.witnessUtxo!=null) { amount = input.witnessUtxo.amount as bigint; script = input.witnessUtxo.script as Uint8Array; } else if(input.nonWitnessUtxo!=null) { amount = input.nonWitnessUtxo.outputs[input.index].amount; script = input.nonWitnessUtxo.outputs[input.index].script; } else throw new Error("Either witnessUtxo or nonWitnessUtxo has to be defined!"); requiredInputs.push({ txId: Buffer.from(input.txid).toString('hex'), vout: input.index, value: Number(amount), type: toCoinselectAddressType(script) }) } const additionalOutputs: {value: number, script: Buffer}[] = []; if(psbt!=null) for(let i=0;i<psbt.outputsLength;i++) { const output = psbt.getOutput(i); if(output.amount==null || output.script==null) throw new Error("Outputs need amount & script!"); additionalOutputs.push({ value: Number(output.amount), script: Buffer.from(output.script) }) } const target: Uint8Array = getDummyOutputScript(outputAddressType ?? "p2wsh"); let coinselectResult = maxSendable(utxoPool, {script: Buffer.from(target), type: outputAddressType ?? "p2wsh"}, feeRate, requiredInputs, additionalOutputs); logger.debug("_getSpendableBalance(): Max spendable result: ", coinselectResult); return { balance: BigInt(Math.floor(coinselectResult.value)), totalFee: coinselectResult.fee } } }