diff --git a/README.md b/README.md index 51e4e4e..ae94690 100644 --- a/README.md +++ b/README.md @@ -357,7 +357,7 @@ These are Babylon parameters and should be collected from the Babylon system. ```ts // The address to which the slashed funds should go to. const slashingAddress: string = ""; -// The slashing percentage rate. +// The slashing percentage rate. It shall be decimal number between 0-1 const slashingRate: number = 0; // The required fee for the slashing transaction in satoshis. const minimumSlashingFee: number = 500; diff --git a/package-lock.json b/package-lock.json index c4f112e..205f9eb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "btc-staking-ts", - "version": "0.3.0-canary.5", + "version": "0.3.0-canary.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "btc-staking-ts", - "version": "0.3.0-canary.5", + "version": "0.3.0-canary.6", "license": "SEE LICENSE IN LICENSE", "dependencies": { "@bitcoin-js/tiny-secp256k1-asmjs": "^2.2.3", diff --git a/package.json b/package.json index 5a85b0f..73cfd9a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "btc-staking-ts", - "version": "0.3.0-canary.5", + "version": "0.3.0-canary.6", "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/index.ts b/src/index.ts index 3dc8e07..d803d5b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -431,7 +431,7 @@ export function slashTimelockUnbondedTransaction( * - 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 transaction. + * - 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. @@ -442,7 +442,7 @@ export function slashTimelockUnbondedTransaction( * - psbt: The partially signed transaction (PSBT). * * @param {Object} scripts - The scripts used in the transaction. e.g slashingScript, unbondingTimelockScript - * @param {Transaction} unbondingTx - The original staking transaction. + * @param {Transaction} unbondingTx - The unbonding transaction. * @param {string} slashingAddress - The address 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. @@ -497,7 +497,7 @@ export function slashEarlyUnbondedTransaction( * - 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 transaction. + * - 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. @@ -505,9 +505,9 @@ export function slashEarlyUnbondedTransaction( * - 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 transaction. + * @param {Transaction} transaction - The original staking/unbonding transaction. * @param {string} slashingAddress - The address to send the slashed funds to. - * @param {number} slashingRate - The rate at which the funds are slashed. + * @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. * @param {number} [outputIndex=0] - The index of the output to be spent in the original transaction. @@ -529,13 +529,24 @@ function slashingTransaction( psbt: Psbt; } { // Check that slashing rate and minimum fee are bigger than 0 - if (slashingRate <= 0 || minimumFee <= 0) { - throw new Error("Slashing rate and minimum fee must be bigger than 0"); + if (slashingRate <= 0 || slashingRate >= 1) { + throw new Error("Slashing rate must be between 0 and 1"); + } + // Round the slashing rate to two decimal places + slashingRate = parseFloat(slashingRate.toFixed(2)); + // Minimum fee must be a postive integer + if (minimumFee <= 0 || !Number.isInteger(minimumFee)) { + throw new Error("Minimum fee must be a positve integer"); } // Check that outputIndex is bigger or equal to 0 - if (outputIndex < 0) { - throw new Error("Output index must be bigger or equal to 0"); + if (outputIndex < 0 || !Number.isInteger(outputIndex)) { + throw new Error("Output index must be an integer bigger or equal to 0"); + } + + // Check that outputIndex is within the bounds of the transaction + if (!transaction.outs[outputIndex]) { + throw new Error("Output index is out of range"); } const redeem = { @@ -556,31 +567,37 @@ function slashingTransaction( controlBlock: p2tr.witness![p2tr.witness!.length - 1], }; + const stakingAmount = transaction.outs[outputIndex].value; + // Slashing rate is a percentage of the staking amount, rounded down to + // the nearest integer to avoid sending decimal satoshis + const slashingAmount = Math.floor(stakingAmount * slashingRate); + if (slashingAmount <= BTC_DUST_SAT) { + throw new Error("Slashing amount is less than dust limit"); + } + + const userFunds = stakingAmount - slashingAmount - minimumFee; + if (userFunds <= BTC_DUST_SAT) { + throw new Error("User funds are less than dust limit"); + } + + const psbt = new Psbt({ network }); psbt.addInput({ hash: transaction.getHash(), index: outputIndex, tapInternalKey: internalPubkey, witnessUtxo: { - value: transaction.outs[outputIndex].value, + value: stakingAmount, script: transaction.outs[outputIndex].script, }, tapLeafScript: [tapLeafScript], }); - const userValue = - transaction.outs[outputIndex].value * (1 - slashingRate) - minimumFee; - - // We need to verify that this is above 0 - if (userValue <= 0) { - // If it is not, then an error is thrown and the user has to stake more - throw new Error("Not enough funds to slash, stake more"); - } // Add the slashing output psbt.addOutput({ address: slashingAddress, - value: transaction.outs[outputIndex].value * slashingRate, + value: slashingAmount, }); // Change output contains unbonding timelock script @@ -589,12 +606,10 @@ function slashingTransaction( scriptTree: { output: scripts.unbondingTimelockScript }, network, }); - // Add the change output psbt.addOutput({ address: changeOutput.address!, - value: - transaction.outs[outputIndex].value * (1 - slashingRate) - minimumFee, + value: userFunds, }); return { psbt }; diff --git a/tests/helper/dataGenerator.ts b/tests/helper/dataGenerator.ts index af5bab9..4b6cd57 100644 --- a/tests/helper/dataGenerator.ts +++ b/tests/helper/dataGenerator.ts @@ -63,6 +63,11 @@ export class DataGenerator { return Math.floor(Math.random() * 1000) + 1; }; + // Real values will likely be in range 0.01 to 0.30 + generateRandomSlashingRate(min: number = 0.01, max: number = 0.30): number { + return parseFloat((Math.random() * (max - min) + min).toFixed(2)); + } + // Convenant committee are a list of public keys that are used to sign a covenant generateRandomCovenantCommittee = (size: number): Buffer[] => { const committe: Buffer[] = []; diff --git a/tests/slashingTransaction.test.ts b/tests/slashingTransaction.test.ts new file mode 100644 index 0000000..319acae --- /dev/null +++ b/tests/slashingTransaction.test.ts @@ -0,0 +1,356 @@ +import { payments } from "bitcoinjs-lib"; +import { + slashEarlyUnbondedTransaction, + slashTimelockUnbondedTransaction, + unbondingTransaction, +} from "../src"; +import { BTC_DUST_SAT } from "../src/constants/dustSat"; +import { internalPubkey } from "../src/constants/internalPubkey"; +import { DEFAULT_TEST_FEE_RATE, testingNetworks } from "./helper"; + +describe("slashingTransaction - ", () => { + testingNetworks.map(({ network, networkName, dataGenerator }) => { + const stakerKeyPair = dataGenerator.generateRandomKeyPair(); + const slashingAddress = dataGenerator.getAddressAndScriptPubKey( + stakerKeyPair.publicKey, + ).nativeSegwit.address; + const stakingScripts = + dataGenerator.generateMockStakingScripts(stakerKeyPair); + const stakingAmount = + dataGenerator.getRandomIntegerBetween(1000, 100000) + 1000000; + const stakingTx = dataGenerator.generateRandomStakingTransaction( + stakerKeyPair, + DEFAULT_TEST_FEE_RATE, + stakingAmount, + ); + const slashingRate = dataGenerator.generateRandomSlashingRate(); + const slashingAmount = Math.floor(stakingAmount * slashingRate); + const minSlashingFee = dataGenerator.getRandomIntegerBetween( + 1, + stakingAmount - slashingAmount - BTC_DUST_SAT - 1, + ); + const defaultOutputIndex = 0; + + describe(`${networkName} - slashTimelockUnbondedTransaction`, () => { + it("should throw an error if the slashing rate is not between 0 and 1", () => { + expect(() => + slashTimelockUnbondedTransaction( + stakingScripts, + stakingTx, + slashingAddress, + 0, + minSlashingFee, + network, + defaultOutputIndex, + ), + ).toThrow("Slashing rate must be between 0 and 1"); + + expect(() => + slashTimelockUnbondedTransaction( + stakingScripts, + stakingTx, + slashingAddress, + -0.1, + minSlashingFee, + network, + defaultOutputIndex, + ), + ).toThrow("Slashing rate must be between 0 and 1"); + + expect(() => + slashTimelockUnbondedTransaction( + stakingScripts, + stakingTx, + slashingAddress, + 1, + minSlashingFee, + network, + defaultOutputIndex, + ), + ).toThrow("Slashing rate must be between 0 and 1"); + + expect(() => + slashTimelockUnbondedTransaction( + stakingScripts, + stakingTx, + slashingAddress, + 1.1, + minSlashingFee, + network, + defaultOutputIndex, + ), + ).toThrow("Slashing rate must be between 0 and 1"); + }); + + it("should throw an error if minimum slashing fee is less than 0", () => { + expect(() => + slashTimelockUnbondedTransaction( + stakingScripts, + stakingTx, + slashingAddress, + slashingRate, + 0, + network, + defaultOutputIndex, + ), + ).toThrow("Minimum fee must be a positve integer"); + }); + + it("should throw an error if minimum slashing fee is not integer", () => { + expect(() => + slashTimelockUnbondedTransaction( + stakingScripts, + stakingTx, + slashingAddress, + slashingRate, + 1.2, + network, + defaultOutputIndex, + ), + ).toThrow("Minimum fee must be a positve integer"); + }); + + it("should throw an error if the output index is less than 0", () => { + expect(() => + slashTimelockUnbondedTransaction( + stakingScripts, + stakingTx, + slashingAddress, + slashingRate, + minSlashingFee, + network, + -1, + ), + ).toThrow("Output index must be an integer bigger or equal to 0"); + }); + + it("should throw an error if the output index is not integer", () => { + expect(() => + slashTimelockUnbondedTransaction( + stakingScripts, + stakingTx, + slashingAddress, + slashingRate, + minSlashingFee, + network, + 1.2, + ), + ).toThrow("Output index must be an integer bigger or equal to 0"); + + expect(() => + slashTimelockUnbondedTransaction( + stakingScripts, + stakingTx, + slashingAddress, + slashingRate, + minSlashingFee, + network, + 0.5, + ), + ).toThrow("Output index must be an integer bigger or equal to 0"); + }); + + it("should throw an error if the output index is greater than the number of outputs", () => { + expect(() => + slashTimelockUnbondedTransaction( + stakingScripts, + stakingTx, + slashingAddress, + slashingRate, + minSlashingFee, + network, + stakingTx.outs.length, + ), + ).toThrow("Output index is out of range"); + }); + + it("should throw error if user funds after slashing and fees is less than dust", () => { + expect(() => + slashTimelockUnbondedTransaction( + stakingScripts, + stakingTx, + slashingAddress, + slashingRate, + Math.ceil(stakingAmount * (1 - slashingRate) + 1), + network, + 0, + ), + ).toThrow("User funds are less than dust limit"); + }); + + it("should create the slashing time lock unbonded tx psbt successfully", () => { + const { psbt } = slashTimelockUnbondedTransaction( + stakingScripts, + stakingTx, + slashingAddress, + slashingRate, + minSlashingFee, + network, + 0, + ); + + 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); + expect(psbt.txOutputs[0].value).toBe( + Math.floor(stakingAmount * slashingRate), + ); + + // second output is the change output which send to unbonding timelock script address + const changeOutput = payments.p2tr({ + internalPubkey, + scriptTree: { output: stakingScripts.unbondingTimelockScript }, + network, + }); + expect(psbt.txOutputs[1].address).toBe(changeOutput.address); + const expectedChangeOutputValue = + stakingAmount - + Math.floor(stakingAmount * slashingRate) - + minSlashingFee; + expect(psbt.txOutputs[1].value).toBe(expectedChangeOutputValue); + }); + }); + + describe(`${networkName} slashEarlyUnbondedTransaction - `, () => { + const unbondingTx = unbondingTransaction( + stakingScripts, + stakingTx, + 1, + network, + ) + .psbt.signAllInputs(stakerKeyPair.keyPair) + .finalizeAllInputs() + .extractTransaction(); + + it("should throw an error if the slashing rate is not between 0 and 1", () => { + expect(() => + slashEarlyUnbondedTransaction( + stakingScripts, + unbondingTx, + slashingAddress, + 0, + minSlashingFee, + network, + ), + ).toThrow("Slashing rate must be between 0 and 1"); + + expect(() => + slashEarlyUnbondedTransaction( + stakingScripts, + unbondingTx, + slashingAddress, + -0.1, + minSlashingFee, + network, + ), + ).toThrow("Slashing rate must be between 0 and 1"); + + expect(() => + slashEarlyUnbondedTransaction( + stakingScripts, + unbondingTx, + slashingAddress, + 1, + minSlashingFee, + network, + ), + ).toThrow("Slashing rate must be between 0 and 1"); + + expect(() => + slashEarlyUnbondedTransaction( + stakingScripts, + unbondingTx, + slashingAddress, + 1.1, + minSlashingFee, + network, + ), + ).toThrow("Slashing rate must be between 0 and 1"); + }); + + it("should throw an error if minimum slashing fee is less than 0", () => { + expect(() => + slashEarlyUnbondedTransaction( + stakingScripts, + unbondingTx, + slashingAddress, + slashingRate, + 0, + network, + ), + ).toThrow("Minimum fee must be a positve integer"); + }); + + it("should throw error if user funds is less than dust", () => { + const unbondingTxWithLimitedAmount = unbondingTransaction( + stakingScripts, + stakingTx, + 1, + network, + ) + .psbt.signAllInputs(stakerKeyPair.keyPair) + .finalizeAllInputs() + .extractTransaction(); + expect(() => + slashEarlyUnbondedTransaction( + stakingScripts, + unbondingTxWithLimitedAmount, + slashingAddress, + slashingRate, + Math.ceil(stakingAmount * (1 - slashingRate) + 1), + network, + ), + ).toThrow("User funds are less than dust limit"); + }); + + it("should throw if its slashing amount is less than dust", () => { + const smallSlashingRate = BTC_DUST_SAT / stakingAmount; + expect(() => + slashEarlyUnbondedTransaction( + stakingScripts, + unbondingTx, + slashingAddress, + smallSlashingRate, + minSlashingFee, + network, + ) + ).toThrow("Slashing amount is less than dust limit"); + }); + + it("should create the slashing time lock unbonded tx psbt successfully", () => { + const { psbt } = slashEarlyUnbondedTransaction( + stakingScripts, + unbondingTx, + slashingAddress, + slashingRate, + minSlashingFee, + network, + ); + + const unbondingTxOutputValue = unbondingTx.outs[0].value; + + 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); + expect(psbt.txOutputs[0].value).toBe( + Math.floor(unbondingTxOutputValue * slashingRate), + ); + + // second output is the change output which send to unbonding timelock script address + const changeOutput = payments.p2tr({ + internalPubkey, + scriptTree: { output: stakingScripts.unbondingTimelockScript }, + network, + }); + expect(psbt.txOutputs[1].address).toBe(changeOutput.address); + const expectedChangeOutputValue = + unbondingTxOutputValue - + Math.floor(unbondingTxOutputValue * slashingRate) - + minSlashingFee; + expect(psbt.txOutputs[1].value).toBe(expectedChangeOutputValue); + }); + }); + }); +}); diff --git a/tests/unbondingTransaction.test.ts b/tests/unbondingTransaction.test.ts index b90d73d..5335833 100644 --- a/tests/unbondingTransaction.test.ts +++ b/tests/unbondingTransaction.test.ts @@ -53,7 +53,7 @@ describe("Unbonding Transaction - ", () => { const unbondingFee = dataGenerator.getRandomIntegerBetween( 1, - stakingAmount - BTC_DUST_SAT - 1, + stakingAmount - BTC_DUST_SAT - 1, ); const { psbt } = unbondingTransaction( stakingScripts,