diff --git a/typescript/package.json b/typescript/package.json index 7cecbf2c0..806339a53 100644 --- a/typescript/package.json +++ b/typescript/package.json @@ -30,9 +30,11 @@ "bcoin": "git+https://github.com/keep-network/bcoin.git#5accd32c63e6025a0d35d67739c4a6e84095a1f8", "bitcoinjs-lib": "6.0.2", "bufio": "^1.0.6", + "ecpair": "^2.1.0", "electrum-client-js": "git+https://github.com/keep-network/electrum-client-js.git#v0.1.1", "ethers": "^5.5.2", "p-timeout": "^4.1.0", + "tiny-secp256k1": "^2.2.3", "wif": "2.0.6" }, "devDependencies": { diff --git a/typescript/src/bitcoin-network.ts b/typescript/src/bitcoin-network.ts index f14dd5ed4..c59a134f2 100644 --- a/typescript/src/bitcoin-network.ts +++ b/typescript/src/bitcoin-network.ts @@ -1,4 +1,5 @@ import { Hex } from "./hex" +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/bitcoin.ts b/typescript/src/bitcoin.ts index 88284dcad..8bf6b6a2e 100644 --- a/typescript/src/bitcoin.ts +++ b/typescript/src/bitcoin.ts @@ -3,7 +3,12 @@ import wif from "wif" import bufio from "bufio" import { BigNumber, utils } from "ethers" import { Hex } from "./hex" -import { BitcoinNetwork, toBcoinNetwork } from "./bitcoin-network" +import { + BitcoinNetwork, + toBcoinNetwork, + toBitcoinJsLibNetwork, +} from "./bitcoin-network" +import { payments } from "bitcoinjs-lib" /** * Represents a transaction hash (or transaction ID) as an un-prefixed hex @@ -644,6 +649,31 @@ export function createAddressFromOutputScript( ?.toString(toBcoinNetwork(network)) } +/** + * 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 createAddressFromPublicKey( + 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! + } +} + /** * Reads the leading compact size uint from the provided variable length data. * @@ -683,3 +713,59 @@ export function readCompactSizeUint(varLenData: Hex): { } } } + +/** + * 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. + */ +export 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. + */ +export 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. + */ +export 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. + */ +export function isP2WSHScript(script: Buffer): boolean { + try { + payments.p2wsh({ output: script }) + return true + } catch (err) { + return false + } +} diff --git a/typescript/src/deposit-sweep.ts b/typescript/src/deposit-sweep.ts index 36f3eb4e4..0627382d7 100644 --- a/typescript/src/deposit-sweep.ts +++ b/typescript/src/deposit-sweep.ts @@ -1,18 +1,34 @@ -import bcoin from "bcoin" +import { + Transaction, + TxOutput, + Stack, + Signer, + payments, + script, +} from "bitcoinjs-lib" import { BigNumber } from "ethers" +import { Hex } from "./hex" import { RawTransaction, UnspentTransactionOutput, Client as BitcoinClient, decomposeRawTransaction, isCompressedPublicKey, - createKeyRing, + createAddressFromPublicKey, TransactionHash, computeHash160, + isP2PKHScript, + isP2WPKHScript, + isP2SHScript, + isP2WSHScript, + createOutputScriptFromAddress, } from "./bitcoin" import { assembleDepositScript, Deposit } from "./deposit" import { Bridge, Identifier } from "./chain" import { assembleTransactionProof } from "./proof" +import { ECPairFactory } from "ecpair" +import * as tinysecp from "tiny-secp256k1" +import { BitcoinNetwork, toBitcoinJsLibNetwork } from "./bitcoin-network" /** * Submits a deposit sweep by combining all the provided P2(W)SH UTXOs and @@ -72,8 +88,11 @@ export async function submitDepositSweepTransaction( } } + const bitcoinNetwork = await bitcoinClient.getNetwork() + const { transactionHash, newMainUtxo, rawTransaction } = await assembleDepositSweepTransaction( + bitcoinNetwork, fee, walletPrivateKey, witness, @@ -91,26 +110,28 @@ export async function submitDepositSweepTransaction( } /** - * Assembles a Bitcoin P2WPKH deposit sweep transaction. + * Constructs a Bitcoin deposit sweep transaction using provided UTXOs. * @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. - * @param fee - the value that should be subtracted from the sum of the UTXOs - * values and used as the transaction fee. + * @param bitcoinNetwork - The target Bitcoin network (mainnet or testnet). + * @param fee - Transaction fee to be subtracted from the sum of the UTXOs' + * values. * @param walletPrivateKey - Bitcoin private key of the wallet in WIF format. - * @param witness - The parameter used to decide about the type of the new main - * UTXO output. P2WPKH if `true`, P2PKH if `false`. + * @param witness - Determines the type of the new main UTXO output: P2WPKH if + * `true`, P2PKH if `false`. * @param utxos - UTXOs from new deposit transactions. Must be P2(W)SH. - * @param deposits - Array of deposits. Each element corresponds to UTXO. - * The number of UTXOs and deposit elements must equal. - * @param mainUtxo - main UTXO of the wallet, which is a P2WKH UTXO resulting - * from the previous wallet transaction (optional). - * @returns The outcome consisting of: - * - the sweep transaction hash, - * - the new wallet's main UTXO produced by this transaction. - * - the sweep transaction in the raw format + * @param deposits - Deposit data corresponding to each UTXO. The number of + * UTXOs and deposits must match. + * @param mainUtxo - The wallet's main UTXO (optional), which is a P2(W)PKH UTXO + * from a previous transaction. + * @returns An object containing the sweep transaction hash, new wallet's main + * UTXO, and the raw deposit sweep transaction representation. + * @throws Error if the provided UTXOs and deposits mismatch or if an unsupported + * UTXO script type is encountered. */ export async function assembleDepositSweepTransaction( + bitcoinNetwork: BitcoinNetwork, fee: BigNumber, walletPrivateKey: string, witness: boolean, @@ -130,238 +151,288 @@ export async function assembleDepositSweepTransaction( throw new Error("Number of UTXOs must equal the number of deposit elements") } - const walletKeyRing = createKeyRing(walletPrivateKey, witness) - const walletAddress = walletKeyRing.getAddress("string") + const network = toBitcoinJsLibNetwork(bitcoinNetwork) + // eslint-disable-next-line new-cap + const walletKeyPair = ECPairFactory(tinysecp).fromWIF( + walletPrivateKey, + network + ) + const walletAddress = createAddressFromPublicKey( + Hex.from(walletKeyPair.publicKey), + bitcoinNetwork, + witness + ) - 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() - - transaction.addOutput({ - script: bcoin.Script.fromAddress(walletAddress), - value: totalInputValue.toNumber(), - }) + const outputScript = createOutputScriptFromAddress(walletAddress) + transaction.addOutput(outputScript.toBuffer(), outputValue.toNumber()) - await transaction.fund(inputCoins, { - changeAddress: walletAddress, - hardFee: fee.toNumber(), - subtractFee: true, - }) - - if (transaction.outputs.length != 1) { - throw new Error("Deposit sweep transaction must have only one output") + // 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 + ] + + await signMainUtxoInput( + transaction, + inputIndex, + previousOutput, + walletKeyPair + ) } - // UTXOs must be mapped to deposits, as `fund` may arrange inputs in any - // order - const utxosWithDeposits: (UnspentTransactionOutput & - RawTransaction & - Deposit)[] = 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 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 + + const deposit = deposits[depositIndex] - if (previousScript.isScripthash()) { + if (isP2SHScript(previousOutputScript)) { // P2SH (deposit UTXO) - await signP2SHDepositInput(transaction, i, utxoWithDeposit, walletKeyRing) - } else if (previousScript.isWitnessScripthash()) { + await signP2SHDepositInput( + transaction, + inputIndex, + deposit, + previousOutputValue, + walletKeyPair + ) + } else if (isP2WSHScript(previousOutputScript)) { // P2WSH (deposit UTXO) await signP2WSHDepositInput( transaction, - i, - utxoWithDeposit, - walletKeyRing + inputIndex, + deposit, + previousOutputValue, + walletKeyPair ) } else { throw new Error("Unsupported UTXO script type") } } - const transactionHash = TransactionHash.from(transaction.txid()) + const transactionHash = TransactionHash.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. + * @param bitcoinNetwork - The Bitcoin network type. + * @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. */ async function 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 ( + !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 (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 (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. */ async function signP2SHDepositInput( - transaction: any, + transaction: Transaction, inputIndex: number, deposit: Deposit, - walletKeyRing: any -): Promise { - const { walletPublicKey, depositScript, previousOutputValue } = - await prepareInputSignData(transaction, inputIndex, deposit, walletKeyRing) + previousOutputValue: number, + walletKeyPair: Signer +) { + const depositScript = await 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 signature = script.signature.encode( + walletKeyPair.sign(sigHash), + 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 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. */ async function signP2WSHDepositInput( - transaction: any, + transaction: Transaction, inputIndex: number, deposit: Deposit, - walletKeyRing: any -): Promise { - const { walletPublicKey, depositScript, previousOutputValue } = - await prepareInputSignData(transaction, inputIndex, deposit, walletKeyRing) + previousOutputValue: number, + walletKeyPair: Signer +) { + const depositScript = await 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 + ) - transaction.inputs[inputIndex].witness = witness + const witness: Buffer[] = [] + witness.push(signature) + witness.push(walletKeyPair.publicKey) + witness.push(depositScript) + + 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. */ -async function prepareInputSignData( - transaction: any, - inputIndex: number, +async function prepareDepositScript( deposit: Deposit, - walletKeyRing: any -): Promise<{ - walletPublicKey: string - depositScript: any - previousOutputValue: number -}> { - const previousOutpoint = transaction.inputs[inputIndex].prevout - const previousOutput = transaction.view.getOutput(previousOutpoint) - - if (previousOutput.value != deposit.amount.toNumber()) { + previousOutputValue: number, + walletKeyPair: Signer +): Promise { + if (previousOutputValue != deposit.amount.toNumber()) { throw new Error("Mismatch between amount in deposit and deposit tx") } - const walletPublicKey = walletKeyRing.getPublicKey("hex") - if ( - computeHash160(walletKeyRing.getPublicKey("hex")) != - deposit.walletPublicKeyHash - ) { + const walletPublicKey = walletKeyPair.publicKey.toString("hex") + + if (computeHash160(walletPublicKey) != deposit.walletPublicKeyHash) { throw new Error( "Wallet public key does not correspond to wallet private key" ) @@ -374,15 +445,12 @@ async function prepareInputSignData( // eslint-disable-next-line no-unused-vars const { amount, vault, ...depositScriptParameters } = deposit - const depositScript = bcoin.Script.fromRaw( - Buffer.from(await assembleDepositScript(depositScriptParameters), "hex") + const depositScript = Buffer.from( + await assembleDepositScript(depositScriptParameters), + "hex" ) - return { - walletPublicKey, - depositScript: depositScript, - previousOutputValue: previousOutput.value, - } + return depositScript } /** @@ -420,3 +488,20 @@ export async function submitDepositSweepProof( vault ) } + +/** + * 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. + */ +function 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/src/deposit.ts b/typescript/src/deposit.ts index a441346ab..59d8606ec 100644 --- a/typescript/src/deposit.ts +++ b/typescript/src/deposit.ts @@ -1,5 +1,6 @@ import bcoin from "bcoin" import { BigNumber } from "ethers" +import { Stack, script, opcodes } from "bitcoinjs-lib" import { Client as BitcoinClient, decomposeRawTransaction, @@ -13,8 +14,6 @@ import { BitcoinNetwork, toBcoinNetwork } from "./bitcoin-network" import { Bridge, Event, Identifier } from "./chain" import { Hex } from "./hex" -const { opcodes } = bcoin.script.common - // TODO: Replace all properties that are expected to be un-prefixed hexadecimal // strings with a Hex type. @@ -244,33 +243,31 @@ export async function assembleDepositScript( ): Promise { validateDepositScriptParameters(deposit) - // All HEXes pushed to the script must be un-prefixed. - const script = new bcoin.Script() - script.clear() - script.pushData(Buffer.from(deposit.depositor.identifierHex, "hex")) - script.pushOp(opcodes.OP_DROP) - script.pushData(Buffer.from(deposit.blindingFactor, "hex")) - script.pushOp(opcodes.OP_DROP) - script.pushOp(opcodes.OP_DUP) - script.pushOp(opcodes.OP_HASH160) - script.pushData(Buffer.from(deposit.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(deposit.refundPublicKeyHash, "hex")) - script.pushOp(opcodes.OP_EQUALVERIFY) - script.pushData(Buffer.from(deposit.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(deposit.depositor.identifierHex, "hex")) + chunks.push(opcodes.OP_DROP) + chunks.push(Buffer.from(deposit.blindingFactor, "hex")) + chunks.push(opcodes.OP_DROP) + chunks.push(opcodes.OP_DUP) + chunks.push(opcodes.OP_HASH160) + chunks.push(Buffer.from(deposit.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(deposit.refundPublicKeyHash, "hex")) + chunks.push(opcodes.OP_EQUALVERIFY) + chunks.push(Buffer.from(deposit.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") } // eslint-disable-next-line valid-jsdoc diff --git a/typescript/test/bitcoin-network.test.ts b/typescript/test/bitcoin-network.test.ts index d2ff7b370..e610c2ee9 100644 --- a/typescript/test/bitcoin-network.test.ts +++ b/typescript/test/bitcoin-network.test.ts @@ -1,6 +1,11 @@ import { expect } from "chai" -import { BitcoinNetwork, toBcoinNetwork } from "../src/bitcoin-network" +import { + BitcoinNetwork, + toBcoinNetwork, + toBitcoinJsLibNetwork, +} from "../src/bitcoin-network" import { TransactionHash } from "../src/bitcoin" +import { networks } from "bitcoinjs-lib" describe("BitcoinNetwork", () => { const testData = [ @@ -10,6 +15,7 @@ describe("BitcoinNetwork", () => { // any value that doesn't match other supported networks genesisHash: TransactionHash.from("0x00010203"), expectedToBcoinResult: new Error("network not supported"), + expectedToBitcoinJsLibResult: new Error("network not supported"), }, { enumKey: BitcoinNetwork.Testnet, @@ -18,6 +24,7 @@ describe("BitcoinNetwork", () => { "0x000000000933ea01ad0ee984209779baaec3ced90fa3f408719526f8d77f4943" ), expectedToBcoinResult: "testnet", + expectedToBitcoinJsLibResult: networks.testnet, }, { enumKey: BitcoinNetwork.Mainnet, @@ -26,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 () => { @@ -59,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 80eebfda5..0bcc335ba 100644 --- a/typescript/test/bitcoin.test.ts +++ b/typescript/test/bitcoin.test.ts @@ -13,15 +13,20 @@ import { targetToDifficulty, createOutputScriptFromAddress, createAddressFromOutputScript, + createAddressFromPublicKey, readCompactSizeUint, computeHash160, computeHash256, + isP2PKHScript, + isP2WPKHScript, + isP2SHScript, + isP2WSHScript, } from "../src/bitcoin" import { calculateDepositRefundLocktime } from "../src/deposit" import { BitcoinNetwork } from "../src/bitcoin-network" import { Hex } from "../src/hex" import { BigNumber } from "ethers" -import { btcAddresses } from "./data/bitcoin" +import { btcAddresses, btcAddressFromPublicKey } from "./data/bitcoin" describe("Bitcoin", () => { describe("compressPublicKey", () => { @@ -535,6 +540,30 @@ describe("Bitcoin", () => { }) }) + describe("createAddressFromPublicKey", () => { + 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 = createAddressFromPublicKey( + publicKey, + bitcoinNetwork === "mainnet" + ? BitcoinNetwork.Mainnet + : BitcoinNetwork.Testnet, + witness + ) + expect(result).to.eq(address) + }) + } + ) + }) + } + ) + }) + describe("readCompactSizeUint", () => { context("when the compact size uint is 1-byte", () => { it("should return the the uint value and byte length", () => { @@ -571,4 +600,57 @@ describe("Bitcoin", () => { }) }) }) + + describe("Bitcoin Script Type", () => { + 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 d44b4b737..b04baad81 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 59e3f5770..74e001b6b 100644 --- a/typescript/test/data/deposit-sweep.ts +++ b/typescript/test/data/deposit-sweep.ts @@ -350,24 +350,23 @@ export const depositSweepWithNonWitnessMainUtxoAndWitnessOutput: DepositSweepTes witness: true, expectedSweep: { transactionHash: TransactionHash.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 494aa24c5..be41c9a81 100644 --- a/typescript/test/deposit-sweep.test.ts +++ b/typescript/test/deposit-sweep.test.ts @@ -31,6 +31,7 @@ import { submitDepositSweepProof, submitDepositSweepTransaction, } from "../src/deposit-sweep" +import { BitcoinNetwork } from "../src/bitcoin-network" describe("Sweep", () => { const fee = BigNumber.from(1600) @@ -39,8 +40,6 @@ describe("Sweep", () => { let bitcoinClient: MockBitcoinClient beforeEach(async () => { - bcoin.set("testnet") - bitcoinClient = new MockBitcoinClient() }) @@ -379,6 +378,7 @@ describe("Sweep", () => { newMainUtxo, rawTransaction: transaction, } = await assembleDepositSweepTransaction( + BitcoinNetwork.Testnet, fee, testnetWalletPrivateKey, witness, @@ -508,6 +508,7 @@ describe("Sweep", () => { newMainUtxo, rawTransaction: transaction, } = await assembleDepositSweepTransaction( + BitcoinNetwork.Testnet, fee, testnetWalletPrivateKey, witness, @@ -658,6 +659,7 @@ describe("Sweep", () => { newMainUtxo, rawTransaction: transaction, } = await assembleDepositSweepTransaction( + BitcoinNetwork.Testnet, fee, testnetWalletPrivateKey, witness, @@ -686,25 +688,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() ) @@ -722,6 +706,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) @@ -791,6 +793,7 @@ describe("Sweep", () => { newMainUtxo, rawTransaction: transaction, } = await assembleDepositSweepTransaction( + BitcoinNetwork.Testnet, fee, testnetWalletPrivateKey, witness, @@ -878,6 +881,7 @@ describe("Sweep", () => { it("should revert", async () => { await expect( assembleDepositSweepTransaction( + BitcoinNetwork.Testnet, fee, testnetWalletPrivateKey, true, @@ -906,6 +910,7 @@ describe("Sweep", () => { it("should revert", async () => { await expect( assembleDepositSweepTransaction( + BitcoinNetwork.Testnet, fee, testnetWalletPrivateKey, witness, @@ -931,6 +936,7 @@ describe("Sweep", () => { it("should revert", async () => { await expect( assembleDepositSweepTransaction( + BitcoinNetwork.Testnet, fee, testnetWalletPrivateKey, true, @@ -970,6 +976,7 @@ describe("Sweep", () => { it("should revert", async () => { await expect( assembleDepositSweepTransaction( + BitcoinNetwork.Testnet, fee, testnetWalletPrivateKey, true, @@ -994,6 +1001,7 @@ describe("Sweep", () => { it("should revert", async () => { await expect( assembleDepositSweepTransaction( + BitcoinNetwork.Testnet, fee, anotherPrivateKey, true, @@ -1027,6 +1035,7 @@ describe("Sweep", () => { it("should revert", async () => { await expect( assembleDepositSweepTransaction( + BitcoinNetwork.Testnet, fee, testnetWalletPrivateKey, true, @@ -1043,8 +1052,6 @@ describe("Sweep", () => { let bridge: MockBridge beforeEach(async () => { - bcoin.set("testnet") - bitcoinClient = new MockBitcoinClient() bridge = new MockBridge() diff --git a/typescript/yarn.lock b/typescript/yarn.lock index f2778ae49..05897068f 100644 --- a/typescript/yarn.lock +++ b/typescript/yarn.lock @@ -3762,6 +3762,15 @@ ecc-jsbn@~0.1.1: jsbn "~0.1.0" safer-buffer "^2.1.0" +ecpair@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/ecpair/-/ecpair-2.1.0.tgz#673f826b1d80d5eb091b8e2010c6b588e8d2cb45" + integrity sha512-cL/mh3MtJutFOvFc27GPZE2pWL3a3k4YvzUWEOvilnfZVlH3Jwgx/7d6tlD7/75tNk8TG2m+7Kgtz0SI1tWcqw== + dependencies: + randombytes "^2.1.0" + typeforce "^1.18.0" + wif "^2.0.6" + ee-first@1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d" @@ -7570,6 +7579,13 @@ tiny-secp256k1@^1.1.3: elliptic "^6.4.0" nan "^2.13.2" +tiny-secp256k1@^2.2.3: + version "2.2.3" + resolved "https://registry.yarnpkg.com/tiny-secp256k1/-/tiny-secp256k1-2.2.3.tgz#fe1dde11a64fcee2091157d4b78bcb300feb9b65" + integrity sha512-SGcL07SxcPN2nGKHTCvRMkQLYPSoeFcvArUSCYtjVARiFAWU44cCIqYS0mYAU6nY7XfvwURuTIGo2Omt3ZQr0Q== + dependencies: + uint8array-tools "0.0.7" + tmp@0.0.33: version "0.0.33" resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.0.33.tgz#6d34335889768d21b2bcda0aa277ced3b1bfadf9" @@ -7770,7 +7786,7 @@ typedarray-to-buffer@^3.1.5: dependencies: is-typedarray "^1.0.0" -typeforce@^1.11.3, typeforce@^1.11.5: +typeforce@^1.11.3, typeforce@^1.11.5, typeforce@^1.18.0: version "1.18.0" resolved "https://registry.yarnpkg.com/typeforce/-/typeforce-1.18.0.tgz#d7416a2c5845e085034d70fcc5b6cc4a90edbfdc" integrity sha512-7uc1O8h1M1g0rArakJdf0uLRSSgFcYexrVoKo+bzJd32gd4gDy2L/Z+8/FjPnU9ydY3pEnVPtr9FyscYY60K1g== @@ -7795,6 +7811,11 @@ typical@^5.2.0: resolved "https://registry.yarnpkg.com/typical/-/typical-5.2.0.tgz#4daaac4f2b5315460804f0acf6cb69c52bb93066" integrity sha512-dvdQgNDNJo+8B2uBQoqdb11eUCE1JQXhvjC/CZtgvZseVd5TYMXnq0+vuUemXbd/Se29cTaUuPX3YIc2xgbvIg== +uint8array-tools@0.0.7: + version "0.0.7" + resolved "https://registry.yarnpkg.com/uint8array-tools/-/uint8array-tools-0.0.7.tgz#a7a2bb5d8836eae2fade68c771454e6a438b390d" + integrity sha512-vrrNZJiusLWoFWBqz5Y5KMCgP9W9hnjZHzZiZRT8oNAkq3d5Z5Oe76jAvVVSRh4U8GGR90N2X1dWtrhvx6L8UQ== + ultron@~1.1.0: version "1.1.1" resolved "https://registry.yarnpkg.com/ultron/-/ultron-1.1.1.tgz#9fe1536a10a664a65266a1e3ccf85fd36302bc9c"