From b8fce38bbd392b2e4747fb041b530bfd0dac1cde Mon Sep 17 00:00:00 2001 From: HFazelinia Date: Thu, 22 Feb 2024 20:50:47 +0000 Subject: [PATCH 01/14] implement most of BitcoinChain functions --- packages/chains/bitcoin/lib/BitcoinChain.ts | 256 +++++++++++++++--- packages/chains/bitcoin/lib/Serializer.ts | 23 ++ packages/chains/bitcoin/lib/bitcoinUtils.ts | 9 + packages/chains/bitcoin/lib/constants.ts | 1 + .../lib/network/AbstractBitcoinNetwork.ts | 4 +- packages/chains/bitcoin/lib/types.ts | 1 + packages/chains/bitcoin/package.json | 1 + 7 files changed, 251 insertions(+), 44 deletions(-) create mode 100644 packages/chains/bitcoin/lib/Serializer.ts create mode 100644 packages/chains/bitcoin/lib/bitcoinUtils.ts diff --git a/packages/chains/bitcoin/lib/BitcoinChain.ts b/packages/chains/bitcoin/lib/BitcoinChain.ts index 31914e5..e569b1b 100644 --- a/packages/chains/bitcoin/lib/BitcoinChain.ts +++ b/packages/chains/bitcoin/lib/BitcoinChain.ts @@ -3,23 +3,29 @@ import { Fee } from '@rosen-bridge/minimum-fee'; import { AbstractUtxoChain, BoxInfo, - ConfirmationStatus, EventTrigger, PaymentOrder, PaymentTransaction, SigningStatus, + SinglePayment, TransactionAssetBalance, TransactionType, } from '@rosen-chains/abstract-chain'; import AbstractBitcoinNetwork from './network/AbstractBitcoinNetwork'; import BitcoinTransaction from './BitcoinTransaction'; import { BitcoinConfigs, BitcoinUtxo } from './types'; +import Serializer from './Serializer'; +import { Psbt, Transaction, address, payments, script } from 'bitcoinjs-lib'; +import JsonBigInt from '@rosen-bridge/json-bigint'; +import { getPsbtTxInputBoxId } from './bitcoinUtils'; class BitcoinChain extends AbstractUtxoChain { declare network: AbstractBitcoinNetwork; declare configs: BitcoinConfigs; feeRatioDivisor: bigint; protected signFunction: (txHash: Uint8Array) => Promise; + protected lockScript: string; + protected signingScript: Buffer; constructor( network: AbstractBitcoinNetwork, @@ -31,6 +37,12 @@ class BitcoinChain extends AbstractUtxoChain { super(network, configs, logger); this.feeRatioDivisor = feeRatioDivisor; this.signFunction = signFunction; + this.lockScript = address + .toOutputScript(this.configs.addresses.lock) + .toString('hex'); + this.signingScript = payments.p2pkh({ + hash: Buffer.from(this.lockScript, 'hex').subarray(2), + }).output!; } /** @@ -58,10 +70,29 @@ class BitcoinChain extends AbstractUtxoChain { * @param transaction the PaymentTransaction * @returns an object containing the amount of input and output assets */ - getTransactionAssets = ( + getTransactionAssets = async ( transaction: PaymentTransaction ): Promise => { - throw Error(`not implemented`); + const bitcoinTx = transaction as BitcoinTransaction; + + let txBtc = 0n; + const inputUtxos = Array.from(new Set(bitcoinTx.inputUtxos)); + for (let i = 0; i < inputUtxos.length; i++) { + const input = JsonBigInt.parse(inputUtxos[i]) as BitcoinUtxo; + txBtc += input.value; + } + + // no need to calculate outBtc, because: inBtc = outBtc + fee + return { + inputAssets: { + nativeToken: txBtc, + tokens: [], + }, + outputAssets: { + nativeToken: txBtc, + tokens: [], + }, + }; }; /** @@ -70,7 +101,29 @@ class BitcoinChain extends AbstractUtxoChain { * @returns the transaction payment order (list of single payments) */ extractTransactionOrder = (transaction: PaymentTransaction): PaymentOrder => { - throw Error(`not implemented`); + const tx = Serializer.deserialize(transaction.txBytes); + + const order: PaymentOrder = []; + for (let i = 0; i < tx.txOutputs.length; i++) { + const output = tx.txOutputs[i]; + + // skip change box (last box & address equal to bank address) + if ( + i === tx.txOutputs.length - 1 && + output.script.toString('hex') === this.lockScript + ) + continue; + + const payment: SinglePayment = { + address: address.fromOutputScript(output.script), + assets: { + nativeToken: BigInt(output.value), + tokens: [], + }, + }; + order.push(payment); + } + return order; }; /** @@ -95,14 +148,27 @@ class BitcoinChain extends AbstractUtxoChain { }; /** - * verifies additional conditions for a PaymentTransaction + * verifies additional conditions for a BitcoinTransaction + * - check change box * @param transaction the PaymentTransaction * @returns true if the transaction is verified */ verifyTransactionExtraConditions = ( transaction: PaymentTransaction ): boolean => { - throw Error(`not implemented`); + const tx = Serializer.deserialize(transaction.txBytes); + + // check change box + const changeBoxIndex = tx.txOutputs.length - 1; + const changeBox = tx.txOutputs[changeBoxIndex]; + if (changeBox.script.toString('hex') !== this.lockScript) { + this.logger.debug( + `Tx [${transaction.txId}] invalid: Change box address is wrong` + ); + return false; + } + + return true; }; /** @@ -121,11 +187,21 @@ class BitcoinChain extends AbstractUtxoChain { * @param signingStatus * @returns true if the transaction is still valid */ - isTxValid = ( + isTxValid = async ( transaction: PaymentTransaction, signingStatus: SigningStatus = SigningStatus.Signed ): Promise => { - throw Error(`not implemented`); + const tx = Serializer.deserialize(transaction.txBytes); + for (let i = 0; i < tx.txInputs.length; i++) { + const boxId = getPsbtTxInputBoxId(tx.txInputs[i]); + if (!(await this.network.isBoxUnspentAndValid(boxId))) { + this.logger.debug( + `Tx [${transaction.txId}] is invalid due to spending invalid input box [${boxId}] at index [${i}]` + ); + return false; + } + } + return true; }; /** @@ -138,28 +214,74 @@ class BitcoinChain extends AbstractUtxoChain { transaction: PaymentTransaction, requiredSign: number ): Promise => { - throw Error(`not implemented`); - }; + const psbt = Serializer.deserialize(transaction.txBytes); + const tx = Transaction.fromBuffer(psbt.data.getTransaction()); + const bitcoinTx = transaction as BitcoinTransaction; - /** - * extracts confirmation status for a transaction - * @param transactionId the transaction id - * @param transactionType type of the transaction - * @returns the transaction confirmation status - */ - getTxConfirmationStatus = ( - transactionId: string, - transactionType: TransactionType - ): Promise => { - throw Error(`not implemented`); + const signaturePromises: Promise[] = []; + for (let i = 0; i < bitcoinTx.inputUtxos.length; i++) { + const input = JsonBigInt.parse(bitcoinTx.inputUtxos[i]) as BitcoinUtxo; + const signMessage = tx.hashForWitnessV0( + i, + this.signingScript, + Number(input.value), + Transaction.SIGHASH_ALL + ); + + const signatureHex = this.signFunction(signMessage).then( + (signatureHex: string) => { + this.logger.debug( + `Input [${i}] of tx [${bitcoinTx.txId}] is signed. signature: ${signatureHex}` + ); + return signatureHex; + } + ); + signaturePromises.push(signatureHex); + } + + return Promise.all(signaturePromises).then((signatures) => { + const signedPsbt = this.buildSignedTransaction( + bitcoinTx.txBytes, + signatures + ); + // check if transaction can be finalized + signedPsbt.finalizeAllInputs().extractTransaction(); + + // generate PaymentTransaction with signed Psbt + return new BitcoinTransaction( + bitcoinTx.txId, + bitcoinTx.eventId, + Serializer.serialize(signedPsbt), + bitcoinTx.txType, + bitcoinTx.inputUtxos + ); + }); }; /** * submits a transaction to the blockchain * @param transaction the transaction */ - submitTransaction = (transaction: PaymentTransaction): Promise => { - throw Error(`not implemented`); + submitTransaction = async ( + transaction: PaymentTransaction + ): Promise => { + // deserialize transaction + const tx = Serializer.deserialize(transaction.txBytes); + + // send transaction + try { + const response = await this.network.submitTransaction(tx); + this.logger.info( + `Bitcoin Transaction [${transaction.txId}] submitted. Response: ${response}` + ); + } catch (e) { + this.logger.warn( + `An error occurred while submitting Bitcoin transaction [${transaction.txId}]: ${e}` + ); + if (e instanceof Error && e.stack) { + this.logger.warn(e.stack); + } + } }; /** @@ -167,8 +289,8 @@ class BitcoinChain extends AbstractUtxoChain { * @param transactionId the transaction id * @returns true if the transaction is in mempool */ - isTxInMempool = (transactionId: string): Promise => { - throw Error(`not implemented`); + isTxInMempool = async (transactionId: string): Promise => { + return (await this.network.getMempoolTxIds()).includes(transactionId); }; /** @@ -177,29 +299,42 @@ class BitcoinChain extends AbstractUtxoChain { */ getMinimumNativeToken = (): bigint => this.configs.minBoxValue; - /** - * gets the RWT token id - * @returns RWT token id - */ - getRWTToken = (): string => this.configs.rwtId; - /** * converts json representation of the payment transaction to PaymentTransaction * @returns PaymentTransaction object */ - PaymentTransactionFromJson = (jsonString: string): PaymentTransaction => { - throw Error(`not implemented`); - }; + PaymentTransactionFromJson = (jsonString: string): BitcoinTransaction => + BitcoinTransaction.fromJson(jsonString); /** - * generates PaymentTransaction object from raw tx json string - * @param rawTxJsonString + * generates PaymentTransaction object from psbt hex string + * @param psbtHex * @returns PaymentTransaction object */ - rawTxToPaymentTransaction = ( - rawTxJsonString: string + rawTxToPaymentTransaction = async ( + psbtHex: string ): Promise => { - throw Error(`not implemented`); + const tx = Psbt.fromHex(psbtHex); + const txBytes = Serializer.serialize(tx); + const txId = Transaction.fromBuffer(tx.data.getTransaction()).getId(); + + const inputBoxes: Array = []; + const inputs = tx.txInputs; + for (let i = 0; i < inputs.length; i++) { + const boxId = getPsbtTxInputBoxId(inputs[i]); + inputBoxes.push(await this.network.getUtxo(boxId)); + } + + const cardanoTx = new BitcoinTransaction( + txId, + '', + txBytes, + TransactionType.manual, + inputBoxes.map((box) => JsonBigInt.stringify(box)) + ); + + this.logger.info(`Parsed Bitcoin transaction [${txId}] successfully`); + return cardanoTx; }; /** @@ -213,7 +348,7 @@ class BitcoinChain extends AbstractUtxoChain { tokenId?: string ): Promise> => { // chaining transaction won't be done in BitcoinChain - // due to heavy size of transaction in mempool + // due to heavy size of transactions in mempool return new Map(); }; @@ -222,8 +357,45 @@ class BitcoinChain extends AbstractUtxoChain { * @param box the box * @returns an object containing the box id and assets */ - protected getBoxInfo = (box: BitcoinUtxo): BoxInfo => { - throw Error(`not implemented`); + getBoxInfo = (box: BitcoinUtxo): BoxInfo => { + return { + id: this.getBoxId(box), + assets: { + nativeToken: box.value, + tokens: [], + }, + }; + }; + + /** + * returns box id + * @param box + */ + protected getBoxId = (box: BitcoinUtxo): string => box.txId + '.' + box.index; + + /** + * inserts signatures into psbt + * @param txBytes + * @param signatures generated signature by signer service + * @returns a signed transaction (in Psbt format) + */ + protected buildSignedTransaction = ( + txBytes: Uint8Array, + signatures: string[] + ): Psbt => { + const psbt = Serializer.deserialize(txBytes); + for (let i = 0; i < signatures.length; i++) { + const signature = Buffer.from(signatures[i], 'hex'); + psbt.updateInput(i, { + partialSig: [ + { + pubkey: Buffer.from(this.configs.aggregatedPublicKey, 'hex'), + signature: script.signature.encode(signature, 1), + }, + ], + }); + } + return psbt; }; } diff --git a/packages/chains/bitcoin/lib/Serializer.ts b/packages/chains/bitcoin/lib/Serializer.ts new file mode 100644 index 0000000..c8f8803 --- /dev/null +++ b/packages/chains/bitcoin/lib/Serializer.ts @@ -0,0 +1,23 @@ +import { Psbt } from 'bitcoinjs-lib'; + +class Serializer { + /** + * converts 'bitcoinjs-lib' PSBT to bytearray + * @param tx the transaction in 'bitcoinjs-lib' PSBT format + * @returns bytearray representation of the transaction + */ + static serialize = (tx: Psbt): Uint8Array => { + return tx.toBuffer(); + }; + + /** + * converts bytearray representation of the transaction to 'bitcoinjs-lib' PSBT format + * @param txBytes bytearray representation of the transaction + * @returns the transaction in 'bitcoinjs-lib' PSBT format + */ + static deserialize = (txBytes: Uint8Array): Psbt => { + return Psbt.fromBuffer(Buffer.from(txBytes)); + }; +} + +export default Serializer; diff --git a/packages/chains/bitcoin/lib/bitcoinUtils.ts b/packages/chains/bitcoin/lib/bitcoinUtils.ts new file mode 100644 index 0000000..22d71ea --- /dev/null +++ b/packages/chains/bitcoin/lib/bitcoinUtils.ts @@ -0,0 +1,9 @@ +import { PsbtTxInput } from 'bitcoinjs-lib'; + +/** + * gets boxId from PsbtTxInput + * @param input + * @returns box id in `{txId}.{index}` format + */ +export const getPsbtTxInputBoxId = (input: PsbtTxInput) => + `${input.hash.reverse().toString('hex')}.${input.index}`; diff --git a/packages/chains/bitcoin/lib/constants.ts b/packages/chains/bitcoin/lib/constants.ts index 3305cc7..c53cee3 100644 --- a/packages/chains/bitcoin/lib/constants.ts +++ b/packages/chains/bitcoin/lib/constants.ts @@ -1 +1,2 @@ export const BITCOIN_CHAIN = 'bitcoin'; +export const OP_RETURN_ADDRESS = 'op_return'; diff --git a/packages/chains/bitcoin/lib/network/AbstractBitcoinNetwork.ts b/packages/chains/bitcoin/lib/network/AbstractBitcoinNetwork.ts index 255d5cb..d40bbce 100644 --- a/packages/chains/bitcoin/lib/network/AbstractBitcoinNetwork.ts +++ b/packages/chains/bitcoin/lib/network/AbstractBitcoinNetwork.ts @@ -1,13 +1,13 @@ import { AbstractUtxoChainNetwork } from '@rosen-chains/abstract-chain'; import { Psbt } from 'bitcoinjs-lib'; import { BitcoinTx, BitcoinUtxo } from '../types'; +import { BitcoinRosenExtractor } from '@rosen-bridge/rosen-extractor'; abstract class AbstractBitcoinNetwork extends AbstractUtxoChainNetwork< BitcoinTx, BitcoinUtxo > { - // TODO: uncomment this line (local:ergo/rosen-bridge/utils#169) - // abstract extractor: BitcoinRosenExtractor; + abstract extractor: BitcoinRosenExtractor; /** * submits a transaction diff --git a/packages/chains/bitcoin/lib/types.ts b/packages/chains/bitcoin/lib/types.ts index aaaf288..8b64d0f 100644 --- a/packages/chains/bitcoin/lib/types.ts +++ b/packages/chains/bitcoin/lib/types.ts @@ -5,6 +5,7 @@ import { export interface BitcoinConfigs extends ChainConfigs { minBoxValue: bigint; + aggregatedPublicKey: string; } export interface BitcoinTransactionJsonModel diff --git a/packages/chains/bitcoin/package.json b/packages/chains/bitcoin/package.json index c7d62b5..f8df645 100644 --- a/packages/chains/bitcoin/package.json +++ b/packages/chains/bitcoin/package.json @@ -34,6 +34,7 @@ }, "dependencies": { "@rosen-bridge/abstract-logger": "^1.0.0", + "@rosen-bridge/json-bigint": "^0.1.0", "@rosen-bridge/minimum-fee": "^0.1.13", "@rosen-bridge/rosen-extractor": "^3.4.0", "bitcoinjs-lib": "^6.1.5" From 7ce9eb751721175ee7038d1dd2cba6afb7404675 Mon Sep 17 00:00:00 2001 From: HFazelinia Date: Thu, 22 Feb 2024 20:51:16 +0000 Subject: [PATCH 02/14] add tests for BitcoinChain implemented functions --- .../chains/bitcoin/tests/BitcoinChain.spec.ts | 409 ++++++++++++++++++ .../tests/network/TestBitcoinNetwork.ts | 77 ++++ packages/chains/bitcoin/tests/testData.ts | 78 ++++ 3 files changed, 564 insertions(+) create mode 100644 packages/chains/bitcoin/tests/BitcoinChain.spec.ts create mode 100644 packages/chains/bitcoin/tests/network/TestBitcoinNetwork.ts create mode 100644 packages/chains/bitcoin/tests/testData.ts diff --git a/packages/chains/bitcoin/tests/BitcoinChain.spec.ts b/packages/chains/bitcoin/tests/BitcoinChain.spec.ts new file mode 100644 index 0000000..3bece81 --- /dev/null +++ b/packages/chains/bitcoin/tests/BitcoinChain.spec.ts @@ -0,0 +1,409 @@ +import { vi } from 'vitest'; +import { TransactionType } from '@rosen-chains/abstract-chain'; +import { BitcoinChain, BitcoinConfigs, BitcoinTransaction } from '../lib'; +import TestBitcoinNetwork from './network/TestBitcoinNetwork'; +import * as testData from './testData'; +import JsonBigInt from '@rosen-bridge/json-bigint'; + +describe('BitcoinChain', () => { + const observationTxConfirmation = 5; + const paymentTxConfirmation = 9; + const coldTxConfirmation = 10; + const manualTxConfirmation = 11; + const rwtId = + '9410db5b39388c6b515160e7248346d7ec63d5457292326da12a26cc02efb526'; + const feeRationDivisor = 1n; + const configs: BitcoinConfigs = { + fee: 1000000n, + minBoxValue: 0n, + addresses: { + lock: testData.lockAddress, + cold: 'cold', + permit: 'permit', + fraud: 'fraud', + }, + rwtId: rwtId, + confirmations: { + observation: observationTxConfirmation, + payment: paymentTxConfirmation, + cold: coldTxConfirmation, + manual: manualTxConfirmation, + }, + aggregatedPublicKey: testData.lockAddressPublicKey, + }; + const mockedSignFn = () => Promise.resolve(''); + const generateChainObject = ( + network: TestBitcoinNetwork, + signFn: (txHash: Uint8Array) => Promise = mockedSignFn + ) => { + return new BitcoinChain(network, configs, feeRationDivisor, signFn); + }; + + describe('getTransactionAssets', () => { + const network = new TestBitcoinNetwork(); + + /** + * @target BitcoinChain.getTransactionAssets should get transaction assets + * successfully + * @dependencies + * @scenario + * - mock PaymentTransaction + * - call the function + * - check returned value + * @expected + * - it should return mocked transaction assets (both input and output assets) + */ + it('should get transaction assets successfully', async () => { + // mock PaymentTransaction + const paymentTx = BitcoinTransaction.fromJson( + testData.transaction2PaymentTransaction + ); + + // call the function + const bitcoinChain = generateChainObject(network); + + // check returned value + const result = await bitcoinChain.getTransactionAssets(paymentTx); + expect(result).toEqual(testData.transaction2Assets); + }); + }); + + describe('extractTransactionOrder', () => { + const network = new TestBitcoinNetwork(); + + /** + * @target BitcoinChain.extractTransactionOrder should extract transaction + * order successfully + * @dependencies + * @scenario + * - mock PaymentTransaction + * - call the function + * - check returned value + * @expected + * - it should return mocked transaction order + */ + it('should extract transaction order successfully', () => { + // mock PaymentTransaction + const paymentTx = BitcoinTransaction.fromJson( + testData.transaction2PaymentTransaction + ); + const expectedOrder = testData.transaction2Order; + + // call the function + const bitcoinChain = generateChainObject(network); + const result = bitcoinChain.extractTransactionOrder(paymentTx); + + // check returned value + expect(result).toEqual(expectedOrder); + }); + + /** + * @target BitcoinChain.extractTransactionOrder should throw error + * when tx has OP_RETURN utxo + * @dependencies + * @scenario + * - mock PaymentTransaction with OP_RETURN output + * - run test & check thrown exception + * @expected + * - it should throw Error + */ + it('should throw error when tx has OP_RETURN utxo', () => { + // mock PaymentTransaction + const paymentTx = BitcoinTransaction.fromJson( + testData.transaction1PaymentTransaction + ); + + // call the function + const bitcoinChain = generateChainObject(network); + + // run test & check thrown exception + expect(() => { + bitcoinChain.extractTransactionOrder(paymentTx); + }).toThrow(Error); + }); + }); + + describe('verifyExtraCondition', () => { + const network = new TestBitcoinNetwork(); + + /** + * @target: BitcoinChain.verifyTransactionExtraConditions should return true when all + * extra conditions are met + * @dependencies + * @scenario + * - mock a payment transaction + * - call the function + * - check returned value + * @expected + * - it should return true + */ + it('should return true when all extra conditions are met', () => { + // mock a payment transaction + const paymentTx = BitcoinTransaction.fromJson( + testData.transaction2PaymentTransaction + ); + + // call the function + const bitcoinChain = generateChainObject(network); + const result = bitcoinChain.verifyTransactionExtraConditions(paymentTx); + + // check returned value + expect(result).toEqual(true); + }); + + /** + * @target: BitcoinChain.verifyTransactionExtraConditions should return false + * when change box address is wrong + * @dependencies + * @scenario + * - mock a payment transaction + * - create a new BitcoinChain object with custom lock address + * - call the function + * - check returned value + * @expected + * - it should return false + */ + it('should return false when change box address is wrong', () => { + // mock a payment transaction + const paymentTx = BitcoinTransaction.fromJson( + testData.transaction0PaymentTransaction + ); + + // create a new BitcoinChain object with custom lock address + const newConfigs = structuredClone(configs); + newConfigs.addresses.lock = 'bc1qs2qr0j7ta5pvdkv53egm38zymgarhq0ugr7x8j'; + const bitcoinChain = new BitcoinChain( + network, + newConfigs, + feeRationDivisor, + mockedSignFn + ); + + // call the function + const result = bitcoinChain.verifyTransactionExtraConditions(paymentTx); + + // check returned value + expect(result).toEqual(false); + }); + }); + + describe('isTxValid', () => { + const network = new TestBitcoinNetwork(); + + /** + * @target BitcoinChain.isTxValid should return true when + * all tx inputs are valid and ttl is less than current slot + * @dependencies + * @scenario + * - mock PaymentTransaction + * - mock a network object to return as valid for all inputs of a mocked + * transaction + * - call the function + * - check returned value + * - check if function got called + * @expected + * - it should return true + */ + it('should return true when all tx inputs are valid and ttl is less than current slot', async () => { + const payment1 = BitcoinTransaction.fromJson( + testData.transaction0PaymentTransaction + ); + + const isBoxUnspentAndValidSpy = vi.spyOn(network, 'isBoxUnspentAndValid'); + isBoxUnspentAndValidSpy.mockResolvedValue(true); + + const bitcoinChain = generateChainObject(network); + const result = await bitcoinChain.isTxValid(payment1); + + expect(result).toEqual(true); + expect(isBoxUnspentAndValidSpy).toHaveBeenCalledWith( + testData.transaction0Input0BoxId + ); + }); + + /** + * @target BitcoinChain.isTxValid should return false when at least one input + * is invalid + * @dependencies + * @scenario + * - mock PaymentTransaction + * - mock a network object to return as valid for all inputs of a mocked + * transaction except for the first one + * - call the function + * - check returned value + * - check if function got called + * @expected + * - it should return false + */ + it('should return false when at least one input is invalid', async () => { + const payment1 = BitcoinTransaction.fromJson( + testData.transaction0PaymentTransaction + ); + + const isBoxUnspentAndValidSpy = vi.spyOn(network, 'isBoxUnspentAndValid'); + isBoxUnspentAndValidSpy + .mockResolvedValue(true) + .mockResolvedValueOnce(false); + + const bitcoinChain = generateChainObject(network); + const result = await bitcoinChain.isTxValid(payment1); + + expect(result).toEqual(false); + expect(isBoxUnspentAndValidSpy).toHaveBeenCalledWith( + testData.transaction0Input0BoxId + ); + }); + }); + + describe('signTransaction', () => { + const network = new TestBitcoinNetwork(); + + /** + * @target BitcoinChain.signTransaction should return PaymentTransaction of the + * signed transaction + * @dependencies + * @scenario + * - mock a sign function to return signature for expected messages + * - mock PaymentTransaction of unsigned transaction + * - call the function + * - check returned value + * @expected + * - it should return PaymentTransaction of signed transaction (all fields + * are same as input object, except txBytes which is signed transaction) + */ + it('should return PaymentTransaction of the signed transaction', async () => { + // mock a sign function to return signature + const signFunction = async (hash: Uint8Array): Promise => { + const hashHex = Buffer.from(hash).toString('hex'); + if (hashHex === testData.transaction2HashMessage0) + return testData.transaction2Signature0; + else if (hashHex === testData.transaction2HashMessage1) + return testData.transaction2Signature1; + else + throw Error( + `TestError: sign function is called with wrong message [${hashHex}]` + ); + }; + + // mock PaymentTransaction of unsigned transaction + const paymentTx = BitcoinTransaction.fromJson( + testData.transaction2PaymentTransaction + ); + + // call the function + const bitcoinChain = generateChainObject(network, signFunction); + const result = await bitcoinChain.signTransaction(paymentTx, 0); + + // check returned value + expect(result.txId).toEqual(paymentTx.txId); + expect(result.eventId).toEqual(paymentTx.eventId); + expect(Buffer.from(result.txBytes).toString('hex')).toEqual( + testData.transaction2SignedTxBytesHex + ); + expect(result.txType).toEqual(paymentTx.txType); + expect(result.network).toEqual(paymentTx.network); + }); + + /** + * @target BitcoinChain.signTransaction should throw error when at least signing of one message is failed + * @dependencies + * @scenario + * - mock a sign function to throw error for 2nd message + * - mock PaymentTransaction of unsigned transaction + * - call the function & check thrown exception + * @expected + * - it should throw the exact error thrown by sign function + */ + it('should throw error when at least signing of one message is failed', async () => { + // mock a sign function to throw error + const signFunction = async (hash: Uint8Array): Promise => { + if ( + Buffer.from(hash).toString('hex') === + testData.transaction2HashMessage0 + ) + return testData.transaction2Signature0; + else throw Error(`TestError: sign failed`); + }; + + // mock PaymentTransaction of unsigned transaction + const paymentTx = BitcoinTransaction.fromJson( + testData.transaction2PaymentTransaction + ); + + // call the function + const bitcoinChain = generateChainObject(network, signFunction); + + await expect(async () => { + await bitcoinChain.signTransaction(paymentTx, 0); + }).rejects.toThrow('TestError: sign failed'); + }); + }); + + describe('rawTxToPaymentTransaction', () => { + const network = new TestBitcoinNetwork(); + + /** + * @target BitcoinChain.rawTxToPaymentTransaction should construct transaction successfully + * @dependencies + * @scenario + * - mock PaymentTransaction + * - mock getUtxo + * - call the function + * - check returned value + * @expected + * - it should return mocked transaction order + */ + it('should construct transaction successfully', async () => { + // mock PaymentTransaction + const expectedTx = BitcoinTransaction.fromJson( + testData.transaction2PaymentTransaction + ); + expectedTx.eventId = ''; + expectedTx.txType = TransactionType.manual; + + // mock getUtxo + const getUtxoSpy = vi.spyOn(network, 'getUtxo'); + expectedTx.inputUtxos.forEach((utxo) => + getUtxoSpy.mockResolvedValueOnce(JsonBigInt.parse(utxo)) + ); + + // call the function + const bitcoinChain = generateChainObject(network); + const result = await bitcoinChain.rawTxToPaymentTransaction( + Buffer.from(expectedTx.txBytes).toString('hex') + ); + + // check returned value + expect(result.toJson()).toEqual(expectedTx.toJson()); + }); + }); + + describe('getBoxInfo', () => { + const network = new TestBitcoinNetwork(); + + /** + * @target BitcoinChain.getBoxInfo should get box id and assets correctly + * @dependencies + * @scenario + * - mock a BitcoinUtxo with assets + * - call the function + * - check returned value + * @expected + * - it should return constructed BoxInfo + */ + it('should get box info successfully', async () => { + // mock a BitcoinUtxo with assets + const rawBox = testData.lockUtxo; + + // call the function + const bitcoinChain = generateChainObject(network); + + // check returned value + const result = await bitcoinChain.getBoxInfo(rawBox); + expect(result.id).toEqual(rawBox.txId + '.' + rawBox.index); + expect(result.assets.nativeToken.toString()).toEqual( + rawBox.value.toString() + ); + }); + }); +}); diff --git a/packages/chains/bitcoin/tests/network/TestBitcoinNetwork.ts b/packages/chains/bitcoin/tests/network/TestBitcoinNetwork.ts new file mode 100644 index 0000000..e275d50 --- /dev/null +++ b/packages/chains/bitcoin/tests/network/TestBitcoinNetwork.ts @@ -0,0 +1,77 @@ +import { AbstractBitcoinNetwork, BitcoinTx, BitcoinUtxo } from '../../lib'; +import { BlockInfo, TokenDetail } from '@rosen-chains/abstract-chain'; +import { BitcoinRosenExtractor } from '@rosen-bridge/rosen-extractor'; + +class TestBitcoinNetwork extends AbstractBitcoinNetwork { + extractor = new BitcoinRosenExtractor( + 'bc1qkgp89fjerymm5ltg0hygnumr0m2qa7n22gyw6h', + { + idKeys: {}, + tokens: [], + } + ); + notImplemented = () => { + throw Error('Not implemented'); + }; + + currentSlot = (): Promise => { + throw Error('Not mocked'); + }; + + getAddressAssets = this.notImplemented; + submitTransaction = this.notImplemented; + + getAddressBoxes = ( + address: string, + offset: number, + limit: number + ): Promise> => { + throw Error('Not mocked'); + }; + + getBlockTransactionIds = (blockId: string): Promise> => { + throw Error('Not mocked'); + }; + + getHeight = (): Promise => { + throw Error('Not mocked'); + }; + + getMempoolTransactions = (): Promise> => { + throw Error('Not mocked'); + }; + + getTransaction = (txId: string, blockId: string): Promise => { + throw Error('Not mocked'); + }; + + getTxConfirmation = (txId: string): Promise => { + throw Error('Not mocked'); + }; + + isBoxUnspentAndValid = (boxId: string): Promise => { + throw Error('Not mocked'); + }; + + getUtxo = (boxId: string): Promise => { + throw Error('Not mocked'); + }; + + getBlockInfo = (blockId: string): Promise => { + throw Error('Not mocked'); + }; + + getTokenDetail = (tokenId: string): Promise => { + throw Error('Not mocked'); + }; + + getFeeRatio = (): Promise => { + throw Error('Not mocked'); + }; + + getMempoolTxIds = (): Promise> => { + throw Error('Not mocked'); + }; +} + +export default TestBitcoinNetwork; diff --git a/packages/chains/bitcoin/tests/testData.ts b/packages/chains/bitcoin/tests/testData.ts new file mode 100644 index 0000000..e9934c9 --- /dev/null +++ b/packages/chains/bitcoin/tests/testData.ts @@ -0,0 +1,78 @@ +import { PaymentOrder } from '@rosen-chains/abstract-chain'; + +export const transaction0PaymentTransaction = `{ + "network": "bitcoin", + "eventId": "", + "txBytes": "70736274ff0100710200000001972da36330161ef9af99788ccc7261f81e2a046049d1ee65ad724288159633640100000000ffffffff02f242993b00000000160014828037cbcbed02c6d9948e51b89c44da3a3b81fca086010000000000160014b20272a6591937ba7d687dc889f3637ed40efa6a000000000001011f00ca9a3b00000000160014fdfe06abec6a565eff3604db30fd30069b2f2a28000000", + "txId": "502559f8e22792d537f226bfac7c3bc972de1a9a13651b325c40ec5a52ea1297", + "txType": "manual", + "inputUtxos": [ + "{\\"txId\\":\\"64339615884272ad65eed14960042a1ef86172cc8c7899aff91e163063a32d97\\",\\"index\\":1,\\"value\\":1000000000}" + ] +}`; +export const transaction0PsbtHex = + '70736274ff0100710200000001972da36330161ef9af99788ccc7261f81e2a046049d1ee65ad724288159633640100000000ffffffff02f242993b00000000160014828037cbcbed02c6d9948e51b89c44da3a3b81fca086010000000000160014b20272a6591937ba7d687dc889f3637ed40efa6a000000000001011f00ca9a3b00000000160014fdfe06abec6a565eff3604db30fd30069b2f2a28000000'; +export const transaction0Input0BoxId = + '64339615884272ad65eed14960042a1ef86172cc8c7899aff91e163063a32d97.1'; + +export const transaction1PaymentTransaction = `{ + "network": "bitcoin", + "eventId": "", + "txBytes": "70736274ff0100880200000001972da36330161ef9af99788ccc7261f81e2a046049d1ee65ad724288159633640100000000ffffffff0300000000000000000e6a0c6161616161616161616161618442993b00000000160014828037cbcbed02c6d9948e51b89c44da3a3b81fca086010000000000160014b20272a6591937ba7d687dc889f3637ed40efa6a000000000001011f00ca9a3b00000000160014fdfe06abec6a565eff3604db30fd30069b2f2a2800000000", + "txId": "a451d0c24a8c871f52707cf2fcf0cb35f5b1ac6c734fb5cd172893d48d782e91", + "txType": "manual", + "inputUtxos": [ + "{\\"txId\\":\\"64339615884272ad65eed14960042a1ef86172cc8c7899aff91e163063a32d97\\",\\"index\\":1,\\"value\\":1000000000}" + ] +}`; + +export const transaction2PaymentTransaction = `{ + "network": "bitcoin", + "eventId": "", + "txBytes": "70736274ff01009a0200000002193a28a12c8be889390e48b30cf9c65096f3f51bc04c2475557096d0cfca4f220100000000ffffffffd2e6232676e35e104927f22d4c90bc367c684209a4937664bad886227cd95c4b0100000000ffffffff028063ef2700000000160014828037cbcbed02c6d9948e51b89c44da3a3b81fcaff9e08a00000000160014fdfe06abec6a565eff3604db30fd30069b2f2a28000000000001011f00ca9a3b00000000160014fdfe06abec6a565eff3604db30fd30069b2f2a280001011f0094357700000000160014fdfe06abec6a565eff3604db30fd30069b2f2a28000000", + "txId": "5bc486302164841b32bdfa03f510590109e3520d0a0aa6a15edfea0c8e33a080", + "txType": "manual", + "inputUtxos": [ + "{\\"txId\\":\\"224fcacfd096705575244cc01bf5f39650c6f90cb3480e3989e88b2ca1283a19\\",\\"index\\":1,\\"value\\":1000000000}", + "{\\"txId\\":\\"4b5cd97c2286d8ba647693a40942687c36bc904c2df22749105ee3762623e6d2\\",\\"index\\":1,\\"value\\":2000000000}" + ] +}`; +export const transaction2Assets = { + inputAssets: { + nativeToken: 3000000000n, + tokens: [], + }, + outputAssets: { + nativeToken: 3000000000n, + tokens: [], + }, +}; +export const transaction2Order: PaymentOrder = [ + { + address: 'bc1qs2qr0j7ta5pvdkv53egm38zymgarhq0ugr7x8j', + assets: { + nativeToken: 670000000n, + tokens: [], + }, + }, +]; +export const transaction2HashMessage0 = + 'e0a1eee59b0b5d5b0b115bece85a4193d7224a6d5dccdb019fe8f9cb0ef39ba5'; +export const transaction2Signature0 = + '22140681b4b7d5a099cb427a0bf0cd3085ecfd583c9908891058a84def1ab8a238e202c53ab6f52776166218353984dbee87ffa5979e72bf2ebf731eadb30093'; +export const transaction2HashMessage1 = + '7596325971d5981233c9cecf0abb52c36a672547d23386fd5666917b3a1cb69f'; +export const transaction2Signature1 = + '802ac030548f5c4e05f071d110f96ca0d18d61dcad93638973d15ab454c7ab9855338130212ce38c26a4f595cbaab4dfd86057827f17f141dbdb2f3f6ff8908f'; +export const transaction2SignedTxBytesHex = + '70736274ff01009a0200000002193a28a12c8be889390e48b30cf9c65096f3f51bc04c2475557096d0cfca4f220100000000ffffffffd2e6232676e35e104927f22d4c90bc367c684209a4937664bad886227cd95c4b0100000000ffffffff028063ef2700000000160014828037cbcbed02c6d9948e51b89c44da3a3b81fcaff9e08a00000000160014fdfe06abec6a565eff3604db30fd30069b2f2a28000000000001011f00ca9a3b00000000160014fdfe06abec6a565eff3604db30fd30069b2f2a2801086b02473044022022140681b4b7d5a099cb427a0bf0cd3085ecfd583c9908891058a84def1ab8a2022038e202c53ab6f52776166218353984dbee87ffa5979e72bf2ebf731eadb3009301210345307e1165c99d12557bea11f8c8cd0f6bc057fb51952e824bc7c760fda073350001011f0094357700000000160014fdfe06abec6a565eff3604db30fd30069b2f2a2801086c02483045022100802ac030548f5c4e05f071d110f96ca0d18d61dcad93638973d15ab454c7ab98022055338130212ce38c26a4f595cbaab4dfd86057827f17f141dbdb2f3f6ff8908f01210345307e1165c99d12557bea11f8c8cd0f6bc057fb51952e824bc7c760fda07335000000'; + +export const lockAddress = 'bc1qlhlqd2lvdft9alekqndnplfsq6dj723gh49wrt'; +export const lockAddressPublicKey = + '0345307e1165c99d12557bea11f8c8cd0f6bc057fb51952e824bc7c760fda07335'; +export const lockUtxo = { + txId: 'f1ac0a7ce8a45aa53ac245ea2178592f708c9ef38bee0a5bd88c9f08d47ce493', + index: 1, + scriptPubKey: '0014b20272a6591937ba7d687dc889f3637ed40efa6a', + value: 3000000000n, +}; From 2651fd8d859add510413cdc9f4807699277304ca Mon Sep 17 00:00:00 2001 From: HFazelinia Date: Thu, 22 Feb 2024 21:33:38 +0000 Subject: [PATCH 03/14] implement BitcoinChain.verifyEvent --- package-lock.json | 7 +- packages/chains/bitcoin/lib/BitcoinChain.ts | 109 +++- packages/chains/bitcoin/package.json | 3 +- .../chains/bitcoin/tests/BitcoinChain.spec.ts | 510 +++++++++++++++++- packages/chains/bitcoin/tests/testData.ts | 84 ++- packages/chains/bitcoin/tests/testUtils.ts | 3 + 6 files changed, 689 insertions(+), 27 deletions(-) create mode 100644 packages/chains/bitcoin/tests/testUtils.ts diff --git a/package-lock.json b/package-lock.json index 143337c..3cb1a16 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6760,7 +6760,8 @@ }, "node_modules/blakejs": { "version": "1.2.1", - "license": "MIT" + "resolved": "https://registry.npmjs.org/blakejs/-/blakejs-1.2.1.tgz", + "integrity": "sha512-QXUSXI3QVc/gJME0dBpXrag1kbzOqCjCX8/b54ntNyW6sjtoqxqRk3LTmXzaJoh71zMsDCjM+47jS7XiwN/+fQ==" }, "node_modules/blueimp-md5": { "version": "2.19.0", @@ -15816,9 +15817,11 @@ "license": "GPL-3.0", "dependencies": { "@rosen-bridge/abstract-logger": "^1.0.0", + "@rosen-bridge/json-bigint": "^0.1.0", "@rosen-bridge/minimum-fee": "^0.1.13", "@rosen-bridge/rosen-extractor": "^3.4.0", - "bitcoinjs-lib": "^6.1.5" + "bitcoinjs-lib": "^6.1.5", + "blakejs": "^1.2.1" }, "devDependencies": { "@types/node": "^20.11.9", diff --git a/packages/chains/bitcoin/lib/BitcoinChain.ts b/packages/chains/bitcoin/lib/BitcoinChain.ts index e569b1b..7872360 100644 --- a/packages/chains/bitcoin/lib/BitcoinChain.ts +++ b/packages/chains/bitcoin/lib/BitcoinChain.ts @@ -4,12 +4,16 @@ import { AbstractUtxoChain, BoxInfo, EventTrigger, + FailedError, + NetworkError, + NotFoundError, PaymentOrder, PaymentTransaction, SigningStatus, SinglePayment, TransactionAssetBalance, TransactionType, + UnexpectedApiError, } from '@rosen-chains/abstract-chain'; import AbstractBitcoinNetwork from './network/AbstractBitcoinNetwork'; import BitcoinTransaction from './BitcoinTransaction'; @@ -18,6 +22,8 @@ import Serializer from './Serializer'; import { Psbt, Transaction, address, payments, script } from 'bitcoinjs-lib'; import JsonBigInt from '@rosen-bridge/json-bigint'; import { getPsbtTxInputBoxId } from './bitcoinUtils'; +import { BITCOIN_CHAIN } from './constants'; +import { blake2b } from 'blakejs'; class BitcoinChain extends AbstractUtxoChain { declare network: AbstractBitcoinNetwork; @@ -177,8 +183,107 @@ class BitcoinChain extends AbstractUtxoChain { * @param feeConfig minimum fee and rsn ratio config for the event * @returns true if the event is verified */ - verifyEvent = (event: EventTrigger, feeConfig: Fee): Promise => { - throw Error(`not implemented`); + verifyEvent = async ( + event: EventTrigger, + feeConfig: Fee + ): Promise => { + const eventId = Buffer.from( + blake2b(event.sourceTxId, undefined, 32) + ).toString('hex'); + const baseError = `Event [${eventId}] is not valid, `; + + try { + const blockTxs = await this.network.getBlockTransactionIds( + event.sourceBlockId + ); + if (!blockTxs.includes(event.sourceTxId)) { + this.logger.info( + baseError + + `block [${event.sourceBlockId}] does not contain tx [${event.sourceTxId}]` + ); + return false; + } + const tx = await this.network.getTransaction( + event.sourceTxId, + event.sourceBlockId + ); + const blockHeight = (await this.network.getBlockInfo(event.sourceBlockId)) + .height; + const data = this.network.extractor.get(JsonBigInt.stringify(tx)); + if (!data) { + this.logger.info( + baseError + `failed to extract rosen data from lock transaction` + ); + return false; + } + if ( + event.fromChain === BITCOIN_CHAIN && + event.toChain === data.toChain && + event.networkFee === data.networkFee && + event.bridgeFee === data.bridgeFee && + event.amount === data.amount && + event.sourceChainTokenId === data.sourceChainTokenId && + event.targetChainTokenId === data.targetChainTokenId && + event.toAddress === data.toAddress && + event.fromAddress === data.fromAddress && + event.sourceChainHeight === blockHeight + ) { + try { + // check if amount is more than fees + const eventAmount = BigInt(event.amount); + const clampedBridgeFee = + BigInt(event.bridgeFee) > feeConfig.bridgeFee + ? BigInt(event.bridgeFee) + : feeConfig.bridgeFee; + const calculatedRatioDivisorFee = + (eventAmount * feeConfig.feeRatio) / this.feeRatioDivisor; + const bridgeFee = + clampedBridgeFee > calculatedRatioDivisorFee + ? clampedBridgeFee + : calculatedRatioDivisorFee; + const networkFee = + BigInt(event.networkFee) > feeConfig.networkFee + ? BigInt(event.networkFee) + : feeConfig.networkFee; + if (eventAmount < bridgeFee + networkFee) { + this.logger.info( + baseError + + `event amount [${eventAmount}] is less than sum of bridgeFee [${bridgeFee}] and networkFee [${networkFee}]` + ); + return false; + } + } catch (e) { + throw new UnexpectedApiError( + `Failed in comparing event amount to fees: ${e}` + ); + } + this.logger.info(`Event [${eventId}] has been successfully validated`); + return true; + } else { + this.logger.info( + baseError + + `event data does not match with lock tx [${event.sourceTxId}]` + ); + return false; + } + } catch (e) { + if (e instanceof NotFoundError) { + this.logger.info( + baseError + + `lock tx [${event.sourceTxId}] is not available in network` + ); + return false; + } else if ( + e instanceof FailedError || + e instanceof NetworkError || + e instanceof UnexpectedApiError + ) { + throw Error(`Skipping event [${eventId}] validation: ${e}`); + } else { + this.logger.warn(`Event [${eventId}] validation failed: ${e}`); + return false; + } + } }; /** diff --git a/packages/chains/bitcoin/package.json b/packages/chains/bitcoin/package.json index f8df645..5fee680 100644 --- a/packages/chains/bitcoin/package.json +++ b/packages/chains/bitcoin/package.json @@ -37,7 +37,8 @@ "@rosen-bridge/json-bigint": "^0.1.0", "@rosen-bridge/minimum-fee": "^0.1.13", "@rosen-bridge/rosen-extractor": "^3.4.0", - "bitcoinjs-lib": "^6.1.5" + "bitcoinjs-lib": "^6.1.5", + "blakejs": "^1.2.1" }, "peerDependencies": { "@rosen-chains/abstract-chain": "^4.0.0" diff --git a/packages/chains/bitcoin/tests/BitcoinChain.spec.ts b/packages/chains/bitcoin/tests/BitcoinChain.spec.ts index 3bece81..7122ff7 100644 --- a/packages/chains/bitcoin/tests/BitcoinChain.spec.ts +++ b/packages/chains/bitcoin/tests/BitcoinChain.spec.ts @@ -3,7 +3,10 @@ import { TransactionType } from '@rosen-chains/abstract-chain'; import { BitcoinChain, BitcoinConfigs, BitcoinTransaction } from '../lib'; import TestBitcoinNetwork from './network/TestBitcoinNetwork'; import * as testData from './testData'; +import * as testUtils from './testUtils'; import JsonBigInt from '@rosen-bridge/json-bigint'; +import { Fee } from '@rosen-bridge/minimum-fee'; +import { RosenData } from '@rosen-bridge/rosen-extractor'; describe('BitcoinChain', () => { const observationTxConfirmation = 5; @@ -48,7 +51,7 @@ describe('BitcoinChain', () => { * @dependencies * @scenario * - mock PaymentTransaction - * - call the function + * - run test * - check returned value * @expected * - it should return mocked transaction assets (both input and output assets) @@ -59,7 +62,7 @@ describe('BitcoinChain', () => { testData.transaction2PaymentTransaction ); - // call the function + // run test const bitcoinChain = generateChainObject(network); // check returned value @@ -77,7 +80,7 @@ describe('BitcoinChain', () => { * @dependencies * @scenario * - mock PaymentTransaction - * - call the function + * - run test * - check returned value * @expected * - it should return mocked transaction order @@ -89,7 +92,7 @@ describe('BitcoinChain', () => { ); const expectedOrder = testData.transaction2Order; - // call the function + // run test const bitcoinChain = generateChainObject(network); const result = bitcoinChain.extractTransactionOrder(paymentTx); @@ -113,10 +116,8 @@ describe('BitcoinChain', () => { testData.transaction1PaymentTransaction ); - // call the function - const bitcoinChain = generateChainObject(network); - // run test & check thrown exception + const bitcoinChain = generateChainObject(network); expect(() => { bitcoinChain.extractTransactionOrder(paymentTx); }).toThrow(Error); @@ -132,7 +133,7 @@ describe('BitcoinChain', () => { * @dependencies * @scenario * - mock a payment transaction - * - call the function + * - run test * - check returned value * @expected * - it should return true @@ -143,7 +144,7 @@ describe('BitcoinChain', () => { testData.transaction2PaymentTransaction ); - // call the function + // run test const bitcoinChain = generateChainObject(network); const result = bitcoinChain.verifyTransactionExtraConditions(paymentTx); @@ -158,7 +159,7 @@ describe('BitcoinChain', () => { * @scenario * - mock a payment transaction * - create a new BitcoinChain object with custom lock address - * - call the function + * - run test * - check returned value * @expected * - it should return false @@ -179,7 +180,7 @@ describe('BitcoinChain', () => { mockedSignFn ); - // call the function + // run test const result = bitcoinChain.verifyTransactionExtraConditions(paymentTx); // check returned value @@ -187,6 +188,473 @@ describe('BitcoinChain', () => { }); }); + describe('verifyEvent', () => { + const feeConfig: Fee = { + bridgeFee: 0n, + networkFee: 0n, + feeRatio: 0n, + rsnRatio: 0n, + }; + + /** + * @target BitcoinChain.verifyEvent should return true when event is valid + * @dependencies + * @scenario + * - mock an event + * - mock a network object with mocked 'getBlockTransactionIds' and + * 'getTransaction' functions + * - mock getBlockInfo to return event block height + * - mock network extractor to return event data + * - run test + * - check returned value + * - check if functions got called + * @expected + * - it should return true + * - `getBlockTransactionIds` and `getBlockInfo` should have been called with event blockId + * - `getTransaction` should have been called with event lock txId + */ + it('should return true when event is valid', async () => { + // mock an event + const event = testData.validEvent; + + // mock a network object with mocked 'getBlockTransactionIds' and + // 'getTransaction' functions + const network = new TestBitcoinNetwork(); + const getBlockTransactionIdsSpy = vi.spyOn( + network, + 'getBlockTransactionIds' + ); + getBlockTransactionIdsSpy.mockResolvedValueOnce([ + testUtils.generateRandomId(), + event.sourceTxId, + testUtils.generateRandomId(), + ]); + + // mock 'getTransaction' (the tx itself doesn't matter) + const tx = testData.bitcoinTx1; + const getTransactionSpy = vi.spyOn(network, 'getTransaction'); + getTransactionSpy.mockResolvedValueOnce(tx); + + // mock getBlockInfo to return event block height + const getBlockInfoSpy = vi.spyOn(network, 'getBlockInfo'); + getBlockInfoSpy.mockResolvedValueOnce({ + height: event.sourceChainHeight, + } as any); + + // mock network extractor to return event data + const extractorSpy = vi.spyOn(network.extractor, 'get'); + extractorSpy.mockReturnValueOnce(event as unknown as RosenData); + + // run test + const bitcoinChain = generateChainObject(network); + const result = await bitcoinChain.verifyEvent(event, feeConfig); + + // check returned value + expect(result).toEqual(true); + + // check if functions got called + expect(getBlockTransactionIdsSpy).toHaveBeenCalledWith( + event.sourceBlockId + ); + expect(getTransactionSpy).toHaveBeenCalledWith( + event.sourceTxId, + event.sourceBlockId + ); + expect(getBlockInfoSpy).toHaveBeenCalledWith(event.sourceBlockId); + }); + + /** + * @target BitcoinChain.verifyEvent should return false when event transaction + * is not in event block + * @dependencies + * @scenario + * - mock an event + * - mock a network object with mocked 'getBlockTransactionIds' + * - run test + * - check returned value + * - check if function got called + * @expected + * - it should return false + * - `getBlockTransactionIds` should have been called with event blockId + */ + it('should return false when event transaction is not in event block', async () => { + // mock an event + const event = testData.validEvent; + + // mock a network object with mocked 'getBlockTransactionIds' + const network = new TestBitcoinNetwork(); + const getBlockTransactionIdsSpy = vi.spyOn( + network, + 'getBlockTransactionIds' + ); + getBlockTransactionIdsSpy.mockResolvedValueOnce([ + testUtils.generateRandomId(), + testUtils.generateRandomId(), + ]); + + // run test + const bitcoinChain = generateChainObject(network); + const result = await bitcoinChain.verifyEvent(event, feeConfig); + + // check returned value + expect(result).toEqual(false); + + // check if function got called + expect(getBlockTransactionIdsSpy).toHaveBeenCalledWith( + event.sourceBlockId + ); + }); + + /** + * @target BitcoinChain.verifyEvent should return false when a field of event + * is wrong + * @dependencies + * @scenario + * - mock an event + * - mock a network object with mocked 'getBlockTransactionIds' and + * 'getTransaction' functions + * - mock getBlockInfo to return event block height + * - mock network extractor to return event data (expect for a key which + * should be wrong) + * - run test + * - check returned value + * - check if functions got called + * @expected + * - it should return false + * - `getBlockTransactionIds` and `getBlockInfo` should have been called with event blockId + * - `getTransaction` should have been called with event lock txId + */ + it.each([ + 'fromChain', + 'toChain', + 'networkFee', + 'bridgeFee', + 'amount', + 'sourceChainTokenId', + 'targetChainTokenId', + 'toAddress', + 'fromAddress', + ])('should return false when event %p is wrong', async (key: string) => { + // mock an event + const event = testData.validEvent; + + // mock a network object with mocked 'getBlockTransactionIds' and + // 'getTransaction' functions + const network = new TestBitcoinNetwork(); + const getBlockTransactionIdsSpy = vi.spyOn( + network, + 'getBlockTransactionIds' + ); + getBlockTransactionIdsSpy.mockResolvedValueOnce([ + testUtils.generateRandomId(), + event.sourceTxId, + testUtils.generateRandomId(), + ]); + + // mock 'getTransaction' (the tx itself doesn't matter) + const tx = testData.bitcoinTx1; + const getTransactionSpy = vi.spyOn(network, 'getTransaction'); + getTransactionSpy.mockResolvedValueOnce(tx); + + // mock getBlockInfo to return event block height + const getBlockInfoSpy = vi.spyOn(network, 'getBlockInfo'); + getBlockInfoSpy.mockResolvedValueOnce({ + height: event.sourceChainHeight, + } as any); + + // mock network extractor to return event data (expect for a key which + // should be wrong) + const invalidData = event as unknown as RosenData; + invalidData[key as keyof RosenData] = `fake_${key}`; + const extractorSpy = vi.spyOn(network.extractor, 'get'); + extractorSpy.mockReturnValueOnce(invalidData); + + // run test + const bitcoinChain = generateChainObject(network); + const result = await bitcoinChain.verifyEvent(event, feeConfig); + + // check returned value + expect(result).toEqual(false); + + // check if functions got called + expect(getBlockTransactionIdsSpy).toHaveBeenCalledWith( + event.sourceBlockId + ); + expect(getTransactionSpy).toHaveBeenCalledWith( + event.sourceTxId, + event.sourceBlockId + ); + expect(getBlockInfoSpy).toHaveBeenCalledWith(event.sourceBlockId); + }); + + /** + * @target BitcoinChain.verifyEvent should return false when event + * sourceChainHeight is wrong + * @dependencies + * @scenario + * - mock an event + * - mock a network object with mocked 'getBlockTransactionIds' and + * 'getTransaction' functions + * - mock getBlockInfo to return -1 as event block height + * - mock network extractor to return event data (expect for a key which + * should be wrong) + * - run test + * - check returned value + * - check if functions got called + * @expected + * - it should return false + * - `getBlockTransactionIds` and `getBlockInfo` should have been called with event blockId + * - `getTransaction` should have been called with event lock txId + */ + it('should return false when event sourceChainHeight is wrong', async () => { + // mock an event + const event = testData.validEvent; + + // mock a network object with mocked 'getBlockTransactionIds' and + // 'getTransaction' functions + const network = new TestBitcoinNetwork(); + const getBlockTransactionIdsSpy = vi.spyOn( + network, + 'getBlockTransactionIds' + ); + getBlockTransactionIdsSpy.mockResolvedValueOnce([ + testUtils.generateRandomId(), + event.sourceTxId, + testUtils.generateRandomId(), + ]); + + // mock 'getTransaction' (the tx itself doesn't matter) + const tx = testData.bitcoinTx1; + const getTransactionSpy = vi.spyOn(network, 'getTransaction'); + getTransactionSpy.mockResolvedValueOnce(tx); + + // mock getBlockInfo to return -1 as event block height + const getBlockInfoSpy = vi.spyOn(network, 'getBlockInfo'); + getBlockInfoSpy.mockResolvedValueOnce({ + height: -1, + } as any); + + // mock network extractor to return event data + const extractorSpy = vi.spyOn(network.extractor, 'get'); + extractorSpy.mockReturnValueOnce(event as unknown as RosenData); + + // run test + const bitcoinChain = generateChainObject(network); + const result = await bitcoinChain.verifyEvent(event, feeConfig); + + // check returned value + expect(result).toEqual(false); + + // check if functions got called + expect(getBlockTransactionIdsSpy).toHaveBeenCalledWith( + event.sourceBlockId + ); + expect(getTransactionSpy).toHaveBeenCalledWith( + event.sourceTxId, + event.sourceBlockId + ); + expect(getBlockInfoSpy).toHaveBeenCalledWith(event.sourceBlockId); + }); + + /** + * @target BitcoinChain.verifyEvent should return false when event amount + * is less than sum of event fees + * @dependencies + * @scenario + * - mock an event + * - mock a network object with mocked 'getBlockTransactionIds' and + * 'getTransaction' functions + * - mock network extractor to return event data + * - run test + * - check returned value + * - check if functions got called + * @expected + * - it should return false + * - `getBlockTransactionIds` should have been called with event blockId + * - `getTransaction` should have been called with event lock txId + */ + it('should return false when event amount is less than sum of event fees', async () => { + // mock an event + const event = testData.invalidEvent; + + // mock a network object with mocked 'getBlockTransactionIds' and + // 'getTransaction' functions + const network = new TestBitcoinNetwork(); + const getBlockTransactionIdsSpy = vi.spyOn( + network, + 'getBlockTransactionIds' + ); + getBlockTransactionIdsSpy.mockResolvedValueOnce([ + testUtils.generateRandomId(), + event.sourceTxId, + testUtils.generateRandomId(), + ]); + + // mock 'getTransaction' (the tx itself doesn't matter) + const tx = testData.bitcoinTx1; + const getTransactionSpy = vi.spyOn(network, 'getTransaction'); + getTransactionSpy.mockResolvedValueOnce(tx); + + // mock network extractor to return event data + const extractorSpy = vi.spyOn(network.extractor, 'get'); + extractorSpy.mockReturnValueOnce(event as unknown as RosenData); + + // run test + const bitcoinChain = generateChainObject(network); + const result = await bitcoinChain.verifyEvent(event, feeConfig); + + // check returned value + expect(result).toEqual(false); + + // check if functions got called + expect(getBlockTransactionIdsSpy).toHaveBeenCalledWith( + event.sourceBlockId + ); + expect(getTransactionSpy).toHaveBeenCalledWith( + event.sourceTxId, + event.sourceBlockId + ); + }); + + /** + * @target BitcoinChain.verifyEvent should return false when event amount + * is less than sum of event fees while bridgeFee is less than minimum-fee + * @dependencies + * @scenario + * - mock feeConfig + * - mock an event + * - mock a network object with mocked 'getBlockTransactionIds' and + * 'getTransaction' functions + * - mock network extractor to return event data + * - run test + * - check returned value + * - check if functions got called + * @expected + * - it should return false + * - `getBlockTransactionIds` should have been called with event blockId + * - `getTransaction` should have been called with event lock txId + */ + it('should return false when event amount is less than sum of event fees while bridgeFee is less than minimum-fee', async () => { + // mock feeConfig + const fee: Fee = { + bridgeFee: 1200000n, + networkFee: 0n, + rsnRatio: 0n, + feeRatio: 0n, + }; + + // mock an event + const event = testData.validEventWithHighFee; + + // mock a network object with mocked 'getBlockTransactionIds' and + // 'getTransaction' functions + const network = new TestBitcoinNetwork(); + const getBlockTransactionIdsSpy = vi.spyOn( + network, + 'getBlockTransactionIds' + ); + getBlockTransactionIdsSpy.mockResolvedValueOnce([ + testUtils.generateRandomId(), + event.sourceTxId, + testUtils.generateRandomId(), + ]); + + // mock 'getTransaction' (the tx itself doesn't matter) + const tx = testData.bitcoinTx1; + const getTransactionSpy = vi.spyOn(network, 'getTransaction'); + getTransactionSpy.mockResolvedValueOnce(tx); + + // mock network extractor to return event data + const extractorSpy = vi.spyOn(network.extractor, 'get'); + extractorSpy.mockReturnValueOnce(event as unknown as RosenData); + + // run test + const bitcoinChain = generateChainObject(network); + const result = await bitcoinChain.verifyEvent(event, fee); + + // check returned value + expect(result).toEqual(false); + + // check if functions got called + expect(getBlockTransactionIdsSpy).toHaveBeenCalledWith( + event.sourceBlockId + ); + expect(getTransactionSpy).toHaveBeenCalledWith( + event.sourceTxId, + event.sourceBlockId + ); + }); + + /** + * @target BitcoinChain.verifyEvent should return false when event amount + * is less than sum of event fees while bridgeFee is less than expected value + * @dependencies + * @scenario + * - mock feeConfig + * - mock an event + * - mock a network object with mocked 'getBlockTransactionIds' and + * 'getTransaction' functions + * - mock network extractor to return event data + * - run test + * - check returned value + * - check if functions got called + * @expected + * - it should return false + * - `getBlockTransactionIds` should have been called with event blockId + * - `getTransaction` should have been called with event lock txId + */ + it('should return false when event amount is less than sum of event fees while bridgeFee is less than expected value', async () => { + // mock feeConfig + const fee: Fee = { + bridgeFee: 0n, + networkFee: 0n, + rsnRatio: 0n, + feeRatio: 1200n, + }; + + // mock an event + const event = testData.validEventWithHighFee; + + // mock a network object with mocked 'getBlockTransactionIds' and + // 'getTransaction' functions + const network = new TestBitcoinNetwork(); + const getBlockTransactionIdsSpy = vi.spyOn( + network, + 'getBlockTransactionIds' + ); + getBlockTransactionIdsSpy.mockResolvedValueOnce([ + testUtils.generateRandomId(), + event.sourceTxId, + testUtils.generateRandomId(), + ]); + + // mock 'getTransaction' (the tx itself doesn't matter) + const tx = testData.bitcoinTx1; + const getTransactionSpy = vi.spyOn(network, 'getTransaction'); + getTransactionSpy.mockResolvedValueOnce(tx); + + // mock network extractor to return event data + const extractorSpy = vi.spyOn(network.extractor, 'get'); + extractorSpy.mockReturnValueOnce(event as unknown as RosenData); + + // run test + const bitcoinChain = generateChainObject(network); + const result = await bitcoinChain.verifyEvent(event, fee); + + // check returned value + expect(result).toEqual(false); + + // check if functions got called + expect(getBlockTransactionIdsSpy).toHaveBeenCalledWith( + event.sourceBlockId + ); + expect(getTransactionSpy).toHaveBeenCalledWith( + event.sourceTxId, + event.sourceBlockId + ); + }); + }); + describe('isTxValid', () => { const network = new TestBitcoinNetwork(); @@ -198,7 +666,7 @@ describe('BitcoinChain', () => { * - mock PaymentTransaction * - mock a network object to return as valid for all inputs of a mocked * transaction - * - call the function + * - run test * - check returned value * - check if function got called * @expected @@ -229,7 +697,7 @@ describe('BitcoinChain', () => { * - mock PaymentTransaction * - mock a network object to return as valid for all inputs of a mocked * transaction except for the first one - * - call the function + * - run test * - check returned value * - check if function got called * @expected @@ -265,7 +733,7 @@ describe('BitcoinChain', () => { * @scenario * - mock a sign function to return signature for expected messages * - mock PaymentTransaction of unsigned transaction - * - call the function + * - run test * - check returned value * @expected * - it should return PaymentTransaction of signed transaction (all fields @@ -290,7 +758,7 @@ describe('BitcoinChain', () => { testData.transaction2PaymentTransaction ); - // call the function + // run test const bitcoinChain = generateChainObject(network, signFunction); const result = await bitcoinChain.signTransaction(paymentTx, 0); @@ -310,7 +778,7 @@ describe('BitcoinChain', () => { * @scenario * - mock a sign function to throw error for 2nd message * - mock PaymentTransaction of unsigned transaction - * - call the function & check thrown exception + * - run test & check thrown exception * @expected * - it should throw the exact error thrown by sign function */ @@ -330,7 +798,7 @@ describe('BitcoinChain', () => { testData.transaction2PaymentTransaction ); - // call the function + // run test const bitcoinChain = generateChainObject(network, signFunction); await expect(async () => { @@ -348,7 +816,7 @@ describe('BitcoinChain', () => { * @scenario * - mock PaymentTransaction * - mock getUtxo - * - call the function + * - run test * - check returned value * @expected * - it should return mocked transaction order @@ -367,7 +835,7 @@ describe('BitcoinChain', () => { getUtxoSpy.mockResolvedValueOnce(JsonBigInt.parse(utxo)) ); - // call the function + // run test const bitcoinChain = generateChainObject(network); const result = await bitcoinChain.rawTxToPaymentTransaction( Buffer.from(expectedTx.txBytes).toString('hex') @@ -386,7 +854,7 @@ describe('BitcoinChain', () => { * @dependencies * @scenario * - mock a BitcoinUtxo with assets - * - call the function + * - run test * - check returned value * @expected * - it should return constructed BoxInfo @@ -395,7 +863,7 @@ describe('BitcoinChain', () => { // mock a BitcoinUtxo with assets const rawBox = testData.lockUtxo; - // call the function + // run test const bitcoinChain = generateChainObject(network); // check returned value diff --git a/packages/chains/bitcoin/tests/testData.ts b/packages/chains/bitcoin/tests/testData.ts index e9934c9..600ccfa 100644 --- a/packages/chains/bitcoin/tests/testData.ts +++ b/packages/chains/bitcoin/tests/testData.ts @@ -1,4 +1,4 @@ -import { PaymentOrder } from '@rosen-chains/abstract-chain'; +import { EventTrigger, PaymentOrder } from '@rosen-chains/abstract-chain'; export const transaction0PaymentTransaction = `{ "network": "bitcoin", @@ -76,3 +76,85 @@ export const lockUtxo = { scriptPubKey: '0014b20272a6591937ba7d687dc889f3637ed40efa6a', value: 3000000000n, }; + +export const validEvent: EventTrigger = { + height: 300, + fromChain: 'bitcoin', + toChain: 'ergo', + fromAddress: 'fromAddress', + toAddress: 'toAddress', + amount: '1000000', + bridgeFee: '1000', + networkFee: '5000', + sourceChainTokenId: 'sourceTokenId', + targetChainTokenId: 'targetTokenId', + sourceTxId: + '6e3dbf41a8e3dbf41a8cd0fe059a54cef8bb140322503d0555a9851f056825bc', + sourceChainHeight: 1000, + sourceBlockId: + '01a33c00accaa91ebe0c946bffe1ec294280a3a51a90f7f4b011f3f37c29c5ed', + WIDsHash: 'bb2b2272816e1e9993fc535c0cf57c668f5cd39c67cfcd55b4422b1aa87cd0c3', + WIDsCount: 2, +}; + +export const invalidEvent: EventTrigger = { + height: 300, + fromChain: 'bitcoin', + toChain: 'ergo', + fromAddress: 'fromAddress', + toAddress: 'toAddress', + amount: '5500', + bridgeFee: '1000', + networkFee: '5000', + sourceChainTokenId: 'sourceTokenId', + targetChainTokenId: 'targetTokenId', + sourceTxId: + '6e3dbf41a8e3dbf41a8cd0fe059a54cef8bb140322503d0555a9851f056825bc', + sourceChainHeight: 1000, + sourceBlockId: + '01a33c00accaa91ebe0c946bffe1ec294280a3a51a90f7f4b011f3f37c29c5ed', + WIDsHash: 'bb2b2272816e1e9993fc535c0cf57c668f5cd39c67cfcd55b4422b1aa87cd0c3', + WIDsCount: 2, +}; + +export const validEventWithHighFee: EventTrigger = { + height: 300, + fromChain: 'bitcoin', + toChain: 'ergo', + fromAddress: 'fromAddress', + toAddress: 'toAddress', + amount: '1000000', + bridgeFee: '1000', + networkFee: '900000', + sourceChainTokenId: 'sourceTokenId', + targetChainTokenId: 'targetTokenId', + sourceTxId: + '6e3dbf41a8e3dbf41a8cd0fe059a54cef8bb140322503d0555a9851f056825bc', + sourceChainHeight: 1000, + sourceBlockId: + '01a33c00accaa91ebe0c946bffe1ec294280a3a51a90f7f4b011f3f37c29c5ed', + WIDsHash: 'bb2b2272816e1e9993fc535c0cf57c668f5cd39c67cfcd55b4422b1aa87cd0c3', + WIDsCount: 2, +}; + +export const bitcoinTx1 = { + id: '6a1b9e7a755afb5d82ecaa5f432d51bd23e452ee1031fc99066e92788a075a84', + inputs: [ + { + txId: 'eff4900465d1603d12c1dc8f231a07ce2196c04196aa26bb80147bb152137aaf', + index: 0, + scriptPubKey: '0014bf1916dc33dbdd65f60d8b1f65eb35e8120835fc', + }, + ], + outputs: [ + { + scriptPubKey: + '6a4c3300000000007554fc820000000000962f582103f999da8e6e42660e4464d17d29e63bc006734a6710a24eb489b466323d3a9339', + value: 0n, + }, + { + scriptPubKey: '0014b20272a6591937ba7d687dc889f3637ed40efa6a', + value: 3000000000n, + }, + ], +}; diff --git a/packages/chains/bitcoin/tests/testUtils.ts b/packages/chains/bitcoin/tests/testUtils.ts new file mode 100644 index 0000000..a2a7ee5 --- /dev/null +++ b/packages/chains/bitcoin/tests/testUtils.ts @@ -0,0 +1,3 @@ +import { randomBytes } from 'crypto'; + +export const generateRandomId = (): string => randomBytes(32).toString('hex'); From 2dc67aabe8505f8af2ee36858a837ef5cb9d4c10 Mon Sep 17 00:00:00 2001 From: HFazelinia Date: Sat, 24 Feb 2024 12:04:21 +0000 Subject: [PATCH 04/14] implement BitcoinChain.generateTransaction --- packages/chains/bitcoin/lib/BitcoinChain.ts | 182 +++++++++++++++++- packages/chains/bitcoin/lib/bitcoinUtils.ts | 24 +++ packages/chains/bitcoin/lib/constants.ts | 3 + .../lib/network/AbstractBitcoinNetwork.ts | 2 +- packages/chains/bitcoin/lib/types.ts | 1 - .../chains/bitcoin/tests/BitcoinChain.spec.ts | 160 ++++++++++++++- .../tests/network/TestBitcoinNetwork.ts | 2 +- packages/chains/bitcoin/tests/testData.ts | 18 ++ 8 files changed, 381 insertions(+), 11 deletions(-) diff --git a/packages/chains/bitcoin/lib/BitcoinChain.ts b/packages/chains/bitcoin/lib/BitcoinChain.ts index 7872360..cadfc07 100644 --- a/packages/chains/bitcoin/lib/BitcoinChain.ts +++ b/packages/chains/bitcoin/lib/BitcoinChain.ts @@ -3,9 +3,12 @@ import { Fee } from '@rosen-bridge/minimum-fee'; import { AbstractUtxoChain, BoxInfo, + ChainUtils, EventTrigger, FailedError, NetworkError, + NotEnoughAssetsError, + NotEnoughValidBoxesError, NotFoundError, PaymentOrder, PaymentTransaction, @@ -21,8 +24,8 @@ import { BitcoinConfigs, BitcoinUtxo } from './types'; import Serializer from './Serializer'; import { Psbt, Transaction, address, payments, script } from 'bitcoinjs-lib'; import JsonBigInt from '@rosen-bridge/json-bigint'; -import { getPsbtTxInputBoxId } from './bitcoinUtils'; -import { BITCOIN_CHAIN } from './constants'; +import { estimateTxFee, getPsbtTxInputBoxId } from './bitcoinUtils'; +import { BITCOIN_CHAIN, SEGWIT_INPUT_WEIGHT_UNIT } from './constants'; import { blake2b } from 'blakejs'; class BitcoinChain extends AbstractUtxoChain { @@ -60,7 +63,7 @@ class BitcoinChain extends AbstractUtxoChain { * @param serializedSignedTransactions the serialized string of ongoing signed transactions (used for chaining transaction) * @returns the generated PaymentTransaction */ - generateTransaction = ( + generateTransaction = async ( eventId: string, txType: TransactionType, order: PaymentOrder, @@ -68,7 +71,124 @@ class BitcoinChain extends AbstractUtxoChain { serializedSignedTransactions: string[], ...extra: Array ): Promise => { - throw Error(`not implemented`); + this.logger.debug( + `Generating Bitcoin transaction for Order: ${JsonBigInt.stringify(order)}` + ); + // calculate required assets + const requiredAssets = order + .map((order) => order.assets) + .reduce(ChainUtils.sumAssetBalance, { + nativeToken: await this.minimumMeaningfulSatoshi(), + tokens: [], + }); + this.logger.debug( + `Required assets: ${JsonBigInt.stringify(requiredAssets)}` + ); + + if (!(await this.hasLockAddressEnoughAssets(requiredAssets))) { + const neededBtc = requiredAssets.nativeToken.toString(); + throw new NotEnoughAssetsError( + `Locked assets cannot cover required assets. BTC: ${neededBtc}` + ); + } + + const forbiddenBoxIds = unsignedTransactions.flatMap((paymentTx) => { + const inputs = Serializer.deserialize(paymentTx.txBytes).txInputs; + const ids: string[] = []; + for (let i = 0; i < inputs.length; i++) + ids.push(getPsbtTxInputBoxId(inputs[i])); + + return ids; + }); + const trackMap = this.getTransactionsBoxMapping( + serializedSignedTransactions.map((serializedTx) => + Psbt.fromHex(serializedTx) + ), + this.configs.addresses.lock + ); + + // TODO: improve box fetching + const coveredBoxes = await this.getCoveringBoxes( + this.configs.addresses.lock, + requiredAssets, + forbiddenBoxIds, + trackMap + ); + if (!coveredBoxes.covered) { + const neededBtc = requiredAssets.nativeToken.toString(); + throw new NotEnoughValidBoxesError( + `Available boxes didn't cover required assets. BTC: ${neededBtc}` + ); + } + + // add inputs + const psbt = new Psbt(); + coveredBoxes.boxes.forEach((box) => { + psbt.addInput({ + hash: box.txId, + index: box.index, + witnessUtxo: { + script: Buffer.from(this.lockScript, 'hex'), + value: Number(box.value), + }, + }); + }); + // calculate input boxes assets + let remainingBtc = coveredBoxes.boxes.reduce((a, b) => a + b.value, 0n); + this.logger.debug(`Input BTC: ${remainingBtc}`); + + // add outputs + order.forEach((order) => { + if (order.extra) { + throw Error('Bitcoin does not support extra data in payment order'); + } + if (order.assets.tokens.length) { + throw Error('Bitcoin does not support tokens in payment order'); + } + if (order.address.slice(0, 4) !== 'bc1q') { + throw Error('Bitcoin does not support payment to non-segwit addresses'); + } + + // reduce order value from remaining assets + remainingBtc -= order.assets.nativeToken; + + // create order output + psbt.addOutput({ + script: address.toOutputScript(order.address), + value: Number(order.assets.nativeToken), + }); + }); + + // create change output + this.logger.debug(`Remaining BTC: ${remainingBtc}`); + const estimatedFee = estimateTxFee( + psbt.txInputs.length, + psbt.txOutputs.length + 1, + await this.network.getFeeRatio() + ); + this.logger.debug(`Estimated Fee: ${estimatedFee}`); + remainingBtc -= estimatedFee; + psbt.addOutput({ + script: Buffer.from(this.lockScript, 'hex'), + value: Number(remainingBtc), + }); + + // create the transaction + const txId = Transaction.fromBuffer(psbt.data.getTransaction()).getId(); + const txBytes = Serializer.serialize(psbt); + + const bitcoinTx = new BitcoinTransaction( + txId, + eventId, + txBytes, + txType, + coveredBoxes.boxes.map((box) => JsonBigInt.stringify(box)) + ); + + this.logger.info( + `Bitcoin transaction [${txId}] as type [${txType}] generated for event [${eventId}]` + ); + return bitcoinTx; }; /** @@ -402,7 +522,10 @@ class BitcoinChain extends AbstractUtxoChain { * gets the minimum amount of native token for transferring asset * @returns the minimum amount */ - getMinimumNativeToken = (): bigint => this.configs.minBoxValue; + getMinimumNativeToken = (): bigint => { + // there is no token in bitcoin + return 0n; + }; /** * converts json representation of the payment transaction to PaymentTransaction @@ -472,6 +595,45 @@ class BitcoinChain extends AbstractUtxoChain { }; }; + /** + * generates mapping from input box id to serialized string of output box (filtered by address, containing the token) + * @param txs list of transactions + * @param address the address + * @param tokenId the token id + * @returns a Map from input box id to output box + */ + protected getTransactionsBoxMapping = ( + txs: Psbt[], + address: string + ): Map => { + const trackMap = new Map(); + + txs.forEach((tx) => { + const txId = Transaction.fromBuffer(tx.data.getTransaction()).getId(); + // iterate over tx inputs + tx.txInputs.forEach((input) => { + let trackedBox: BitcoinUtxo | undefined; + // iterate over tx outputs + let index = 0; + for (index = 0; index < tx.txOutputs.length; index++) { + const output = tx.txOutputs[index]; + // check if box satisfy conditions + if (output.address !== address) continue; + + // mark the tracked box + trackedBox = { + txId: txId, + index: index, + value: BigInt(output.value), + }; + break; + } + }); + }); + + return trackMap; + }; + /** * returns box id * @param box @@ -502,6 +664,16 @@ class BitcoinChain extends AbstractUtxoChain { } return psbt; }; + + /** + * gets the minimum amount of satoshi for a utxo that can cover + * additional fee for adding it to a tx + * @returns the minimum amount + */ + minimumMeaningfulSatoshi = async (): Promise => { + const currentFeeRatio = await this.network.getFeeRatio(); + return BigInt(Math.ceil((currentFeeRatio * SEGWIT_INPUT_WEIGHT_UNIT) / 4)); + }; } export default BitcoinChain; diff --git a/packages/chains/bitcoin/lib/bitcoinUtils.ts b/packages/chains/bitcoin/lib/bitcoinUtils.ts index 22d71ea..0a7d60a 100644 --- a/packages/chains/bitcoin/lib/bitcoinUtils.ts +++ b/packages/chains/bitcoin/lib/bitcoinUtils.ts @@ -1,4 +1,8 @@ import { PsbtTxInput } from 'bitcoinjs-lib'; +import { + SEGWIT_INPUT_WEIGHT_UNIT, + SEGWIT_OUTPUT_WEIGHT_UNIT, +} from './constants'; /** * gets boxId from PsbtTxInput @@ -7,3 +11,23 @@ import { PsbtTxInput } from 'bitcoinjs-lib'; */ export const getPsbtTxInputBoxId = (input: PsbtTxInput) => `${input.hash.reverse().toString('hex')}.${input.index}`; + +/** + * estimates required fee for tx based on number of inputs, outputs and current network fee ratio + * inputs and outputs required fee are estimated by segwit weight unit + * @param inputSize + * @param outputSize + * @param feeRatio + */ +export const estimateTxFee = ( + inputSize: number, + outputSize: number, + feeRatio: number +): bigint => { + const txBaseWeight = 40 + 2; + const inputsWeight = inputSize * SEGWIT_INPUT_WEIGHT_UNIT; + const outputWeight = outputSize * SEGWIT_OUTPUT_WEIGHT_UNIT; + return BigInt( + Math.ceil(((txBaseWeight + inputsWeight + outputWeight) / 4) * feeRatio) + ); +}; diff --git a/packages/chains/bitcoin/lib/constants.ts b/packages/chains/bitcoin/lib/constants.ts index c53cee3..3535a82 100644 --- a/packages/chains/bitcoin/lib/constants.ts +++ b/packages/chains/bitcoin/lib/constants.ts @@ -1,2 +1,5 @@ export const BITCOIN_CHAIN = 'bitcoin'; export const OP_RETURN_ADDRESS = 'op_return'; + +export const SEGWIT_INPUT_WEIGHT_UNIT = 272; +export const SEGWIT_OUTPUT_WEIGHT_UNIT = 124; diff --git a/packages/chains/bitcoin/lib/network/AbstractBitcoinNetwork.ts b/packages/chains/bitcoin/lib/network/AbstractBitcoinNetwork.ts index d40bbce..2f4cebe 100644 --- a/packages/chains/bitcoin/lib/network/AbstractBitcoinNetwork.ts +++ b/packages/chains/bitcoin/lib/network/AbstractBitcoinNetwork.ts @@ -26,7 +26,7 @@ abstract class AbstractBitcoinNetwork extends AbstractUtxoChainNetwork< * gets current fee ratio of the network * @returns */ - abstract getFeeRatio: () => Promise; + abstract getFeeRatio: () => Promise; /** * gets id of transactions in mempool diff --git a/packages/chains/bitcoin/lib/types.ts b/packages/chains/bitcoin/lib/types.ts index 8b64d0f..bb41f89 100644 --- a/packages/chains/bitcoin/lib/types.ts +++ b/packages/chains/bitcoin/lib/types.ts @@ -4,7 +4,6 @@ import { } from '@rosen-chains/abstract-chain'; export interface BitcoinConfigs extends ChainConfigs { - minBoxValue: bigint; aggregatedPublicKey: string; } diff --git a/packages/chains/bitcoin/tests/BitcoinChain.spec.ts b/packages/chains/bitcoin/tests/BitcoinChain.spec.ts index 7122ff7..beed3ef 100644 --- a/packages/chains/bitcoin/tests/BitcoinChain.spec.ts +++ b/packages/chains/bitcoin/tests/BitcoinChain.spec.ts @@ -1,6 +1,15 @@ import { vi } from 'vitest'; -import { TransactionType } from '@rosen-chains/abstract-chain'; -import { BitcoinChain, BitcoinConfigs, BitcoinTransaction } from '../lib'; +import { + NotEnoughAssetsError, + NotEnoughValidBoxesError, + TransactionType, +} from '@rosen-chains/abstract-chain'; +import { + BitcoinChain, + BitcoinConfigs, + BitcoinTransaction, + SEGWIT_INPUT_WEIGHT_UNIT, +} from '../lib'; import TestBitcoinNetwork from './network/TestBitcoinNetwork'; import * as testData from './testData'; import * as testUtils from './testUtils'; @@ -18,7 +27,6 @@ describe('BitcoinChain', () => { const feeRationDivisor = 1n; const configs: BitcoinConfigs = { fee: 1000000n, - minBoxValue: 0n, addresses: { lock: testData.lockAddress, cold: 'cold', @@ -42,6 +50,152 @@ describe('BitcoinChain', () => { return new BitcoinChain(network, configs, feeRationDivisor, signFn); }; + describe('generateTransaction', () => { + const network = new TestBitcoinNetwork(); + + /** + * @target BitcoinChain.generateTransaction should generate payment + * transaction successfully + * @dependencies + * @scenario + * - mock transaction order, getFeeRatio + * - mock getCoveringBoxes, hasLockAddressEnoughAssets + * - call the function + * - check returned value + * @expected + * - PaymentTransaction txType, eventId, network and inputUtxos should be as + * expected + * - extracted order of generated transaction should be the same as input + * order + * - getCoveringBoxes should have been called with correct arguments + */ + it('should generate payment transaction successfully', async () => { + // mock transaction order + const order = testData.transaction2Order; + const payment1 = BitcoinTransaction.fromJson( + testData.transaction2PaymentTransaction + ); + const getFeeRatioSpy = vi.spyOn(network, 'getFeeRatio'); + getFeeRatioSpy.mockResolvedValue(1); + + // mock getCoveringBoxes, hasLockAddressEnoughAssets + const bitcoinChain = generateChainObject(network); + const getCovBoxesSpy = vi.spyOn(bitcoinChain, 'getCoveringBoxes'); + getCovBoxesSpy.mockResolvedValue({ + covered: true, + boxes: testData.lockAddressUtxos, + }); + const hasLockAddressEnoughAssetsSpy = vi.spyOn( + bitcoinChain, + 'hasLockAddressEnoughAssets' + ); + hasLockAddressEnoughAssetsSpy.mockResolvedValue(true); + + // call the function + const result = await bitcoinChain.generateTransaction( + payment1.eventId, + payment1.txType, + order, + [BitcoinTransaction.fromJson(testData.transaction1PaymentTransaction)], + [] + ); + const bitcoinTx = result as BitcoinTransaction; + + // check returned value + expect(bitcoinTx.txType).toEqual(payment1.txType); + expect(bitcoinTx.eventId).toEqual(payment1.eventId); + expect(bitcoinTx.network).toEqual(payment1.network); + expect(bitcoinTx.inputUtxos).toEqual( + testData.lockAddressUtxos.map((utxo) => JsonBigInt.stringify(utxo)) + ); + + // extracted order of generated transaction should be the same as input order + const extractedOrder = bitcoinChain.extractTransactionOrder(bitcoinTx); + expect(extractedOrder).toEqual(order); + + // getCoveringBoxes should have been called with correct arguments + const expectedRequiredAssets = structuredClone( + testData.transaction2Order[0].assets + ); + expectedRequiredAssets.nativeToken += BigInt( + Math.ceil(SEGWIT_INPUT_WEIGHT_UNIT / 4) + ); + expect(getCovBoxesSpy).toHaveBeenCalledWith( + configs.addresses.lock, + expectedRequiredAssets, + testData.transaction1InputIds, + new Map() + ); + }); + + /** + * @target BitcoinChain.generateTransaction should throw error + * when lock address does not have enough assets + * @dependencies + * @scenario + * - mock hasLockAddressEnoughAssets + * - call the function and expect error + * @expected + * - generateTransaction should throw NotEnoughAssetsError + */ + it('should throw error when lock address does not have enough assets', async () => { + // mock hasLockAddressEnoughAssets + const bitcoinChain = generateChainObject(network); + const hasLockAddressEnoughAssetsSpy = vi.spyOn( + bitcoinChain, + 'hasLockAddressEnoughAssets' + ); + hasLockAddressEnoughAssetsSpy.mockResolvedValue(false); + + // call the function and expect error + await expect(async () => { + await bitcoinChain.generateTransaction( + 'event1', + TransactionType.payment, + testData.transaction2Order, + [], + [] + ); + }).rejects.toThrow(NotEnoughAssetsError); + }); + + /** + * @target BitcoinChain.generateTransaction should throw error + * when bank boxes can not cover order assets + * @dependencies + * @scenario + * - mock getCoveringBoxes, hasLockAddressEnoughAssets + * - call the function and expect error + * @expected + * - generateTransaction should throw NotEnoughAssetsError + */ + it('should throw error when bank boxes can not cover order assets', async () => { + // mock getCoveringBoxes, hasLockAddressEnoughAssets + const bitcoinChain = generateChainObject(network); + const getCovBoxesSpy = vi.spyOn(bitcoinChain, 'getCoveringBoxes'); + getCovBoxesSpy.mockResolvedValue({ + covered: false, + boxes: testData.lockAddressUtxos, + }); + const hasLockAddressEnoughAssetsSpy = vi.spyOn( + bitcoinChain, + 'hasLockAddressEnoughAssets' + ); + hasLockAddressEnoughAssetsSpy.mockResolvedValue(true); + + // call the function and expect error + await expect(async () => { + await bitcoinChain.generateTransaction( + 'event1', + TransactionType.payment, + testData.transaction2Order, + [], + [] + ); + }).rejects.toThrow(NotEnoughValidBoxesError); + }); + }); + describe('getTransactionAssets', () => { const network = new TestBitcoinNetwork(); diff --git a/packages/chains/bitcoin/tests/network/TestBitcoinNetwork.ts b/packages/chains/bitcoin/tests/network/TestBitcoinNetwork.ts index e275d50..3c01f5c 100644 --- a/packages/chains/bitcoin/tests/network/TestBitcoinNetwork.ts +++ b/packages/chains/bitcoin/tests/network/TestBitcoinNetwork.ts @@ -65,7 +65,7 @@ class TestBitcoinNetwork extends AbstractBitcoinNetwork { throw Error('Not mocked'); }; - getFeeRatio = (): Promise => { + getFeeRatio = (): Promise => { throw Error('Not mocked'); }; diff --git a/packages/chains/bitcoin/tests/testData.ts b/packages/chains/bitcoin/tests/testData.ts index 600ccfa..fb4e4b4 100644 --- a/packages/chains/bitcoin/tests/testData.ts +++ b/packages/chains/bitcoin/tests/testData.ts @@ -25,6 +25,9 @@ export const transaction1PaymentTransaction = `{ "{\\"txId\\":\\"64339615884272ad65eed14960042a1ef86172cc8c7899aff91e163063a32d97\\",\\"index\\":1,\\"value\\":1000000000}" ] }`; +export const transaction1InputIds = [ + '64339615884272ad65eed14960042a1ef86172cc8c7899aff91e163063a32d97.1', +]; export const transaction2PaymentTransaction = `{ "network": "bitcoin", @@ -158,3 +161,18 @@ export const bitcoinTx1 = { }, ], }; + +export const lockAddressUtxos = [ + { + txId: '224fcacfd096705575244cc01bf5f39650c6f90cb3480e3989e88b2ca1283a19', + index: 1, + scriptPubKey: '0014b20272a6591937ba7d687dc889f3637ed40efa6a', + value: 1000000000n, + }, + { + txId: '4b5cd97c2286d8ba647693a40942687c36bc904c2df22749105ee3762623e6d2', + index: 1, + scriptPubKey: '0014b20272a6591937ba7d687dc889f3637ed40efa6a', + value: 2000000000n, + }, +]; From aef9f730a3af186696d5f4f2ac55b6faa163a32a Mon Sep 17 00:00:00 2001 From: HFazelinia Date: Sat, 24 Feb 2024 14:37:22 +0000 Subject: [PATCH 05/14] implement BitcoinChain.verifyTransactionFee --- packages/chains/bitcoin/lib/BitcoinChain.ts | 34 +++++++++++- packages/chains/bitcoin/lib/types.ts | 1 + .../chains/bitcoin/tests/BitcoinChain.spec.ts | 55 +++++++++++++++++++ 3 files changed, 89 insertions(+), 1 deletion(-) diff --git a/packages/chains/bitcoin/lib/BitcoinChain.ts b/packages/chains/bitcoin/lib/BitcoinChain.ts index fed64da..6cff4b8 100644 --- a/packages/chains/bitcoin/lib/BitcoinChain.ts +++ b/packages/chains/bitcoin/lib/BitcoinChain.ts @@ -260,7 +260,39 @@ class BitcoinChain extends AbstractUtxoChain { verifyTransactionFee = async ( transaction: PaymentTransaction ): Promise => { - throw Error(`not implemented`); + const tx = Serializer.deserialize(transaction.txBytes); + const bitcoinTx = transaction as BitcoinTransaction; + + let inBtc = 0n; + const inputUtxos = Array.from(new Set(bitcoinTx.inputUtxos)); + for (let i = 0; i < inputUtxos.length; i++) { + const input = JsonBigInt.parse(inputUtxos[i]) as BitcoinUtxo; + inBtc += input.value; + } + + let outBtc = 0n; + for (let i = 0; i < tx.txOutputs.length; i++) { + const output = tx.txOutputs[i]; + outBtc += BigInt(output.value); + } + + const fee = inBtc - outBtc; + const estimatedFee = estimateTxFee( + tx.txInputs.length, + tx.txOutputs.length, + await this.network.getFeeRatio() + ); + + const feeDifferencePercent = Math.abs( + (Number(fee - estimatedFee) * 100) / Number(fee) + ); + if (feeDifferencePercent > this.configs.txFeeSlippage) { + this.logger.warn( + `Fee difference is high. Slippage is higher than allowed value [${feeDifferencePercent} > ${this.configs.txFeeSlippage}]. fee: ${fee}, estimated fee: ${estimatedFee}` + ); + return false; + } + return true; }; /** diff --git a/packages/chains/bitcoin/lib/types.ts b/packages/chains/bitcoin/lib/types.ts index bb41f89..0860c1f 100644 --- a/packages/chains/bitcoin/lib/types.ts +++ b/packages/chains/bitcoin/lib/types.ts @@ -5,6 +5,7 @@ import { export interface BitcoinConfigs extends ChainConfigs { aggregatedPublicKey: string; + txFeeSlippage: number; } export interface BitcoinTransactionJsonModel diff --git a/packages/chains/bitcoin/tests/BitcoinChain.spec.ts b/packages/chains/bitcoin/tests/BitcoinChain.spec.ts index beed3ef..59efc49 100644 --- a/packages/chains/bitcoin/tests/BitcoinChain.spec.ts +++ b/packages/chains/bitcoin/tests/BitcoinChain.spec.ts @@ -41,6 +41,7 @@ describe('BitcoinChain', () => { manual: manualTxConfirmation, }, aggregatedPublicKey: testData.lockAddressPublicKey, + txFeeSlippage: 10, }; const mockedSignFn = () => Promise.resolve(''); const generateChainObject = ( @@ -278,6 +279,60 @@ describe('BitcoinChain', () => { }); }); + describe('verifyTransactionFee', () => { + const network = new TestBitcoinNetwork(); + + /** + * @target BitcoinChain.verifyTransactionFee should return true when fee + * difference is less than allowed slippage + * @dependencies + * @scenario + * - mock PaymentTransaction + * - mock getFeeRatio + * - call the function + * - check returned value + * @expected + * - it should return true + */ + it('should return true when fee difference is less than allowed slippage', async () => { + const paymentTx = BitcoinTransaction.fromJson( + testData.transaction2PaymentTransaction + ); + const getFeeRatioSpy = vi.spyOn(network, 'getFeeRatio'); + getFeeRatioSpy.mockResolvedValue(1); + + const bitcoinChain = generateChainObject(network); + const result = await bitcoinChain.verifyTransactionFee(paymentTx); + + expect(result).toEqual(true); + }); + + /** + * @target BitcoinChain.verifyTransactionFee should return false when fee + * difference is more than allowed slippage + * @dependencies + * @scenario + * - mock PaymentTransaction + * - mock getFeeRatio + * - call the function + * - check returned value + * @expected + * - it should return false + */ + it('should return false when fee difference is more than allowed slippage', async () => { + const paymentTx = BitcoinTransaction.fromJson( + testData.transaction2PaymentTransaction + ); + const getFeeRatioSpy = vi.spyOn(network, 'getFeeRatio'); + getFeeRatioSpy.mockResolvedValue(1.2); + + const bitcoinChain = generateChainObject(network); + const result = await bitcoinChain.verifyTransactionFee(paymentTx); + + expect(result).toEqual(false); + }); + }); + describe('verifyExtraCondition', () => { const network = new TestBitcoinNetwork(); From bc8e4a9c51dbf02b4a1c4b1f8b96e342f4fbbed5 Mon Sep 17 00:00:00 2001 From: HFazelinia Date: Sat, 24 Feb 2024 16:52:39 +0000 Subject: [PATCH 06/14] add todo to improve box selection --- packages/chains/bitcoin/lib/BitcoinChain.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/chains/bitcoin/lib/BitcoinChain.ts b/packages/chains/bitcoin/lib/BitcoinChain.ts index 6cff4b8..610502d 100644 --- a/packages/chains/bitcoin/lib/BitcoinChain.ts +++ b/packages/chains/bitcoin/lib/BitcoinChain.ts @@ -107,7 +107,8 @@ class BitcoinChain extends AbstractUtxoChain { this.configs.addresses.lock ); - // TODO: improve box fetching + // TODO: improve box fetching (use bitcoin-box-selection package) + // local:ergo/rosen-bridge/utils#176 const coveredBoxes = await this.getCoveringBoxes( this.configs.addresses.lock, requiredAssets, From 4237709a92bdf13cfa24b1a77196d452fd240426 Mon Sep 17 00:00:00 2001 From: HFazelinia Date: Sat, 24 Feb 2024 16:53:56 +0000 Subject: [PATCH 07/14] add empty changeset --- .changeset/angry-countries-pump.md | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 .changeset/angry-countries-pump.md diff --git a/.changeset/angry-countries-pump.md b/.changeset/angry-countries-pump.md new file mode 100644 index 0000000..a845151 --- /dev/null +++ b/.changeset/angry-countries-pump.md @@ -0,0 +1,2 @@ +--- +--- From a857ea305cf5b08ba571fc31c15fa9b8381c30ed Mon Sep 17 00:00:00 2001 From: HFazelinia Date: Sat, 24 Feb 2024 21:22:08 +0000 Subject: [PATCH 08/14] fix verifyEvent tests --- .../chains/bitcoin/tests/BitcoinChain.spec.ts | 101 +++++++++++++++++- 1 file changed, 100 insertions(+), 1 deletion(-) diff --git a/packages/chains/bitcoin/tests/BitcoinChain.spec.ts b/packages/chains/bitcoin/tests/BitcoinChain.spec.ts index 59efc49..d3f9c4a 100644 --- a/packages/chains/bitcoin/tests/BitcoinChain.spec.ts +++ b/packages/chains/bitcoin/tests/BitcoinChain.spec.ts @@ -48,7 +48,13 @@ describe('BitcoinChain', () => { network: TestBitcoinNetwork, signFn: (txHash: Uint8Array) => Promise = mockedSignFn ) => { - return new BitcoinChain(network, configs, feeRationDivisor, signFn); + return new BitcoinChain( + network, + configs, + feeRationDivisor, + signFn, + console + ); }; describe('generateTransaction', () => { @@ -665,6 +671,75 @@ describe('BitcoinChain', () => { expect(getBlockInfoSpy).toHaveBeenCalledWith(event.sourceBlockId); }); + /** + * @target BitcoinChain.verifyEvent should return false when event + * data is not extracted + * @dependencies + * @scenario + * - mock an event + * - mock a network object with mocked 'getBlockTransactionIds' and + * 'getTransaction' functions + * - mock getBlockInfo to return event block height + * - mock network extractor to return event data (expect for a key which + * should be wrong) + * - run test + * - check returned value + * - check if functions got called + * @expected + * - it should return false + * - `getBlockTransactionIds` and `getBlockInfo` should have been called with event blockId + * - `getTransaction` should have been called with event lock txId + */ + it('should return false when event data is not extracted', async () => { + // mock an event + const event = testData.validEvent; + + // mock a network object with mocked 'getBlockTransactionIds' and + // 'getTransaction' functions + const network = new TestBitcoinNetwork(); + const getBlockTransactionIdsSpy = vi.spyOn( + network, + 'getBlockTransactionIds' + ); + getBlockTransactionIdsSpy.mockResolvedValueOnce([ + testUtils.generateRandomId(), + event.sourceTxId, + testUtils.generateRandomId(), + ]); + + // mock 'getTransaction' (the tx itself doesn't matter) + const tx = testData.bitcoinTx1; + const getTransactionSpy = vi.spyOn(network, 'getTransaction'); + getTransactionSpy.mockResolvedValueOnce(tx); + + // mock getBlockInfo to return event block height + const getBlockInfoSpy = vi.spyOn(network, 'getBlockInfo'); + getBlockInfoSpy.mockResolvedValueOnce({ + height: event.sourceChainHeight, + } as any); + + // mock network extractor to return event data + const extractorSpy = vi.spyOn(network.extractor, 'get'); + extractorSpy.mockReturnValueOnce(undefined); + + // run test + const bitcoinChain = generateChainObject(network); + const result = await bitcoinChain.verifyEvent(event, feeConfig); + + // check returned value + expect(result).toEqual(false); + + // check if functions got called + expect(getBlockTransactionIdsSpy).toHaveBeenCalledWith( + event.sourceBlockId + ); + expect(getTransactionSpy).toHaveBeenCalledWith( + event.sourceTxId, + event.sourceBlockId + ); + expect(getBlockInfoSpy).toHaveBeenCalledWith(event.sourceBlockId); + }); + /** * @target BitcoinChain.verifyEvent should return false when event amount * is less than sum of event fees @@ -673,6 +748,7 @@ describe('BitcoinChain', () => { * - mock an event * - mock a network object with mocked 'getBlockTransactionIds' and * 'getTransaction' functions + * - mock getBlockInfo to return event block height * - mock network extractor to return event data * - run test * - check returned value @@ -704,6 +780,12 @@ describe('BitcoinChain', () => { const getTransactionSpy = vi.spyOn(network, 'getTransaction'); getTransactionSpy.mockResolvedValueOnce(tx); + // mock getBlockInfo to return event block height + const getBlockInfoSpy = vi.spyOn(network, 'getBlockInfo'); + getBlockInfoSpy.mockResolvedValueOnce({ + height: event.sourceChainHeight, + } as any); + // mock network extractor to return event data const extractorSpy = vi.spyOn(network.extractor, 'get'); extractorSpy.mockReturnValueOnce(event as unknown as RosenData); @@ -723,6 +805,7 @@ describe('BitcoinChain', () => { event.sourceTxId, event.sourceBlockId ); + expect(getBlockInfoSpy).toHaveBeenCalledWith(event.sourceBlockId); }); /** @@ -734,6 +817,7 @@ describe('BitcoinChain', () => { * - mock an event * - mock a network object with mocked 'getBlockTransactionIds' and * 'getTransaction' functions + * - mock getBlockInfo to return event block height * - mock network extractor to return event data * - run test * - check returned value @@ -773,6 +857,12 @@ describe('BitcoinChain', () => { const getTransactionSpy = vi.spyOn(network, 'getTransaction'); getTransactionSpy.mockResolvedValueOnce(tx); + // mock getBlockInfo to return event block height + const getBlockInfoSpy = vi.spyOn(network, 'getBlockInfo'); + getBlockInfoSpy.mockResolvedValueOnce({ + height: event.sourceChainHeight, + } as any); + // mock network extractor to return event data const extractorSpy = vi.spyOn(network.extractor, 'get'); extractorSpy.mockReturnValueOnce(event as unknown as RosenData); @@ -792,6 +882,7 @@ describe('BitcoinChain', () => { event.sourceTxId, event.sourceBlockId ); + expect(getBlockInfoSpy).toHaveBeenCalledWith(event.sourceBlockId); }); /** @@ -803,6 +894,7 @@ describe('BitcoinChain', () => { * - mock an event * - mock a network object with mocked 'getBlockTransactionIds' and * 'getTransaction' functions + * - mock getBlockInfo to return event block height * - mock network extractor to return event data * - run test * - check returned value @@ -842,6 +934,12 @@ describe('BitcoinChain', () => { const getTransactionSpy = vi.spyOn(network, 'getTransaction'); getTransactionSpy.mockResolvedValueOnce(tx); + // mock getBlockInfo to return event block height + const getBlockInfoSpy = vi.spyOn(network, 'getBlockInfo'); + getBlockInfoSpy.mockResolvedValueOnce({ + height: event.sourceChainHeight, + } as any); + // mock network extractor to return event data const extractorSpy = vi.spyOn(network.extractor, 'get'); extractorSpy.mockReturnValueOnce(event as unknown as RosenData); @@ -861,6 +959,7 @@ describe('BitcoinChain', () => { event.sourceTxId, event.sourceBlockId ); + expect(getBlockInfoSpy).toHaveBeenCalledWith(event.sourceBlockId); }); }); From aaf41ee77dbe782dd9aafc43aa355d94bf8d4b3a Mon Sep 17 00:00:00 2001 From: HFazelinia Date: Sat, 24 Feb 2024 21:50:03 +0000 Subject: [PATCH 09/14] add tests for BitcoinChain.getTransactionsBoxMapping --- packages/chains/bitcoin/lib/BitcoinChain.ts | 6 +- .../chains/bitcoin/tests/BitcoinChain.spec.ts | 98 +++++++++++++++++-- .../chains/bitcoin/tests/TestBitcoinChain.ts | 11 +++ packages/chains/bitcoin/tests/testData.ts | 14 +++ 4 files changed, 121 insertions(+), 8 deletions(-) create mode 100644 packages/chains/bitcoin/tests/TestBitcoinChain.ts diff --git a/packages/chains/bitcoin/lib/BitcoinChain.ts b/packages/chains/bitcoin/lib/BitcoinChain.ts index 610502d..862abf7 100644 --- a/packages/chains/bitcoin/lib/BitcoinChain.ts +++ b/packages/chains/bitcoin/lib/BitcoinChain.ts @@ -647,7 +647,7 @@ class BitcoinChain extends AbstractUtxoChain { const txId = Transaction.fromBuffer(tx.data.getTransaction()).getId(); // iterate over tx inputs tx.txInputs.forEach((input) => { - let trackedBox: BitcoinUtxo | undefined; + let trackedBox: BitcoinUtxo | undefined = undefined; // iterate over tx outputs let index = 0; for (index = 0; index < tx.txOutputs.length; index++) { @@ -663,6 +663,10 @@ class BitcoinChain extends AbstractUtxoChain { }; break; } + + // add input box to trackMap + const boxId = getPsbtTxInputBoxId(input); + trackMap.set(boxId, trackedBox); }); }); diff --git a/packages/chains/bitcoin/tests/BitcoinChain.spec.ts b/packages/chains/bitcoin/tests/BitcoinChain.spec.ts index d3f9c4a..fcb2a5b 100644 --- a/packages/chains/bitcoin/tests/BitcoinChain.spec.ts +++ b/packages/chains/bitcoin/tests/BitcoinChain.spec.ts @@ -8,6 +8,7 @@ import { BitcoinChain, BitcoinConfigs, BitcoinTransaction, + BitcoinUtxo, SEGWIT_INPUT_WEIGHT_UNIT, } from '../lib'; import TestBitcoinNetwork from './network/TestBitcoinNetwork'; @@ -16,6 +17,8 @@ import * as testUtils from './testUtils'; import JsonBigInt from '@rosen-bridge/json-bigint'; import { Fee } from '@rosen-bridge/minimum-fee'; import { RosenData } from '@rosen-bridge/rosen-extractor'; +import { Psbt } from 'bitcoinjs-lib'; +import { TestBitcoinChain } from './TestBitcoinChain'; describe('BitcoinChain', () => { const observationTxConfirmation = 5; @@ -48,13 +51,7 @@ describe('BitcoinChain', () => { network: TestBitcoinNetwork, signFn: (txHash: Uint8Array) => Promise = mockedSignFn ) => { - return new BitcoinChain( - network, - configs, - feeRationDivisor, - signFn, - console - ); + return new BitcoinChain(network, configs, feeRationDivisor, signFn); }; describe('generateTransaction', () => { @@ -1182,4 +1179,91 @@ describe('BitcoinChain', () => { ); }); }); + + describe('getTransactionsBoxMapping', () => { + const network = new TestBitcoinNetwork(); + const testInstance = new TestBitcoinChain( + network, + configs, + feeRationDivisor, + null as any + ); + + /** + * @target BitcoinChain.getTransactionsBoxMapping should construct mapping + * successfully + * @dependencies + * @scenario + * - mock serialized transactions + * - call the function + * - check returned value + * @expected + * - it should return a map equal to constructed map + */ + it('should construct mapping successfully', () => { + // mock serialized transactions + const transactions = [testData.transaction2PaymentTransaction].map( + (txJson) => + Psbt.fromBuffer( + Buffer.from(BitcoinTransaction.fromJson(txJson).txBytes) + ) + ); + + // call the function + const result = testInstance.callGetTransactionsBoxMapping( + transactions, + configs.addresses.lock + ); + + // check returned value + const trackMap = new Map(); + const boxMapping = testData.transaction2BoxMapping; + boxMapping.forEach((mapping) => { + const candidate = JsonBigInt.parse( + mapping.serializedOutput + ) as BitcoinUtxo; + trackMap.set(mapping.inputId, { + txId: candidate.txId, + index: Number(candidate.index), + value: candidate.value, + }); + }); + expect(result).toEqual(trackMap); + }); + + /** + * @target BitcoinChain.getTransactionsBoxMapping should map inputs to + * undefined when no valid output box found + * @dependencies + * @scenario + * - mock serialized transactions + * - call the function + * - check returned value + * @expected + * - it should return a map of each box to undefined + */ + it('should map inputs to undefined when no valid output box found', () => { + // mock serialized transactions + const transactions = [testData.transaction2PaymentTransaction].map( + (txJson) => + Psbt.fromBuffer( + Buffer.from(BitcoinTransaction.fromJson(txJson).txBytes) + ) + ); + + // call the function + const result = testInstance.callGetTransactionsBoxMapping( + transactions, + 'another address' + ); + + // check returned value + const trackMap = new Map(); + const boxMapping = testData.transaction2BoxMapping; + boxMapping.forEach((mapping) => { + trackMap.set(mapping.inputId, undefined); + }); + expect(result).toEqual(trackMap); + }); + }); }); diff --git a/packages/chains/bitcoin/tests/TestBitcoinChain.ts b/packages/chains/bitcoin/tests/TestBitcoinChain.ts new file mode 100644 index 0000000..55dc5a1 --- /dev/null +++ b/packages/chains/bitcoin/tests/TestBitcoinChain.ts @@ -0,0 +1,11 @@ +import { Psbt } from 'bitcoinjs-lib'; +import { BitcoinChain } from '../lib'; + +export class TestBitcoinChain extends BitcoinChain { + callGetTransactionsBoxMapping = ( + serializedTransactions: Psbt[], + address: string + ) => { + return this.getTransactionsBoxMapping(serializedTransactions, address); + }; +} diff --git a/packages/chains/bitcoin/tests/testData.ts b/packages/chains/bitcoin/tests/testData.ts index fb4e4b4..6778c82 100644 --- a/packages/chains/bitcoin/tests/testData.ts +++ b/packages/chains/bitcoin/tests/testData.ts @@ -69,6 +69,20 @@ export const transaction2Signature1 = '802ac030548f5c4e05f071d110f96ca0d18d61dcad93638973d15ab454c7ab9855338130212ce38c26a4f595cbaab4dfd86057827f17f141dbdb2f3f6ff8908f'; export const transaction2SignedTxBytesHex = '70736274ff01009a0200000002193a28a12c8be889390e48b30cf9c65096f3f51bc04c2475557096d0cfca4f220100000000ffffffffd2e6232676e35e104927f22d4c90bc367c684209a4937664bad886227cd95c4b0100000000ffffffff028063ef2700000000160014828037cbcbed02c6d9948e51b89c44da3a3b81fcaff9e08a00000000160014fdfe06abec6a565eff3604db30fd30069b2f2a28000000000001011f00ca9a3b00000000160014fdfe06abec6a565eff3604db30fd30069b2f2a2801086b02473044022022140681b4b7d5a099cb427a0bf0cd3085ecfd583c9908891058a84def1ab8a2022038e202c53ab6f52776166218353984dbee87ffa5979e72bf2ebf731eadb3009301210345307e1165c99d12557bea11f8c8cd0f6bc057fb51952e824bc7c760fda073350001011f0094357700000000160014fdfe06abec6a565eff3604db30fd30069b2f2a2801086c02483045022100802ac030548f5c4e05f071d110f96ca0d18d61dcad93638973d15ab454c7ab98022055338130212ce38c26a4f595cbaab4dfd86057827f17f141dbdb2f3f6ff8908f01210345307e1165c99d12557bea11f8c8cd0f6bc057fb51952e824bc7c760fda07335000000'; +export const transaction2BoxMapping = [ + { + inputId: + '224fcacfd096705575244cc01bf5f39650c6f90cb3480e3989e88b2ca1283a19.1', + serializedOutput: + '{"txId":"5bc486302164841b32bdfa03f510590109e3520d0a0aa6a15edfea0c8e33a080","index":1,"value":2329999791}', + }, + { + inputId: + '4b5cd97c2286d8ba647693a40942687c36bc904c2df22749105ee3762623e6d2.1', + serializedOutput: + '{"txId":"5bc486302164841b32bdfa03f510590109e3520d0a0aa6a15edfea0c8e33a080","index":1,"value":2329999791}', + }, +]; export const lockAddress = 'bc1qlhlqd2lvdft9alekqndnplfsq6dj723gh49wrt'; export const lockAddressPublicKey = From 619627e071aac5353f63a12c602d39d9a84dc06c Mon Sep 17 00:00:00 2001 From: HFazelinia Date: Sat, 24 Feb 2024 21:52:17 +0000 Subject: [PATCH 10/14] remove redundant variable --- packages/chains/bitcoin/lib/constants.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/chains/bitcoin/lib/constants.ts b/packages/chains/bitcoin/lib/constants.ts index 3535a82..1057cc0 100644 --- a/packages/chains/bitcoin/lib/constants.ts +++ b/packages/chains/bitcoin/lib/constants.ts @@ -1,5 +1,4 @@ export const BITCOIN_CHAIN = 'bitcoin'; -export const OP_RETURN_ADDRESS = 'op_return'; export const SEGWIT_INPUT_WEIGHT_UNIT = 272; export const SEGWIT_OUTPUT_WEIGHT_UNIT = 124; From 95bd6d5628b6a02f8b2562efda3b3cdf6a171103 Mon Sep 17 00:00:00 2001 From: HFazelinia Date: Tue, 27 Feb 2024 13:47:30 +0000 Subject: [PATCH 11/14] fix var name --- packages/chains/bitcoin/lib/BitcoinChain.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/chains/bitcoin/lib/BitcoinChain.ts b/packages/chains/bitcoin/lib/BitcoinChain.ts index 862abf7..c1879a4 100644 --- a/packages/chains/bitcoin/lib/BitcoinChain.ts +++ b/packages/chains/bitcoin/lib/BitcoinChain.ts @@ -588,7 +588,7 @@ class BitcoinChain extends AbstractUtxoChain { inputBoxes.push(await this.network.getUtxo(boxId)); } - const cardanoTx = new BitcoinTransaction( + const bitcoinTx = new BitcoinTransaction( txId, '', txBytes, @@ -597,7 +597,7 @@ class BitcoinChain extends AbstractUtxoChain { ); this.logger.info(`Parsed Bitcoin transaction [${txId}] successfully`); - return cardanoTx; + return bitcoinTx; }; /** From bd24f926c4b3af1aa43a35b006226a6dd007513f Mon Sep 17 00:00:00 2001 From: HFazelinia Date: Wed, 28 Feb 2024 08:50:16 +0000 Subject: [PATCH 12/14] update TODO issue number --- packages/chains/bitcoin/lib/BitcoinChain.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/chains/bitcoin/lib/BitcoinChain.ts b/packages/chains/bitcoin/lib/BitcoinChain.ts index c1879a4..f5fdd38 100644 --- a/packages/chains/bitcoin/lib/BitcoinChain.ts +++ b/packages/chains/bitcoin/lib/BitcoinChain.ts @@ -108,7 +108,7 @@ class BitcoinChain extends AbstractUtxoChain { ); // TODO: improve box fetching (use bitcoin-box-selection package) - // local:ergo/rosen-bridge/utils#176 + // local:ergo/rosen-bridge/rosen-chains#90 const coveredBoxes = await this.getCoveringBoxes( this.configs.addresses.lock, requiredAssets, From c687c75bb93b72cdb2c9d059e70828f379ba1d83 Mon Sep 17 00:00:00 2001 From: HFazelinia Date: Fri, 1 Mar 2024 12:31:44 +0000 Subject: [PATCH 13/14] minor improvements --- packages/chains/bitcoin/lib/BitcoinChain.ts | 1 - packages/chains/bitcoin/lib/bitcoinUtils.ts | 2 +- .../chains/bitcoin/tests/BitcoinChain.spec.ts | 34 ++++++++++++------- .../chains/cardano/tests/CardanoChain.spec.ts | 2 +- 4 files changed, 23 insertions(+), 16 deletions(-) diff --git a/packages/chains/bitcoin/lib/BitcoinChain.ts b/packages/chains/bitcoin/lib/BitcoinChain.ts index f5fdd38..7db1178 100644 --- a/packages/chains/bitcoin/lib/BitcoinChain.ts +++ b/packages/chains/bitcoin/lib/BitcoinChain.ts @@ -634,7 +634,6 @@ class BitcoinChain extends AbstractUtxoChain { * generates mapping from input box id to serialized string of output box (filtered by address, containing the token) * @param txs list of transactions * @param address the address - * @param tokenId the token id * @returns a Map from input box id to output box */ protected getTransactionsBoxMapping = ( diff --git a/packages/chains/bitcoin/lib/bitcoinUtils.ts b/packages/chains/bitcoin/lib/bitcoinUtils.ts index 0a7d60a..a956283 100644 --- a/packages/chains/bitcoin/lib/bitcoinUtils.ts +++ b/packages/chains/bitcoin/lib/bitcoinUtils.ts @@ -24,7 +24,7 @@ export const estimateTxFee = ( outputSize: number, feeRatio: number ): bigint => { - const txBaseWeight = 40 + 2; + const txBaseWeight = 40 + 2; // all txs include 40W. P2WPKH txs need additional 2W const inputsWeight = inputSize * SEGWIT_INPUT_WEIGHT_UNIT; const outputWeight = outputSize * SEGWIT_OUTPUT_WEIGHT_UNIT; return BigInt( diff --git a/packages/chains/bitcoin/tests/BitcoinChain.spec.ts b/packages/chains/bitcoin/tests/BitcoinChain.spec.ts index fcb2a5b..4ed58c7 100644 --- a/packages/chains/bitcoin/tests/BitcoinChain.spec.ts +++ b/packages/chains/bitcoin/tests/BitcoinChain.spec.ts @@ -64,7 +64,7 @@ describe('BitcoinChain', () => { * @scenario * - mock transaction order, getFeeRatio * - mock getCoveringBoxes, hasLockAddressEnoughAssets - * - call the function + * - run test * - check returned value * @expected * - PaymentTransaction txType, eventId, network and inputUtxos should be as @@ -95,7 +95,7 @@ describe('BitcoinChain', () => { ); hasLockAddressEnoughAssetsSpy.mockResolvedValue(true); - // call the function + // run test const result = await bitcoinChain.generateTransaction( payment1.eventId, payment1.txType, @@ -138,7 +138,7 @@ describe('BitcoinChain', () => { * @dependencies * @scenario * - mock hasLockAddressEnoughAssets - * - call the function and expect error + * - run test and expect error * @expected * - generateTransaction should throw NotEnoughAssetsError */ @@ -151,7 +151,7 @@ describe('BitcoinChain', () => { ); hasLockAddressEnoughAssetsSpy.mockResolvedValue(false); - // call the function and expect error + // run test and expect error await expect(async () => { await bitcoinChain.generateTransaction( 'event1', @@ -169,9 +169,9 @@ describe('BitcoinChain', () => { * @dependencies * @scenario * - mock getCoveringBoxes, hasLockAddressEnoughAssets - * - call the function and expect error + * - run test and expect error * @expected - * - generateTransaction should throw NotEnoughAssetsError + * - generateTransaction should throw NotEnoughValidBoxesError */ it('should throw error when bank boxes can not cover order assets', async () => { // mock getCoveringBoxes, hasLockAddressEnoughAssets @@ -187,7 +187,7 @@ describe('BitcoinChain', () => { ); hasLockAddressEnoughAssetsSpy.mockResolvedValue(true); - // call the function and expect error + // run test and expect error await expect(async () => { await bitcoinChain.generateTransaction( 'event1', @@ -292,7 +292,7 @@ describe('BitcoinChain', () => { * @scenario * - mock PaymentTransaction * - mock getFeeRatio - * - call the function + * - run test * - check returned value * @expected * - it should return true @@ -317,7 +317,7 @@ describe('BitcoinChain', () => { * @scenario * - mock PaymentTransaction * - mock getFeeRatio - * - call the function + * - run test * - check returned value * @expected * - it should return false @@ -424,6 +424,7 @@ describe('BitcoinChain', () => { * - it should return true * - `getBlockTransactionIds` and `getBlockInfo` should have been called with event blockId * - `getTransaction` should have been called with event lock txId + * - `getBlockInfoSpy` should have been called with event blockId */ it('should return true when event is valid', async () => { // mock an event @@ -535,6 +536,7 @@ describe('BitcoinChain', () => { * - it should return false * - `getBlockTransactionIds` and `getBlockInfo` should have been called with event blockId * - `getTransaction` should have been called with event lock txId + * - `getBlockInfoSpy` should have been called with event blockId */ it.each([ 'fromChain', @@ -617,6 +619,7 @@ describe('BitcoinChain', () => { * - it should return false * - `getBlockTransactionIds` and `getBlockInfo` should have been called with event blockId * - `getTransaction` should have been called with event lock txId + * - `getBlockInfoSpy` should have been called with event blockId */ it('should return false when event sourceChainHeight is wrong', async () => { // mock an event @@ -686,6 +689,7 @@ describe('BitcoinChain', () => { * - it should return false * - `getBlockTransactionIds` and `getBlockInfo` should have been called with event blockId * - `getTransaction` should have been called with event lock txId + * - `getBlockInfoSpy` should have been called with event blockId */ it('should return false when event data is not extracted', async () => { // mock an event @@ -754,6 +758,7 @@ describe('BitcoinChain', () => { * - it should return false * - `getBlockTransactionIds` should have been called with event blockId * - `getTransaction` should have been called with event lock txId + * - `getBlockInfoSpy` should have been called with event blockId */ it('should return false when event amount is less than sum of event fees', async () => { // mock an event @@ -823,6 +828,7 @@ describe('BitcoinChain', () => { * - it should return false * - `getBlockTransactionIds` should have been called with event blockId * - `getTransaction` should have been called with event lock txId + * - `getBlockInfoSpy` should have been called with event blockId */ it('should return false when event amount is less than sum of event fees while bridgeFee is less than minimum-fee', async () => { // mock feeConfig @@ -900,6 +906,7 @@ describe('BitcoinChain', () => { * - it should return false * - `getBlockTransactionIds` should have been called with event blockId * - `getTransaction` should have been called with event lock txId + * - `getBlockInfoSpy` should have been called with event blockId */ it('should return false when event amount is less than sum of event fees while bridgeFee is less than expected value', async () => { // mock feeConfig @@ -976,6 +983,7 @@ describe('BitcoinChain', () => { * - check if function got called * @expected * - it should return true + * - `isBoxUnspentAndValidSpy` should have been called with tx input ids */ it('should return true when all tx inputs are valid and ttl is less than current slot', async () => { const payment1 = BitcoinTransaction.fromJson( @@ -1195,7 +1203,7 @@ describe('BitcoinChain', () => { * @dependencies * @scenario * - mock serialized transactions - * - call the function + * - run test * - check returned value * @expected * - it should return a map equal to constructed map @@ -1209,7 +1217,7 @@ describe('BitcoinChain', () => { ) ); - // call the function + // run test const result = testInstance.callGetTransactionsBoxMapping( transactions, configs.addresses.lock @@ -1237,7 +1245,7 @@ describe('BitcoinChain', () => { * @dependencies * @scenario * - mock serialized transactions - * - call the function + * - run test * - check returned value * @expected * - it should return a map of each box to undefined @@ -1251,7 +1259,7 @@ describe('BitcoinChain', () => { ) ); - // call the function + // run test const result = testInstance.callGetTransactionsBoxMapping( transactions, 'another address' diff --git a/packages/chains/cardano/tests/CardanoChain.spec.ts b/packages/chains/cardano/tests/CardanoChain.spec.ts index a2936dd..94f77cd 100644 --- a/packages/chains/cardano/tests/CardanoChain.spec.ts +++ b/packages/chains/cardano/tests/CardanoChain.spec.ts @@ -261,7 +261,7 @@ describe('CardanoChain', () => { * - mock getCoveringBoxes, hasLockAddressEnoughAssets * - call the function and expect error * @expected - * - generateTransaction should throw NotEnoughAssetsError + * - generateTransaction should throw NotEnoughValidBoxesError */ it('should throw error when bank boxes can not cover order assets', async () => { // mock getCoveringBoxes, hasLockAddressEnoughAssets From 69b04b82d22d180b4b27558034b3325e7ee601d6 Mon Sep 17 00:00:00 2001 From: HFazelinia Date: Fri, 1 Mar 2024 14:09:24 +0000 Subject: [PATCH 14/14] add comments for vSize convertion --- packages/chains/bitcoin/lib/BitcoinChain.ts | 6 +++++- packages/chains/bitcoin/lib/bitcoinUtils.ts | 5 ++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/chains/bitcoin/lib/BitcoinChain.ts b/packages/chains/bitcoin/lib/BitcoinChain.ts index 7db1178..8c48a11 100644 --- a/packages/chains/bitcoin/lib/BitcoinChain.ts +++ b/packages/chains/bitcoin/lib/BitcoinChain.ts @@ -710,7 +710,11 @@ class BitcoinChain extends AbstractUtxoChain { */ minimumMeaningfulSatoshi = async (): Promise => { const currentFeeRatio = await this.network.getFeeRatio(); - return BigInt(Math.ceil((currentFeeRatio * SEGWIT_INPUT_WEIGHT_UNIT) / 4)); + return BigInt( + Math.ceil( + (currentFeeRatio * SEGWIT_INPUT_WEIGHT_UNIT) / 4 // estimate fee per weight and convert to virtual size + ) + ); }; } diff --git a/packages/chains/bitcoin/lib/bitcoinUtils.ts b/packages/chains/bitcoin/lib/bitcoinUtils.ts index a956283..ebd5e2e 100644 --- a/packages/chains/bitcoin/lib/bitcoinUtils.ts +++ b/packages/chains/bitcoin/lib/bitcoinUtils.ts @@ -28,6 +28,9 @@ export const estimateTxFee = ( const inputsWeight = inputSize * SEGWIT_INPUT_WEIGHT_UNIT; const outputWeight = outputSize * SEGWIT_OUTPUT_WEIGHT_UNIT; return BigInt( - Math.ceil(((txBaseWeight + inputsWeight + outputWeight) / 4) * feeRatio) + Math.ceil( + ((txBaseWeight + inputsWeight + outputWeight) / 4) * // estimate tx weight and convert to virtual size + feeRatio + ) ); };