From 48a02375d92d061f4e225caab6e0675e91f42e61 Mon Sep 17 00:00:00 2001 From: Crypto Minion <154598612+jrwbabylonlab@users.noreply.github.com> Date: Wed, 30 Oct 2024 12:37:21 +1100 Subject: [PATCH] Support slashing & simply data structure for phase 2 (#37) * feat: support slashing in phase-2 staking --- package-lock.json | 4 +- package.json | 2 +- src/constants/unbonding.ts | 3 + src/staking/index.ts | 418 +++++++----------- src/staking/observable/index.ts | 109 ++--- src/staking/transactions.ts | 46 +- src/types/params.ts | 3 +- src/utils/btc.ts | 2 +- src/utils/staking/index.ts | 199 ++++++++- tests/helper/datagen/base.ts | 29 ++ tests/staking/createSlashingTx.test.ts | 165 +++++++ tests/staking/createStakingTx.test.ts | 42 +- tests/staking/createUnbondingtx.test.ts | 80 +--- tests/staking/createWithdrawTx.test.ts | 56 +-- .../observable/createStakingTx.test.ts | 30 +- tests/staking/observable/validation.test.ts | 76 ++-- tests/staking/stakingScript.test.ts | 1 - .../transactions/slashingTransaction.test.ts | 55 +-- tests/staking/validation.test.ts | 273 ++++++++---- tests/utils/btc.test.ts | 69 ++- tests/utils/staking/index.test.ts | 23 - 21 files changed, 961 insertions(+), 724 deletions(-) create mode 100644 src/constants/unbonding.ts create mode 100644 tests/staking/createSlashingTx.test.ts diff --git a/package-lock.json b/package-lock.json index 2c16bcb..7f19f0b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@babylonlabs-io/btc-staking-ts", - "version": "0.4.0-canary.1", + "version": "0.4.0-canary.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@babylonlabs-io/btc-staking-ts", - "version": "0.4.0-canary.1", + "version": "0.4.0-canary.2", "license": "SEE LICENSE IN LICENSE", "dependencies": { "@bitcoin-js/tiny-secp256k1-asmjs": "2.2.3", diff --git a/package.json b/package.json index 5023bdf..2882860 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@babylonlabs-io/btc-staking-ts", - "version": "0.4.0-canary.1", + "version": "0.4.0-canary.2", "description": "Library exposing methods for the creation and consumption of Bitcoin transactions pertaining to Babylon's Bitcoin Staking protocol.", "module": "dist/index.js", "main": "dist/index.cjs", diff --git a/src/constants/unbonding.ts b/src/constants/unbonding.ts new file mode 100644 index 0000000..8d745bf --- /dev/null +++ b/src/constants/unbonding.ts @@ -0,0 +1,3 @@ +// minimum unbonding output value to avoid the unbonding output value being +// less than Bitcoin dust +export const MIN_UNBONDING_OUTPUT_VALUE = 1000; \ No newline at end of file diff --git a/src/staking/index.ts b/src/staking/index.ts index 93c1ce6..87f53dd 100644 --- a/src/staking/index.ts +++ b/src/staking/index.ts @@ -4,43 +4,47 @@ import { UTXO } from "../types/UTXO"; import { StakingScriptData, StakingScripts } from "./stakingScript"; import { StakingError, StakingErrorCode } from "../error"; import { + slashEarlyUnbondedTransaction, + slashTimelockUnbondedTransaction, stakingTransaction, unbondingTransaction, withdrawEarlyUnbondedTransaction, withdrawTimelockUnbondedTransaction } from "./transactions"; -import { +import { isTaproot, isValidBitcoinAddress, isValidNoCoordPublicKey } from "../utils/btc"; -import { validateStakingTxInputData } from "../utils/staking"; +import { + deriveStakingOutputAddress, + findMatchingStakingTxOutputIndex, + validateParams, + validateStakingTimelock, + validateStakingTxInputData, +} from "../utils/staking"; import { PsbtTransactionResult } from "../types/transaction"; import { toBuffers } from "../utils/staking"; export * from "./stakingScript"; -// minimum unbonding output value to avoid the unbonding output value being -// less than Bitcoin dust -const MIN_UNBONDING_OUTPUT_VALUE = 1000; - export interface StakerInfo { address: string; publicKeyNoCoordHex: string; } -export interface Delegation { - stakingTxHashHex: string; - stakerPkNoCoordHex: string; - finalityProviderPkNoCoordHex: string; - stakingTx: Transaction; - stakingOutputIndex: number; - startHeight: number; - timelock: number; -} - export class Staking { network: networks.Network; stakerInfo: StakerInfo; - - constructor(network: networks.Network, stakerInfo: StakerInfo) { + params: StakingParams; + finalityProviderPkNoCoordHex: string; + stakingTimelock: number; + + constructor( + network: networks.Network, + stakerInfo: StakerInfo, + params: StakingParams, + finalityProviderPkNoCoordHex: string, + stakingTimelock: number, + ) { + // Perform validations if (!isValidBitcoinAddress(stakerInfo.address, network)) { throw new StakingError( StakingErrorCode.INVALID_INPUT, "Invalid staker bitcoin address", @@ -51,38 +55,40 @@ export class Staking { StakingErrorCode.INVALID_INPUT, "Invalid staker public key", ); } + if (!isValidNoCoordPublicKey(finalityProviderPkNoCoordHex)) { + throw new StakingError( + StakingErrorCode.INVALID_INPUT, "Invalid finality provider public key", + ); + } + validateParams(params); + validateStakingTimelock(stakingTimelock, params); + this.network = network; this.stakerInfo = stakerInfo; + this.params = params; + this.finalityProviderPkNoCoordHex = finalityProviderPkNoCoordHex; + this.stakingTimelock = stakingTimelock; } /** * buildScripts builds the staking scripts for the staking transaction. * Note: different staking types may have different scripts. * e.g the observable staking script has a data embed script. - * @param {StakingParams} params - The staking parameters. - * @param {string} finalityProviderPkNoCoordHex - The finality provider's public key - * without coordinates. - * @param {number} timelock - The staking time in blocks. - * @param {string} stakerPkNoCoordHex - The staker's public key without coordinates. * * @returns {StakingScripts} - The staking scripts. */ - buildScripts( - params: StakingParams, - finalityProviderPkNoCoordHex: string, - timelock: number, - stakerPkNoCoordHex: string - ): StakingScripts { + buildScripts(): StakingScripts { + const { covenantQuorum, covenantNoCoordPks, unbondingTime } = this.params; // Create staking script data let stakingScriptData; try { stakingScriptData = new StakingScriptData( - Buffer.from(stakerPkNoCoordHex, "hex"), - [Buffer.from(finalityProviderPkNoCoordHex, "hex")], - toBuffers(params.covenantNoCoordPks), - params.covenantQuorum, - timelock, - params.unbondingTime + Buffer.from(this.stakerInfo.publicKeyNoCoordHex, "hex"), + [Buffer.from(this.finalityProviderPkNoCoordHex, "hex")], + toBuffers(covenantNoCoordPks), + covenantQuorum, + this.stakingTimelock, + unbondingTime ); } catch (error: unknown) { throw StakingError.fromUnknown( @@ -104,172 +110,29 @@ export class Staking { return scripts; } - /** - * Validate the staking parameters. - * Extend this method to add additional validation for staking parameters based - * on the staking type. - * @param {StakingParams} params - The staking parameters. - * @throws {StakingError} - If the parameters are invalid. - */ - validateParams(params: StakingParams) { - // Check covenant public keys - if (params.covenantNoCoordPks.length == 0) { - throw new StakingError( - StakingErrorCode.INVALID_PARAMS, - "Could not find any covenant public keys", - ); - } - if (params.covenantNoCoordPks.length < params.covenantQuorum) { - throw new StakingError( - StakingErrorCode.INVALID_PARAMS, - "Covenant public keys must be greater than or equal to the quorum", - ); - } - params.covenantNoCoordPks.forEach((pk) => { - if (!isValidNoCoordPublicKey(pk)) { - throw new StakingError( - StakingErrorCode.INVALID_PARAMS, - "Covenant public key should contains no coordinate", - ); - } - }); - // Check other parameters - if (params.unbondingTime <= 0) { - throw new StakingError( - StakingErrorCode.INVALID_PARAMS, - "Unbonding time must be greater than 0", - ); - } - if (params.unbondingFeeSat <= 0) { - throw new StakingError( - StakingErrorCode.INVALID_PARAMS, - "Unbonding fee must be greater than 0", - ); - } - if (params.maxStakingAmountSat < params.minStakingAmountSat) { - throw new StakingError( - StakingErrorCode.INVALID_PARAMS, - "Max staking amount must be greater or equal to min staking amount", - ); - } - if (params.minStakingAmountSat < params.unbondingFeeSat + MIN_UNBONDING_OUTPUT_VALUE) { - throw new StakingError( - StakingErrorCode.INVALID_PARAMS, - `Min staking amount must be greater than unbonding fee plus ${MIN_UNBONDING_OUTPUT_VALUE}`, - ); - } - if (params.maxStakingTimeBlocks < params.minStakingTimeBlocks) { - throw new StakingError( - StakingErrorCode.INVALID_PARAMS, - "Max staking time must be greater or equal to min staking time", - ); - } - if (params.minStakingTimeBlocks <= 0) { - throw new StakingError( - StakingErrorCode.INVALID_PARAMS, - "Min staking time must be greater than 0", - ); - } - if (params.covenantQuorum <= 0) { - throw new StakingError( - StakingErrorCode.INVALID_PARAMS, - "Covenant quorum must be greater than 0", - ); - } - } - - /** - * Validate the Babylon delegation inputs. - * - * @param {Delegation} delegation - The delegation to validate. - * @param {StakingParams} stakingParams - The staking parameters. - * @param {StakerInfo} stakerInfo - The staker information. - * - * @throws {StakingError} - If the delegation inputs are invalid. - */ - validateDelegationInputs( - delegation: Delegation, - stakingParams: StakingParams, - stakerInfo: StakerInfo, - ) { - const { stakingTx, timelock, stakingOutputIndex } = delegation; - if ( - timelock < stakingParams.minStakingTimeBlocks || - timelock > stakingParams.maxStakingTimeBlocks - ) { - throw new StakingError( - StakingErrorCode.INVALID_INPUT, - "Staking transaction timelock is out of range", - ); - } - - if (delegation.stakerPkNoCoordHex !== stakerInfo.publicKeyNoCoordHex) { - throw new StakingError( - StakingErrorCode.INVALID_INPUT, - "Staker public key does not match between connected staker and delegation staker", - ); - } - - if (!isValidNoCoordPublicKey(delegation.finalityProviderPkNoCoordHex)) { - throw new StakingError( - StakingErrorCode.INVALID_INPUT, - "Finality provider public key should not have a coordinate", - ); - } - - if (!stakingTx.outs[stakingOutputIndex]) { - throw new StakingError( - StakingErrorCode.INVALID_INPUT, - "Staking transaction output index is out of range", - ); - } - - if (stakingTx.getId() !== delegation.stakingTxHashHex) { - throw new StakingError( - StakingErrorCode.INVALID_INPUT, - "Staking transaction hash does not match between the btc transaction and the provided staking hash", - ); - } - } - /** * Create a staking transaction for staking. * - * @param {Params} params - The staking parameters for staking. * @param {number} stakingAmountSat - The amount to stake in satoshis. - * @param {number} timelock - The staking time in blocks. - * @param {string} finalityProviderPkNoCoord - The finality provider's public key - * without coordinates. * @param {UTXO[]} inputUTXOs - The UTXOs to use as inputs for the staking * transaction. * @param {number} feeRate - The fee rate for the transaction in satoshis per byte. - * * @returns {PsbtTransactionResult} - An object containing the unsigned psbt and fee */ - public createStakingTransaction ( - params: StakingParams, + public createStakingTransaction( stakingAmountSat: number, - timelock: number, - finalityProviderPkNoCoord: string, inputUTXOs: UTXO[], feeRate: number, ): PsbtTransactionResult { - this.validateParams(params); validateStakingTxInputData( stakingAmountSat, - timelock, - params, + this.stakingTimelock, + this.params, inputUTXOs, feeRate, - finalityProviderPkNoCoord, ); - const scripts = this.buildScripts( - params, - finalityProviderPkNoCoord, - timelock, - this.stakerInfo.publicKeyNoCoordHex, - ); + const scripts = this.buildScripts(); try { return stakingTransaction( @@ -290,47 +153,34 @@ export class Staking { }; /** - * Create an unbonding transaction for observable staking. - * - * @param {ObservableStakingParams} stakingParams - The staking parameters for observable staking. - * @param {Delegation} delegation - The delegation to unbond. - * - * @returns {Psbt} - The unsigned unbonding transaction + * Create an unbonding transaction for staking. * - * @throws {StakingError} - If the delegation is invalid or the transaction cannot be built + * @param {Transaction} stakingTx - The staking transaction to unbond. + * @returns {PsbtTransactionResult} - An object containing the unsigned psbt and fee + * @throws {StakingError} - If the transaction cannot be built */ - public createUnbondingTransaction = ( - stakingParams: StakingParams, - delegation: Delegation, - ) : PsbtTransactionResult => { - this.validateParams(stakingParams); - this.validateDelegationInputs( - delegation, stakingParams, this.stakerInfo, - ); - const { - stakingTx, - stakingOutputIndex, - timelock, - finalityProviderPkNoCoordHex, - stakerPkNoCoordHex - } = delegation; + public createUnbondingTransaction( + stakingTx: Transaction, + ) : PsbtTransactionResult { // Build scripts - const scripts = this.buildScripts( - stakingParams, - finalityProviderPkNoCoordHex, - timelock, - stakerPkNoCoordHex, - ); + const scripts = this.buildScripts(); + + // Reconstruct the stakingOutputIndex + const stakingOutputIndex = findMatchingStakingTxOutputIndex( + stakingTx, + deriveStakingOutputAddress(scripts, this.network), + this.network, + ) // Create the unbonding transaction try { const { psbt } = unbondingTransaction( scripts, stakingTx, - stakingParams.unbondingFeeSat, + this.params.unbondingFeeSat, this.network, stakingOutputIndex, ); - return { psbt, fee: stakingParams.unbondingFeeSat }; + return { psbt, fee: this.params.unbondingFeeSat }; } catch (error) { throw StakingError.fromUnknown( error, StakingErrorCode.BUILD_TRANSACTION_FAILURE, @@ -342,37 +192,17 @@ export class Staking { /** * Create a withdrawal transaction that spends an unbonding transaction for observable staking. * - * @param {P} stakingParams - The staking parameters for observable staking. - * @param {Delegation} delegation - The delegation that has been on-demand unbonded. * @param {Transaction} unbondingTx - The unbonding transaction to withdraw from. * @param {number} feeRate - The fee rate for the transaction in satoshis per byte. - * * @returns {PsbtTransactionResult} - An object containing the unsigned psbt and fee - * * @throws {StakingError} - If the delegation is invalid or the transaction cannot be built */ - public createWithdrawEarlyUnbondedTransaction = ( - stakingParams: StakingParams, - delegation: Delegation, + public createWithdrawEarlyUnbondedTransaction ( unbondingTx: Transaction, feeRate: number, - ): PsbtTransactionResult => { - this.validateParams(stakingParams); - this.validateDelegationInputs( - delegation, stakingParams, this.stakerInfo, - ); - const { - timelock, - finalityProviderPkNoCoordHex, - stakerPkNoCoordHex - } = delegation; + ): PsbtTransactionResult { // Build scripts - const scripts = this.buildScripts( - stakingParams, - finalityProviderPkNoCoordHex, - timelock, - stakerPkNoCoordHex, - ); + const scripts = this.buildScripts(); // Create the withdraw early unbonded transaction try { @@ -392,40 +222,27 @@ export class Staking { } /** - * Create a withdrawal transaction that spends a naturally expired staking transaction for observable staking. + * Create a withdrawal transaction that spends a naturally expired staking + * transaction. * - * @param {P} stakingParams - The staking parameters for observable staking. - * @param {Delegation} delegation - The delegation to withdraw from. + * @param {Transaction} stakingTx - The staking transaction to withdraw from. * @param {number} feeRate - The fee rate for the transaction in satoshis per byte. - * * @returns {PsbtTransactionResult} - An object containing the unsigned psbt and fee - * * @throws {StakingError} - If the delegation is invalid or the transaction cannot be built */ - public createWithdrawTimelockUnbondedTransaction = ( - stakingParams: StakingParams, - delegation: Delegation, + public createWithdrawTimelockUnbondedTransaction( + stakingTx: Transaction, feeRate: number, - ): PsbtTransactionResult => { - this.validateParams(stakingParams); - this.validateDelegationInputs( - delegation, stakingParams, this.stakerInfo, - ); - const { - stakingTx, - stakingOutputIndex, - timelock, - finalityProviderPkNoCoordHex, - stakerPkNoCoordHex, - } = delegation; - + ): PsbtTransactionResult { // Build scripts - const scripts = this.buildScripts( - stakingParams, - finalityProviderPkNoCoordHex, - timelock, - stakerPkNoCoordHex, - ); + const scripts = this.buildScripts(); + + // Reconstruct the stakingOutputIndex + const stakingOutputIndex = findMatchingStakingTxOutputIndex( + stakingTx, + deriveStakingOutputAddress(scripts, this.network), + this.network, + ) // Create the timelock unbonded transaction try { @@ -444,4 +261,81 @@ export class Staking { ); } } + + /** + * Create a slashing transaction spending from the staking output. + * + * @param {Transaction} stakingTx - The staking transaction to slash. + * @returns {PsbtTransactionResult} - An object containing the unsigned psbt and fee + * @throws {StakingError} - If the delegation is invalid or the transaction cannot be built + */ + public createStakingOutputSlashingTransaction( + stakingTx: Transaction, + ) : PsbtTransactionResult { + if (!this.params.slashing) { + throw new StakingError( + StakingErrorCode.INVALID_PARAMS, + "Slashing parameters are missing", + ); + } + + // Build scripts + const scripts = this.buildScripts(); + + // create the slash timelock unbonded transaction + try { + const { psbt } = slashTimelockUnbondedTransaction( + scripts, + stakingTx, + this.params.slashing.slashingPkScriptHex, + this.params.slashing.slashingRate, + this.params.slashing.minSlashingTxFeeSat, + this.network, + ); + return { psbt, fee: this.params.slashing.minSlashingTxFeeSat }; + } catch (error) { + throw StakingError.fromUnknown( + error, StakingErrorCode.BUILD_TRANSACTION_FAILURE, + "Cannot build the slash timelock unbonded transaction", + ); + } + } + + /** + * Create a slashing transaction for an unbonding output. + * + * @param {Transaction} unbondingTx - The unbonding transaction to slash. + * @returns {PsbtTransactionResult} - An object containing the unsigned psbt and fee + * @throws {StakingError} - If the delegation is invalid or the transaction cannot be built + */ + public createUnbondingOutputSlashingTransaction( + unbondingTx: Transaction, + ): PsbtTransactionResult { + if (!this.params.slashing) { + throw new StakingError( + StakingErrorCode.INVALID_PARAMS, + "Slashing parameters are missing", + ); + } + // Build scripts + const scripts = this.buildScripts(); + + // create the slash timelock unbonded transaction + try { + const { psbt } = slashEarlyUnbondedTransaction( + scripts, + unbondingTx, + this.params.slashing.slashingPkScriptHex, + this.params.slashing.slashingRate, + this.params.slashing.minSlashingTxFeeSat, + this.network, + ); + return { psbt, fee: this.params.slashing.minSlashingTxFeeSat }; + } catch (error) { + throw StakingError.fromUnknown( + error, StakingErrorCode.BUILD_TRANSACTION_FAILURE, + "Cannot build the slash early unbonded transaction", + ); + } + } } diff --git a/src/staking/observable/index.ts b/src/staking/observable/index.ts index 3be4e37..44ded1c 100644 --- a/src/staking/observable/index.ts +++ b/src/staking/observable/index.ts @@ -6,7 +6,8 @@ import { isTaproot } from "../../utils/btc"; import { toBuffers, validateStakingTxInputData } from "../../utils/staking"; import { PsbtTransactionResult } from "../../types/transaction"; import { ObservableStakingScriptData, ObservableStakingScripts } from "./observableStakingScript"; -import { Delegation, StakerInfo, Staking } from ".."; +import { StakerInfo, Staking } from ".."; +import { networks } from "bitcoinjs-lib"; export * from "./observableStakingScript"; /** @@ -19,15 +20,21 @@ export * from "./observableStakingScript"; * public key(without coordinates). */ export class ObservableStaking extends Staking { - /** - * validateParams validates the staking parameters for observable staking. - * - * @param {ObservableStakingParams} params - The staking parameters for observable staking. - * - * @throws {StakingError} - If the staking parameters are invalid - */ - validateParams(params: ObservableStakingParams): void { - super.validateParams(params); + params: ObservableStakingParams; + constructor( + network: networks.Network, + stakerInfo: StakerInfo, + params: ObservableStakingParams, + finalityProviderPkNoCoordHex: string, + stakingTimelock: number, + ) { + super( + network, + stakerInfo, + params, + finalityProviderPkNoCoordHex, + stakingTimelock, + ); if (!params.tag) { throw new StakingError( StakingErrorCode.INVALID_INPUT, @@ -40,64 +47,31 @@ export class ObservableStaking extends Staking { "Observable staking parameters must include a positive activation height", ); } + // Override the staking parameters type to ObservableStakingParams + this.params = params; } - - /** - * Validate the delegation inputs for observable staking. - * This method overwrites the base method to include the start height validation. - * - * @param {Delegation} delegation - The delegation to validate. - * @param {ObservableStakingParams} stakingParams - The staking parameters. - * @param {StakerInfo} stakerInfo - The staker information. - * - * @throws {StakingError} - If the delegation inputs are invalid. - */ - validateDelegationInputs( - delegation: Delegation, - stakingParams: ObservableStakingParams, - stakerInfo: StakerInfo, - ) { - super.validateDelegationInputs(delegation, stakingParams, stakerInfo); - if (delegation.startHeight < stakingParams.activationHeight) { - throw new StakingError( - StakingErrorCode.INVALID_INPUT, - "Staking transaction start height cannot be less than activation height", - ); - } - } - + /** * Build the staking scripts for observable staking. * This method overwrites the base method to include the OP_RETURN tag based * on the tag provided in the parameters. * - * @param {ObservableStakingParams} params - The staking parameters for observable staking. - * @param {string} finalityProviderPkNoCoordHex - The finality provider's public key - * without coordinates. - * @param {number} timelock - The staking time in blocks. - * @param {string} stakerPkNoCoordHex - The staker's public key without coordinates. - * * @returns {ObservableStakingScripts} - The staking scripts for observable staking. - * * @throws {StakingError} - If the scripts cannot be built. */ - buildScripts( - params: ObservableStakingParams, - finalityProviderPkNoCoordHex: string, - timelock: number, - stakerPkNoCoordHex: string, - ): ObservableStakingScripts { + buildScripts(): ObservableStakingScripts { + const { covenantQuorum, covenantNoCoordPks, unbondingTime, tag } = this.params; // Create staking script data let stakingScriptData; try { stakingScriptData = new ObservableStakingScriptData( - Buffer.from(stakerPkNoCoordHex, "hex"), - [Buffer.from(finalityProviderPkNoCoordHex, "hex")], - toBuffers(params.covenantNoCoordPks), - params.covenantQuorum, - timelock, - params.unbondingTime, - Buffer.from(params.tag, "hex"), + Buffer.from(this.stakerInfo.publicKeyNoCoordHex, "hex"), + [Buffer.from(this.finalityProviderPkNoCoordHex, "hex")], + toBuffers(covenantNoCoordPks), + covenantQuorum, + this.stakingTimelock, + unbondingTime, + Buffer.from(tag, "hex"), ); } catch (error: unknown) { throw StakingError.fromUnknown( @@ -126,41 +100,26 @@ export class ObservableStaking extends Staking { * 1. OP_RETURN tag in the staking scripts * 2. lockHeight parameter * - * @param {ObservableStakingParams} params - The staking parameters for observable staking. * @param {number} stakingAmountSat - The amount to stake in satoshis. - * @param {number} timelock - The staking time in blocks. - * @param {string} finalityProviderPkNoCoord - The finality provider's public key - * without coordinates. * @param {UTXO[]} inputUTXOs - The UTXOs to use as inputs for the staking * transaction. * @param {number} feeRate - The fee rate for the transaction in satoshis per byte. - * * @returns {PsbtTransactionResult} - An object containing the unsigned psbt and fee */ - public createStakingTransaction ( - params: ObservableStakingParams, + public createStakingTransaction( stakingAmountSat: number, - timelock: number, - finalityProviderPkNoCoord: string, inputUTXOs: UTXO[], feeRate: number, ): PsbtTransactionResult{ - this.validateParams(params); validateStakingTxInputData( stakingAmountSat, - timelock, - params, + this.stakingTimelock, + this.params, inputUTXOs, feeRate, - finalityProviderPkNoCoord, ); - const scripts = this.buildScripts( - params, - finalityProviderPkNoCoord, - timelock, - this.stakerInfo.publicKeyNoCoordHex, - ); + const scripts = this.buildScripts(); // Create the staking transaction try { @@ -176,7 +135,7 @@ export class ObservableStaking extends Staking { // For example, if a Bitcoin height of X is provided, // the transaction will be included starting from height X+1. // https://learnmeabitcoin.com/technical/transaction/locktime/ - params.activationHeight - 1, + this.params.activationHeight - 1, ); } catch (error: unknown) { throw StakingError.fromUnknown( diff --git a/src/staking/transactions.ts b/src/staking/transactions.ts index 3597094..c7e0359 100644 --- a/src/staking/transactions.ts +++ b/src/staking/transactions.ts @@ -379,7 +379,7 @@ function withdrawalTransaction( * * @param {Object} scripts - The scripts used in the transaction. * @param {Transaction} stakingTransaction - The original staking transaction. - * @param {string} slashingAddress - The address to send the slashed funds to. + * @param {string} slashingPkScriptHex - The public key script to send the slashed funds to. * @param {number} slashingRate - The rate at which the funds are slashed. * @param {number} minimumFee - The minimum fee for the transaction in satoshis. * @param {networks.Network} network - The Bitcoin network. @@ -394,7 +394,7 @@ export function slashTimelockUnbondedTransaction( unbondingTimelockScript: Buffer; }, stakingTransaction: Transaction, - slashingAddress: string, + slashingPkScriptHex: string, slashingRate: number, minimumFee: number, network: networks.Network, @@ -413,7 +413,7 @@ export function slashTimelockUnbondedTransaction( }, slashingScriptTree, stakingTransaction, - slashingAddress, + slashingPkScriptHex, slashingRate, minimumFee, network, @@ -427,26 +427,13 @@ export function slashTimelockUnbondedTransaction( * This transaction spends the staking output of the staking transaction and distributes the funds * according to the specified slashing rate. * - * Outputs: + * Transaction outputs: * - The first output sends `input * slashing_rate` funds to the slashing address. * - The second output sends `input * (1 - slashing_rate) - fee` funds back to the user's address. * - * Inputs: - * - scripts: Scripts used to construct the taproot output. - * - slashingScript: Script for the slashing condition. - * - unbondingTimelockScript: Script for the unbonding timelock condition. - * - transaction: The unbonding transaction. - * - slashingAddress: The address to send the slashed funds to. - * - slashingRate: The rate at which the funds are slashed (0 < slashingRate < 1). - * - minimumFee: The minimum fee for the transaction in satoshis. - * - network: The Bitcoin network. - * - * Returns: - * - psbt: The partially signed transaction (PSBT). - * * @param {Object} scripts - The scripts used in the transaction. e.g slashingScript, unbondingTimelockScript * @param {Transaction} unbondingTx - The unbonding transaction. - * @param {string} slashingAddress - The address to send the slashed funds to. + * @param {string} slashingPkScriptHex - The public key script to send the slashed funds to. * @param {number} slashingRate - The rate at which the funds are slashed. * @param {number} minimumSlashingFee - The minimum fee for the transaction in satoshis. * @param {networks.Network} network - The Bitcoin network. @@ -458,7 +445,7 @@ export function slashEarlyUnbondedTransaction( unbondingTimelockScript: Buffer; }, unbondingTx: Transaction, - slashingAddress: string, + slashingPkScriptHex: string, slashingRate: number, minimumSlashingFee: number, network: networks.Network, @@ -478,7 +465,7 @@ export function slashEarlyUnbondedTransaction( }, unbondingScriptTree, unbondingTx, - slashingAddress, + slashingPkScriptHex, slashingRate, minimumSlashingFee, network, @@ -492,24 +479,13 @@ export function slashEarlyUnbondedTransaction( * This transaction spends the staking output of the staking transaction and distributes the funds * according to the specified slashing rate. * - * Outputs: + * Transaction outputs: * - The first output sends `input * slashing_rate` funds to the slashing address. * - The second output sends `input * (1 - slashing_rate) - fee` funds back to the user's address. * - * Inputs: - * - scripts: Scripts used to construct the taproot output. - * - slashingScript: Script for the slashing condition. - * - unbondingTimelockScript: Script for the unbonding timelock condition. - * - transaction: The original staking/unbonding transaction. - * - slashingAddress: The address to send the slashed funds to. - * - slashingRate: The rate at which the funds are slashed (0 < slashingRate < 1). - * - minimumFee: The minimum fee for the transaction in satoshis. - * - network: The Bitcoin network. - * - outputIndex: The index of the output to be spent in the original transaction (default is 0). - * * @param {Object} scripts - The scripts used in the transaction. e.g slashingScript, unbondingTimelockScript * @param {Transaction} transaction - The original staking/unbonding transaction. - * @param {string} slashingAddress - The address to send the slashed funds to. + * @param {string} slashingPkScriptHex - The public key script to send the slashed funds to. * @param {number} slashingRate - The rate at which the funds are slashed. Two decimal places, otherwise it will be rounded down. * @param {number} minimumFee - The minimum fee for the transaction in satoshis. * @param {networks.Network} network - The Bitcoin network. @@ -523,7 +499,7 @@ function slashingTransaction( }, scriptTree: Taptree, transaction: Transaction, - slashingAddress: string, + slashingPkScriptHex: string, slashingRate: number, minimumFee: number, network: networks.Network, @@ -601,7 +577,7 @@ function slashingTransaction( // Add the slashing output psbt.addOutput({ - address: slashingAddress, + script: Buffer.from(slashingPkScriptHex, "hex"), value: slashingAmount, }); diff --git a/src/types/params.ts b/src/types/params.ts index 6ff4e66..7656e12 100644 --- a/src/types/params.ts +++ b/src/types/params.ts @@ -8,8 +8,9 @@ export interface StakingParams { maxStakingTimeBlocks: number; minStakingTimeBlocks: number; slashing?: { - slashingPkScript: string; + slashingPkScriptHex: string; slashingRate: number; + minSlashingTxFeeSat: number; } } diff --git a/src/utils/btc.ts b/src/utils/btc.ts index 9b9a019..9755315 100644 --- a/src/utils/btc.ts +++ b/src/utils/btc.ts @@ -103,4 +103,4 @@ const validateNoCoordPublicKeyBuffer = (pkBuffer: Buffer): boolean => { return ( ecc.isPoint(compressedKeyEven) || ecc.isPoint(compressedKeyOdd) ); -}; \ No newline at end of file +}; diff --git a/src/utils/staking/index.ts b/src/utils/staking/index.ts index 01b3879..a391146 100644 --- a/src/utils/staking/index.ts +++ b/src/utils/staking/index.ts @@ -1,4 +1,4 @@ -import { networks, payments } from "bitcoinjs-lib"; +import { address, networks, payments, Transaction } from "bitcoinjs-lib"; import { Taptree } from "bitcoinjs-lib/src/types"; import { internalPubkey } from "../../constants/internalPubkey"; import { PsbtOutputExtended } from "../../types/psbtOutputs"; @@ -6,6 +6,7 @@ import { StakingError, StakingErrorCode } from "../../error"; import { UTXO } from "../../types/UTXO"; import { isValidNoCoordPublicKey } from "../btc"; import { StakingParams } from "../../types/params"; +import { MIN_UNBONDING_OUTPUT_VALUE } from "../../constants/unbonding"; @@ -28,6 +29,40 @@ export const buildStakingOutput = ( }, network: networks.Network, amount: number, +) => { + const stakingOutputAddress = deriveStakingOutputAddress(scripts, network); + const psbtOutputs: PsbtOutputExtended[] = [ + { + address: stakingOutputAddress, + value: amount, + }, + ]; + if (scripts.dataEmbedScript) { + // Add the data embed output to the transaction + psbtOutputs.push({ + script: scripts.dataEmbedScript, + value: 0, + }); + } + return psbtOutputs; +}; + +/** + * Derive the staking output address from the staking scripts. + * + * @param {StakingScripts} scripts - The staking scripts. + * @param {networks.Network} network - The Bitcoin network. + * @returns {string} - The staking output address. + * @throws {StakingError} - If the staking output address cannot be derived. + */ +export const deriveStakingOutputAddress = ( + scripts: { + timelockScript: Buffer; + unbondingScript: Buffer; + slashingScript: Buffer; + dataEmbedScript?: Buffer; + }, + network: networks.Network, ) => { // Build outputs const scriptTree: Taptree = [ @@ -50,22 +85,34 @@ export const buildStakingOutput = ( "Failed to build staking output", ); } + + return stakingOutput.address; +}; - 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, - }); +/** + * Find the matching output index for the given staking transaction. + * + * @param {Transaction} stakingTx - The staking transaction. + * @param {string} stakingOutputAddress - The staking output address. + * @param {networks.Network} network - The Bitcoin network. + * @returns {number} - The output index. + * @throws {Error} - If the matching output is not found. + */ +export const findMatchingStakingTxOutputIndex = ( + stakingTx: Transaction, + stakingOutputAddress: string, + network: networks.Network, +) => { + const index = stakingTx.outs.findIndex(output => { + return address.fromOutputScript(output.script, network); + }); + + if (index === -1) { + throw new Error(`Matching output not found for address: ${stakingOutputAddress}`); } - return psbtOutputs; -}; + + return index; +} /** * Validate the staking transaction input data. @@ -83,7 +130,6 @@ export const validateStakingTxInputData = ( params: StakingParams, inputUTXOs: UTXO[], feeRate: number, - finalityProviderPkNoCoord: string, ) => { if ( stakingAmountSat < params.minStakingAmountSat || @@ -113,13 +159,130 @@ export const validateStakingTxInputData = ( StakingErrorCode.INVALID_INPUT, "Invalid fee rate", ); } - if (!isValidNoCoordPublicKey(finalityProviderPkNoCoord)) { +} + + +/** + * Validate the staking parameters. + * Extend this method to add additional validation for staking parameters based + * on the staking type. + * @param {StakingParams} params - The staking parameters. + * @throws {StakingError} - If the parameters are invalid. + */ +export const validateParams = (params: StakingParams) => { + // Check covenant public keys + if (params.covenantNoCoordPks.length == 0) { + throw new StakingError( + StakingErrorCode.INVALID_PARAMS, + "Could not find any covenant public keys", + ); + } + if (params.covenantNoCoordPks.length < params.covenantQuorum) { + throw new StakingError( + StakingErrorCode.INVALID_PARAMS, + "Covenant public keys must be greater than or equal to the quorum", + ); + } + params.covenantNoCoordPks.forEach((pk) => { + if (!isValidNoCoordPublicKey(pk)) { + throw new StakingError( + StakingErrorCode.INVALID_PARAMS, + "Covenant public key should contains no coordinate", + ); + } + }); + // Check other parameters + if (params.unbondingTime <= 0) { + throw new StakingError( + StakingErrorCode.INVALID_PARAMS, + "Unbonding time must be greater than 0", + ); + } + if (params.unbondingFeeSat <= 0) { + throw new StakingError( + StakingErrorCode.INVALID_PARAMS, + "Unbonding fee must be greater than 0", + ); + } + if (params.maxStakingAmountSat < params.minStakingAmountSat) { + throw new StakingError( + StakingErrorCode.INVALID_PARAMS, + "Max staking amount must be greater or equal to min staking amount", + ); + } + if (params.minStakingAmountSat < params.unbondingFeeSat + MIN_UNBONDING_OUTPUT_VALUE) { + throw new StakingError( + StakingErrorCode.INVALID_PARAMS, + `Min staking amount must be greater than unbonding fee plus ${MIN_UNBONDING_OUTPUT_VALUE}`, + ); + } + if (params.maxStakingTimeBlocks < params.minStakingTimeBlocks) { + throw new StakingError( + StakingErrorCode.INVALID_PARAMS, + "Max staking time must be greater or equal to min staking time", + ); + } + if (params.minStakingTimeBlocks <= 0) { + throw new StakingError( + StakingErrorCode.INVALID_PARAMS, + "Min staking time must be greater than 0", + ); + } + if (params.covenantQuorum <= 0) { throw new StakingError( - StakingErrorCode.INVALID_INPUT, "Finality provider public key should contains no coordinate", + StakingErrorCode.INVALID_PARAMS, + "Covenant quorum must be greater than 0", ); } + if (params.slashing) { + if (params.slashing.slashingRate <= 0) { + throw new StakingError( + StakingErrorCode.INVALID_PARAMS, + "Slashing rate must be greater than 0", + ); + } + if (params.slashing.slashingRate > 1) { + throw new StakingError( + StakingErrorCode.INVALID_PARAMS, + "Slashing rate must be less or equal to 1", + ); + } + if (params.slashing.slashingPkScriptHex.length == 0) { + throw new StakingError( + StakingErrorCode.INVALID_PARAMS, + "Slashing public key script is missing", + ); + } + if (params.slashing.minSlashingTxFeeSat <= 0) { + throw new StakingError( + StakingErrorCode.INVALID_PARAMS, + "Minimum slashing transaction fee must be greater than 0", + ); + } + } } +/** + * Validate the staking timelock. + * + * @param {number} stakingTimelock - The staking timelock. + * @param {StakingParams} params - The staking parameters. + * @throws {StakingError} - If the staking timelock is invalid. + */ +export const validateStakingTimelock = ( + stakingTimelock: number, params: StakingParams, +) => { + if ( + stakingTimelock < params.minStakingTimeBlocks || + stakingTimelock > params.maxStakingTimeBlocks + ) { + throw new StakingError( + StakingErrorCode.INVALID_INPUT, + "Staking transaction timelock is out of range", + ); + } +}; + /** * toBuffers converts an array of strings to an array of buffers. * diff --git a/tests/helper/datagen/base.ts b/tests/helper/datagen/base.ts index c404533..09485a5 100644 --- a/tests/helper/datagen/base.ts +++ b/tests/helper/datagen/base.ts @@ -42,6 +42,8 @@ export class StakingDataGenerator { const maxStakingTimeBlocks = fixedTerm ? minStakingTimeBlocks : this.getRandomIntegerBetween(minStakingTimeBlocks, minStakingTimeBlocks + 1000); const timelock = this.generateRandomTimelock({minStakingTimeBlocks, maxStakingTimeBlocks}); const unbondingTime = this.generateRandomUnbondingTime(timelock); + const slashingRate = this.generateRandomSlashingRate(); + const minSlashingTxFeeSat = this.getRandomIntegerBetween(1000, 100000); return { covenantNoCoordPks, covenantQuorum, @@ -53,6 +55,11 @@ export class StakingDataGenerator { ), minStakingTimeBlocks, maxStakingTimeBlocks, + slashing: { + slashingRate, + slashingPkScriptHex: getRandomPaymentScriptHex(this.generateRandomKeyPair().publicKey), + minSlashingTxFeeSat, + } }; } @@ -309,3 +316,25 @@ export class StakingDataGenerator { }; }; } + +export const getRandomPaymentScriptHex = (pubKeyHex: string): string => { + const pubKeyBuf = Buffer.from(pubKeyHex, "hex"); + + // Define the possible payment types + const paymentTypes = [ + bitcoin.payments.p2pkh({ pubkey: pubKeyBuf }), + bitcoin.payments.p2sh({ redeem: bitcoin.payments.p2wpkh({ pubkey: pubKeyBuf }) }), + bitcoin.payments.p2wpkh({ pubkey: pubKeyBuf }), + ]; + + // Randomly pick one payment type + const randomIndex = Math.floor(Math.random() * paymentTypes.length); + const payment = paymentTypes[randomIndex]; + + // Get the scriptPubKey from the selected payment type and return its hex representation + if (!payment.output) { + throw new Error("Failed to generate scriptPubKey."); + } + + return payment.output.toString("hex"); +} \ No newline at end of file diff --git a/tests/staking/createSlashingTx.test.ts b/tests/staking/createSlashingTx.test.ts new file mode 100644 index 0000000..b61e879 --- /dev/null +++ b/tests/staking/createSlashingTx.test.ts @@ -0,0 +1,165 @@ +import * as stakingScript from "../../src/staking/stakingScript"; +import { testingNetworks } from "../helper"; +import * as transaction from "../../src/staking/transactions"; +import { Staking } from "../../src/staking"; +import { opcodes, payments, script } from "bitcoinjs-lib"; +import { internalPubkey } from "../../src/constants/internalPubkey"; + +describe.each(testingNetworks)("Create slashing transactions", ({ + network, networkName, datagen: { stakingDatagen: dataGenerator } +}) => { + const params = dataGenerator.generateStakingParams(); + const keys = dataGenerator.generateRandomKeyPair(); + const feeRate = 1; + const stakingAmount = dataGenerator.getRandomIntegerBetween( + params.minStakingAmountSat, params.maxStakingAmountSat, + ); + const finalityProviderPkNoCoordHex = dataGenerator.generateRandomKeyPair().publicKeyNoCoord; + const { stakingTx, timelock} = dataGenerator.generateRandomStakingTransaction( + keys, feeRate, stakingAmount, "nativeSegwit", params, + ); + const stakerPkNoCoordHex = keys.publicKeyNoCoord; + const stakerInfo = { + address: dataGenerator.getAddressAndScriptPubKey(keys.publicKey).nativeSegwit.address, + publicKeyNoCoordHex: keys.publicKeyNoCoord, + publicKeyWithCoord: keys.publicKey, + } + const staking = new Staking( + network, stakerInfo, + params, finalityProviderPkNoCoordHex, timelock, + + ); + const unbondingTx = staking.createUnbondingTransaction( + stakingTx, + ).psbt.signAllInputs(keys.keyPair).finalizeAllInputs().extractTransaction(); + + + beforeEach(() => { + jest.clearAllMocks(); + jest.resetAllMocks(); + jest.restoreAllMocks(); + }); + + describe("Create slash early unbonded transaction", () => { + it(`${networkName} should throw an error if fail to build scripts`, () => { + jest.spyOn(stakingScript, "StakingScriptData").mockImplementation(() => { + throw new Error("slash early unbonded delegation build script error"); + }); + + expect(() => staking.createUnbondingOutputSlashingTransaction( + unbondingTx, + )).toThrow("slash early unbonded delegation build script error"); + }); + + it(`${networkName} should throw an error if fail to build early unbonded slash tx`, () => { + jest.spyOn(transaction, "slashEarlyUnbondedTransaction").mockImplementation(() => { + throw new Error("fail to build slash tx"); + }); + expect(() => staking.createUnbondingOutputSlashingTransaction( + unbondingTx, + )).toThrow("fail to build slash tx"); + }); + + it(`${networkName} should create slash early unbonded transaction`, () => { + const slashTx = staking.createUnbondingOutputSlashingTransaction( + unbondingTx, + ); + expect(slashTx.psbt.txInputs.length).toBe(1) + expect(slashTx.psbt.txInputs[0].hash.toString("hex")). + toBe(unbondingTx.getHash().toString("hex")); + expect(slashTx.psbt.txInputs[0].index).toBe(0); + // verify outputs + expect(slashTx.psbt.txOutputs.length).toBe(2); + // slash amount + const stakingAmountLeftInUnbondingTx = unbondingTx.outs[0].value; + const slashAmount = Math.floor(stakingAmountLeftInUnbondingTx * params.slashing!.slashingRate); + expect(slashTx.psbt.txOutputs[0].value).toBe( + slashAmount, + ); + expect(Buffer.from(slashTx.psbt.txOutputs[0].script).toString("hex")).toBe( + params.slashing!.slashingPkScriptHex + ); + // change output + const unbondingTimelockScript = script.compile([ + Buffer.from(stakerPkNoCoordHex, "hex"), + opcodes.OP_CHECKSIGVERIFY, + script.number.encode(params.unbondingTime), + opcodes.OP_CHECKSEQUENCEVERIFY, + ]); + const { address } = payments.p2tr({ + internalPubkey, + scriptTree: { output: unbondingTimelockScript }, + network, + }); + expect(slashTx.psbt.txOutputs[1].address).toBe(address); + const userFunds = stakingAmountLeftInUnbondingTx - slashAmount - params.slashing!.minSlashingTxFeeSat; + expect(slashTx.psbt.txOutputs[1].value).toBe(userFunds); + expect(slashTx.psbt.locktime).toBe(0); + expect(slashTx.psbt.version).toBe(2); + }); + }); + + describe("Create slash timelock unbonded transaction", () => { + it(`${networkName} should throw an error if fail to build scripts`, async () => { + jest.spyOn(stakingScript, "StakingScriptData").mockImplementation(() => { + throw new Error("slash timelock unbonded delegation build script error"); + }); + const staking = new Staking( + network, stakerInfo, + params, finalityProviderPkNoCoordHex, timelock, + ); + + expect(() => staking.createStakingOutputSlashingTransaction( + stakingTx, + )).toThrow("slash timelock unbonded delegation build script error"); + }); + + it(`${networkName} should throw an error if fail to build timelock unbonded slash tx`, async () => { + jest.spyOn(transaction, "slashTimelockUnbondedTransaction").mockImplementation(() => { + throw new Error("fail to build slash tx"); + }); + + expect(() => staking.createStakingOutputSlashingTransaction( + stakingTx, + )).toThrow("fail to build slash tx"); + }); + + it(`${networkName} should create slash timelock unbonded transaction`, async () => { + const slashTx = staking.createStakingOutputSlashingTransaction( + stakingTx, + ); + expect(slashTx.psbt.txInputs.length).toBe(1) + expect(slashTx.psbt.txInputs[0].hash.toString("hex")). + toBe(stakingTx.getHash().toString("hex")); + expect(slashTx.psbt.txInputs[0].index).toBe(0); + // verify outputs + expect(slashTx.psbt.txOutputs.length).toBe(2); + // slash amount + const slashAmount = Math.floor(stakingAmount * params.slashing!.slashingRate); + expect(slashTx.psbt.txOutputs[0].value).toBe( + slashAmount, + ); + expect(Buffer.from(slashTx.psbt.txOutputs[0].script).toString("hex")).toBe( + params.slashing!.slashingPkScriptHex + ); + // change output + const unbondingTimelockScript = script.compile([ + Buffer.from(stakerPkNoCoordHex, "hex"), + opcodes.OP_CHECKSIGVERIFY, + script.number.encode(params.unbondingTime), + opcodes.OP_CHECKSEQUENCEVERIFY, + ]); + const { address } = payments.p2tr({ + internalPubkey, + scriptTree: { output: unbondingTimelockScript }, + network, + }); + expect(slashTx.psbt.txOutputs[1].address).toBe(address); + const userFunds = stakingAmount - slashAmount - params.slashing!.minSlashingTxFeeSat; + expect(slashTx.psbt.txOutputs[1].value).toBe(userFunds); + expect(slashTx.psbt.locktime).toBe(0); + expect(slashTx.psbt.version).toBe(2); + }); + }); +}); + diff --git a/tests/staking/createStakingTx.test.ts b/tests/staking/createStakingTx.test.ts index 9bc5f24..2283749 100644 --- a/tests/staking/createStakingTx.test.ts +++ b/tests/staking/createStakingTx.test.ts @@ -49,7 +49,10 @@ describe.each(testingNetworks)("Create staking transaction", ({ address: stakerInfo.address, publicKeyNoCoordHex: stakerInfo.publicKeyWithCoord, }; - expect(() => new Staking(network, stakerInfoWithCoordPk)).toThrow( + expect(() => new Staking( + network, stakerInfoWithCoordPk, + params, finalityProviderPublicKey, timelock, + )).toThrow( "Invalid staker public key" ); @@ -57,7 +60,10 @@ describe.each(testingNetworks)("Create staking transaction", ({ address: "abc", publicKeyNoCoordHex: stakerInfo.publicKeyNoCoordHex, }; - expect(() => new Staking(network, stakerInfoWithInvalidAddress)).toThrow( + expect(() => new Staking( + network, stakerInfoWithInvalidAddress, + params, finalityProviderPublicKey, timelock, + )).toThrow( "Invalid staker bitcoin address" ); }); @@ -66,13 +72,13 @@ describe.each(testingNetworks)("Create staking transaction", ({ jest.spyOn(stakingUtils, "validateStakingTxInputData").mockImplementation(() => { throw new StakingError(StakingErrorCode.INVALID_INPUT, "some error"); }); - const staking = new Staking(network, stakerInfo); + const staking = new Staking( + network, stakerInfo, + params, finalityProviderPublicKey, timelock, + ); expect(() => staking.createStakingTransaction( - params, params.minStakingAmountSat, - timelock, - finalityProviderPublicKey, utxos, feeRate, )).toThrow( @@ -84,13 +90,13 @@ describe.each(testingNetworks)("Create staking transaction", ({ jest.spyOn(stakingScript, "StakingScriptData").mockImplementation(() => { throw new StakingError(StakingErrorCode.SCRIPT_FAILURE, "some error"); }); - const staking = new Staking(network, stakerInfo); + const staking = new Staking( + network, stakerInfo, + params, finalityProviderPublicKey, timelock, + ); expect(() => staking.createStakingTransaction( - params, params.minStakingAmountSat, - timelock, - finalityProviderPublicKey, utxos, feeRate, )).toThrow( @@ -102,13 +108,13 @@ describe.each(testingNetworks)("Create staking transaction", ({ jest.spyOn(stakingTx, "stakingTransaction").mockImplementation(() => { throw new Error("fail to build staking tx"); }); - const staking = new Staking(network, stakerInfo); + const staking = new Staking( + network, stakerInfo, + params, finalityProviderPublicKey, timelock, + ); expect(() => staking.createStakingTransaction( - params, params.minStakingAmountSat, - timelock, - finalityProviderPublicKey, utxos, feeRate, )).toThrow( @@ -117,15 +123,15 @@ describe.each(testingNetworks)("Create staking transaction", ({ }); it(`${networkName} should successfully create a staking transaction`, async () => { - const staking = new Staking(network, stakerInfo); + const staking = new Staking( + network, stakerInfo, + params, finalityProviderPublicKey, timelock, + ); const amount = dataGenerator.getRandomIntegerBetween( params.minStakingAmountSat, params.maxStakingAmountSat, ); const { psbt, fee} = staking.createStakingTransaction( - params, amount, - timelock, - finalityProviderPublicKey, utxos, feeRate, ); diff --git a/tests/staking/createUnbondingtx.test.ts b/tests/staking/createUnbondingtx.test.ts index c633f5b..dd1526e 100644 --- a/tests/staking/createUnbondingtx.test.ts +++ b/tests/staking/createUnbondingtx.test.ts @@ -5,6 +5,7 @@ import { StakingError, StakingErrorCode } from "../../src/error"; import { testingNetworks } from "../helper"; import { NON_RBF_SEQUENCE } from "../../src/constants/psbt"; import * as stakingScript from "../../src/staking/stakingScript"; +import { deriveStakingOutputAddress, findMatchingStakingTxOutputIndex } from "../../src/utils/staking"; describe.each(testingNetworks)("Create unbonding transaction", ({ network, networkName, datagen: { stakingDatagen : dataGenerator } @@ -19,81 +20,30 @@ describe.each(testingNetworks)("Create unbonding transaction", ({ const { stakingTx, timelock} = dataGenerator.generateRandomStakingTransaction( keys, feeRate, stakingAmount, "nativeSegwit", params, ); - const delegation = { - stakingTxHashHex: stakingTx.getId(), - stakerPkNoCoordHex: keys.publicKeyNoCoord, - finalityProviderPkNoCoordHex, - stakingTx, - stakingOutputIndex: 0, - startHeight: dataGenerator.getRandomIntegerBetween( - 700000, 800000, - ), - timelock, - } const stakerInfo = { address: dataGenerator.getAddressAndScriptPubKey(keys.publicKey).nativeSegwit.address, publicKeyNoCoordHex: keys.publicKeyNoCoord, publicKeyWithCoord: keys.publicKey, } + let staking: Staking; beforeEach(() => { jest.clearAllMocks(); jest.resetAllMocks(); jest.restoreAllMocks(); - }); - - it(`${networkName} should throw an error if delegation input is invalid`, async () => { - const staking = new Staking(network, stakerInfo); - - // Staking tx timelock is out of range - const invalidStakingTxTimelock = dataGenerator.getRandomIntegerBetween( - params.minStakingTimeBlocks - 100, params.minStakingTimeBlocks - 1, + staking = new Staking( + network, stakerInfo, + params, finalityProviderPkNoCoordHex, timelock, ); - const invalidDelegation2 = { - ...delegation, - timelock: invalidStakingTxTimelock, - } - expect(() => staking.createUnbondingTransaction(params, invalidDelegation2)) - .toThrow("Staking transaction timelock is out of range"); - - // Staker public key does not match - const invalidStakerPk = dataGenerator.generateRandomKeyPair().publicKeyNoCoord; - const invalidDelegation3 = { - ...delegation, - stakerPkNoCoordHex: invalidStakerPk, - } - expect(() => staking.createUnbondingTransaction(params, invalidDelegation3)) - .toThrow("Staker public key does not match between connected staker and delegation staker"); - - // Transaction output index is out of range - const invalidDelegation5 = { - ...delegation, - stakingOutputIndex: dataGenerator.getRandomIntegerBetween(100, 1000), - } - expect(() => staking.createUnbondingTransaction(params, invalidDelegation5)) - .toThrow("Staking transaction output index is out of range"); - - // StakingTxHashHex does not match from the staking transaction - const anotherTx = dataGenerator.generateRandomStakingTransaction(dataGenerator.generateRandomKeyPair()) - const invalidStakingTxHashHex = anotherTx.stakingTx.getHash().toString("hex"); - const invalidDelegation6 = { - ...delegation, - stakingTxHashHex: invalidStakingTxHashHex, - } - expect(() => staking.createUnbondingTransaction(params, invalidDelegation6)) - .toThrow("Staking transaction hash does not match between the btc transaction and the provided staking hash"); }); - it(`${networkName} should throw an error if fail to build scripts`, async () => { jest.spyOn(stakingScript, "StakingScriptData").mockImplementation(() => { throw new StakingError(StakingErrorCode.SCRIPT_FAILURE, "build script error"); }); - const staking = new Staking(network, stakerInfo); expect(() => staking.createUnbondingTransaction( - params, - delegation, + stakingTx, )).toThrow("build script error"); }); @@ -101,19 +51,19 @@ describe.each(testingNetworks)("Create unbonding transaction", ({ jest.spyOn(transaction, "unbondingTransaction").mockImplementation(() => { throw new Error("fail to build unbonding tx"); }); - const staking = new Staking(network, stakerInfo); - expect(() => staking.createUnbondingTransaction( - params, - delegation, + stakingTx, )).toThrow("fail to build unbonding tx"); }); it(`${networkName} should successfully create an unbonding transaction`, async () => { - const staking = new Staking(network, stakerInfo); const { psbt } = staking.createUnbondingTransaction( - params, - delegation, + stakingTx, + ); + const scripts = staking.buildScripts(); + + const stakingOutputIndex = findMatchingStakingTxOutputIndex( + stakingTx, deriveStakingOutputAddress(scripts, network), network, ); expect(psbt).toBeDefined(); @@ -124,10 +74,10 @@ describe.each(testingNetworks)("Create unbonding transaction", ({ expect(psbt.data.inputs[0].tapLeafScript?.length).toBe(1); expect(psbt.data.inputs[0].witnessUtxo?.value).toEqual(stakingAmount); expect(psbt.data.inputs[0].witnessUtxo?.script).toEqual( - delegation.stakingTx.outs[delegation.stakingOutputIndex].script, + stakingTx.outs[stakingOutputIndex].script, ); expect(psbt.txInputs[0].sequence).toEqual(NON_RBF_SEQUENCE); - expect(psbt.txInputs[0].index).toEqual(delegation.stakingOutputIndex); + expect(psbt.txInputs[0].index).toEqual(stakingOutputIndex); // Check the psbt outputs expect(psbt.txOutputs.length).toBe(1); diff --git a/tests/staking/createWithdrawTx.test.ts b/tests/staking/createWithdrawTx.test.ts index 9fb2e69..b42a0bb 100644 --- a/tests/staking/createWithdrawTx.test.ts +++ b/tests/staking/createWithdrawTx.test.ts @@ -2,7 +2,7 @@ import * as stakingScript from "../../src/staking/stakingScript"; import { testingNetworks } from "../helper"; import * as transaction from "../../src/staking/transactions"; import { getWithdrawTxFee } from "../../src/utils/fee"; -import { Delegation, Staking } from "../../src/staking"; +import { Staking } from "../../src/staking"; describe.each(testingNetworks)("Create withdrawal transactions", ({ network, networkName, datagen: { stakingDatagen: dataGenerator } @@ -17,26 +17,17 @@ describe.each(testingNetworks)("Create withdrawal transactions", ({ const { stakingTx, timelock} = dataGenerator.generateRandomStakingTransaction( keys, feeRate, stakingAmount, "nativeSegwit", params, ); - const delegation: Delegation = { - stakingTxHashHex: stakingTx.getId(), - stakerPkNoCoordHex: keys.publicKeyNoCoord, - finalityProviderPkNoCoordHex, - stakingTx, - stakingOutputIndex: 0, - startHeight: dataGenerator.getRandomIntegerBetween( - 700000, 800000, - ), - timelock, - } const stakerInfo = { address: dataGenerator.getAddressAndScriptPubKey(keys.publicKey).nativeSegwit.address, publicKeyNoCoordHex: keys.publicKeyNoCoord, publicKeyWithCoord: keys.publicKey, } - const staking = new Staking(network, stakerInfo); + const staking = new Staking( + network, stakerInfo, + params, finalityProviderPkNoCoordHex, timelock, + ); const unbondingTx = staking.createUnbondingTransaction( - params, - delegation, + stakingTx, ).psbt.signAllInputs(keys.keyPair).finalizeAllInputs().extractTransaction(); @@ -47,23 +38,12 @@ describe.each(testingNetworks)("Create withdrawal transactions", ({ }); describe("Create withdraw early unbonded transaction", () => { - it(`${networkName} should throw an error if delegation input is invalid`, () => { - jest.spyOn(staking, "validateDelegationInputs").mockImplementationOnce(() => { - throw new Error("Fail to validate delegation inputs"); - }); - expect(() => staking.createWithdrawEarlyUnbondedTransaction( - params, delegation, unbondingTx, feeRate, - )).toThrow("Fail to validate delegation inputs"); - }); - it(`${networkName} should throw an error if fail to build scripts`, () => { jest.spyOn(stakingScript, "StakingScriptData").mockImplementation(() => { throw new Error("withdraw early unbonded delegation build script error"); }); expect(() => staking.createWithdrawEarlyUnbondedTransaction( - params, - delegation, unbondingTx, feeRate, )).toThrow("withdraw early unbonded delegation build script error"); @@ -74,8 +54,6 @@ describe.each(testingNetworks)("Create withdrawal transactions", ({ throw new Error("fail to build withdraw tx"); }); expect(() => staking.createWithdrawEarlyUnbondedTransaction( - params, - delegation, unbondingTx, feeRate, )).toThrow("fail to build withdraw tx"); @@ -83,8 +61,6 @@ describe.each(testingNetworks)("Create withdrawal transactions", ({ it(`${networkName} should create withdraw early unbonded transaction`, () => { const withdrawTx = staking.createWithdrawEarlyUnbondedTransaction( - params, - delegation, unbondingTx, feeRate, ); @@ -104,24 +80,12 @@ describe.each(testingNetworks)("Create withdrawal transactions", ({ }); describe("Create timelock unbonded transaction", () => { - it(`${networkName} should throw an error if delegation input is invalid`, async () => { - jest.spyOn(staking, "validateDelegationInputs").mockImplementationOnce(() => { - throw new Error("Fail to validate delegation inputs"); - }); - expect(() => staking.createWithdrawTimelockUnbondedTransaction( - params, delegation, feeRate, - )).toThrow("Fail to validate delegation inputs"); - }); - it(`${networkName} should throw an error if fail to build scripts`, async () => { jest.spyOn(stakingScript, "StakingScriptData").mockImplementation(() => { throw new Error("withdraw timelock unbonded delegation build script error"); }); - const staking = new Staking(network, stakerInfo); - expect(() => staking.createWithdrawTimelockUnbondedTransaction( - params, - delegation, + stakingTx, feeRate, )).toThrow("withdraw timelock unbonded delegation build script error"); }); @@ -132,16 +96,14 @@ describe.each(testingNetworks)("Create withdrawal transactions", ({ }); expect(() => staking.createWithdrawTimelockUnbondedTransaction( - params, - delegation, + stakingTx, feeRate, )).toThrow("fail to build withdraw tx"); }); it(`${networkName} should create withdraw timelock unbonded transaction`, async () => { const withdrawTx = staking.createWithdrawTimelockUnbondedTransaction( - params, - delegation, + stakingTx, feeRate, ); expect(withdrawTx.psbt.txInputs.length).toBe(1) diff --git a/tests/staking/observable/createStakingTx.test.ts b/tests/staking/observable/createStakingTx.test.ts index 52fb3cd..7dff582 100644 --- a/tests/staking/observable/createStakingTx.test.ts +++ b/tests/staking/observable/createStakingTx.test.ts @@ -18,6 +18,7 @@ describe.each(testingNetworks)("Observal - Create staking transaction", ({ let timelock: number; let utxos: UTXO[]; const feeRate = 1; + let observableStaking: ObservableStaking; beforeEach(() => { jest.clearAllMocks(); @@ -28,7 +29,6 @@ describe.each(testingNetworks)("Observal - Create staking transaction", ({ const { address, scriptPubKey } = dataGenerator.getAddressAndScriptPubKey( publicKey, ).taproot; - stakerInfo = { address, publicKeyNoCoordHex: publicKeyNoCoord, @@ -42,19 +42,19 @@ describe.each(testingNetworks)("Observal - Create staking transaction", ({ dataGenerator.getRandomIntegerBetween(1, 10), scriptPubKey, ); + observableStaking = new ObservableStaking( + network, stakerInfo, + params, finalityProviderPkNoCoord, timelock, + ); }); it(`${networkName} should throw an error if input data validation failed`, async () => { jest.spyOn(stakingUtils, "validateStakingTxInputData").mockImplementation(() => { throw new StakingError(StakingErrorCode.INVALID_INPUT, "some error"); }); - const observableStaking = new ObservableStaking(network, stakerInfo); expect(() => observableStaking.createStakingTransaction( - params, params.minStakingAmountSat, - timelock, - finalityProviderPkNoCoord, utxos, feeRate, )).toThrow( @@ -66,13 +66,8 @@ describe.each(testingNetworks)("Observal - Create staking transaction", ({ jest.spyOn(observableStakingScriptData, "ObservableStakingScriptData").mockImplementation(() => { throw new StakingError(StakingErrorCode.SCRIPT_FAILURE, "some error"); }); - const observableStaking = new ObservableStaking(network, stakerInfo); - expect(() => observableStaking.createStakingTransaction( - params, params.minStakingAmountSat, - timelock, - finalityProviderPkNoCoord, utxos, feeRate, )).toThrow( @@ -84,13 +79,9 @@ describe.each(testingNetworks)("Observal - Create staking transaction", ({ jest.spyOn(staking, "stakingTransaction").mockImplementation(() => { throw new Error("fail to build staking tx"); }); - const observableStaking = new ObservableStaking(network, stakerInfo); expect(() => observableStaking.createStakingTransaction( - params, params.minStakingAmountSat, - timelock, - finalityProviderPkNoCoord, utxos, feeRate, )).toThrow( @@ -99,15 +90,11 @@ describe.each(testingNetworks)("Observal - Create staking transaction", ({ }); it(`${networkName} should successfully create a observable staking transaction`, async () => { - const observableStaking = new ObservableStaking(network, stakerInfo); const amount = dataGenerator.getRandomIntegerBetween( params.minStakingAmountSat, params.maxStakingAmountSat, ); const { psbt, fee} = observableStaking.createStakingTransaction( - params, amount, - timelock, - finalityProviderPkNoCoord, utxos, feeRate, ); @@ -144,12 +131,7 @@ describe.each(testingNetworks)("Observal - Create staking transaction", ({ }); // Check the data embed script(OP_RETURN) - const scripts = observableStaking.buildScripts( - params, - finalityProviderPkNoCoord, - timelock, - stakerInfo.publicKeyNoCoordHex, - ); + const scripts = observableStaking.buildScripts(); psbt.txOutputs.find((output) => output.script.equals(scripts.dataEmbedScript), ); diff --git a/tests/staking/observable/validation.test.ts b/tests/staking/observable/validation.test.ts index ca0b744..244ec98 100644 --- a/tests/staking/observable/validation.test.ts +++ b/tests/staking/observable/validation.test.ts @@ -2,56 +2,15 @@ import { ObservableStaking } from '../../../src/staking/observable'; import { testingNetworks } from '../../helper'; -describe.each(testingNetworks)("ObservableStaking input validations", ({ - network, datagen: { observableStakingDatagen: dataGenerator } +describe.each(testingNetworks)("Observable", ({ + network, networkName, datagen: { observableStakingDatagen: dataGenerator } }) => { - describe('validateDelegationInputs', () => { - const params = dataGenerator.generateStakingParams(true); - const keys = dataGenerator.generateRandomKeyPair(); - const feeRate = 1; - const stakingAmount = dataGenerator.getRandomIntegerBetween( - params.minStakingAmountSat, params.maxStakingAmountSat, - ); - const finalityProviderPkNoCoordHex = dataGenerator.generateRandomKeyPair().publicKeyNoCoord; - const { stakingTx, timelock} = dataGenerator.generateRandomStakingTransaction( - keys, feeRate, stakingAmount, "nativeSegwit", params, - ); - const delegation = { - stakingTxHashHex: stakingTx.getId(), - stakerPkNoCoordHex: keys.publicKeyNoCoord, - finalityProviderPkNoCoordHex, - stakingTx, - stakingOutputIndex: 0, - startHeight: dataGenerator.getRandomIntegerBetween(700000, 800000), - timelock, - } - const stakerInfo = { - address: dataGenerator.getAddressAndScriptPubKey(keys.publicKey).nativeSegwit.address, - publicKeyNoCoordHex: keys.publicKeyNoCoord, - } - - const stakingInstance = new ObservableStaking(network, stakerInfo); - beforeEach(() => { - jest.restoreAllMocks(); - }); - - it('should throw an error if staking transaction start height is less than activation height', () => { - const invalidDelegation = { - ...delegation, - startHeight: params.activationHeight - 1, - }; - - expect(() => { - stakingInstance.validateDelegationInputs(invalidDelegation, params, stakerInfo); - }).toThrow('Staking transaction start height cannot be less than activation height'); - }); - }); - - describe('Observable - validateParams', () => { + describe(`${networkName} validateParams`, () => { const { publicKey, publicKeyNoCoord} = dataGenerator.generateRandomKeyPair(); const { address } = dataGenerator.getAddressAndScriptPubKey( publicKey, ).taproot; + const params = dataGenerator.generateStakingParams(true); const stakerInfo = { address, @@ -61,17 +20,32 @@ describe.each(testingNetworks)("ObservableStaking input validations", ({ const observable = new ObservableStaking( network, stakerInfo, + params, + publicKeyNoCoord, + dataGenerator.generateRandomTimelock(params), ); const validParams = dataGenerator.generateStakingParams(); it('should pass with valid parameters', () => { - expect(() => observable.validateParams(validParams)).not.toThrow(); + expect(() => new ObservableStaking( + network, + stakerInfo, + params, + publicKeyNoCoord, + dataGenerator.generateRandomTimelock(params), + )).not.toThrow(); }); it('should throw an error if no tag', () => { const params = { ...validParams, tag: "" }; - expect(() => observable.validateParams(params)).toThrow( + expect(() => new ObservableStaking( + network, + stakerInfo, + params, + publicKeyNoCoord, + dataGenerator.generateRandomTimelock(params), + )).toThrow( "Observable staking parameters must include tag" ); }); @@ -79,7 +53,13 @@ describe.each(testingNetworks)("ObservableStaking input validations", ({ it('should throw an error if no activationHeight', () => { const params = { ...validParams, activationHeight: 0 }; - expect(() => observable.validateParams(params)).toThrow( + expect(() => new ObservableStaking( + network, + stakerInfo, + params, + publicKeyNoCoord, + dataGenerator.generateRandomTimelock(params), + )).toThrow( "Observable staking parameters must include a positive activation height" ); }); diff --git a/tests/staking/stakingScript.test.ts b/tests/staking/stakingScript.test.ts index c7b4a0b..e8150ef 100644 --- a/tests/staking/stakingScript.test.ts +++ b/tests/staking/stakingScript.test.ts @@ -32,7 +32,6 @@ describe("stakingScript", () => { ); const stakingTimeLock = 65535; const unbondingTimeLock = 1000; - const magicBytes = Buffer.from("62626234", "hex"); describe("Error path", () => { it("should fail if the staker key is not 32 bytes", () => { diff --git a/tests/staking/transactions/slashingTransaction.test.ts b/tests/staking/transactions/slashingTransaction.test.ts index 450528f..2137987 100644 --- a/tests/staking/transactions/slashingTransaction.test.ts +++ b/tests/staking/transactions/slashingTransaction.test.ts @@ -8,6 +8,7 @@ import { BTC_DUST_SAT } from "../../../src/constants/dustSat"; import { internalPubkey } from "../../../src/constants/internalPubkey"; import { DEFAULT_TEST_FEE_RATE, testingNetworks } from "../../helper"; import { NON_RBF_SEQUENCE, TRANSACTION_VERSION } from "../../../src/constants/psbt"; +import { getRandomPaymentScriptHex } from "../../helper/datagen/base"; describe.each(testingNetworks)("Transactions - ", ( {network, networkName, datagen} @@ -16,9 +17,6 @@ describe.each(testingNetworks)("Transactions - ", ( dataGenerator ) => { const stakerKeyPair = dataGenerator.generateRandomKeyPair(); - const slashingAddress = dataGenerator.getAddressAndScriptPubKey( - stakerKeyPair.publicKey, - ).nativeSegwit.address; const stakingScripts = dataGenerator.generateMockStakingScripts(stakerKeyPair); const stakingAmount = @@ -35,6 +33,9 @@ describe.each(testingNetworks)("Transactions - ", ( stakingAmount - slashingAmount - BTC_DUST_SAT - 1, ); const defaultOutputIndex = 0; + const slashingPkScriptHex = getRandomPaymentScriptHex( + dataGenerator.generateRandomKeyPair().publicKey, + ); describe(`${networkName} - slashTimelockUnbondedTransaction`, () => { it("should throw an error if the slashing rate is not between 0 and 1", () => { @@ -42,7 +43,7 @@ describe.each(testingNetworks)("Transactions - ", ( slashTimelockUnbondedTransaction( stakingScripts, stakingTx, - slashingAddress, + slashingPkScriptHex, 0, minSlashingFee, network, @@ -54,7 +55,7 @@ describe.each(testingNetworks)("Transactions - ", ( slashTimelockUnbondedTransaction( stakingScripts, stakingTx, - slashingAddress, + slashingPkScriptHex, -0.1, minSlashingFee, network, @@ -66,7 +67,7 @@ describe.each(testingNetworks)("Transactions - ", ( slashTimelockUnbondedTransaction( stakingScripts, stakingTx, - slashingAddress, + slashingPkScriptHex, 1, minSlashingFee, network, @@ -78,7 +79,7 @@ describe.each(testingNetworks)("Transactions - ", ( slashTimelockUnbondedTransaction( stakingScripts, stakingTx, - slashingAddress, + slashingPkScriptHex, 1.1, minSlashingFee, network, @@ -92,7 +93,7 @@ describe.each(testingNetworks)("Transactions - ", ( slashTimelockUnbondedTransaction( stakingScripts, stakingTx, - slashingAddress, + slashingPkScriptHex, slashingRate, 0, network, @@ -106,7 +107,7 @@ describe.each(testingNetworks)("Transactions - ", ( slashTimelockUnbondedTransaction( stakingScripts, stakingTx, - slashingAddress, + slashingPkScriptHex, slashingRate, 1.2, network, @@ -120,7 +121,7 @@ describe.each(testingNetworks)("Transactions - ", ( slashTimelockUnbondedTransaction( stakingScripts, stakingTx, - slashingAddress, + slashingPkScriptHex, slashingRate, minSlashingFee, network, @@ -134,7 +135,7 @@ describe.each(testingNetworks)("Transactions - ", ( slashTimelockUnbondedTransaction( stakingScripts, stakingTx, - slashingAddress, + slashingPkScriptHex, slashingRate, minSlashingFee, network, @@ -146,7 +147,7 @@ describe.each(testingNetworks)("Transactions - ", ( slashTimelockUnbondedTransaction( stakingScripts, stakingTx, - slashingAddress, + slashingPkScriptHex, slashingRate, minSlashingFee, network, @@ -160,7 +161,7 @@ describe.each(testingNetworks)("Transactions - ", ( slashTimelockUnbondedTransaction( stakingScripts, stakingTx, - slashingAddress, + slashingPkScriptHex, slashingRate, minSlashingFee, network, @@ -174,7 +175,7 @@ describe.each(testingNetworks)("Transactions - ", ( slashTimelockUnbondedTransaction( stakingScripts, stakingTx, - slashingAddress, + slashingPkScriptHex, slashingRate, Math.ceil(stakingAmount * (1 - slashingRate) + 1), network, @@ -187,7 +188,7 @@ describe.each(testingNetworks)("Transactions - ", ( const { psbt } = slashTimelockUnbondedTransaction( stakingScripts, stakingTx, - slashingAddress, + slashingPkScriptHex, slashingRate, minSlashingFee, network, @@ -196,8 +197,8 @@ describe.each(testingNetworks)("Transactions - ", ( expect(psbt).toBeDefined(); expect(psbt.txOutputs.length).toBe(2); - // first output shall send slashed amount to the slashing address - expect(psbt.txOutputs[0].address).toBe(slashingAddress); + // first output shall send slashed amount to the slashing script + expect(Buffer.from(psbt.txOutputs[0].script).toString("hex")).toBe(slashingPkScriptHex); expect(psbt.txOutputs[0].value).toBe( Math.floor(stakingAmount * slashingRate), ); @@ -233,7 +234,7 @@ describe.each(testingNetworks)("Transactions - ", ( slashEarlyUnbondedTransaction( stakingScripts, unbondingTx, - slashingAddress, + slashingPkScriptHex, 0, minSlashingFee, network, @@ -244,7 +245,7 @@ describe.each(testingNetworks)("Transactions - ", ( slashEarlyUnbondedTransaction( stakingScripts, unbondingTx, - slashingAddress, + slashingPkScriptHex, -0.1, minSlashingFee, network, @@ -255,7 +256,7 @@ describe.each(testingNetworks)("Transactions - ", ( slashEarlyUnbondedTransaction( stakingScripts, unbondingTx, - slashingAddress, + slashingPkScriptHex, 1, minSlashingFee, network, @@ -266,7 +267,7 @@ describe.each(testingNetworks)("Transactions - ", ( slashEarlyUnbondedTransaction( stakingScripts, unbondingTx, - slashingAddress, + slashingPkScriptHex, 1.1, minSlashingFee, network, @@ -279,7 +280,7 @@ describe.each(testingNetworks)("Transactions - ", ( slashEarlyUnbondedTransaction( stakingScripts, unbondingTx, - slashingAddress, + slashingPkScriptHex, slashingRate, 0, network, @@ -301,7 +302,7 @@ describe.each(testingNetworks)("Transactions - ", ( slashEarlyUnbondedTransaction( stakingScripts, unbondingTxWithLimitedAmount, - slashingAddress, + slashingPkScriptHex, slashingRate, Math.ceil(stakingAmount * (1 - slashingRate) + 1), network, @@ -315,7 +316,7 @@ describe.each(testingNetworks)("Transactions - ", ( slashEarlyUnbondedTransaction( stakingScripts, unbondingTx, - slashingAddress, + slashingPkScriptHex, smallSlashingRate, minSlashingFee, network, @@ -327,7 +328,7 @@ describe.each(testingNetworks)("Transactions - ", ( const { psbt } = slashEarlyUnbondedTransaction( stakingScripts, unbondingTx, - slashingAddress, + slashingPkScriptHex, slashingRate, minSlashingFee, network, @@ -337,8 +338,8 @@ describe.each(testingNetworks)("Transactions - ", ( expect(psbt).toBeDefined(); expect(psbt.txOutputs.length).toBe(2); - // first output shall send slashed amount to the slashing address (i.e burn output) - expect(psbt.txOutputs[0].address).toBe(slashingAddress); + // first output shall send slashed amount to the slashing pk script (i.e burn output) + expect(Buffer.from(psbt.txOutputs[0].script).toString("hex")).toBe(slashingPkScriptHex); expect(psbt.txOutputs[0].value).toBe( Math.floor(unbondingTxOutputValue * slashingRate), ); diff --git a/tests/staking/validation.test.ts b/tests/staking/validation.test.ts index ce8dacd..09264dd 100644 --- a/tests/staking/validation.test.ts +++ b/tests/staking/validation.test.ts @@ -1,4 +1,5 @@ import { Staking } from '../../src'; +import * as utils from '../../src/utils/staking'; import { testingNetworks } from '../helper'; describe.each(testingNetworks)("Staking input validations", ({ @@ -15,86 +16,50 @@ describe.each(testingNetworks)("Staking input validations", ({ const { stakingTx, timelock} = dataGenerator.generateRandomStakingTransaction( keys, feeRate, stakingAmount, "nativeSegwit", params, ); - const delegation = { - stakingTxHashHex: stakingTx.getId(), - stakerPkNoCoordHex: keys.publicKeyNoCoord, - finalityProviderPkNoCoordHex, - stakingTx, - stakingOutputIndex: 0, - startHeight: dataGenerator.getRandomIntegerBetween(700000, 800000), - timelock, - } const stakerInfo = { address: dataGenerator.getAddressAndScriptPubKey(keys.publicKey).nativeSegwit.address, publicKeyNoCoordHex: keys.publicKeyNoCoord, } - const stakingInstance = new Staking(network, stakerInfo); + const stakingInstance = new Staking( + network, stakerInfo, + params, finalityProviderPkNoCoordHex, timelock, + ); beforeEach(() => { jest.restoreAllMocks(); }); it('should throw an error if the timelock is out of range', () => { - let invalidDelegation = { - ...delegation, - timelock: params.minStakingTimeBlocks - 1, - }; - expect(() => { - stakingInstance.validateDelegationInputs(invalidDelegation, params, stakerInfo); + new Staking( + network, stakerInfo, + params, finalityProviderPkNoCoordHex, params.minStakingTimeBlocks - 1, + ); }).toThrow('Staking transaction timelock is out of range'); - invalidDelegation = { - ...delegation, - timelock: params.maxStakingTimeBlocks + 1, - }; - expect(() => { - stakingInstance.validateDelegationInputs(invalidDelegation, params, stakerInfo); + new Staking( + network, stakerInfo, + params, finalityProviderPkNoCoordHex, params.maxStakingTimeBlocks + 1, + ); }).toThrow('Staking transaction timelock is out of range'); }); - it('should throw an error if the staker public key does not match', () => { - const invalidDelegation = { - ...delegation, - stakerPkNoCoordHex: dataGenerator.generateRandomKeyPair().publicKey - }; - - expect(() => { - stakingInstance.validateDelegationInputs(invalidDelegation, params, stakerInfo); - }).toThrow('Staker public key does not match between connected staker and delegation staker'); - }); - it('should throw an error if the output index is out of range', () => { - const invalidDelegation = { - ...delegation, - stakingOutputIndex: delegation.stakingTx.outs.length, - }; - + jest.spyOn(utils, "findMatchingStakingTxOutputIndex").mockImplementation(() => { + throw new Error('Staking transaction output index is out of range'); + }); expect(() => { - stakingInstance.validateDelegationInputs(invalidDelegation, params, stakerInfo); + stakingInstance.createWithdrawTimelockUnbondedTransaction( + stakingTx, feeRate, + ); }).toThrow('Staking transaction output index is out of range'); - }); - - it('should throw an error if the transaction hash does not match', () => { - const invalidDelegation = { - ...delegation, - stakingTxHashHex: dataGenerator.generateRandomTxId(), - }; - + expect(() => { - stakingInstance.validateDelegationInputs( - invalidDelegation, params, stakerInfo, + stakingInstance.createUnbondingTransaction( + stakingTx ); - }).toThrow( - 'Staking transaction hash does not match between the btc transaction and the provided staking hash', - ); - }); - - it('should validate input is valid', () => { - expect(() => { - stakingInstance.validateDelegationInputs(delegation, params, stakerInfo); - }).not.toThrow(); + }).toThrow('Staking transaction output index is out of range'); }); }); @@ -109,20 +74,48 @@ describe.each(testingNetworks)("Staking input validations", ({ publicKeyNoCoordHex: publicKeyNoCoord, publicKeyWithCoord: publicKey, }; + const finalityProviderPkNoCoordHex = dataGenerator.generateRandomKeyPair().publicKeyNoCoord; + const validParams = dataGenerator.generateStakingParams(); const stakingInstance = new Staking( network, stakerInfo, + validParams, + finalityProviderPkNoCoordHex, + dataGenerator.generateRandomTimelock(validParams), ); - const validParams = dataGenerator.generateStakingParams(); + it('should pass with valid parameters', () => { - expect(() => stakingInstance.validateParams(validParams)).not.toThrow(); + expect(() => new Staking( + network, + stakerInfo, + validParams, + finalityProviderPkNoCoordHex, + dataGenerator.generateRandomTimelock(validParams), + )).not.toThrow(); + }); + + it('should pass with valid parameters without slashing', () => { + const paramsWithoutSlashing = { ...validParams, slashing: undefined }; + expect(() => new Staking( + network, + stakerInfo, + paramsWithoutSlashing, + finalityProviderPkNoCoordHex, + dataGenerator.generateRandomTimelock(validParams), + )).not.toThrow(); }); it('should throw an error if covenant public keys are empty', () => { const params = { ...validParams, covenantNoCoordPks: [] }; - expect(() => stakingInstance.validateParams(params)).toThrow( + expect(() => new Staking( + network, + stakerInfo, + params, + finalityProviderPkNoCoordHex, + dataGenerator.generateRandomTimelock(validParams), + )).toThrow( 'Could not find any covenant public keys' ); }); @@ -133,7 +126,13 @@ describe.each(testingNetworks)("Staking input validations", ({ covenantNoCoordPks: validParams.covenantNoCoordPks.map(pk => '02' + pk ) }; - expect(() => stakingInstance.validateParams(params)).toThrow( + expect(() => new Staking( + network, + stakerInfo, + params, + finalityProviderPkNoCoordHex, + dataGenerator.generateRandomTimelock(validParams), + )).toThrow( 'Covenant public key should contains no coordinate' ); }); @@ -141,7 +140,13 @@ describe.each(testingNetworks)("Staking input validations", ({ it('should throw an error if covenant public keys are less than the quorum', () => { const params = { ...validParams, covenantQuorum: validParams.covenantNoCoordPks.length + 1 }; - expect(() => stakingInstance.validateParams(params)).toThrow( + expect(() => new Staking( + network, + stakerInfo, + params, + finalityProviderPkNoCoordHex, + dataGenerator.generateRandomTimelock(validParams), + )).toThrow( 'Covenant public keys must be greater than or equal to the quorum' ); }); @@ -149,7 +154,13 @@ describe.each(testingNetworks)("Staking input validations", ({ it('should throw an error if unbonding time is less than or equal to 0', () => { const params = { ...validParams, unbondingTime: 0 }; - expect(() => stakingInstance.validateParams(params)).toThrow( + expect(() => new Staking( + network, + stakerInfo, + params, + finalityProviderPkNoCoordHex, + dataGenerator.generateRandomTimelock(validParams), + )).toThrow( 'Unbonding time must be greater than 0' ); }); @@ -157,7 +168,13 @@ describe.each(testingNetworks)("Staking input validations", ({ it('should throw an error if unbonding fee is less than or equal to 0', () => { const params = { ...validParams, unbondingFeeSat: 0 }; - expect(() => stakingInstance.validateParams(params)).toThrow( + expect(() => new Staking( + network, + stakerInfo, + params, + finalityProviderPkNoCoordHex, + dataGenerator.generateRandomTimelock(validParams), + )).toThrow( 'Unbonding fee must be greater than 0' ); }); @@ -165,21 +182,39 @@ describe.each(testingNetworks)("Staking input validations", ({ it('should throw an error if max staking amount is less than min staking amount', () => { const params = { ...validParams, maxStakingAmountSat: 500 }; - expect(() => stakingInstance.validateParams(params)).toThrow( + expect(() => new Staking( + network, + stakerInfo, + params, + finalityProviderPkNoCoordHex, + dataGenerator.generateRandomTimelock(validParams), + )).toThrow( 'Max staking amount must be greater or equal to min staking amount' ); }); it('should throw an error if min staking amount is less than 1', () => { - const paramsMinutes = { ...validParams, minStakingAmountSat: -1 }; + const params = { ...validParams, minStakingAmountSat: -1 }; - expect(() => stakingInstance.validateParams(paramsMinutes)).toThrow( + expect(() => new Staking( + network, + stakerInfo, + params, + finalityProviderPkNoCoordHex, + dataGenerator.generateRandomTimelock(validParams), + )).toThrow( 'Min staking amount must be greater than unbonding fee plus 1000' ); const params0 = { ...validParams, minStakingAmountSat: 0 }; - expect(() => stakingInstance.validateParams(params0)).toThrow( + expect(() => new Staking( + network, + stakerInfo, + params0, + finalityProviderPkNoCoordHex, + dataGenerator.generateRandomTimelock(validParams), + )).toThrow( 'Min staking amount must be greater than unbonding fee plus 1000' ); }); @@ -187,21 +222,39 @@ describe.each(testingNetworks)("Staking input validations", ({ it('should throw an error if max staking time is less than min staking time', () => { const params = { ...validParams, maxStakingTimeBlocks: validParams.minStakingTimeBlocks - 1 }; - expect(() => stakingInstance.validateParams(params)).toThrow( + expect(() => new Staking( + network, + stakerInfo, + params, + finalityProviderPkNoCoordHex, + dataGenerator.generateRandomTimelock(validParams), + )).toThrow( 'Max staking time must be greater or equal to min staking time' ); }); it('should throw an error if min staking time is less than 1', () => { - const paramsMinutes = { ...validParams, minStakingTimeBlocks: -1 }; + const params = { ...validParams, minStakingTimeBlocks: -1 }; - expect(() => stakingInstance.validateParams(paramsMinutes)).toThrow( + expect(() => new Staking( + network, + stakerInfo, + params, + finalityProviderPkNoCoordHex, + dataGenerator.generateRandomTimelock(validParams), + )).toThrow( 'Min staking time must be greater than 0' ); const params0 = { ...validParams, minStakingTimeBlocks: 0 }; - expect(() => stakingInstance.validateParams(params0)).toThrow( + expect(() => new Staking( + network, + stakerInfo, + params0, + finalityProviderPkNoCoordHex, + dataGenerator.generateRandomTimelock(validParams), + )).toThrow( 'Min staking time must be greater than 0' ); }); @@ -209,10 +262,82 @@ describe.each(testingNetworks)("Staking input validations", ({ it('should throw an error if covenant quorum is less than or equal to 0', () => { const params = { ...validParams, covenantQuorum: 0 }; - expect(() => stakingInstance.validateParams(params)).toThrow( + expect(() => new Staking( + network, + stakerInfo, + params, + finalityProviderPkNoCoordHex, + dataGenerator.generateRandomTimelock(validParams), + )).toThrow( 'Covenant quorum must be greater than 0' ); }); + + it('should throw an error if slashing rate is not within the range', () => { + const params0 = { ...validParams, slashing: { + ...validParams.slashing!, + slashingRate: 0, + } }; + + expect(() => new Staking( + network, + stakerInfo, + params0, + finalityProviderPkNoCoordHex, + dataGenerator.generateRandomTimelock(validParams), + )).toThrow( + 'Slashing rate must be greater than 0' + ); + + const params1 = { ...validParams, slashing: { + ...validParams.slashing!, + slashingRate: 1.1, + } }; + + expect(() => new Staking( + network, + stakerInfo, + params1, + finalityProviderPkNoCoordHex, + dataGenerator.generateRandomTimelock(validParams), + )).toThrow( + 'Slashing rate must be less or equal to 1' + ); + }); + + it('should throw an error if slashing public key scrit is empty', () => { + const params = { ...validParams, slashing: { + ...validParams.slashing!, + slashingPkScriptHex: "", + } }; + + expect(() => new Staking( + network, + stakerInfo, + params, + finalityProviderPkNoCoordHex, + dataGenerator.generateRandomTimelock(validParams), + )).toThrow( + 'Slashing public key script is missing' + ); + }); + + it('should throw an error if minSlashingTxFeeSat is not positive number', () => { + const params = { ...validParams, slashing: { + ...validParams.slashing!, + minSlashingTxFeeSat: 0, + } }; + + expect(() => new Staking( + network, + stakerInfo, + params, + finalityProviderPkNoCoordHex, + dataGenerator.generateRandomTimelock(validParams), + )).toThrow( + 'Minimum slashing transaction fee must be greater than 0' + ); + }); }); }); diff --git a/tests/utils/btc.test.ts b/tests/utils/btc.test.ts index efe38fe..a681dde 100644 --- a/tests/utils/btc.test.ts +++ b/tests/utils/btc.test.ts @@ -1,10 +1,13 @@ +import { payments } from "bitcoinjs-lib"; import { getPublicKeyNoCoord, isTaproot, isValidNoCoordPublicKey } from '../../src/utils/btc'; import { networks } from 'bitcoinjs-lib'; import { testingNetworks } from '../helper'; +import { Staking } from '../../src'; +import { deriveStakingOutputAddress } from '../../src/utils/staking'; describe('isTaproot', () => { describe.each(testingNetworks)('should return true for a valid Taproot address', - ({ network, datagen: {stakingDatagen: dataGenerator} }) => { + ({ network, datagen: { stakingDatagen: dataGenerator } }) => { const addresses = dataGenerator.getAddressAndScriptPubKey( dataGenerator.generateRandomKeyPair().publicKey ); @@ -65,7 +68,7 @@ describe('isTaproot', () => { describe.each(testingNetworks)('public keys', ({ datagen: { stakingDatagen: dataGenerator -} }) => { +}}) => { const { publicKey, publicKeyNoCoord } = dataGenerator.generateRandomKeyPair() describe('isValidNoCoordPublicKey', () => { it('should return true for a valid public key without a coordinate', () => { @@ -97,3 +100,65 @@ describe.each(testingNetworks)('public keys', ({ datagen: { }); }); }); + +describe.each(testingNetworks)('Derive staking output address', ({ + network, + datagen: { + stakingDatagen: dataGenerator + } +}) => { + const params = dataGenerator.generateStakingParams(); + const keys = dataGenerator.generateRandomKeyPair(); + const feeRate = 1; + const stakingAmount = dataGenerator.getRandomIntegerBetween( + params.minStakingAmountSat, params.maxStakingAmountSat, + ); + const finalityProviderPkNoCoordHex = dataGenerator.generateRandomKeyPair().publicKeyNoCoord; + const { timelock} = dataGenerator.generateRandomStakingTransaction( + keys, feeRate, stakingAmount, "nativeSegwit", params, + ); + const stakerInfo = { + address: dataGenerator.getAddressAndScriptPubKey(keys.publicKey).nativeSegwit.address, + publicKeyNoCoordHex: keys.publicKeyNoCoord, + publicKeyWithCoord: keys.publicKey, + } + + + describe("should derive the staking output address from the scripts", () => { + const staking = new Staking( + network, stakerInfo, + params, finalityProviderPkNoCoordHex, timelock, + ); + const scripts = staking.buildScripts(); + const slashingAddress = deriveStakingOutputAddress( + scripts, network + ); + expect(isTaproot(slashingAddress, network)).toBe(true); + }); + + it("should throw an error if no address available from creation of pay-2-taproot output", () => { + jest.spyOn(payments, "p2tr").mockImplementation(() => { + return {}; + }); + const staking = new Staking( + network, stakerInfo, + params, finalityProviderPkNoCoordHex, timelock, + ); + const scripts = staking.buildScripts(); + expect(() => deriveStakingOutputAddress(scripts, network)) + .toThrow("Failed to build staking output"); + }); + + it("should throw an error if fail to create pay-2-taproot output", () => { + jest.spyOn(payments, "p2tr").mockImplementation(() => { + throw new Error("oops"); + }); + const staking = new Staking( + network, stakerInfo, + params, finalityProviderPkNoCoordHex, timelock, + ); + const scripts = staking.buildScripts(); + expect(() => deriveStakingOutputAddress(scripts, network)) + .toThrow("oops"); + }); +}); \ No newline at end of file diff --git a/tests/utils/staking/index.test.ts b/tests/utils/staking/index.test.ts index 152beb8..4d211c0 100644 --- a/tests/utils/staking/index.test.ts +++ b/tests/utils/staking/index.test.ts @@ -14,7 +14,6 @@ describe.each(testingNetworks)('validateStakingTxInputData', ( ); const numberOfUTXOs = dataGenerator.getRandomIntegerBetween(1, 10); const validInputUTXOs = dataGenerator.generateRandomUTXOs(balance, numberOfUTXOs); - const { publicKeyNoCoord : finalityProviderPublicKey } = dataGenerator.generateRandomKeyPair(); const feeRate = 1; it('should pass with valid staking amount, term, UTXOs, and fee rate', () => { @@ -25,7 +24,6 @@ describe.each(testingNetworks)('validateStakingTxInputData', ( params, validInputUTXOs, feeRate, - finalityProviderPublicKey, ) ).not.toThrow(); }); @@ -38,7 +36,6 @@ describe.each(testingNetworks)('validateStakingTxInputData', ( params, validInputUTXOs, feeRate, - finalityProviderPublicKey, ) ).toThrow('Invalid staking amount'); }); @@ -51,7 +48,6 @@ describe.each(testingNetworks)('validateStakingTxInputData', ( params, validInputUTXOs, feeRate, - finalityProviderPublicKey, ) ).toThrow('Invalid staking amount'); }); @@ -64,7 +60,6 @@ describe.each(testingNetworks)('validateStakingTxInputData', ( params, validInputUTXOs, feeRate, - finalityProviderPublicKey, ) ).toThrow('Invalid timelock'); }); @@ -77,7 +72,6 @@ describe.each(testingNetworks)('validateStakingTxInputData', ( params, validInputUTXOs, feeRate, - finalityProviderPublicKey, ) ).toThrow('Invalid timelock'); }); @@ -90,7 +84,6 @@ describe.each(testingNetworks)('validateStakingTxInputData', ( params, [], feeRate, - finalityProviderPublicKey, ) ).toThrow('No input UTXOs provided'); }); @@ -103,24 +96,8 @@ describe.each(testingNetworks)('validateStakingTxInputData', ( params, validInputUTXOs, 0, - finalityProviderPublicKey, ) ).toThrow('Invalid fee rate'); }); - - it('should throw an error if finality provider public key contains coordinates', () => { - const invalidFinalityProviderPublicKey = '02' + finalityProviderPublicKey; - - expect(() => - validateStakingTxInputData( - params.maxStakingAmountSat, - params.maxStakingTimeBlocks, - params, - validInputUTXOs, - feeRate, - invalidFinalityProviderPublicKey, - ) - ).toThrow('Finality provider public key should contains no coordinate'); - }); }); }); \ No newline at end of file