PHP WebShell
Текущая директория: /opt/BitGoJS/modules/babylonlabs-io-btc-staking-ts/src/staking
Просмотр файла: transactions.ts
import { Psbt, Transaction, networks, payments, script, address, opcodes } from "bitcoinjs-lib";
import { Taptree } from "bitcoinjs-lib/src/types";
import { BTC_DUST_SAT } from "../constants/dustSat";
import { internalPubkey } from "../constants/internalPubkey";
import { UTXO } from "../types/UTXO";
import { PsbtResult, TransactionResult } from "../types/transaction";
import { isValidBitcoinAddress, transactionIdToHash } from "../utils/btc";
import { getStakingTxInputUTXOsAndFees, getWithdrawTxFee } from "../utils/fee";
import { inputValueSum } from "../utils/fee/utils";
import { buildStakingTransactionOutputs, deriveUnbondingOutputInfo } from "../utils/staking";
import { NON_RBF_SEQUENCE, TRANSACTION_VERSION } from "../constants/psbt";
import { CovenantSignature } from "../types/covenantSignatures";
import { REDEEM_VERSION } from "../constants/transaction";
// https://bips.xyz/370
const BTC_LOCKTIME_HEIGHT_TIME_CUTOFF = 500000000;
const BTC_SLASHING_FRACTION_DIGITS = 4;
/**
* Constructs an unsigned BTC Staking transaction in psbt format.
*
* Outputs:
* - psbt:
* - The first output corresponds to the staking script with the specified amount.
* - The second output corresponds to the change from spending the amount and the transaction fee.
* - If a data embed script is provided, it will be added as the second output, and the fee will be the third output.
* - fee: The total fee amount for the transaction.
*
* Inputs:
* - scripts:
* - timelockScript, unbondingScript, slashingScript: Scripts for different transaction types.
* - dataEmbedScript: Optional data embed script.
* - amount: Amount to stake.
* - changeAddress: Address to send the change to.
* - inputUTXOs: All available UTXOs from the wallet.
* - network: Bitcoin network.
* - feeRate: Fee rate in satoshis per byte.
* - publicKeyNoCoord: Public key if the wallet is in taproot mode.
* - lockHeight: Optional block height locktime to set for the transaction (i.e., not mined until the block height).
*
* @param {Object} scripts - Scripts used to construct the taproot output.
* such as timelockScript, unbondingScript, slashingScript, and dataEmbedScript.
* @param {number} amount - The amount to stake.
* @param {string} changeAddress - The address to send the change to.
* @param {UTXO[]} inputUTXOs - All available UTXOs from the wallet.
* @param {networks.Network} network - The Bitcoin network.
* @param {number} feeRate - The fee rate in satoshis per byte.
* @param {number} [lockHeight] - The optional block height locktime.
* @returns {TransactionResult} - An object containing the unsigned transaction and fee
* @throws Will throw an error if the amount or fee rate is less than or equal
* to 0, if the change address is invalid, or if the public key is invalid.
*/
export function stakingTransaction(
scripts: {
timelockScript: Buffer;
unbondingScript: Buffer;
slashingScript: Buffer;
dataEmbedScript?: Buffer;
},
amount: number,
changeAddress: string,
inputUTXOs: UTXO[],
network: networks.Network,
feeRate: number,
lockHeight?: number,
): TransactionResult {
// Check that amount and fee are bigger than 0
if (amount <= 0 || feeRate <= 0) {
throw new Error("Amount and fee rate must be bigger than 0");
}
// Check whether the change address is a valid Bitcoin address.
if (!isValidBitcoinAddress(changeAddress, network)) {
throw new Error("Invalid change address");
}
// Build outputs and estimate the fee
const stakingOutputs = buildStakingTransactionOutputs(scripts, network, amount);
const { selectedUTXOs, fee } = getStakingTxInputUTXOsAndFees(
inputUTXOs,
amount,
feeRate,
stakingOutputs,
);
const tx = new Transaction();
tx.version = TRANSACTION_VERSION;
for (let i = 0; i < selectedUTXOs.length; ++i) {
const input = selectedUTXOs[i];
tx.addInput(
transactionIdToHash(input.txid),
input.vout,
NON_RBF_SEQUENCE,
);
}
stakingOutputs.forEach((o) => {
tx.addOutput(o.scriptPubKey, o.value);
});
// Add a change output only if there's any amount leftover from the inputs
const inputsSum = inputValueSum(selectedUTXOs);
// Check if the change amount is above the dust limit, and if so, add it as a change output
if (inputsSum - (amount + fee) > BTC_DUST_SAT) {
tx.addOutput(
address.toOutputScript(changeAddress, network),
inputsSum - (amount + fee),
);
}
// Set the locktime field if provided. If not provided, the locktime will be set to 0 by default
// Only height based locktime is supported
if (lockHeight) {
if (lockHeight >= BTC_LOCKTIME_HEIGHT_TIME_CUTOFF) {
throw new Error("Invalid lock height");
}
tx.locktime = lockHeight;
}
return {
transaction: tx,
fee,
};
}
/**
* Constructs a withdrawal transaction for manually unbonded delegation.
*
* This transaction spends the unbonded output from the staking transaction.
*
* Inputs:
* - scripts: Scripts used to construct the taproot output.
* - unbondingTimelockScript: Script for the unbonding timelock condition.
* - slashingScript: Script for the slashing condition.
* - unbondingTx: The unbonding transaction.
* - withdrawalAddress: The address to send the withdrawn funds to.
* - network: The Bitcoin network.
* - feeRate: The fee rate for the transaction in satoshis per byte.
*
* Returns:
* - psbt: The partially signed transaction (PSBT).
*
* @param {Object} scripts - The scripts used in the transaction.
* @param {Transaction} unbondingTx - The unbonding transaction.
* @param {string} withdrawalAddress - The address to send the withdrawn funds to.
* @param {networks.Network} network - The Bitcoin network.
* @param {number} feeRate - The fee rate for the transaction in satoshis per byte.
* @returns {PsbtResult} An object containing the partially signed transaction (PSBT).
*/
export function withdrawEarlyUnbondedTransaction(
scripts: {
unbondingTimelockScript: Buffer;
slashingScript: Buffer;
},
unbondingTx: Transaction,
withdrawalAddress: string,
network: networks.Network,
feeRate: number,
): PsbtResult {
const scriptTree: Taptree = [
{
output: scripts.slashingScript,
},
{ output: scripts.unbondingTimelockScript },
];
return withdrawalTransaction(
{
timelockScript: scripts.unbondingTimelockScript,
},
scriptTree,
unbondingTx,
withdrawalAddress,
network,
feeRate,
0, // unbonding always has a single output
);
}
/**
* Constructs a withdrawal transaction for naturally unbonded delegation.
*
* This transaction spends the unbonded output from the staking transaction when the timelock has expired.
*
* Inputs:
* - scripts: Scripts used to construct the taproot output.
* - timelockScript: Script for the timelock condition.
* - slashingScript: Script for the slashing condition.
* - unbondingScript: Script for the unbonding condition.
* - tx: The original staking transaction.
* - withdrawalAddress: The address to send the withdrawn funds to.
* - network: The Bitcoin network.
* - feeRate: The fee rate for the transaction in satoshis per byte.
* - outputIndex: The index of the output to be spent in the original transaction (default is 0).
*
* Returns:
* - psbt: The partially signed transaction (PSBT).
*
* @param {Object} scripts - The scripts used in the transaction.
* @param {Transaction} tx - The original staking transaction.
* @param {string} withdrawalAddress - The address to send the withdrawn funds to.
* @param {networks.Network} network - The Bitcoin network.
* @param {number} feeRate - The fee rate for the transaction in satoshis per byte.
* @param {number} [outputIndex=0] - The index of the output to be spent in the original transaction.
* @returns {PsbtResult} An object containing the partially signed transaction (PSBT).
*/
export function withdrawTimelockUnbondedTransaction(
scripts: {
timelockScript: Buffer;
slashingScript: Buffer;
unbondingScript: Buffer;
},
tx: Transaction,
withdrawalAddress: string,
network: networks.Network,
feeRate: number,
outputIndex: number = 0,
): PsbtResult {
const scriptTree: Taptree = [
{
output: scripts.slashingScript,
},
[{ output: scripts.unbondingScript }, { output: scripts.timelockScript }],
];
return withdrawalTransaction(
scripts,
scriptTree,
tx,
withdrawalAddress,
network,
feeRate,
outputIndex,
);
}
/**
* Constructs a withdrawal transaction for a slashing transaction.
*
* This transaction spends the output from the slashing transaction.
*
* @param {Object} scripts - The unbondingTimelockScript
* We use the unbonding timelock script as the timelock of the slashing transaction.
* This is due to slashing tx timelock is the same as the unbonding timelock.
* @param {Transaction} slashingTx - The slashing transaction.
* @param {string} withdrawalAddress - The address to send the withdrawn funds to.
* @param {networks.Network} network - The Bitcoin network.
* @param {number} feeRate - The fee rate for the transaction in satoshis per byte.
* @param {number} outputIndex - The index of the output to be spent in the original transaction.
* @returns {PsbtResult} An object containing the partially signed transaction (PSBT).
*/
export function withdrawSlashingTransaction(
scripts: {
unbondingTimelockScript: Buffer;
},
slashingTx: Transaction,
withdrawalAddress: string,
network: networks.Network,
feeRate: number,
outputIndex: number,
): PsbtResult {
const scriptTree: Taptree = { output: scripts.unbondingTimelockScript };
return withdrawalTransaction(
{
timelockScript: scripts.unbondingTimelockScript,
},
scriptTree,
slashingTx,
withdrawalAddress,
network,
feeRate,
outputIndex,
);
}
// withdrawalTransaction generates a transaction that
// spends the staking output of the staking transaction
function withdrawalTransaction(
scripts: {
timelockScript: Buffer;
},
scriptTree: Taptree,
tx: Transaction,
withdrawalAddress: string,
network: networks.Network,
feeRate: number,
outputIndex: number = 0,
): PsbtResult {
// Check that withdrawal feeRate is bigger than 0
if (feeRate <= 0) {
throw new Error("Withdrawal feeRate must be bigger than 0");
}
// Check that outputIndex is bigger or equal to 0
if (outputIndex < 0) {
throw new Error("Output index must be bigger or equal to 0");
}
// position of time in the timelock script
const timePosition = 2;
const decompiled = script.decompile(scripts.timelockScript);
if (!decompiled) {
throw new Error("Timelock script is not valid");
}
let timelock = 0;
// if the timelock is a buffer, it means it's a number bigger than 16 blocks
if (typeof decompiled[timePosition] !== "number") {
const timeBuffer = decompiled[timePosition] as Buffer;
timelock = script.number.decode(timeBuffer);
} else {
// in case timelock is <= 16 it will be a number, not a buffer
const wrap = decompiled[timePosition] % 16;
timelock = wrap === 0 ? 16 : wrap;
}
const redeem = {
output: scripts.timelockScript,
redeemVersion: REDEEM_VERSION,
};
const p2tr = payments.p2tr({
internalPubkey,
scriptTree,
redeem,
network,
});
const tapLeafScript = {
leafVersion: redeem.redeemVersion,
script: redeem.output,
controlBlock: p2tr.witness![p2tr.witness!.length - 1],
};
const psbt = new Psbt({ network });
// only transactions with version 2 can trigger OP_CHECKSEQUENCEVERIFY
// https://github.com/btcsuite/btcd/blob/master/txscript/opcode.go#L1174
psbt.setVersion(TRANSACTION_VERSION);
psbt.addInput({
hash: tx.getHash(),
index: outputIndex,
tapInternalKey: internalPubkey,
witnessUtxo: {
value: tx.outs[outputIndex].value,
script: tx.outs[outputIndex].script,
},
tapLeafScript: [tapLeafScript],
sequence: timelock,
});
const estimatedFee = getWithdrawTxFee(feeRate);
const outputValue = tx.outs[outputIndex].value - estimatedFee;
if (outputValue < 0) {
throw new Error(
"Not enough funds to cover the fee for withdrawal transaction",
);
}
if (outputValue < BTC_DUST_SAT) {
throw new Error("Output value is less than dust limit");
}
psbt.addOutput({
address: withdrawalAddress,
value: outputValue,
});
// Withdraw transaction has no time-based restrictions and can be included
// in the next block immediately.
psbt.setLocktime(0);
return {
psbt,
fee: estimatedFee,
};
}
/**
* Constructs a slashing transaction for a staking output without prior unbonding.
*
* This transaction spends the staking output of the staking transaction and distributes the funds
* according to the specified slashing rate.
*
* 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.
* - timelockScript: Script for the timelock condition.
* - unbondingScript: Script for the unbonding condition.
* - unbondingTimelockScript: Script for the unbonding timelock condition.
* - transaction: The original staking 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.
* @param {Transaction} stakingTransaction - The original staking transaction.
* @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.
* @param {number} [outputIndex=0] - The index of the output to be spent in the original transaction.
* @returns {{ psbt: Psbt }} An object containing the partially signed transaction (PSBT).
*/
export function slashTimelockUnbondedTransaction(
scripts: {
slashingScript: Buffer;
timelockScript: Buffer;
unbondingScript: Buffer;
unbondingTimelockScript: Buffer;
},
stakingTransaction: Transaction,
slashingPkScriptHex: string,
slashingRate: number,
minimumFee: number,
network: networks.Network,
outputIndex: number = 0,
): { psbt: Psbt } {
const slashingScriptTree: Taptree = [
{
output: scripts.slashingScript,
},
[{ output: scripts.unbondingScript }, { output: scripts.timelockScript }],
];
return slashingTransaction(
{
unbondingTimelockScript: scripts.unbondingTimelockScript,
slashingScript: scripts.slashingScript,
},
slashingScriptTree,
stakingTransaction,
slashingPkScriptHex,
slashingRate,
minimumFee,
network,
outputIndex,
);
}
/**
* Constructs a slashing transaction for an early unbonded transaction.
*
* This transaction spends the staking output of the staking transaction and distributes the funds
* according to the specified slashing rate.
*
* 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.
*
* @param {Object} scripts - The scripts used in the transaction. e.g slashingScript, unbondingTimelockScript
* @param {Transaction} unbondingTx - The unbonding transaction.
* @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.
* @returns {{ psbt: Psbt }} An object containing the partially signed transaction (PSBT).
*/
export function slashEarlyUnbondedTransaction(
scripts: {
slashingScript: Buffer;
unbondingTimelockScript: Buffer;
},
unbondingTx: Transaction,
slashingPkScriptHex: string,
slashingRate: number,
minimumSlashingFee: number,
network: networks.Network,
): { psbt: Psbt } {
const unbondingScriptTree: Taptree = [
{
output: scripts.slashingScript,
},
{
output: scripts.unbondingTimelockScript,
},
];
return slashingTransaction(
{
unbondingTimelockScript: scripts.unbondingTimelockScript,
slashingScript: scripts.slashingScript,
},
unbondingScriptTree,
unbondingTx,
slashingPkScriptHex,
slashingRate,
minimumSlashingFee,
network,
0, // unbonding always has a single output
);
}
/**
* Constructs a slashing transaction for an on-demand unbonding.
*
* This transaction spends the staking output of the staking transaction and distributes the funds
* according to the specified slashing rate.
*
* 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.
*
* @param {Object} scripts - The scripts used in the transaction. e.g slashingScript, unbondingTimelockScript
* @param {Transaction} transaction - The original staking/unbonding transaction.
* @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.
* @param {number} [outputIndex=0] - The index of the output to be spent in the original transaction.
* @returns {{ psbt: Psbt }} An object containing the partially signed transaction (PSBT).
*/
function slashingTransaction(
scripts: {
unbondingTimelockScript: Buffer;
slashingScript: Buffer;
},
scriptTree: Taptree,
transaction: Transaction,
slashingPkScriptHex: string,
slashingRate: number,
minimumFee: number,
network: networks.Network,
outputIndex: number = 0,
): {
psbt: Psbt;
} {
// Check that slashing rate and minimum fee are 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(BTC_SLASHING_FRACTION_DIGITS));
// 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 || !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 = {
output: scripts.slashingScript,
redeemVersion: REDEEM_VERSION,
};
const p2tr = payments.p2tr({
internalPubkey,
scriptTree,
redeem,
network,
});
const tapLeafScript = {
leafVersion: redeem.redeemVersion,
script: redeem.output,
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.round(stakingAmount * slashingRate);
// Compute the slashing output
const slashingOutput = Buffer.from(slashingPkScriptHex, "hex");
// If OP_RETURN is not included, the slashing amount must be greater than the
// dust limit.
if (opcodes.OP_RETURN != slashingOutput[0]) {
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.setVersion(TRANSACTION_VERSION);
psbt.addInput({
hash: transaction.getHash(),
index: outputIndex,
tapInternalKey: internalPubkey,
witnessUtxo: {
value: stakingAmount,
script: transaction.outs[outputIndex].script,
},
tapLeafScript: [tapLeafScript],
// not RBF-able
sequence: NON_RBF_SEQUENCE,
});
// Add the slashing output
psbt.addOutput({
script: slashingOutput,
value: slashingAmount,
});
// Change output contains unbonding timelock script
const changeOutput = payments.p2tr({
internalPubkey,
scriptTree: { output: scripts.unbondingTimelockScript },
network,
});
// Add the change output
psbt.addOutput({
address: changeOutput.address!,
value: userFunds,
});
// Slashing transaction has no time-based restrictions and can be included
// in the next block immediately.
psbt.setLocktime(0);
return { psbt };
}
export function unbondingTransaction(
scripts: {
unbondingTimelockScript: Buffer;
slashingScript: Buffer;
},
stakingTx: Transaction,
unbondingFee: number,
network: networks.Network,
outputIndex: number = 0,
): TransactionResult {
// Check that transaction fee is bigger than 0
if (unbondingFee <= 0) {
throw new Error("Unbonding fee must be bigger than 0");
}
// Check that outputIndex is bigger or equal to 0
if (outputIndex < 0) {
throw new Error("Output index must be bigger or equal to 0");
}
const tx = new Transaction();
tx.version = TRANSACTION_VERSION;
tx.addInput(
stakingTx.getHash(),
outputIndex,
NON_RBF_SEQUENCE, // not RBF-able
);
const unbondingOutputInfo = deriveUnbondingOutputInfo(scripts, network);
const outputValue = stakingTx.outs[outputIndex].value - unbondingFee;
if (outputValue < BTC_DUST_SAT) {
throw new Error("Output value is less than dust limit for unbonding transaction");
}
// Add the unbonding output
if (!unbondingOutputInfo.outputAddress) {
throw new Error("Unbonding output address is not defined");
}
tx.addOutput(
unbondingOutputInfo.scriptPubKey,
outputValue,
);
// Unbonding transaction has no time-based restrictions and can be included
// in the next block immediately.
tx.locktime = 0;
return {
transaction: tx,
fee: unbondingFee,
};
}
// This function attaches covenant signatures as the transaction's witness
// Note that the witness script expects exactly covenantQuorum number of signatures
// to match the covenant parameters.
export const createCovenantWitness = (
originalWitness: Buffer[],
paramsCovenants: Buffer[],
covenantSigs: CovenantSignature[],
covenantQuorum: number,
) => {
if (covenantSigs.length < covenantQuorum) {
throw new Error(
`Not enough covenant signatures. Required: ${covenantQuorum}, `
+ `got: ${covenantSigs.length}`
);
}
// Verify all btcPkHex from covenantSigs exist in paramsCovenants
for (const sig of covenantSigs) {
const btcPkHexBuf = Buffer.from(sig.btcPkHex, "hex");
if (!paramsCovenants.some(covenant => covenant.equals(btcPkHexBuf))) {
throw new Error(
`Covenant signature public key ${sig.btcPkHex} not found in params covenants`
);
}
}
// We only take exactly covenantQuorum number of signatures, even if more are provided.
// Including extra signatures will cause the unbonding transaction to fail validation.
// This is because the witness script expects exactly covenantQuorum number of signatures
// to match the covenant parameters.
const covenantSigsBuffers = covenantSigs
.slice(0, covenantQuorum)
.map((sig) => ({
btcPkHex: Buffer.from(sig.btcPkHex, "hex"),
sigHex: Buffer.from(sig.sigHex, "hex"),
}));
// we need covenant from params to be sorted in reverse order
const paramsCovenantsSorted = [...paramsCovenants]
.sort(Buffer.compare)
.reverse();
const composedCovenantSigs = paramsCovenantsSorted.map((covenant) => {
// in case there's covenant with this btc_pk_hex we return the sig
// otherwise we return empty Buffer
const covenantSig = covenantSigsBuffers.find(
(sig) => sig.btcPkHex.compare(covenant) === 0,
);
return covenantSig?.sigHex || Buffer.alloc(0);
});
return [...composedCovenantSigs, ...originalWitness];
};
Выполнить команду
Для локальной разработки. Не используйте в интернете!