From f6da09b8cf830e0f0dba5c372c12e49057900da4 Mon Sep 17 00:00:00 2001 From: wjrjerome Date: Tue, 25 Jun 2024 19:50:35 +1000 Subject: [PATCH] feat: more accurate fee calculation --- .husky/pre-commit | 1 + package-lock.json | 4 +- package.json | 6 +- src/constants/fee.ts | 16 ++ src/index.ts | 46 +--- src/types/psbtOutputs.ts | 21 ++ src/utils/fee.ts | 117 --------- src/utils/fee/index.ts | 170 +++++++++++++ src/utils/fee/utils.ts | 65 +++++ src/utils/staking/index.ts | 45 ++++ tests/helper/index.ts | 124 ++++++---- tests/helper/math.ts | 27 +++ tests/stakingTransaction.test.ts | 332 +++++++++++++++----------- tests/utils/fee/stakingtxFee.test.ts | 220 +++++++++++++++++ tests/utils/fee/utils.test.ts | 125 ++++++++++ tests/utils/fee/withdrawTxFee.test.ts | 22 ++ 16 files changed, 1004 insertions(+), 337 deletions(-) create mode 100644 src/constants/fee.ts create mode 100644 src/types/psbtOutputs.ts delete mode 100644 src/utils/fee.ts create mode 100644 src/utils/fee/index.ts create mode 100644 src/utils/fee/utils.ts create mode 100644 src/utils/staking/index.ts create mode 100644 tests/helper/math.ts create mode 100644 tests/utils/fee/stakingtxFee.test.ts create mode 100644 tests/utils/fee/utils.test.ts create mode 100644 tests/utils/fee/withdrawTxFee.test.ts diff --git a/.husky/pre-commit b/.husky/pre-commit index 72c4429..c23db29 100644 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1 +1,2 @@ npm test +npm run build diff --git a/package-lock.json b/package-lock.json index 8683847..eeb6f88 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "btc-staking-ts", - "version": "0.2.4", + "version": "0.2.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "btc-staking-ts", - "version": "0.2.4", + "version": "0.2.5", "license": "SEE LICENSE IN LICENSE", "dependencies": { "@bitcoin-js/tiny-secp256k1-asmjs": "^2.2.3", diff --git a/package.json b/package.json index 207215e..b514191 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "btc-staking-ts", - "version": "0.2.4", + "version": "0.2.5", "description": "Library exposing methods for the creation and consumption of Bitcoin transactions pertaining to Babylon's Bitcoin Staking protocol. Experimental version, should not be used for production purposes or with real funds.", "module": "dist/index.js", "main": "dist/index.cjs", @@ -9,8 +9,8 @@ "scripts": { "generate-types": "dts-bundle-generator -o ./dist/index.d.ts ./src/index.ts", "build": "node build.js && npm run generate-types", - "format": "prettier --check \"src/**/*.ts\"", - "format:fix": "prettier --write \"src/**/*.ts\"", + "format": "prettier --check \"src/**/*.ts\" \"tests/**/*.ts\"", + "format:fix": "prettier --write \"src/**/*.ts\" \"tests/**/*.ts\"", "lint": "eslint ./src --fix", "prepare": "husky", "prepublishOnly": "npm run build", diff --git a/src/constants/fee.ts b/src/constants/fee.ts new file mode 100644 index 0000000..0590725 --- /dev/null +++ b/src/constants/fee.ts @@ -0,0 +1,16 @@ +// Estimated size of a non-SegWit input in bytes +export const DEFAULT_INPUT_SIZE = 180; +// Estimated size of a P2WPKH input in bytes +export const P2WPKH_INPUT_SIZE = 68; +// Estimated size of a P2TR input in bytes +export const P2TR_INPUT_SIZE = 58; +// Estimated size of a transaction buffer in bytes +export const TX_BUFFER_SIZE_OVERHEAD = 11; +// Buffer for estimation accuracy when fee rate <= 2 sat/byte +export const LOW_RATE_ESTIMATION_ACCURACY_BUFFER = 30; +// Size of a Taproot output, the largest non-legacy output type +export const MAX_NON_LEGACY_OUTPUT_SIZE = 43; +// Buffer size for withdraw transaction fee calculation +export const WITHDRAW_TX_BUFFER_SIZE = 17; +// Threshold for wallet relay fee rate. Different buffer fees are used based on this threshold +export const WALLET_RELAY_FEE_RATE_THRESHOLD = 2; diff --git a/src/index.ts b/src/index.ts index 43c445a..f455982 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,11 +7,9 @@ import { UTXO } from "./types/UTXO"; import { PsbtTransactionResult } from "./types/transaction"; import { isValidBitcoinAddress } from "./utils/address"; import { initBTCCurve } from "./utils/curve"; -import { - getEstimatedFee, - getStakingTxInputUTXOsAndFees, - inputValueSum, -} from "./utils/fee"; +import { getStakingTxInputUTXOsAndFees, getWithdrawTxFee } from "./utils/fee"; +import { inputValueSum } from "./utils/fee/utils"; +import { buildStakingOutput } from "./utils/staking"; import { PK_LENGTH, StakingScriptData } from "./utils/stakingScript"; export { StakingScriptData, initBTCCurve }; @@ -84,14 +82,14 @@ export function stakingTransaction( throw new Error("Invalid public key"); } - // Calculate the number of outputs based on the presence of the data embed script - // We have 2 outputs by default: staking output and change output - const numOutputs = scripts.dataEmbedScript ? 3 : 2; + // Build outputs and estimate the fee + const psbtOutputs = buildStakingOutput(scripts, network, amount); const { selectedUTXOs, fee } = getStakingTxInputUTXOsAndFees( + network, inputUTXOs, amount, feeRate, - numOutputs, + psbtOutputs, ); // Create a partially signed transaction @@ -112,33 +110,8 @@ export function stakingTransaction( }); } - const scriptTree: Taptree = [ - { - output: scripts.slashingScript, - }, - [{ output: scripts.unbondingScript }, { output: scripts.timelockScript }], - ]; - - // Create an pay-2-taproot (p2tr) output using the staking script - const stakingOutput = payments.p2tr({ - internalPubkey, - scriptTree, - network, - }); - // Add the staking output to the transaction - psbt.addOutput({ - address: stakingOutput.address!, - value: amount, - }); - - if (scripts.dataEmbedScript) { - // Add the data embed output to the transaction - psbt.addOutput({ - script: scripts.dataEmbedScript, - value: 0, - }); - } + psbt.addOutputs(psbtOutputs); // Add a change output only if there's any amount leftover from the inputs const inputsSum = inputValueSum(selectedUTXOs); @@ -362,8 +335,7 @@ function withdrawalTransaction( if (outputValue < BTC_DUST_SAT) { throw new Error("Output value is less than dust limit"); } - // withdraw tx always has 1 output only - const estimatedFee = getEstimatedFee(feeRate, psbt.txInputs.length, 1); + const estimatedFee = getWithdrawTxFee(feeRate); const value = tx.outs[outputIndex].value - estimatedFee; if (!value) { throw new Error( diff --git a/src/types/psbtOutputs.ts b/src/types/psbtOutputs.ts new file mode 100644 index 0000000..ff583d4 --- /dev/null +++ b/src/types/psbtOutputs.ts @@ -0,0 +1,21 @@ +import { PsbtOutput } from "bip174/src/lib/interfaces"; + +export type PsbtOutputExtended = + | PsbtOutputExtendedAddress + | PsbtOutputExtendedScript; + +interface PsbtOutputExtendedAddress extends PsbtOutput { + address: string; + value: number; +} + +interface PsbtOutputExtendedScript extends PsbtOutput { + script: Buffer; + value: number; +} + +export const isPsbtOutputExtendedAddress = ( + output: PsbtOutputExtended, +): output is PsbtOutputExtendedAddress => { + return (output as PsbtOutputExtendedAddress).address !== undefined; +}; diff --git a/src/utils/fee.ts b/src/utils/fee.ts deleted file mode 100644 index 5c77daa..0000000 --- a/src/utils/fee.ts +++ /dev/null @@ -1,117 +0,0 @@ -import { UTXO } from "../types/UTXO"; - -// Estimated size of a transaction input in bytes for fee calculation purpose only -export const INPUT_SIZE_FOR_FEE_CAL = 180; - -// Estimated size of a transaction output in bytes for fee calculation purpose only -export const OUTPUT_SIZE_FOR_FEE_CAL = 34; - -// Buffer size for a transaction in bytes for fee calculation purpose only -export const TX_BUFFER_SIZE_FOR_FEE_CAL = 10; - -// Estimated size of an OP_RETURN output in bytes for fee calculation purpose only -export const ESTIMATED_OP_RETURN_SIZE = 40; - -/** - * Calculates the estimated transaction fee using a heuristic formula. - * - * This method estimates the transaction fee based on the formula: - * `numInputs * 180 + numOutputs * 34 + 10 + numInputs` - * - * The formula provides an overestimated transaction size to ensure sufficient fees: - * - Each input is approximated to 180 bytes. - * - Each output is approximated to 34 bytes. - * - Adds 10 bytes as a buffer for the transaction. - * - Adds 40 bytes for an OP_RETURN output. - * - Adds the number of inputs to account for additional overhead. - * - * @param feeRate - The fee rate in satoshis per byte. - * @param numInputs - The number of inputs in the transaction. - * @param numOutputs - The number of outputs in the transaction. - * @returns The estimated transaction fee in satoshis. - */ -export const getEstimatedFee = ( - feeRate: number, - numInputs: number, - numOutputs: number, -): number => { - return ( - (numInputs * INPUT_SIZE_FOR_FEE_CAL + - numOutputs * OUTPUT_SIZE_FOR_FEE_CAL + - TX_BUFFER_SIZE_FOR_FEE_CAL + - numInputs + - ESTIMATED_OP_RETURN_SIZE) * - feeRate - ); -}; - -// inputValueSum returns the sum of the values of the UTXOs -export const inputValueSum = (inputUTXOs: UTXO[]): number => { - return inputUTXOs.reduce((acc, utxo) => acc + utxo.value, 0); -}; - -/** - * Selects UTXOs and calculates the fee for a staking transaction. - * - * This method selects the highest value UTXOs from all available UTXOs to - * cover the staking amount and the transaction fees. - * - * Inputs: - * - availableUTXOs: All available UTXOs from the wallet. - * - stakingAmount: Amount to stake. - * - feeRate: Fee rate for the transaction in satoshis per byte. - * - numOfOutputs: Number of outputs in the transaction. - * - * Returns: - * - selectedUTXOs: The UTXOs selected to cover the staking amount and fees. - * - fee: The total fee amount for the transaction. - * - * @param {UTXO[]} availableUTXOs - All available UTXOs from the wallet. - * @param {number} stakingAmount - The amount to stake. - * @param {number} feeRate - The fee rate in satoshis per byte. - * @param {number} numOfOutputs - The number of outputs in the transaction. - * @returns {PsbtTransactionResult} An object containing the selected UTXOs and the fee. - * @throws Will throw an error if there are insufficient funds or if the fee cannot be calculated. - */ -export const getStakingTxInputUTXOsAndFees = ( - availableUTXOs: UTXO[], - stakingAmount: number, - feeRate: number, - numOfOutputs: number, -): { - selectedUTXOs: UTXO[]; - fee: number; -} => { - if (availableUTXOs.length === 0) { - throw new Error("Insufficient funds"); - } - // Sort available UTXOs from highest to lowest value - availableUTXOs.sort((a, b) => b.value - a.value); - - const selectedUTXOs: UTXO[] = []; - let accumulatedValue = 0; - let estimatedFee; - - for (const utxo of availableUTXOs) { - selectedUTXOs.push(utxo); - accumulatedValue += utxo.value; - estimatedFee = getEstimatedFee(feeRate, selectedUTXOs.length, numOfOutputs); - if (accumulatedValue >= stakingAmount + estimatedFee) { - break; - } - } - if (!estimatedFee) { - throw new Error("Unable to calculate fee."); - } - - if (accumulatedValue < stakingAmount + estimatedFee) { - throw new Error( - "Insufficient funds: unable to gather enough UTXOs to cover the staking amount and fees.", - ); - } - - return { - selectedUTXOs, - fee: estimatedFee, - }; -}; diff --git a/src/utils/fee/index.ts b/src/utils/fee/index.ts new file mode 100644 index 0000000..5420ebc --- /dev/null +++ b/src/utils/fee/index.ts @@ -0,0 +1,170 @@ +import { Network, address, script as bitcoinScript } from "bitcoinjs-lib"; +import { BTC_DUST_SAT } from "../../constants/dustSat"; +import { + LOW_RATE_ESTIMATION_ACCURACY_BUFFER, + MAX_NON_LEGACY_OUTPUT_SIZE, + P2TR_INPUT_SIZE, + TX_BUFFER_SIZE_OVERHEAD, + WALLET_RELAY_FEE_RATE_THRESHOLD, + WITHDRAW_TX_BUFFER_SIZE, +} from "../../constants/fee"; +import { UTXO } from "../../types/UTXO"; +import { + PsbtOutputExtended, + isPsbtOutputExtendedAddress, +} from "../../types/psbtOutputs"; +import { + getEstimatedChangeOutputSize, + getInputSizeByScript, + isOP_RETURN, +} from "./utils"; + +/** + * Selects UTXOs and calculates the fee for a staking transaction. + * This method selects the highest value UTXOs from all available UTXOs to + * cover the staking amount and the transaction fees. + * The formula used is: + * + * totalFee = (inputSize + outputSize) * feeRate + buffer + * where outputSize may or may not include the change output size depending on the remaining value. + * + * @param network - The Bitcoin network. + * @param availableUTXOs - All available UTXOs from the wallet. + * @param stakingAmount - The amount to stake. + * @param feeRate - The fee rate in satoshis per byte. + * @param outputs - The outputs in the transaction. + * @returns An object containing the selected UTXOs and the fee. + * @throws Will throw an error if there are insufficient funds or if the fee cannot be calculated. + */ +export const getStakingTxInputUTXOsAndFees = ( + network: Network, + availableUTXOs: UTXO[], + stakingAmount: number, + feeRate: number, + outputs: PsbtOutputExtended[], +): { + selectedUTXOs: UTXO[]; + fee: number; +} => { + if (availableUTXOs.length === 0) { + throw new Error("Insufficient funds"); + } + // Sort available UTXOs from highest to lowest value + availableUTXOs.sort((a, b) => b.value - a.value); + + const selectedUTXOs: UTXO[] = []; + let accumulatedValue = 0; + let estimatedFee; + + for (const utxo of availableUTXOs) { + selectedUTXOs.push(utxo); + accumulatedValue += utxo.value; + + // Calculate the fee for the current set of UTXOs and outputs + const estimatedSize = getEstimatedSize(network, selectedUTXOs, outputs); + estimatedFee = estimatedSize * feeRate + rateBasedTxBufferFee(feeRate); + // Check if there will be any change left after the staking amount and fee. + // If there is, a change output needs to be added, which also comes with an additional fee. + if (accumulatedValue - (stakingAmount + estimatedFee) > BTC_DUST_SAT) { + estimatedFee += getEstimatedChangeOutputSize() * feeRate; + } + if (accumulatedValue >= stakingAmount + estimatedFee) { + break; + } + } + if (!estimatedFee) { + throw new Error("Unable to calculate fee"); + } + + if (accumulatedValue < stakingAmount + estimatedFee) { + throw new Error( + "Insufficient funds: unable to gather enough UTXOs to cover the staking amount and fees", + ); + } + + return { + selectedUTXOs, + fee: estimatedFee, + }; +}; + +/** + * Calculates the estimated fee for a withdrawal transaction. + * The fee calculation is based on estimated constants for input size, + * output size, and additional overhead specific to withdrawal transactions. + * Due to the slightly larger size of withdrawal transactions, an additional + * buffer is included to account for this difference. + * + * @param feeRate - The fee rate in satoshis per vbyte. + * @returns The estimated fee for a withdrawal transaction in satoshis. + */ +export const getWithdrawTxFee = (feeRate: number): number => { + const inputSize = P2TR_INPUT_SIZE; + const outputSize = getEstimatedChangeOutputSize(); + return feeRate * ( + inputSize + outputSize + TX_BUFFER_SIZE_OVERHEAD + WITHDRAW_TX_BUFFER_SIZE + ) + rateBasedTxBufferFee(feeRate); +}; + +/** + * Calculates the estimated transaction size using a heuristic formula which + * includes the input size, output size, and a fixexd buffer for the transaction size. + * The formula used is: + * + * totalSize = inputSize + outputSize + TX_BUFFER_SIZE_OVERHEAD + * + * @param network - The Bitcoin network being used. + * @param inputUtxos - The UTXOs used as inputs in the transaction. + * @param outputs - The outputs in the transaction. + * @returns The estimated transaction size in bytes. + */ +const getEstimatedSize = ( + network: Network, + inputUtxos: UTXO[], + outputs: PsbtOutputExtended[], +): number => { + // Estimate the input size + const inputSize = inputUtxos.reduce((acc: number, u: UTXO): number => { + const script = Buffer.from(u.scriptPubKey, "hex"); + const decompiledScript = bitcoinScript.decompile(script); + if (!decompiledScript) { + throw new Error( + "Failed to decompile script when estimating fees for inputs", + ); + } + return acc + getInputSizeByScript(script); + }, 0); + + // Estimate the output size + const outputSize = outputs.reduce((acc, output): number => { + const script = isPsbtOutputExtendedAddress(output) + ? address.toOutputScript(output.address, network) + : output.script; + if (isOP_RETURN(script)) { + return acc + script.length; + } + return acc + MAX_NON_LEGACY_OUTPUT_SIZE; + }, 0); + + return inputSize + outputSize + TX_BUFFER_SIZE_OVERHEAD; +}; + +/** + * Adds a buffer to the transaction size-based fee calculation if the fee rate is low. + * Some wallets have a relayer fee requirement, which means if the fee rate is + * less than or equal to WALLET_RELAY_FEE_RATE_THRESHOLD (2 satoshis per byte), + * there is a risk that the fee might not be sufficient to get the transaction relayed. + * To mitigate this risk, we add a buffer to the fee calculation to ensure that + * the transaction can be relayed. + * + * If the fee rate is less than or equal to WALLET_RELAY_FEE_RATE_THRESHOLD, a fixed buffer is added + * (LOW_RATE_ESTIMATION_ACCURACY_BUFFER). If the fee rate is higher, no buffer is added. + * + * @param feeRate - The fee rate in satoshis per byte. + * @returns The buffer amount in satoshis to be added to the transaction fee. + */ +const rateBasedTxBufferFee = (feeRate: number): number => { + return feeRate <= WALLET_RELAY_FEE_RATE_THRESHOLD + ? LOW_RATE_ESTIMATION_ACCURACY_BUFFER + : 0; +}; \ No newline at end of file diff --git a/src/utils/fee/utils.ts b/src/utils/fee/utils.ts new file mode 100644 index 0000000..bbc8b66 --- /dev/null +++ b/src/utils/fee/utils.ts @@ -0,0 +1,65 @@ +import { script as bitcoinScript, opcodes, payments } from "bitcoinjs-lib"; +import { + DEFAULT_INPUT_SIZE, + MAX_NON_LEGACY_OUTPUT_SIZE, + P2TR_INPUT_SIZE, + P2WPKH_INPUT_SIZE, +} from "../../constants/fee"; +import { UTXO } from "../../types/UTXO"; + +// Helper function to check if a script is OP_RETURN +export const isOP_RETURN = (script: Buffer): boolean => { + const decompiled = bitcoinScript.decompile(script); + return !!decompiled && decompiled[0] === opcodes.OP_RETURN; +}; + +/** + * Determines the size of a transaction input based on its script type. + * + * @param script - The script of the input. + * @returns The estimated size of the input in bytes. + */ +export const getInputSizeByScript = (script: Buffer): number => { + // Check if input is in the format of "00 <20-byte public key hash>" + // If yes, it is a P2WPKH input + try { + const { address: p2wpkhAddress } = payments.p2wpkh({ + output: script, + }); + if (p2wpkhAddress) { + return P2WPKH_INPUT_SIZE; + } + } catch (error) {} // Ignore errors + // Check if input is in the format of "51 <32-byte public key>" + // If yes, it is a P2TR input + try { + const { address: p2trAddress } = payments.p2tr({ + output: script, + }); + if (p2trAddress) { + return P2TR_INPUT_SIZE; + } + } catch (error) {} // Ignore errors + // Otherwise, assume the input is largest P2PKH address type + return DEFAULT_INPUT_SIZE; +}; + +/** + * Returns the estimated size for a change output. + * This is used when the transaction has a change output to a particular address. + * + * @returns The estimated size for a change output in bytes. + */ +export const getEstimatedChangeOutputSize = (): number => { + return MAX_NON_LEGACY_OUTPUT_SIZE; +}; + +/** + * Returns the sum of the values of the UTXOs. + * + * @param inputUTXOs - The UTXOs to sum the values of. + * @returns The sum of the values of the UTXOs in satoshis. + */ +export const inputValueSum = (inputUTXOs: UTXO[]): number => { + return inputUTXOs.reduce((acc, utxo) => acc + utxo.value, 0); +}; diff --git a/src/utils/staking/index.ts b/src/utils/staking/index.ts new file mode 100644 index 0000000..709e9ff --- /dev/null +++ b/src/utils/staking/index.ts @@ -0,0 +1,45 @@ +import { networks, payments } from "bitcoinjs-lib"; +import { Taptree } from "bitcoinjs-lib/src/types"; +import { internalPubkey } from "../../constants/internalPubkey"; +import { PsbtOutputExtended } from "../../types/psbtOutputs"; + +export const buildStakingOutput = ( + scripts: { + timelockScript: Buffer; + unbondingScript: Buffer; + slashingScript: Buffer; + dataEmbedScript?: Buffer; + }, + network: networks.Network, + amount: number, +) => { + // Build outputs + const scriptTree: Taptree = [ + { + output: scripts.slashingScript, + }, + [{ output: scripts.unbondingScript }, { output: scripts.timelockScript }], + ]; + + // Create an pay-2-taproot (p2tr) output using the staking script + const stakingOutput = payments.p2tr({ + internalPubkey, + scriptTree, + network, + }); + + const psbtOutputs: PsbtOutputExtended[] = [ + { + address: stakingOutput.address!, + value: amount, + }, + ]; + if (scripts.dataEmbedScript) { + // Add the data embed output to the transaction + psbtOutputs.push({ + script: scripts.dataEmbedScript, + value: 0, + }); + } + return psbtOutputs; +}; diff --git a/tests/helper/index.ts b/tests/helper/index.ts index 64e16c5..5152707 100644 --- a/tests/helper/index.ts +++ b/tests/helper/index.ts @@ -1,12 +1,16 @@ -import ECPairFactory from "ecpair"; import * as ecc from "@bitcoin-js/tiny-secp256k1-asmjs"; -import * as bitcoin from 'bitcoinjs-lib'; -import { StakingScripts } from "../../src/types/StakingScripts"; +import * as bitcoin from "bitcoinjs-lib"; +import ECPairFactory from "ecpair"; import { StakingScriptData } from "../../src"; +import { StakingScripts } from "../../src/types/StakingScripts"; import { UTXO } from "../../src/types/UTXO"; +import { generateRandomAmountSlices } from "./math"; +bitcoin.initEccLib(ecc); const ECPair = ECPairFactory(ecc); +export const DEFAULT_TEST_FEE_RATE = 15; + export class DataGenerator { private network: bitcoin.networks.Network; @@ -85,34 +89,11 @@ export class DataGenerator { }; }; - getTaprootAddress = (publicKey: string) => { - // Remove the prefix if it exists - if (publicKey.length == 66) { - publicKey = publicKey.slice(2); - } - const internalPubkey = Buffer.from(publicKey, "hex"); - const { address } = bitcoin.payments.p2tr({ - internalPubkey, - network: this.network, - }); - if (!address) { - throw new Error("Failed to generate taproot address from public key"); - } - return address; - }; - - getNativeSegwitAddress = (publicKey: string) => { - const internalPubkey = Buffer.from(publicKey, "hex"); - const { address } = bitcoin.payments.p2wpkh({ - pubkey: internalPubkey, - network: this.network, - }); - if (!address) { - throw new Error( - "Failed to generate native segwit address from public key", - ); - } - return address; + getAddressAndScriptPubKey = (publicKey: string) => { + return { + taproot: this.getTaprootAddress(publicKey), + nativeSegwit: this.getNativeSegwitAddress(publicKey), + }; }; getNetwork = () => { @@ -162,23 +143,78 @@ export class DataGenerator { }; generateRandomUTXOs = ( - minAvailableBalance: number, + balance: number, numberOfUTXOs: number, ): UTXO[] => { - const utxos = []; - let sum = 0; - for (let i = 0; i < numberOfUTXOs; i++) { - utxos.push({ + const slices = generateRandomAmountSlices(balance, numberOfUTXOs); + return slices.map((v) => { + const { taproot, nativeSegwit } = this.getAddressAndScriptPubKey( + this.generateRandomKeyPair().publicKey, + ); + // Randomly select either taproot or nativeSegwit for scriptPubKey + const selectedScriptPubKey = + Math.random() < 0.5 ? taproot.scriptPubKey : nativeSegwit.scriptPubKey; + return { txid: this.generateRandomTxId(), vout: Math.floor(Math.random() * 10), - scriptPubKey: this.generateRandomKeyPair().publicKey, - value: Math.floor(Math.random() * 9000) + minAvailableBalance, - }); - sum += utxos[i].value; - if (sum >= minAvailableBalance) { - break; - } + scriptPubKey: selectedScriptPubKey, + value: v, + }; + }); + }; + + private getTaprootAddress = (publicKey: string) => { + // Remove the prefix if it exists + if (publicKey.length == 66) { + publicKey = publicKey.slice(2); + } + const internalPubkey = Buffer.from(publicKey, "hex"); + const { address, output: scriptPubKey } = bitcoin.payments.p2tr({ + internalPubkey, + network: this.network, + }); + if (!address || !scriptPubKey) { + throw new Error( + "Failed to generate taproot address or script from public key", + ); } - return utxos; + return { + address, + scriptPubKey: scriptPubKey.toString("hex"), + }; + }; + + private getNativeSegwitAddress = (publicKey: string) => { + // check the public key length is 66, otherwise throw + if (publicKey.length !== 66) { + throw new Error("Invalid public key length for generating native segwit address"); + } + const internalPubkey = Buffer.from(publicKey, "hex"); + const { address, output: scriptPubKey } = bitcoin.payments.p2wpkh({ + pubkey: internalPubkey, + network: this.network, + }); + if (!address || !scriptPubKey) { + throw new Error( + "Failed to generate native segwit address or script from public key", + ); + } + return { + address, + scriptPubKey: scriptPubKey.toString("hex"), + }; }; } + +export const testingNetworks = [ + { + networkName: "mainnet", + network: bitcoin.networks.bitcoin, + dataGenerator: new DataGenerator(bitcoin.networks.bitcoin), + }, + { + networkName: "testnet", + network: bitcoin.networks.testnet, + dataGenerator: new DataGenerator(bitcoin.networks.testnet), + }, +]; diff --git a/tests/helper/math.ts b/tests/helper/math.ts new file mode 100644 index 0000000..8e9e0a6 --- /dev/null +++ b/tests/helper/math.ts @@ -0,0 +1,27 @@ +/** + * Generates an array of random integers for each slice that sum up to the total amount. + * + * @param totalAmount - The total amount to be distributed across the slices (must be an integer). + * @param numOfSlices - The number of slices (must be an integer). + * @returns An array of integers representing the amount for each slice. + */ +export const generateRandomAmountSlices = (totalAmount: number, numOfSlices: number): number[] => { + if (numOfSlices <= 0) { + throw new Error("Number of slices must be greater than zero."); + } + + const amounts: number[] = []; + let remainingAmount = totalAmount; + + for (let i = 0; i < numOfSlices - 1; i++) { + const max = Math.floor(remainingAmount / (numOfSlices - i)); + const amount = Math.floor(Math.random() * max); + amounts.push(amount); + remainingAmount -= amount; + } + + // Push the remaining amount as the last slice amount + amounts.push(remainingAmount); + + return amounts; +} \ No newline at end of file diff --git a/tests/stakingTransaction.test.ts b/tests/stakingTransaction.test.ts index 2620e05..574d8ec 100644 --- a/tests/stakingTransaction.test.ts +++ b/tests/stakingTransaction.test.ts @@ -1,27 +1,12 @@ -import { networks } from "bitcoinjs-lib"; -import { initBTCCurve, stakingTransaction } from "../src/index"; -import { getStakingTxInputUTXOsAndFees } from "../src/utils/fee"; import { BTC_DUST_SAT } from "../src/constants/dustSat"; -import { PsbtTransactionResult } from "../src/types/transaction"; +import { stakingTransaction } from "../src/index"; import { StakingScripts } from "../src/types/StakingScripts"; -import { DataGenerator } from "./helper"; - +import { PsbtTransactionResult } from "../src/types/transaction"; +import { getStakingTxInputUTXOsAndFees } from "../src/utils/fee"; +import { buildStakingOutput } from "../src/utils/staking"; +import { DEFAULT_TEST_FEE_RATE, testingNetworks } from "./helper"; describe("stakingTransaction", () => { - beforeAll(() => { - initBTCCurve(); - }); - const testingNetworks = [{ - networkName: "mainnet", - network: networks.bitcoin, - dataGenerator: new DataGenerator(networks.bitcoin), - }, { - networkName: "testnet", - network: networks.testnet, - dataGenerator: new DataGenerator(networks.testnet) - }]; - - describe("Cross env error", () => { const [mainnet, testnet] = testingNetworks; const mainnetDataGenerator = mainnet.dataGenerator; @@ -29,11 +14,12 @@ describe("stakingTransaction", () => { const randomAmount = Math.floor(Math.random() * 100000000) + 1000; it("should throw an error if the testnet inputs are used on mainnet", () => { - const randomChangeAddress = testnetDataGenerator.getNativeSegwitAddress( - mainnetDataGenerator.generateRandomKeyPair().publicKey, - ); + const randomChangeAddress = + testnetDataGenerator.getAddressAndScriptPubKey( + mainnetDataGenerator.generateRandomKeyPair().publicKey, + ).nativeSegwit.address; const utxos = testnetDataGenerator.generateRandomUTXOs( - Math.floor(Math.random() * 1000000) + randomAmount, + randomAmount + 1000000, Math.floor(Math.random() * 10) + 1, ); expect(() => @@ -53,11 +39,12 @@ describe("stakingTransaction", () => { }); it("should throw an error if the mainnet inputs are used on testnet", () => { - const randomChangeAddress = mainnetDataGenerator.getNativeSegwitAddress( - mainnetDataGenerator.generateRandomKeyPair().publicKey, - ); + const randomChangeAddress = + mainnetDataGenerator.getAddressAndScriptPubKey( + mainnetDataGenerator.generateRandomKeyPair().publicKey, + ).nativeSegwit.address; const utxos = mainnetDataGenerator.generateRandomUTXOs( - Math.floor(Math.random() * 1000000) + randomAmount, + randomAmount + 1000000, Math.floor(Math.random() * 10) + 1, ); expect(() => @@ -79,30 +66,25 @@ describe("stakingTransaction", () => { testingNetworks.map(({ networkName, network, dataGenerator }) => { const mockScripts = dataGenerator.generateMockStakingScripts(); - // for easier calculation, we set the fee rate to 1. The dynamic fee rate is tested in the other tests - const feeRate = 1 + const feeRate = DEFAULT_TEST_FEE_RATE; const randomAmount = Math.floor(Math.random() * 100000000) + 1000; // Create enough utxos to cover the amount const utxos = dataGenerator.generateRandomUTXOs( - Math.floor(Math.random() * 1000000) + randomAmount, + randomAmount + 1000000, // let's give enough satoshis to cover the fee Math.floor(Math.random() * 10) + 1, ); - const maxNumOfOutputs = 3; - // A rough estimating of the fee, the end result should not be too far from this - const { fee: estimatedFee } = getStakingTxInputUTXOsAndFees(utxos, randomAmount, feeRate, maxNumOfOutputs); - const changeAddress = dataGenerator.getNativeSegwitAddress( - dataGenerator.generateRandomKeyPair().publicKey, - ); - describe("Error path", () => {{ + describe("Error path", () => { + const randomChangeAddress = dataGenerator.getAddressAndScriptPubKey( + dataGenerator.generateRandomKeyPair().publicKey, + ).taproot.address; + it(`${networkName} - should throw an error if the public key is invalid`, () => { const invalidPublicKey = Buffer.from("invalidPublicKey", "hex"); expect(() => stakingTransaction( mockScripts, randomAmount, - dataGenerator.getNativeSegwitAddress( - dataGenerator.generateRandomKeyPair().publicKey, - ), + randomChangeAddress, utxos, network, feeRate, @@ -112,13 +94,19 @@ describe("stakingTransaction", () => { }); it(`${networkName} - should throw an error if the change address is invalid`, () => { - const validAddress = dataGenerator.getNativeSegwitAddress( + const validAddress = dataGenerator.getAddressAndScriptPubKey( dataGenerator.generateRandomKeyPair().publicKey, - ); - const invalidCharInAddress = validAddress.replace(validAddress[0], "I") // I is an invalid character in base58 - const invalidAddressLegnth = validAddress.slice(0, -1) - const invalidAddresses = ["" , " ", "banana", invalidCharInAddress, invalidAddressLegnth] - invalidAddresses.map(a => { + ).taproot.address; + const invalidCharInAddress = validAddress.replace(validAddress[0], "I"); // I is an invalid character in base58 + const invalidAddressLegnth = validAddress.slice(0, -1); + const invalidAddresses = [ + "", + " ", + "banana", + invalidCharInAddress, + invalidAddressLegnth, + ]; + invalidAddresses.map((a) => { expect(() => stakingTransaction( mockScripts, @@ -134,24 +122,46 @@ describe("stakingTransaction", () => { it(`${networkName} - should throw an error if the utxo value is too low`, () => { // generate a UTXO that is too small to cover the fee + const scriptPubKey = dataGenerator.getAddressAndScriptPubKey( + dataGenerator.generateRandomKeyPair().publicKey, + ).taproot.scriptPubKey; const utxo = { txid: dataGenerator.generateRandomTxId(), vout: Math.floor(Math.random() * 10), - scriptPubKey: dataGenerator.generateRandomKeyPair().publicKey, + scriptPubKey: scriptPubKey, value: 1, - } + }; expect(() => stakingTransaction( mockScripts, randomAmount, - dataGenerator.getNativeSegwitAddress( - dataGenerator.generateRandomKeyPair().publicKey, - ), + randomChangeAddress, [utxo], network, 1, ), - ).toThrow("Insufficient funds: unable to gather enough UTXOs to cover the staking amount and fees."); + ).toThrow( + "Insufficient funds: unable to gather enough UTXOs to cover the staking amount and fees", + ); + }); + + it(`${networkName} - should throw an error if the utxo scriptPubKey is invalid`, () => { + const utxo = { + txid: dataGenerator.generateRandomTxId(), + vout: Math.floor(Math.random() * 10), + scriptPubKey: `abc${dataGenerator.generateRandomKeyPair().publicKey}`, // this is not a valid scriptPubKey + value: 10000000000000, + }; + expect(() => + stakingTransaction( + mockScripts, + randomAmount, + randomChangeAddress, + [utxo], + network, + 1, + ), + ).toThrow("Failed to decompile script when estimating fees for inputs"); }); it(`${networkName} - should throw an error if UTXO is empty`, () => { @@ -159,9 +169,7 @@ describe("stakingTransaction", () => { stakingTransaction( mockScripts, randomAmount, - dataGenerator.getNativeSegwitAddress( - dataGenerator.generateRandomKeyPair().publicKey, - ), + randomChangeAddress, [], network, 1, @@ -176,9 +184,7 @@ describe("stakingTransaction", () => { stakingTransaction( mockScripts, randomAmount, - dataGenerator.getNativeSegwitAddress( - dataGenerator.generateRandomKeyPair().publicKey, - ), + randomChangeAddress, utxos, network, feeRate, @@ -194,139 +200,197 @@ describe("stakingTransaction", () => { stakingTransaction( mockScripts, 0, // Invalid amount - dataGenerator.getNativeSegwitAddress( - dataGenerator.generateRandomKeyPair().publicKey, - ), + randomChangeAddress, utxos, network, dataGenerator.generateRandomFeeRates(), // Valid fee rate ), ).toThrow("Amount and fee rate must be bigger than 0"); - + // Test case: amount is -1 expect(() => stakingTransaction( mockScripts, -1, // Invalid amount - dataGenerator.getNativeSegwitAddress( - dataGenerator.generateRandomKeyPair().publicKey, - ), + randomChangeAddress, utxos, network, dataGenerator.generateRandomFeeRates(), // Valid fee rate ), ).toThrow("Amount and fee rate must be bigger than 0"); }); - - + it("should throw an error if the fee rate is less than or equal to 0", () => { // Test case: fee rate is 0 expect(() => stakingTransaction( mockScripts, randomAmount, - dataGenerator.getNativeSegwitAddress( - dataGenerator.generateRandomKeyPair().publicKey, - ), + randomChangeAddress, utxos, network, 0, // Invalid fee rate ), ).toThrow("Amount and fee rate must be bigger than 0"); - + // Test case: fee rate is -1 expect(() => stakingTransaction( mockScripts, randomAmount, - dataGenerator.getNativeSegwitAddress( - dataGenerator.generateRandomKeyPair().publicKey, - ), + randomChangeAddress, utxos, network, -1, // Invalid fee rate ), ).toThrow("Amount and fee rate must be bigger than 0"); }); - }}); - describe("Happy path", () => { - it(`${networkName} - should return a valid psbt result`, () => { - const psbtResult = stakingTransaction( - mockScripts, - randomAmount, - changeAddress, - utxos, + describe("Happy path", () => { + // build the outputs + const outputs = buildStakingOutput(mockScripts, network, randomAmount); + // A rough estimating of the fee, the end result should not be too far from this + const { fee: estimatedFee } = getStakingTxInputUTXOsAndFees( network, - feeRate, - ) - validateCommonFields(psbtResult, randomAmount, estimatedFee, changeAddress, mockScripts) - }); - - it(`${networkName} - should return a valid psbt result with tapInternalKey`, () => { - const psbtResult = stakingTransaction( - mockScripts, - randomAmount, - changeAddress, utxos, - network, - feeRate, - Buffer.from( - dataGenerator.generateRandomKeyPair(true).publicKey, - "hex", - ), - ) - validateCommonFields(psbtResult, randomAmount, estimatedFee, changeAddress, mockScripts) - }); - - it(`${networkName} - should return a valid psbt result with lock field`, () => { - const lockHeight = Math.floor(Math.random() * 1000000) + 100; - const psbtResult = stakingTransaction( - mockScripts, randomAmount, - changeAddress, - utxos, - network, feeRate, - Buffer.from( - dataGenerator.generateRandomKeyPair(true).publicKey, - "hex", - ), - lockHeight, - ) - validateCommonFields(psbtResult, randomAmount, estimatedFee, changeAddress, mockScripts) - // check the lock height is correct - expect(psbtResult.psbt.locktime).toEqual(lockHeight) + outputs, + ); + const { taproot, nativeSegwit } = + dataGenerator.getAddressAndScriptPubKey( + dataGenerator.generateRandomKeyPair().publicKey, + ); + it(`${networkName} - should return a valid psbt result`, () => { + const psbtResultTaproot = stakingTransaction( + mockScripts, + randomAmount, + taproot.address, + utxos, + network, + feeRate, + ); + validateCommonFields( + psbtResultTaproot, + randomAmount, + estimatedFee, + taproot.address, + mockScripts, + ); + + const psbtResultNativeSegwit = stakingTransaction( + mockScripts, + randomAmount, + nativeSegwit.address, + utxos, + network, + feeRate, + ); + validateCommonFields( + psbtResultNativeSegwit, + randomAmount, + estimatedFee, + nativeSegwit.address, + mockScripts, + ); + }); + + it(`${networkName} - should return a valid psbt result with tapInternalKey`, () => { + const psbtResult = stakingTransaction( + mockScripts, + randomAmount, + taproot.address, + utxos, + network, + feeRate, + Buffer.from( + dataGenerator.generateRandomKeyPair(true).publicKey, + "hex", + ), + ); + validateCommonFields( + psbtResult, + randomAmount, + estimatedFee, + taproot.address, + mockScripts, + ); + }); + + it(`${networkName} - should return a valid psbt result with lock field`, () => { + const lockHeight = Math.floor(Math.random() * 1000000) + 100; + const psbtResult = stakingTransaction( + mockScripts, + randomAmount, + taproot.address, + utxos, + network, + feeRate, + Buffer.from( + dataGenerator.generateRandomKeyPair(true).publicKey, + "hex", + ), + lockHeight, + ); + validateCommonFields( + psbtResult, + randomAmount, + estimatedFee, + taproot.address, + mockScripts, + ); + // check the lock height is correct + expect(psbtResult.psbt.locktime).toEqual(lockHeight); + }); }); - }); + }); }); }); const validateCommonFields = ( - psbtResult: PsbtTransactionResult, randomAmount: number, estimatedFee: number, - changeAddress: string, mockScripts: StakingScripts, + psbtResult: PsbtTransactionResult, + randomAmount: number, + estimatedFee: number, + changeAddress: string, + mockScripts: StakingScripts, ) => { expect(psbtResult).toBeDefined(); - // Expect not be too far from the estimated fee - expect(Math.abs(psbtResult.fee-estimatedFee)).toBeLessThan(1000) + // expect the estimated fee and the actual fee is the same + expect(psbtResult.fee).toBe(estimatedFee); // make sure the input amount is greater than the output amount - const { psbt, fee} = psbtResult; - const inputAmount = psbt.data.inputs.reduce((sum, input) => sum + input.witnessUtxo!.value, 0); - const outputAmount = psbt.txOutputs.reduce((sum, output) => sum + output.value, 0); - expect(inputAmount).toBeGreaterThan(outputAmount) - expect(inputAmount - outputAmount).toEqual(fee) + const { psbt, fee } = psbtResult; + const inputAmount = psbt.data.inputs.reduce( + (sum, input) => sum + input.witnessUtxo!.value, + 0, + ); + const outputAmount = psbt.txOutputs.reduce( + (sum, output) => sum + output.value, + 0, + ); + expect(inputAmount).toBeGreaterThan(outputAmount); + expect(inputAmount - outputAmount - fee).toBeLessThan(BTC_DUST_SAT); // check the change amount is correct and send to the correct address if (inputAmount - (randomAmount + fee) > BTC_DUST_SAT) { const expectedChangeAmount = inputAmount - (randomAmount + fee); - const changeOutput = psbt.txOutputs.find(output => output.value === expectedChangeAmount); + const changeOutput = psbt.txOutputs.find( + (output) => output.value === expectedChangeAmount, + ); expect(changeOutput).toBeDefined(); // also make sure the change address is correct by look up the `address` - expect(psbt.txOutputs.find(output => output.address === changeAddress)).toBeDefined(); + expect( + psbt.txOutputs.find((output) => output.address === changeAddress), + ).toBeDefined(); } // check data embed output added to the transaction - expect(psbt.txOutputs.find(output => output.script.equals(mockScripts.dataEmbedScript))).toBeDefined(); + expect( + psbt.txOutputs.find((output) => + output.script.equals(mockScripts.dataEmbedScript), + ), + ).toBeDefined(); // Check the staking amount is correct - expect(psbt.txOutputs.find(output => output.value === randomAmount)).toBeDefined(); -} \ No newline at end of file + expect( + psbt.txOutputs.find((output) => output.value === randomAmount), + ).toBeDefined(); +}; diff --git a/tests/utils/fee/stakingtxFee.test.ts b/tests/utils/fee/stakingtxFee.test.ts new file mode 100644 index 0000000..435fef8 --- /dev/null +++ b/tests/utils/fee/stakingtxFee.test.ts @@ -0,0 +1,220 @@ +import { UTXO } from "../../../src/types/UTXO"; +import { PsbtOutputExtended } from "../../../src/types/psbtOutputs"; +import { getStakingTxInputUTXOsAndFees } from "../../../src/utils/fee"; +import { buildStakingOutput } from "../../../src/utils/staking"; +import { DEFAULT_TEST_FEE_RATE, testingNetworks } from "../../helper"; + +testingNetworks.forEach(({ networkName, network, dataGenerator }) => { + describe(`${networkName} - getStakingTxInputUTXOsAndFees`, () => { + const mockScripts = dataGenerator.generateMockStakingScripts(); + const feeRate = DEFAULT_TEST_FEE_RATE; + const randomAmount = Math.floor(Math.random() * 100000000) + 1000; + + it("should throw an error if there are no available UTXOs", () => { + const availableUTXOs: UTXO[] = []; + const outputs: PsbtOutputExtended[] = []; + expect(() => + getStakingTxInputUTXOsAndFees( + network, + availableUTXOs, + randomAmount, + feeRate, + outputs, + ), + ).toThrow("Insufficient funds"); + }); + + it("should throw if total utxos value can not cover the staking value + fee", () => { + const availableUTXOs: UTXO[] = dataGenerator.generateRandomUTXOs( + randomAmount + 1, + Math.floor(Math.random() * 10) + 1, + ); + const outputs = buildStakingOutput(mockScripts, network, randomAmount); + expect(() => + getStakingTxInputUTXOsAndFees( + network, + availableUTXOs, + randomAmount, + feeRate, + outputs, + ), + ).toThrow("Insufficient funds: unable to gather enough UTXOs to cover the staking amount and fees"); + }); + + it("should successfully select the correct UTXOs and calculate the fee", () => { + const availableUTXOs: UTXO[] = dataGenerator.generateRandomUTXOs( + randomAmount + 10000000, // give enough satoshis to cover the fee + Math.floor(Math.random() * 10) + 1, + ); + const outputs = buildStakingOutput(mockScripts, network, randomAmount); + + const result = getStakingTxInputUTXOsAndFees( + network, + availableUTXOs, + randomAmount, + feeRate, + outputs, + ); + // Ensure the correct UTXOs are selected + expect(result.selectedUTXOs.length).toBeLessThanOrEqual( + availableUTXOs.length, + ); + // Ensure the highest value UTXOs are selected + availableUTXOs.sort((a, b) => b.value - a.value); + expect(result.selectedUTXOs).toEqual( + availableUTXOs.slice(0, result.selectedUTXOs.length), + ); + expect(result.fee).toBeGreaterThan(0); + }); + + it("should successfully return the accurate fee for taproot input", () => { + const stakeAmount = 2000; + const { taproot } = dataGenerator.getAddressAndScriptPubKey( + dataGenerator.generateRandomKeyPair().publicKey, + ); + const availableUTXOs = [ + { + txid: dataGenerator.generateRandomTxId(), + vout: Math.floor(Math.random() * 10), + scriptPubKey: taproot.scriptPubKey, + value: 1000, + }, + { + txid: dataGenerator.generateRandomTxId(), + vout: Math.floor(Math.random() * 10), + scriptPubKey: taproot.scriptPubKey, + value: 2000, + }, + ]; + + const outputs = buildStakingOutput(mockScripts, network, stakeAmount); + // Manually setting fee rate less than 2 so that the fee calculation included ESTIMATION_ACCUARACY_BUFFER + let result = getStakingTxInputUTXOsAndFees( + network, + availableUTXOs, + stakeAmount, + 1, + outputs, + ); + expect(result.fee).toBe(316); // This number is calculated manually + expect(result.selectedUTXOs.length).toEqual(2); + + result = getStakingTxInputUTXOsAndFees( + network, + availableUTXOs, + stakeAmount, + 2, + outputs, + ); + expect(result.fee).toBe(516); // This number is calculated manually + expect(result.selectedUTXOs.length).toEqual(2); + + // Once fee rate is set to 3, the fee will be calculated with addition of TX_BUFFER_SIZE_OVERHEAD * feeRate + result = getStakingTxInputUTXOsAndFees( + network, + availableUTXOs, + stakeAmount, + 3, + outputs, + ); + expect(result.fee).toBe(729); // This number is calculated manually + expect(result.selectedUTXOs.length).toEqual(2); + }); + + it("should successfully return the accurate fee for native segwit input", () => { + const stakeAmount = 2000; + const { nativeSegwit } = dataGenerator.getAddressAndScriptPubKey( + dataGenerator.generateRandomKeyPair().publicKey, + ); + const availableUTXOs = [ + { + txid: dataGenerator.generateRandomTxId(), + vout: Math.floor(Math.random() * 10), + scriptPubKey: nativeSegwit.scriptPubKey, + value: 1000, + }, + { + txid: dataGenerator.generateRandomTxId(), + vout: Math.floor(Math.random() * 10), + scriptPubKey: nativeSegwit.scriptPubKey, + value: 2000, + }, + ]; + + const outputs = buildStakingOutput(mockScripts, network, stakeAmount); + let result = getStakingTxInputUTXOsAndFees( + network, + availableUTXOs, + stakeAmount, + 1, + outputs, + ); + expect(result.fee).toBe(336); // This number is calculated manually + expect(result.selectedUTXOs.length).toEqual(2); + }); + + it("should successfully return the accurate fee without change", () => { + const stakeAmount = 2000; + const { nativeSegwit } = dataGenerator.getAddressAndScriptPubKey( + dataGenerator.generateRandomKeyPair().publicKey, + ); + const availableUTXOs = [ + { + txid: dataGenerator.generateRandomTxId(), + vout: Math.floor(Math.random() * 10), + scriptPubKey: nativeSegwit.scriptPubKey, + value: 1000, + }, + { + txid: dataGenerator.generateRandomTxId(), + vout: Math.floor(Math.random() * 10), + scriptPubKey: nativeSegwit.scriptPubKey, + value: 1293, + }, + ]; + + const outputs = buildStakingOutput(mockScripts, network, stakeAmount); + let result = getStakingTxInputUTXOsAndFees( + network, + availableUTXOs, + stakeAmount, + 1, + outputs, + ); + expect(result.fee).toBe(293); // This is the fee for 2 inputs and 2 outputs without change + expect(result.selectedUTXOs.length).toEqual(2); + }); + + it("should successfully return the accurate fee utilising only one of the UTXOs", () => { + const stakeAmount = 2000; + const { nativeSegwit } = dataGenerator.getAddressAndScriptPubKey( + dataGenerator.generateRandomKeyPair().publicKey, + ); + const availableUTXOs = [ + { + txid: dataGenerator.generateRandomTxId(), + vout: Math.floor(Math.random() * 10), + scriptPubKey: nativeSegwit.scriptPubKey, + value: 1000, + }, + { + txid: dataGenerator.generateRandomTxId(), + vout: Math.floor(Math.random() * 10), + scriptPubKey: nativeSegwit.scriptPubKey, + value: 2500, + }, + ]; + + const outputs = buildStakingOutput(mockScripts, network, stakeAmount); + let result = getStakingTxInputUTXOsAndFees( + network, + availableUTXOs, + stakeAmount, + 1, + outputs, + ); + expect(result.fee).toBe(225); + expect(result.selectedUTXOs.length).toEqual(1); + }); + }); +}); diff --git a/tests/utils/fee/utils.test.ts b/tests/utils/fee/utils.test.ts new file mode 100644 index 0000000..3de4b6b --- /dev/null +++ b/tests/utils/fee/utils.test.ts @@ -0,0 +1,125 @@ +import { script as bitcoinScript, opcodes, payments } from "bitcoinjs-lib"; +import { + DEFAULT_INPUT_SIZE, + MAX_NON_LEGACY_OUTPUT_SIZE, + P2TR_INPUT_SIZE, + P2WPKH_INPUT_SIZE, +} from "../../../src/constants/fee"; +import { UTXO } from "../../../src/types/UTXO"; +import { + getEstimatedChangeOutputSize, + getInputSizeByScript, + inputValueSum, + isOP_RETURN, +} from "../../../src/utils/fee/utils"; +import { testingNetworks } from "../../helper"; + +describe("scriptUtils", () => { + describe("isOP_RETURN", () => { + it("should return true for an OP_RETURN script", () => { + const script = bitcoinScript.compile([ + opcodes.OP_RETURN, + Buffer.from("hello world"), + ]); + expect(isOP_RETURN(script)).toBe(true); + }); + + it("should return false for a non-OP_RETURN script", () => { + const script = bitcoinScript.compile([ + opcodes.OP_DUP, + opcodes.OP_HASH160, + Buffer.alloc(20), + opcodes.OP_EQUALVERIFY, + opcodes.OP_CHECKSIG, + ]); + expect(isOP_RETURN(script)).toBe(false); + }); + + it("should return false for an invalid script", () => { + const script = Buffer.from("invalidscript", "hex"); + expect(isOP_RETURN(script)).toBe(false); + }); + }); + + testingNetworks.forEach(({ networkName, dataGenerator }) => { + describe(`${networkName} - getInputSizeByScript`, () => { + it("should return P2WPKH_INPUT_SIZE for a valid P2WPKH script", () => { + const pk = dataGenerator.generateRandomKeyPair().publicKey; + const { output } = payments.p2wpkh({ pubkey: Buffer.from(pk, "hex") }); + if (output) { + expect(getInputSizeByScript(output)).toBe(P2WPKH_INPUT_SIZE); + } + }); + + it("should return P2TR_INPUT_SIZE for a valid P2TR script", () => { + const pk = dataGenerator.generateRandomKeyPair(true).publicKey; + const { output } = payments.p2tr({ + internalPubkey: Buffer.from(pk, "hex"), + }); + expect(getInputSizeByScript(output!)).toBe(P2TR_INPUT_SIZE); + }); + + it("should return DEFAULT_INPUT_SIZE for an invalid or unrecognized script", () => { + const script = bitcoinScript.compile([ + opcodes.OP_DUP, + opcodes.OP_HASH160, + Buffer.alloc(20), + opcodes.OP_EQUALVERIFY, + opcodes.OP_CHECKSIG, + ]); + expect(getInputSizeByScript(script)).toBe(DEFAULT_INPUT_SIZE); + }); + + it("should handle malformed scripts gracefully and return DEFAULT_INPUT_SIZE", () => { + const malformedScript = Buffer.from("00", "hex"); + expect(getInputSizeByScript(malformedScript)).toBe(DEFAULT_INPUT_SIZE); + }); + }); + }); + + describe("getEstimatedChangeOutputSize", () => { + it("should return correct value for the estimated change output size", () => { + expect(getEstimatedChangeOutputSize()).toBe(MAX_NON_LEGACY_OUTPUT_SIZE); + }); + }); + + describe("inputValueSum", () => { + it("should return the correct sum of UTXO values", () => { + const inputUTXOs: UTXO[] = [ + { txid: "txid1", vout: 0, value: 5000, scriptPubKey: "script1" }, + { txid: "txid2", vout: 1, value: 10000, scriptPubKey: "script2" }, + ]; + const expectedSum = 15000; + const actualSum = inputValueSum(inputUTXOs); + expect(actualSum).toBe(expectedSum); + }); + + it("should return zero for an empty UTXO list", () => { + const inputUTXOs: UTXO[] = []; + const expectedSum = 0; + const actualSum = inputValueSum(inputUTXOs); + expect(actualSum).toBe(expectedSum); + }); + + it("should return the correct sum for UTXOs with varying values", () => { + const inputUTXOs: UTXO[] = [ + { txid: "txid1", vout: 0, value: 2500, scriptPubKey: "script1" }, + { txid: "txid2", vout: 1, value: 7500, scriptPubKey: "script2" }, + { txid: "txid3", vout: 2, value: 10000, scriptPubKey: "script3" }, + ]; + const expectedSum = 20000; + const actualSum = inputValueSum(inputUTXOs); + expect(actualSum).toBe(expectedSum); + }); + + it("should handle large UTXO values correctly", () => { + const inputUTXOs: UTXO[] = [ + { txid: "txid1", vout: 0, value: 2 ** 53 - 1, scriptPubKey: "script1" }, + { txid: "txid2", vout: 1, value: 1, scriptPubKey: "script2" }, + ]; + const expectedSum = 2 ** 53 - 1 + 1; + const actualSum = inputValueSum(inputUTXOs); + expect(actualSum).toBe(expectedSum); + }); + }); +}); diff --git a/tests/utils/fee/withdrawTxFee.test.ts b/tests/utils/fee/withdrawTxFee.test.ts new file mode 100644 index 0000000..a8281f6 --- /dev/null +++ b/tests/utils/fee/withdrawTxFee.test.ts @@ -0,0 +1,22 @@ +import { + LOW_RATE_ESTIMATION_ACCURACY_BUFFER, + MAX_NON_LEGACY_OUTPUT_SIZE, + P2TR_INPUT_SIZE, + TX_BUFFER_SIZE_OVERHEAD, + WITHDRAW_TX_BUFFER_SIZE, +} from "../../../src/constants/fee"; +import { getWithdrawTxFee } from "../../../src/utils/fee"; + +describe("getWithdrawTxFee", () => { + it("should calculate the correct withdraw transaction fee for a given fee rate", () => { + const feeRate = Math.floor(Math.random() * 100); + let expectedTotalFee = + feeRate * (P2TR_INPUT_SIZE + MAX_NON_LEGACY_OUTPUT_SIZE + WITHDRAW_TX_BUFFER_SIZE + TX_BUFFER_SIZE_OVERHEAD); + if (feeRate <= 2) { + expectedTotalFee += LOW_RATE_ESTIMATION_ACCURACY_BUFFER; + } + const actualFee = getWithdrawTxFee(feeRate); + + expect(actualFee).toBe(expectedTotalFee); + }); +});