diff --git a/typescript/src/lib/bitcoin/address.ts b/typescript/src/lib/bitcoin/address.ts index 874d995ab..cc10aebda 100644 --- a/typescript/src/lib/bitcoin/address.ts +++ b/typescript/src/lib/bitcoin/address.ts @@ -1,6 +1,36 @@ import bcoin, { Script } from "bcoin" import { Hex } from "../utils" -import { BitcoinNetwork, toBcoinNetwork } from "./network" +import { + BitcoinNetwork, + toBcoinNetwork, + toBitcoinJsLibNetwork, +} from "./network" +import { payments } from "bitcoinjs-lib" + +/** + * Creates the Bitcoin address from the public key. Supports SegWit (P2WPKH) and + * Legacy (P2PKH) formats. + * @param publicKey - Public key used to derive the Bitcoin address. + * @param bitcoinNetwork - Target Bitcoin network. + * @param witness - Flag to determine address format: true for SegWit (P2WPKH) + * and false for Legacy (P2PKH). Default is true. + * @returns The derived Bitcoin address. + */ +export function publicKeyToAddress( + publicKey: Hex, + bitcoinNetwork: BitcoinNetwork, + witness: boolean = true +): string { + const network = toBitcoinJsLibNetwork(bitcoinNetwork) + + if (witness) { + // P2WPKH (SegWit) + return payments.p2wpkh({ pubkey: publicKey.toBuffer(), network }).address! + } else { + // P2PKH (Legacy) + return payments.p2pkh({ pubkey: publicKey.toBuffer(), network }).address! + } +} /** * Converts a public key hash into a P2PKH/P2WPKH address. @@ -71,6 +101,7 @@ function outputScriptToAddress( * Utility functions allowing to perform Bitcoin address conversions. */ export const BitcoinAddressConverter = { + publicKeyToAddress, publicKeyHashToAddress, addressToPublicKeyHash, addressToOutputScript, diff --git a/typescript/src/lib/bitcoin/index.ts b/typescript/src/lib/bitcoin/index.ts index 52028bc46..5b9529bb9 100644 --- a/typescript/src/lib/bitcoin/index.ts +++ b/typescript/src/lib/bitcoin/index.ts @@ -5,5 +5,6 @@ export * from "./ecdsa-key" export * from "./hash" export * from "./header" export * from "./network" +export * from "./script" export * from "./spv" export * from "./tx" diff --git a/typescript/src/lib/bitcoin/network.ts b/typescript/src/lib/bitcoin/network.ts index bdb810e44..c40afb3bf 100644 --- a/typescript/src/lib/bitcoin/network.ts +++ b/typescript/src/lib/bitcoin/network.ts @@ -1,4 +1,5 @@ import { Hex } from "../utils" +import { networks } from "bitcoinjs-lib" /** * Bitcoin networks. @@ -64,3 +65,26 @@ export function toBcoinNetwork(bitcoinNetwork: BitcoinNetwork): string { } } } + +/** + * Converts the provided {@link BitcoinNetwork} enumeration to a format expected + * by the `bitcoinjs-lib` library. + * @param bitcoinNetwork - Specified Bitcoin network. + * @returns Network representation compatible with the `bitcoinjs-lib` library. + * @throws An error if the network is not supported by `bitcoinjs-lib`. + */ +export function toBitcoinJsLibNetwork( + bitcoinNetwork: BitcoinNetwork +): networks.Network { + switch (bitcoinNetwork) { + case BitcoinNetwork.Mainnet: { + return networks.bitcoin + } + case BitcoinNetwork.Testnet: { + return networks.testnet + } + default: { + throw new Error(`network not supported`) + } + } +} diff --git a/typescript/src/lib/bitcoin/script.ts b/typescript/src/lib/bitcoin/script.ts new file mode 100644 index 000000000..a66c39305 --- /dev/null +++ b/typescript/src/lib/bitcoin/script.ts @@ -0,0 +1,67 @@ +import { payments } from "bitcoinjs-lib" + +/** + * Checks if the provided script comes from a P2PKH input. + * @param script The script to be checked. + * @returns True if the script is P2PKH, false otherwise. + */ +function isP2PKHScript(script: Buffer): boolean { + try { + payments.p2pkh({ output: script }) + return true + } catch (err) { + return false + } +} + +/** + * Checks if the provided script comes from a P2WPKH input. + * @param script The script to be checked. + * @returns True if the script is P2WPKH, false otherwise. + */ +function isP2WPKHScript(script: Buffer): boolean { + try { + payments.p2wpkh({ output: script }) + return true + } catch (err) { + return false + } +} + +/** + * Checks if the provided script comes from a P2SH input. + * @param script The script to be checked. + * @returns True if the script is P2SH, false otherwise. + */ +function isP2SHScript(script: Buffer): boolean { + try { + payments.p2sh({ output: script }) + return true + } catch (err) { + return false + } +} + +/** + * Checks if the provided script comes from a P2PKH input. + * @param script The script to be checked. + * @returns True if the script is P2WSH, false otherwise. + */ +function isP2WSHScript(script: Buffer): boolean { + try { + payments.p2wsh({ output: script }) + return true + } catch (err) { + return false + } +} + +/** + * Utility functions allowing to deal with Bitcoin scripts. + */ +export const BitcoinScriptUtils = { + isP2PKHScript, + isP2WPKHScript, + isP2SHScript, + isP2WSHScript, +} diff --git a/typescript/src/services/deposits/deposit.ts b/typescript/src/services/deposits/deposit.ts index 4ce3099a3..19e36e9e0 100644 --- a/typescript/src/services/deposits/deposit.ts +++ b/typescript/src/services/deposits/deposit.ts @@ -12,8 +12,7 @@ import { extractBitcoinRawTxVectors, toBcoinNetwork, } from "../../lib/bitcoin" - -const { opcodes } = bcoin.script.common +import { Stack, script, opcodes } from "bitcoinjs-lib" /** * Component representing an instance of the tBTC v2 deposit process. @@ -193,33 +192,31 @@ export class DepositScript { * @returns Plain-text deposit script as an un-prefixed hex string. */ async getPlainText(): Promise { - // All HEXes pushed to the script must be un-prefixed. - const script = new bcoin.Script() - script.clear() - script.pushData(Buffer.from(this.receipt.depositor.identifierHex, "hex")) - script.pushOp(opcodes.OP_DROP) - script.pushData(Buffer.from(this.receipt.blindingFactor, "hex")) - script.pushOp(opcodes.OP_DROP) - script.pushOp(opcodes.OP_DUP) - script.pushOp(opcodes.OP_HASH160) - script.pushData(Buffer.from(this.receipt.walletPublicKeyHash, "hex")) - script.pushOp(opcodes.OP_EQUAL) - script.pushOp(opcodes.OP_IF) - script.pushOp(opcodes.OP_CHECKSIG) - script.pushOp(opcodes.OP_ELSE) - script.pushOp(opcodes.OP_DUP) - script.pushOp(opcodes.OP_HASH160) - script.pushData(Buffer.from(this.receipt.refundPublicKeyHash, "hex")) - script.pushOp(opcodes.OP_EQUALVERIFY) - script.pushData(Buffer.from(this.receipt.refundLocktime, "hex")) - script.pushOp(opcodes.OP_CHECKLOCKTIMEVERIFY) - script.pushOp(opcodes.OP_DROP) - script.pushOp(opcodes.OP_CHECKSIG) - script.pushOp(opcodes.OP_ENDIF) - script.compile() - - // Return script as HEX string. - return script.toRaw().toString("hex") + const chunks: Stack = [] + + // All HEXes pushed to the script must be un-prefixed + chunks.push(Buffer.from(this.receipt.depositor.identifierHex, "hex")) + chunks.push(opcodes.OP_DROP) + chunks.push(Buffer.from(this.receipt.blindingFactor, "hex")) + chunks.push(opcodes.OP_DROP) + chunks.push(opcodes.OP_DUP) + chunks.push(opcodes.OP_HASH160) + chunks.push(Buffer.from(this.receipt.walletPublicKeyHash, "hex")) + chunks.push(opcodes.OP_EQUAL) + chunks.push(opcodes.OP_IF) + chunks.push(opcodes.OP_CHECKSIG) + chunks.push(opcodes.OP_ELSE) + chunks.push(opcodes.OP_DUP) + chunks.push(opcodes.OP_HASH160) + chunks.push(Buffer.from(this.receipt.refundPublicKeyHash, "hex")) + chunks.push(opcodes.OP_EQUALVERIFY) + chunks.push(Buffer.from(this.receipt.refundLocktime, "hex")) + chunks.push(opcodes.OP_CHECKLOCKTIMEVERIFY) + chunks.push(opcodes.OP_DROP) + chunks.push(opcodes.OP_CHECKSIG) + chunks.push(opcodes.OP_ENDIF) + + return script.compile(chunks).toString("hex") } /** diff --git a/typescript/src/services/maintenance/wallet-tx.ts b/typescript/src/services/maintenance/wallet-tx.ts index 970f50574..4c017bebb 100644 --- a/typescript/src/services/maintenance/wallet-tx.ts +++ b/typescript/src/services/maintenance/wallet-tx.ts @@ -1,11 +1,15 @@ import { + BitcoinAddressConverter, BitcoinClient, BitcoinHashUtils, + BitcoinNetwork, BitcoinPrivateKeyUtils, BitcoinPublicKeyUtils, BitcoinRawTx, + BitcoinScriptUtils, BitcoinTxHash, BitcoinUtxo, + toBitcoinJsLibNetwork, } from "../../lib/bitcoin" import { BigNumber } from "ethers" import { @@ -15,6 +19,17 @@ import { } from "../../lib/contracts" import bcoin from "bcoin" import { DepositScript } from "../deposits" +import { ECPairFactory } from "ecpair" +import * as tinysecp from "tiny-secp256k1" +import { Hex } from "../../lib/utils" +import { + payments, + script, + Signer, + Stack, + Transaction, + TxOutput, +} from "bitcoinjs-lib" /** * Wallet transactions builder. This feature set is supposed to be used only @@ -111,8 +126,11 @@ class DepositSweep { } } + const bitcoinNetwork = await this.bitcoinClient.getNetwork() + const { transactionHash, newMainUtxo, rawTransaction } = await this.assembleTransaction( + bitcoinNetwork, fee, walletPrivateKey, utxosWithRaw, @@ -132,7 +150,8 @@ class DepositSweep { * Assembles a Bitcoin P2WPKH deposit sweep transaction. * @dev The caller is responsible for ensuring the provided UTXOs are correctly * formed, can be spent by the wallet and their combined value is greater - * then the fee. + * than the fee. + * @param bitcoinNetwork - The target Bitcoin network. * @param fee - the value that should be subtracted from the sum of the UTXOs * values and used as the transaction fee. * @param walletPrivateKey - Bitcoin private key of the wallet in WIF format. @@ -145,8 +164,11 @@ class DepositSweep { * - the sweep transaction hash, * - the new wallet's main UTXO produced by this transaction. * - the sweep transaction in the raw format + * @throws Error if the provided UTXOs and deposits mismatch or if an unsupported + * UTXO script type is encountered. */ async assembleTransaction( + bitcoinNetwork: BitcoinNetwork, fee: BigNumber, walletPrivateKey: string, utxos: (BitcoinUtxo & BitcoinRawTx)[], @@ -167,252 +189,289 @@ class DepositSweep { ) } - const walletKeyRing = BitcoinPrivateKeyUtils.createKeyRing( + const network = toBitcoinJsLibNetwork(bitcoinNetwork) + // eslint-disable-next-line new-cap + const walletKeyPair = ECPairFactory(tinysecp).fromWIF( walletPrivateKey, + network + ) + const walletAddress = BitcoinAddressConverter.publicKeyToAddress( + Hex.from(walletKeyPair.publicKey), + bitcoinNetwork, this.witness ) - const walletAddress = walletKeyRing.getAddress("string") - const inputCoins = [] - let totalInputValue = BigNumber.from(0) + const transaction = new Transaction() + let outputValue = BigNumber.from(0) if (mainUtxo) { - inputCoins.push( - bcoin.Coin.fromTX( - bcoin.MTX.fromRaw(mainUtxo.transactionHex, "hex"), - mainUtxo.outputIndex, - -1 - ) + transaction.addInput( + mainUtxo.transactionHash.reverse().toBuffer(), + mainUtxo.outputIndex ) - totalInputValue = totalInputValue.add(mainUtxo.value) + outputValue = outputValue.add(mainUtxo.value) } - for (const utxo of utxos) { - inputCoins.push( - bcoin.Coin.fromTX( - bcoin.MTX.fromRaw(utxo.transactionHex, "hex"), - utxo.outputIndex, - -1 - ) + transaction.addInput( + utxo.transactionHash.reverse().toBuffer(), + utxo.outputIndex ) - totalInputValue = totalInputValue.add(utxo.value) + outputValue = outputValue.add(utxo.value) } + outputValue = outputValue.sub(fee) - const transaction = new bcoin.MTX() + const outputScript = + BitcoinAddressConverter.addressToOutputScript(walletAddress) - transaction.addOutput({ - script: bcoin.Script.fromAddress(walletAddress), - value: totalInputValue.toNumber(), - }) + transaction.addOutput(outputScript.toBuffer(), outputValue.toNumber()) - await transaction.fund(inputCoins, { - changeAddress: walletAddress, - hardFee: fee.toNumber(), - subtractFee: true, - }) + // Sign the main UTXO input if there is main UTXO. + if (mainUtxo) { + const inputIndex = 0 // Main UTXO is the first input. + const previousOutput = Transaction.fromHex(mainUtxo.transactionHex).outs[ + mainUtxo.outputIndex + ] - if (transaction.outputs.length != 1) { - throw new Error("Deposit sweep transaction must have only one output") + await this.signMainUtxoInput( + transaction, + inputIndex, + previousOutput, + walletKeyPair + ) } - // UTXOs must be mapped to deposits, as `fund` may arrange inputs in any - // order - const utxosWithDeposits: (BitcoinUtxo & BitcoinRawTx & DepositReceipt)[] = - utxos.map((utxo, index) => ({ - ...utxo, - ...deposits[index], - })) - - for (let i = 0; i < transaction.inputs.length; i++) { - const previousOutpoint = transaction.inputs[i].prevout - const previousOutput = transaction.view.getOutput(previousOutpoint) - const previousScript = previousOutput.script - - // P2(W)PKH (main UTXO) - if ( - previousScript.isPubkeyhash() || - previousScript.isWitnessPubkeyhash() - ) { - await this.signMainUtxoInput(transaction, i, walletKeyRing) - continue - } + // Sign the deposit inputs. + for (let depositIndex = 0; depositIndex < deposits.length; depositIndex++) { + // If there is a main UTXO index, we must adjust input index as the first + // input is the main UTXO input. + const inputIndex = mainUtxo ? depositIndex + 1 : depositIndex - const utxoWithDeposit = utxosWithDeposits.find( - (u) => - u.transactionHash.toString() === previousOutpoint.txid() && - u.outputIndex == previousOutpoint.index - ) - if (!utxoWithDeposit) { - throw new Error("Unknown input") - } + const utxo = utxos[depositIndex] + const previousOutput = Transaction.fromHex(utxo.transactionHex).outs[ + utxo.outputIndex + ] + const previousOutputValue = previousOutput.value + const previousOutputScript = previousOutput.script - if (previousScript.isScripthash()) { + const deposit = deposits[depositIndex] + + if (BitcoinScriptUtils.isP2SHScript(previousOutputScript)) { // P2SH (deposit UTXO) await this.signP2SHDepositInput( transaction, - i, - utxoWithDeposit, - walletKeyRing + inputIndex, + deposit, + previousOutputValue, + walletKeyPair ) - } else if (previousScript.isWitnessScripthash()) { + } else if (BitcoinScriptUtils.isP2WSHScript(previousOutputScript)) { // P2WSH (deposit UTXO) await this.signP2WSHDepositInput( transaction, - i, - utxoWithDeposit, - walletKeyRing + inputIndex, + deposit, + previousOutputValue, + walletKeyPair ) } else { throw new Error("Unsupported UTXO script type") } } - const transactionHash = BitcoinTxHash.from(transaction.txid()) + const transactionHash = BitcoinTxHash.from(transaction.getId()) return { transactionHash, newMainUtxo: { transactionHash, outputIndex: 0, // There is only one output. - value: BigNumber.from(transaction.outputs[0].value), + value: BigNumber.from(transaction.outs[0].value), }, rawTransaction: { - transactionHex: transaction.toRaw().toString("hex"), + transactionHex: transaction.toHex(), }, } } /** - * Creates script for the transaction input at the given index and signs the - * input. - * @param transaction - Mutable transaction containing the input to be signed. - * @param inputIndex - Index that points to the input to be signed. - * @param walletKeyRing - Key ring created using the wallet's private key. - * @returns Empty promise. + * Signs the main UTXO transaction input and sets the appropriate script or + * witness data. + * @param transaction - The transaction containing the input to be signed. + * @param inputIndex - Index pointing to the input within the transaction. + * @param previousOutput - The previous output for the main UTXO input. + * @param walletKeyPair - A Signer object with the wallet's public and private + * key pair. + * @returns An empty promise upon successful signing. + * @throws Error if the UTXO doesn't belong to the wallet, or if the script + * format is invalid or unknown. */ private async signMainUtxoInput( - transaction: any, + transaction: Transaction, inputIndex: number, - walletKeyRing: any + previousOutput: TxOutput, + walletKeyPair: Signer ) { - const previousOutpoint = transaction.inputs[inputIndex].prevout - const previousOutput = transaction.view.getOutput(previousOutpoint) - if (!walletKeyRing.ownOutput(previousOutput)) { + if ( + !this.canSpendOutput( + Hex.from(walletKeyPair.publicKey), + previousOutput.script + ) + ) { throw new Error("UTXO does not belong to the wallet") } - // Build script and set it as input's witness - transaction.scriptInput(inputIndex, previousOutput, walletKeyRing) - // Build signature and add it in front of script in input's witness - transaction.signInput(inputIndex, previousOutput, walletKeyRing) + + const sigHashType = Transaction.SIGHASH_ALL + + if (BitcoinScriptUtils.isP2PKHScript(previousOutput.script)) { + // P2PKH + const sigHash = transaction.hashForSignature( + inputIndex, + previousOutput.script, + sigHashType + ) + + const signature = script.signature.encode( + walletKeyPair.sign(sigHash), + sigHashType + ) + + const scriptSig = payments.p2pkh({ + signature: signature, + pubkey: walletKeyPair.publicKey, + }).input! + + transaction.ins[inputIndex].script = scriptSig + } else if (BitcoinScriptUtils.isP2WPKHScript(previousOutput.script)) { + // P2WPKH + const publicKeyHash = payments.p2wpkh({ output: previousOutput.script }) + .hash! + const p2pkhScript = payments.p2pkh({ hash: publicKeyHash }).output! + + const sigHash = transaction.hashForWitnessV0( + inputIndex, + p2pkhScript, + previousOutput.value, + sigHashType + ) + + const signature = script.signature.encode( + walletKeyPair.sign(sigHash), + sigHashType + ) + + transaction.ins[inputIndex].witness = [signature, walletKeyPair.publicKey] + } else { + throw new Error("Unknown type of main UTXO") + } } /** - * Creates and sets `scriptSig` for the transaction input at the given index by - * combining signature, wallet public key and deposit script. - * @param transaction - Mutable transaction containing the input to be signed. - * @param inputIndex - Index that points to the input to be signed. - * @param deposit - Data of the deposit. - * @param walletKeyRing - Key ring created using the wallet's private key. - * @returns Empty promise. + * Signs a P2SH deposit transaction input and sets the `scriptSig`. + * @param transaction - The transaction containing the input to be signed. + * @param inputIndex - Index pointing to the input within the transaction. + * @param deposit - Details of the deposit transaction. + * @param previousOutputValue - The value from the previous transaction output. + * @param walletKeyPair - A Signer object with the wallet's public and private + * key pair. + * @returns An empty promise upon successful signing. */ private async signP2SHDepositInput( - transaction: any, + transaction: Transaction, inputIndex: number, deposit: DepositReceipt, - walletKeyRing: any + previousOutputValue: number, + walletKeyPair: Signer ): Promise { - const { walletPublicKey, depositScript, previousOutputValue } = - await this.prepareInputSignData( - transaction, - inputIndex, - deposit, - walletKeyRing - ) + const depositScript = await this.prepareDepositScript( + deposit, + previousOutputValue, + walletKeyPair + ) + + const sigHashType = Transaction.SIGHASH_ALL - const signature: Buffer = transaction.signature( + const sigHash = transaction.hashForSignature( inputIndex, depositScript, - previousOutputValue, - walletKeyRing.privateKey, - bcoin.Script.hashType.ALL, - 0 // legacy sighash version + sigHashType ) - const scriptSig = new bcoin.Script() - scriptSig.clear() - scriptSig.pushData(signature) - scriptSig.pushData(Buffer.from(walletPublicKey, "hex")) - scriptSig.pushData(depositScript.toRaw()) - scriptSig.compile() - - transaction.inputs[inputIndex].script = scriptSig + + const signature = script.signature.encode( + walletKeyPair.sign(sigHash), + sigHashType + ) + + const scriptSig: Stack = [] + scriptSig.push(signature) + scriptSig.push(walletKeyPair.publicKey) + scriptSig.push(depositScript) + + transaction.ins[inputIndex].script = script.compile(scriptSig) } /** - * Creates and sets witness script for the transaction input at the given index - * by combining signature, wallet public key and deposit script. - * @param transaction - Mutable transaction containing the input to be signed. - * @param inputIndex - Index that points to the input to be signed. - * @param deposit - Data of the deposit. - * @param walletKeyRing - Key ring created using the wallet's private key. - * @returns Empty promise. + * Signs a P2WSH deposit transaction input and sets the witness script. + * @param transaction - The transaction containing the input to be signed. + * @param inputIndex - Index pointing to the input within the transaction. + * @param deposit - Details of the deposit transaction. + * @param previousOutputValue - The value from the previous transaction output. + * @param walletKeyPair - A Signer object with the wallet's public and private + * key pair. + * @returns An empty promise upon successful signing. */ private async signP2WSHDepositInput( - transaction: any, + transaction: Transaction, inputIndex: number, deposit: DepositReceipt, - walletKeyRing: any + previousOutputValue: number, + walletKeyPair: Signer ): Promise { - const { walletPublicKey, depositScript, previousOutputValue } = - await this.prepareInputSignData( - transaction, - inputIndex, - deposit, - walletKeyRing - ) + const depositScript = await this.prepareDepositScript( + deposit, + previousOutputValue, + walletKeyPair + ) + + const sigHashType = Transaction.SIGHASH_ALL - const signature: Buffer = transaction.signature( + const sigHash = transaction.hashForWitnessV0( inputIndex, depositScript, previousOutputValue, - walletKeyRing.privateKey, - bcoin.Script.hashType.ALL, - 1 // segwit sighash version + sigHashType ) - const witness = new bcoin.Witness() - witness.clear() - witness.pushData(signature) - witness.pushData(Buffer.from(walletPublicKey, "hex")) - witness.pushData(depositScript.toRaw()) - witness.compile() + const signature = script.signature.encode( + walletKeyPair.sign(sigHash), + sigHashType + ) + + const witness: Buffer[] = [] + witness.push(signature) + witness.push(walletKeyPair.publicKey) + witness.push(depositScript) - transaction.inputs[inputIndex].witness = witness + transaction.ins[inputIndex].witness = witness } /** - * Creates data needed to sign a deposit input. - * @param transaction - Mutable transaction containing the input. - * @param inputIndex - Index that points to the input. - * @param deposit - Data of the deposit. - * @param walletKeyRing - Key ring created using the wallet's private key. - * @returns Data needed to sign the input. + * Assembles the deposit script based on the given deposit details. Performs + * validations on values and key formats. + * @param deposit - The deposit details. + * @param previousOutputValue - Value from the previous transaction output. + * @param walletKeyPair - Signer object containing the wallet's key pair. + * @returns A Promise resolving to the assembled deposit script as a Buffer. + * @throws Error if there are discrepancies in values or key formats. */ - private async prepareInputSignData( - transaction: any, - inputIndex: number, + private async prepareDepositScript( deposit: DepositReceipt, - walletKeyRing: any - ): Promise<{ - walletPublicKey: string - depositScript: any - previousOutputValue: number - }> { - const previousOutpoint = transaction.inputs[inputIndex].prevout - const previousOutput = transaction.view.getOutput(previousOutpoint) + previousOutputValue: number, + walletKeyPair: Signer + ): Promise { + const walletPublicKey = walletKeyPair.publicKey.toString("hex") - const walletPublicKey = walletKeyRing.getPublicKey("hex") if ( - BitcoinHashUtils.computeHash160(walletKeyRing.getPublicKey("hex")) != + BitcoinHashUtils.computeHash160(walletPublicKey) != deposit.walletPublicKeyHash ) { throw new Error( @@ -424,18 +483,27 @@ class DepositSweep { throw new Error("Wallet public key must be compressed") } - const depositScript = bcoin.Script.fromRaw( - Buffer.from( - await DepositScript.fromReceipt(deposit).getPlainText(), - "hex" - ) + return Buffer.from( + await DepositScript.fromReceipt(deposit).getPlainText(), + "hex" ) + } - return { - walletPublicKey, - depositScript: depositScript, - previousOutputValue: previousOutput.value, - } + /** + * Determines if a UTXO's output script can be spent using the provided public + * key. + * @param publicKey - Public key used to derive the corresponding P2PKH and + * P2WPKH output scripts. + * @param outputScript - The output script of the UTXO in question. + * @returns True if the provided output script matches the P2PKH or P2WPKH + * output scripts derived from the given public key. False otherwise. + */ + private canSpendOutput(publicKey: Hex, outputScript: Buffer): boolean { + const pubkeyBuffer = publicKey.toBuffer() + const p2pkhOutput = payments.p2pkh({ pubkey: pubkeyBuffer }).output! + const p2wpkhOutput = payments.p2wpkh({ pubkey: pubkeyBuffer }).output! + + return outputScript.equals(p2pkhOutput) || outputScript.equals(p2wpkhOutput) } } diff --git a/typescript/test/bitcoin-network.test.ts b/typescript/test/bitcoin-network.test.ts index db4555b74..32102f996 100644 --- a/typescript/test/bitcoin-network.test.ts +++ b/typescript/test/bitcoin-network.test.ts @@ -1,5 +1,11 @@ import { expect } from "chai" -import { BitcoinTxHash, BitcoinNetwork, toBcoinNetwork } from "../src" +import { + BitcoinTxHash, + BitcoinNetwork, + toBcoinNetwork, + toBitcoinJsLibNetwork, +} from "../src" +import { networks } from "bitcoinjs-lib" describe("BitcoinNetwork", () => { const testData = [ @@ -9,6 +15,7 @@ describe("BitcoinNetwork", () => { // any value that doesn't match other supported networks genesisHash: BitcoinTxHash.from("0x00010203"), expectedToBcoinResult: new Error("network not supported"), + expectedToBitcoinJsLibResult: new Error("network not supported"), }, { enumKey: BitcoinNetwork.Testnet, @@ -17,6 +24,7 @@ describe("BitcoinNetwork", () => { "0x000000000933ea01ad0ee984209779baaec3ced90fa3f408719526f8d77f4943" ), expectedToBcoinResult: "testnet", + expectedToBitcoinJsLibResult: networks.testnet, }, { enumKey: BitcoinNetwork.Mainnet, @@ -25,11 +33,18 @@ describe("BitcoinNetwork", () => { "000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f" ), expectedToBcoinResult: "main", + expectedToBitcoinJsLibResult: networks.bitcoin, }, ] testData.forEach( - ({ enumKey, enumValue, genesisHash, expectedToBcoinResult }) => { + ({ + enumKey, + enumValue, + genesisHash, + expectedToBcoinResult, + expectedToBitcoinJsLibResult, + }) => { context(enumKey, async () => { describe(`toString`, async () => { it(`should return correct value`, async () => { @@ -58,6 +73,22 @@ describe("BitcoinNetwork", () => { }) } }) + + describe(`toBitcoinJsLibNetwork`, async () => { + if (expectedToBitcoinJsLibResult instanceof Error) { + it(`should throw an error`, async () => { + expect(() => toBitcoinJsLibNetwork(enumKey)).to.throw( + expectedToBitcoinJsLibResult.message + ) + }) + } else { + it(`should return ${expectedToBitcoinJsLibResult}`, async () => { + expect(toBitcoinJsLibNetwork(enumKey)).to.be.equal( + expectedToBitcoinJsLibResult + ) + }) + } + }) }) } ) diff --git a/typescript/test/bitcoin.test.ts b/typescript/test/bitcoin.test.ts index 785875480..c695fbee4 100644 --- a/typescript/test/bitcoin.test.ts +++ b/typescript/test/bitcoin.test.ts @@ -10,9 +10,10 @@ import { BitcoinCompactSizeUint, BitcoinAddressConverter, Hex, + BitcoinScriptUtils, } from "../src" import { BigNumber } from "ethers" -import { btcAddresses } from "./data/bitcoin" +import { btcAddresses, btcAddressFromPublicKey } from "./data/bitcoin" describe("Bitcoin", () => { describe("BitcoinPublicKeyUtils", () => { @@ -113,12 +114,37 @@ describe("Bitcoin", () => { const P2PKHAddressTestnet = "mkpoZkRvtd3SDGWgUDuXK1aEXZfHRM2gKw" const { + publicKeyToAddress, publicKeyHashToAddress, addressToPublicKeyHash, addressToOutputScript, outputScriptToAddress, } = BitcoinAddressConverter + describe("publicKeyToAddress", () => { + Object.entries(btcAddressFromPublicKey).forEach( + ([bitcoinNetwork, addressData]) => { + context(`with ${bitcoinNetwork} addresses`, () => { + Object.entries(addressData).forEach( + ([addressType, { publicKey, address }]) => { + it(`should return correct ${addressType} address for ${bitcoinNetwork}`, () => { + const witness = addressType === "P2WPKH" + const result = publicKeyToAddress( + publicKey, + bitcoinNetwork === "mainnet" + ? BitcoinNetwork.Mainnet + : BitcoinNetwork.Testnet, + witness + ) + expect(result).to.eq(address) + }) + } + ) + }) + } + ) + }) + describe("publicKeyHashToAddress", () => { context("when network is mainnet", () => { context("when witness option is true", () => { @@ -612,4 +638,62 @@ describe("Bitcoin", () => { }) }) }) + + describe("BitcoinScriptUtils", () => { + const { isP2PKHScript, isP2WPKHScript, isP2SHScript, isP2WSHScript } = + BitcoinScriptUtils + + describe("isScript", () => { + const testData = [ + { + testFunction: isP2PKHScript, + validScript: Buffer.from( + "76a9148db50eb52063ea9d98b3eac91489a90f738986f688ac", + "hex" + ), + name: "P2PKH", + }, + { + testFunction: isP2WPKHScript, + validScript: Buffer.from( + "00148db50eb52063ea9d98b3eac91489a90f738986f6", + "hex" + ), + name: "P2WPKH", + }, + { + testFunction: isP2SHScript, + validScript: Buffer.from( + "a914a9a5f97d5d3c4687a52e90718168270005b369c487", + "hex" + ), + name: "P2SH", + }, + { + testFunction: isP2WSHScript, + validScript: Buffer.from( + "0020b1f83e226979dc9fe74e87f6d303dbb08a27a1c7ce91664033f34c7f2d214cd7", + "hex" + ), + name: "P2WSH", + }, + ] + + testData.forEach(({ testFunction, validScript, name }) => { + describe(`is${name}Script`, () => { + it(`should return true for a valid ${name} script`, () => { + expect(testFunction(validScript)).to.be.true + }) + + it("should return false for other scripts", () => { + testData.forEach((data) => { + if (data.name !== name) { + expect(testFunction(data.validScript)).to.be.false + } + }) + }) + }) + }) + }) + }) }) diff --git a/typescript/test/data/bitcoin.ts b/typescript/test/data/bitcoin.ts index aaa323d55..ad0e777d6 100644 --- a/typescript/test/data/bitcoin.ts +++ b/typescript/test/data/bitcoin.ts @@ -70,3 +70,37 @@ export const btcAddresses: Record< }, }, } + +export const btcAddressFromPublicKey: Record< + Exclude, + Record +> = { + testnet: { + P2PKH: { + publicKey: Hex.from( + "0304cc460f320822d17d567a9a1b1039f765ff72512758605b5962226b3d8e5329" + ), + address: "msVQ3CCdqffxc5BtxUrtHFPq6CoZSTaJTq", + }, + P2WPKH: { + publicKey: Hex.from( + "0304cc460f320822d17d567a9a1b1039f765ff72512758605b5962226b3d8e5329" + ), + address: "tb1qsdtz442y5fmay39rj39vancf7jm0jrf40qkulw", + }, + }, + mainnet: { + P2PKH: { + publicKey: Hex.from( + "0304cc460f320822d17d567a9a1b1039f765ff72512758605b5962226b3d8e5329" + ), + address: "1CySk97f2eEhpxiHEutWTLBWEDCrZDbSCr", + }, + P2WPKH: { + publicKey: Hex.from( + "0304cc460f320822d17d567a9a1b1039f765ff72512758605b5962226b3d8e5329" + ), + address: "bc1qsdtz442y5fmay39rj39vancf7jm0jrf49xd0ya", + }, + }, +} diff --git a/typescript/test/data/deposit-sweep.ts b/typescript/test/data/deposit-sweep.ts index 0c962b841..0ba6ffee6 100644 --- a/typescript/test/data/deposit-sweep.ts +++ b/typescript/test/data/deposit-sweep.ts @@ -374,24 +374,23 @@ export const depositSweepWithNonWitnessMainUtxoAndWitnessOutput: DepositSweepTes witness: true, expectedSweep: { transactionHash: BitcoinTxHash.from( - "7831d0dfde7e160f3b9bb66c433710f0d3110d73ea78b9db65e81c091a6718a0" + "1933781f01f27f086c3a31c4a53035ebc7c4688e1f4b316babefa8f6dab77dc2" ), transaction: { transactionHex: - "01000000000102173a201f597a2c8ccd7842303a6653bb87437fb08dae671731a0" + - "75403b32a2fd0000000000ffffffffe19612be756bf7e740b47bec0e24845089ac" + - "e48c78d473cb34949b3007c4a2c8000000006a47304402204382deb051f9f3e2b5" + - "39e4bac2d1a50faf8d66bc7a3a3f3d286dabd96d92b58b02207c74c6aaf48e25d0" + - "7e02bb4039606d77ecfd80c492c050ab2486af6027fc2d5a012103989d253b17a6" + - "a0f41838b84ff0d20e8898f9d7b1a98f2564da4cc29dcf8581d9ffffffff010884" + - "0000000000001600148db50eb52063ea9d98b3eac91489a90f738986f603483045" + - "022100c52bc876cdee80a3061ace3ffbce5e860942d444cd38e00e5f63fd8e818d" + - "7e7c022040a7017bb8213991697705e7092c481526c788a4731d06e582dc1c57be" + - "d7243b012103989d253b17a6a0f41838b84ff0d20e8898f9d7b1a98f2564da4cc2" + - "9dcf8581d95c14934b98637ca318a4d6e7ca6ffd1690b8e77df6377508f9f0c90d" + - "000395237576a9148db50eb52063ea9d98b3eac91489a90f738986f68763ac6776" + - "a914e257eccafbc07c381642ce6e7e55120fb077fbed880448f2b262b175ac6800" + - "00000000", + "01000000000102e19612be756bf7e740b47bec0e24845089ace48c78d473cb34949" + + "b3007c4a2c8000000006a473044022013787be70eda0620002fa55c92abfcd32257" + + "d64fa652dd32bac65d705162a95902203407fea1abc99a9273ead3179ce60f60a34" + + "33fb2e93f58569e4bee9f63c0d679012103989d253b17a6a0f41838b84ff0d20e88" + + "98f9d7b1a98f2564da4cc29dcf8581d9ffffffff173a201f597a2c8ccd7842303a6" + + "653bb87437fb08dae671731a075403b32a2fd0000000000ffffffff010884000000" + + "0000001600148db50eb52063ea9d98b3eac91489a90f738986f6000348304502210" + + "0804f0fa989d632cda99a24159e28b8d31d4033c2d5de47d8207ea2767273d10a02" + + "20278e82d0714867b31eb013762306e2b97c2c1cc74b8135bee78d565e72ee630e0" + + "12103989d253b17a6a0f41838b84ff0d20e8898f9d7b1a98f2564da4cc29dcf8581" + + "d95c14934b98637ca318a4d6e7ca6ffd1690b8e77df6377508f9f0c90d000395237" + + "576a9148db50eb52063ea9d98b3eac91489a90f738986f68763ac6776a914e257ec" + + "cafbc07c381642ce6e7e55120fb077fbed880448f2b262b175ac6800000000", }, }, } diff --git a/typescript/test/deposit-sweep.test.ts b/typescript/test/deposit-sweep.test.ts index 403d56020..10aa2b640 100644 --- a/typescript/test/deposit-sweep.test.ts +++ b/typescript/test/deposit-sweep.test.ts @@ -1,5 +1,6 @@ import { BigNumber } from "ethers" import { + BitcoinNetwork, BitcoinRawTx, BitcoinTxHash, BitcoinUtxo, @@ -39,8 +40,6 @@ describe("Sweep", () => { let bitcoinClient: MockBitcoinClient beforeEach(async () => { - bcoin.set("testnet") - tbtcContracts = new MockTBTCContracts() bitcoinClient = new MockBitcoinClient() }) @@ -430,6 +429,7 @@ describe("Sweep", () => { newMainUtxo, rawTransaction: transaction, } = await walletTx.depositSweep.assembleTransaction( + BitcoinNetwork.Testnet, fee, testnetWalletPrivateKey, utxosWithRaw, @@ -570,6 +570,7 @@ describe("Sweep", () => { newMainUtxo, rawTransaction: transaction, } = await walletTx.depositSweep.assembleTransaction( + BitcoinNetwork.Testnet, fee, testnetWalletPrivateKey, utxosWithRaw, @@ -726,6 +727,7 @@ describe("Sweep", () => { newMainUtxo, rawTransaction: transaction, } = await walletTx.depositSweep.assembleTransaction( + BitcoinNetwork.Testnet, fee, testnetWalletPrivateKey, utxosWithRaw, @@ -753,25 +755,7 @@ describe("Sweep", () => { // Validate inputs. expect(txJSON.inputs.length).to.be.equal(2) - const p2wshInput = txJSON.inputs[0] - expect(p2wshInput.prevout.hash).to.be.equal( - depositSweepWithNonWitnessMainUtxoAndWitnessOutput.deposits[0].utxo.transactionHash.toString() - ) - expect(p2wshInput.prevout.index).to.be.equal( - depositSweepWithNonWitnessMainUtxoAndWitnessOutput - .deposits[0].utxo.outputIndex - ) - // Transaction should be signed. As it's a SegWit input, the `witness` - // field should be filled, while the `script` field should be empty. - expect(p2wshInput.witness.length).to.be.greaterThan(0) - expect(p2wshInput.script.length).to.be.equal(0) - // Input's address should be set to the address generated from deposit - // script hash - expect(p2wshInput.address).to.be.equal( - "tb1qk8urugnf08wfle6wslmdxq7mkz9z0gw8e6gkvspn7dx87tfpfntshdm7qr" - ) - - const p2pkhInput = txJSON.inputs[1] // main UTXO + const p2pkhInput = txJSON.inputs[0] // main UTXO expect(p2pkhInput.prevout.hash).to.be.equal( depositSweepWithNonWitnessMainUtxoAndWitnessOutput.mainUtxo.transactionHash.toString() ) @@ -789,6 +773,24 @@ describe("Sweep", () => { "mtSEUCE7G8om9zJttG9twtjoiSsUz7QnY9" ) + const p2wshInput = txJSON.inputs[1] + expect(p2wshInput.prevout.hash).to.be.equal( + depositSweepWithNonWitnessMainUtxoAndWitnessOutput.deposits[0].utxo.transactionHash.toString() + ) + expect(p2wshInput.prevout.index).to.be.equal( + depositSweepWithNonWitnessMainUtxoAndWitnessOutput + .deposits[0].utxo.outputIndex + ) + // Transaction should be signed. As it's a SegWit input, the `witness` + // field should be filled, while the `script` field should be empty. + expect(p2wshInput.witness.length).to.be.greaterThan(0) + expect(p2wshInput.script.length).to.be.equal(0) + // Input's address should be set to the address generated from deposit + // script hash + expect(p2wshInput.address).to.be.equal( + "tb1qk8urugnf08wfle6wslmdxq7mkz9z0gw8e6gkvspn7dx87tfpfntshdm7qr" + ) + // Validate outputs. expect(txJSON.outputs.length).to.be.equal(1) @@ -860,6 +862,7 @@ describe("Sweep", () => { newMainUtxo, rawTransaction: transaction, } = await walletTx.depositSweep.assembleTransaction( + BitcoinNetwork.Testnet, fee, testnetWalletPrivateKey, utxosWithRaw, @@ -948,6 +951,7 @@ describe("Sweep", () => { await expect( walletTx.depositSweep.assembleTransaction( + BitcoinNetwork.Testnet, fee, testnetWalletPrivateKey, [], @@ -985,6 +989,7 @@ describe("Sweep", () => { await expect( walletTx.depositSweep.assembleTransaction( + BitcoinNetwork.Testnet, fee, testnetWalletPrivateKey, utxosWithRaw, @@ -1025,6 +1030,7 @@ describe("Sweep", () => { await expect( walletTx.depositSweep.assembleTransaction( + BitcoinNetwork.Testnet, fee, testnetWalletPrivateKey, [utxoWithRaw], @@ -1050,6 +1056,7 @@ describe("Sweep", () => { await expect( walletTx.depositSweep.assembleTransaction( + BitcoinNetwork.Testnet, fee, anotherPrivateKey, [utxoWithRaw], @@ -1084,6 +1091,7 @@ describe("Sweep", () => { await expect( walletTx.depositSweep.assembleTransaction( + BitcoinNetwork.Testnet, fee, testnetWalletPrivateKey, [utxoWithRaw], @@ -1103,8 +1111,6 @@ describe("Sweep", () => { let maintenanceService: MaintenanceService beforeEach(async () => { - bcoin.set("testnet") - bitcoinClient = new MockBitcoinClient() tbtcContracts = new MockTBTCContracts()