@btc-vision/transaction
Version:
OPNet transaction library allows you to create and sign transactions for the OPNet network.
393 lines (392 loc) • 14.9 kB
JavaScript
import { initEccLib, opcodes, Psbt, script, Transaction, varuint, } from '@btc-vision/bitcoin';
import * as ecc from '@bitcoinerlab/secp256k1';
import { EcKeyPair } from '../../keypair/EcKeyPair.js';
import { AddressVerificator } from '../../keypair/AddressVerificator.js';
import { TweakedTransaction } from '../shared/TweakedTransaction.js';
initEccLib(ecc);
export const MINIMUM_AMOUNT_REWARD = 540n;
export const MINIMUM_AMOUNT_CA = 297n;
export class TransactionBuilder extends TweakedTransaction {
constructor(parameters) {
super(parameters);
this.logColor = '#785def';
this.overflowFees = 0n;
this.transactionFee = 0n;
this.estimatedFees = 0n;
this.updateInputs = [];
this.outputs = [];
this.feeOutput = null;
this._maximumFeeRate = 100000000;
if (parameters.estimatedFees) {
this.estimatedFees = parameters.estimatedFees;
}
this.signer = parameters.signer;
this.network = parameters.network;
this.feeRate = parameters.feeRate;
this.priorityFee = parameters.priorityFee ?? 0n;
this.gasSatFee = parameters.gasSatFee ?? 0n;
this.utxos = parameters.utxos;
this.optionalInputs = parameters.optionalInputs || [];
this.to = parameters.to || undefined;
this.isPubKeyDestination = this.to
? AddressVerificator.isValidPublicKey(this.to, this.network)
: false;
this.optionalOutputs = parameters.optionalOutputs;
this.from = TransactionBuilder.getFrom(parameters.from, this.signer, this.network);
this.totalInputAmount = this.calculateTotalUTXOAmount();
const totalVOut = this.calculateTotalVOutAmount();
if (totalVOut < this.totalInputAmount) {
throw new Error(`Vout value is less than the value to send`);
}
this.transaction = new Psbt({
network: this.network,
});
}
static getFrom(from, keypair, network) {
return from || EcKeyPair.getTaprootAddress(keypair, network);
}
static witnessStackToScriptWitness(witness) {
let buffer = Buffer.allocUnsafe(0);
function writeSlice(slice) {
buffer = Buffer.concat([buffer, Buffer.from(slice)]);
}
function writeVarInt(i) {
const currentLen = buffer.length;
const varintLen = varuint.encodingLength(i);
buffer = Buffer.concat([buffer, Buffer.allocUnsafe(varintLen)]);
varuint.encode(i, buffer, currentLen);
}
function writeVarSlice(slice) {
writeVarInt(slice.length);
writeSlice(slice);
}
function writeVector(vector) {
writeVarInt(vector.length);
vector.forEach(writeVarSlice);
}
writeVector(witness);
return buffer;
}
async getFundingTransactionParameters() {
if (!this.estimatedFees) {
this.estimatedFees = await this.estimateTransactionFees();
}
return {
utxos: this.utxos,
to: this.getScriptAddress(),
signer: this.signer,
network: this.network,
feeRate: this.feeRate,
priorityFee: this.priorityFee ?? 0n,
gasSatFee: this.gasSatFee ?? 0n,
from: this.from,
amount: this.estimatedFees,
optionalOutputs: this.optionalOutputs,
optionalInputs: this.optionalInputs,
};
}
setDestinationAddress(address) {
this.to = address;
}
setMaximumFeeRate(feeRate) {
this._maximumFeeRate = feeRate;
}
async signTransaction() {
if (!this.utxos.length) {
throw new Error('No UTXOs specified');
}
if (this.to &&
!this.isPubKeyDestination &&
!EcKeyPair.verifyContractAddress(this.to, this.network)) {
throw new Error('Invalid contract address. The contract address must be a taproot address.');
}
if (this.signed)
throw new Error('Transaction is already signed');
this.signed = true;
await this.buildTransaction();
const builtTx = await this.internalBuildTransaction(this.transaction);
if (builtTx) {
if (this.regenerated) {
throw new Error('Transaction was regenerated');
}
return this.transaction.extractTransaction(true, true);
}
throw new Error('Could not sign transaction');
}
async generateTransactionMinimalSignatures(checkPartialSigs = false) {
if (this.to &&
!this.isPubKeyDestination &&
!EcKeyPair.verifyContractAddress(this.to, this.network)) {
throw new Error('Invalid contract address. The contract address must be a taproot address.');
}
await this.buildTransaction();
if (this.transaction.data.inputs.length === 0) {
const inputs = this.getInputs();
const outputs = this.getOutputs();
this.transaction.setMaximumFeeRate(this._maximumFeeRate);
this.transaction.addInputs(inputs, checkPartialSigs);
for (let i = 0; i < this.updateInputs.length; i++) {
this.transaction.updateInput(i, this.updateInputs[i]);
}
this.transaction.addOutputs(outputs);
}
}
async signPSBT() {
if (await this.signTransaction()) {
return this.transaction;
}
throw new Error('Could not sign transaction');
}
addInput(input) {
this.inputs.push(input);
}
addOutput(output) {
if (output.value === 0)
return;
if (output.value < TransactionBuilder.MINIMUM_DUST) {
throw new Error(`Output value is less than the minimum dust ${output.value} < ${TransactionBuilder.MINIMUM_DUST}`);
}
this.outputs.push(output);
}
toAddress() {
return this.to;
}
address() {
return this.tapData?.address;
}
async estimateTransactionFees() {
if (!this.utxos.length) {
throw new Error('No UTXOs specified');
}
if (this.estimatedFees)
return this.estimatedFees;
const fakeTx = new Psbt({
network: this.network,
});
const builtTx = await this.internalBuildTransaction(fakeTx);
if (builtTx) {
const tx = fakeTx.extractTransaction(true, true);
const size = tx.virtualSize();
const fee = this.feeRate * size;
this.estimatedFees = BigInt(Math.ceil(fee) + 1);
return this.estimatedFees;
}
else {
throw new Error(`Could not build transaction to estimate fee. Something went wrong while building the transaction.`);
}
}
async rebuildFromBase64(base64) {
this.transaction = Psbt.fromBase64(base64, { network: this.network });
this.signed = false;
this.sighashTypes = [Transaction.SIGHASH_ANYONECANPAY, Transaction.SIGHASH_ALL];
return await this.signPSBT();
}
setPSBT(psbt) {
this.transaction = psbt;
}
getInputs() {
return this.inputs;
}
getOutputs() {
const outputs = [...this.outputs];
if (this.feeOutput)
outputs.push(this.feeOutput);
return outputs;
}
getOptionalOutputValue() {
if (!this.optionalOutputs)
return 0n;
let total = 0n;
for (let i = 0; i < this.optionalOutputs.length; i++) {
total += BigInt(this.optionalOutputs[i].value);
}
return total;
}
async addRefundOutput(amountSpent) {
const sendBackAmount = this.totalInputAmount - amountSpent;
if (sendBackAmount >= TransactionBuilder.MINIMUM_DUST) {
if (AddressVerificator.isValidP2TRAddress(this.from, this.network)) {
await this.setFeeOutput({
value: Number(sendBackAmount),
address: this.from,
tapInternalKey: this.internalPubKeyToXOnly(),
});
}
else if (AddressVerificator.isValidPublicKey(this.from, this.network)) {
const pubKeyScript = script.compile([
Buffer.from(this.from.replace('0x', ''), 'hex'),
opcodes.OP_CHECKSIG,
]);
await this.setFeeOutput({
value: Number(sendBackAmount),
script: pubKeyScript,
});
}
else {
await this.setFeeOutput({
value: Number(sendBackAmount),
address: this.from,
});
}
return;
}
this.warn(`Amount to send back (${sendBackAmount} sat) is less than the minimum dust (${TransactionBuilder.MINIMUM_DUST} sat), it will be consumed in fees instead.`);
}
addValueToToOutput(value) {
if (value < TransactionBuilder.MINIMUM_DUST) {
throw new Error(`Value to send is less than the minimum dust ${value} < ${TransactionBuilder.MINIMUM_DUST}`);
}
for (const output of this.outputs) {
if ('address' in output && output.address === this.to) {
output.value += Number(value);
return;
}
}
throw new Error('Output not found');
}
getTransactionOPNetFee() {
const totalFee = this.priorityFee + this.gasSatFee;
if (totalFee > TransactionBuilder.MINIMUM_DUST) {
return totalFee;
}
return TransactionBuilder.MINIMUM_DUST;
}
calculateTotalUTXOAmount() {
let total = 0n;
for (const utxo of this.utxos) {
total += utxo.value;
}
for (const utxo of this.optionalInputs) {
total += utxo.value;
}
return total;
}
calculateTotalVOutAmount() {
let total = 0n;
for (const utxo of this.utxos) {
total += utxo.value;
}
for (const utxo of this.optionalInputs) {
total += utxo.value;
}
return total;
}
addOptionalOutputsAndGetAmount() {
if (!this.optionalOutputs)
return 0n;
let refundedFromOptionalOutputs = 0n;
for (let i = 0; i < this.optionalOutputs.length; i++) {
this.addOutput(this.optionalOutputs[i]);
refundedFromOptionalOutputs += BigInt(this.optionalOutputs[i].value);
}
return refundedFromOptionalOutputs;
}
addInputsFromUTXO() {
if (this.utxos.length) {
if (this.totalInputAmount < TransactionBuilder.MINIMUM_DUST) {
throw new Error(`Total input amount is ${this.totalInputAmount} sat which is less than the minimum dust ${TransactionBuilder.MINIMUM_DUST} sat.`);
}
for (let i = 0; i < this.utxos.length; i++) {
const utxo = this.utxos[i];
const input = this.generatePsbtInputExtended(utxo, i);
this.addInput(input);
}
}
if (this.optionalInputs) {
for (let i = this.utxos.length; i < this.optionalInputs.length + this.utxos.length; i++) {
const utxo = this.optionalInputs[i - this.utxos.length];
const input = this.generatePsbtInputExtended(utxo, i, true);
this.addInput(input);
}
}
}
internalInit() {
this.verifyUTXOValidity();
super.internalInit();
}
updateInput(input) {
this.updateInputs.push(input);
}
getWitness() {
if (!this.tapData || !this.tapData.witness) {
throw new Error('Witness is required');
}
if (this.tapData.witness.length === 0) {
throw new Error('Witness is empty');
}
return this.tapData.witness[this.tapData.witness.length - 1];
}
getTapOutput() {
if (!this.tapData || !this.tapData.output) {
throw new Error('Tap data is required');
}
return this.tapData.output;
}
verifyUTXOValidity() {
for (const utxo of this.utxos) {
if (!utxo.scriptPubKey) {
throw new Error('Address is required');
}
}
for (const utxo of this.optionalInputs) {
if (!utxo.scriptPubKey) {
throw new Error('Address is required');
}
}
}
async setFeeOutput(output) {
const initialValue = output.value;
const fee = await this.estimateTransactionFees();
output.value = initialValue - Number(fee);
if (output.value < TransactionBuilder.MINIMUM_DUST) {
this.feeOutput = null;
if (output.value < 0) {
throw new Error(`setFeeOutput: Insufficient funds to pay the fees. Fee: ${fee} < Value: ${initialValue}. Total input: ${this.totalInputAmount} sat`);
}
}
else {
this.feeOutput = output;
const fee = await this.estimateTransactionFees();
if (fee > BigInt(initialValue)) {
throw new Error(`estimateTransactionFees: Insufficient funds to pay the fees. Fee: ${fee} > Value: ${initialValue}. Total input: ${this.totalInputAmount} sat`);
}
const valueLeft = initialValue - Number(fee);
if (valueLeft < TransactionBuilder.MINIMUM_DUST) {
this.feeOutput = null;
}
else {
this.feeOutput.value = valueLeft;
}
this.overflowFees = BigInt(valueLeft);
}
}
async internalBuildTransaction(transaction, checkPartialSigs = false) {
if (transaction.data.inputs.length === 0) {
const inputs = this.getInputs();
const outputs = this.getOutputs();
transaction.setMaximumFeeRate(this._maximumFeeRate);
transaction.addInputs(inputs, checkPartialSigs);
for (let i = 0; i < this.updateInputs.length; i++) {
transaction.updateInput(i, this.updateInputs[i]);
}
transaction.addOutputs(outputs);
}
try {
await this.signInputs(transaction);
if (this.finalized) {
this.transactionFee = BigInt(transaction.getFee());
}
return true;
}
catch (e) {
const err = e;
this.error(`[internalBuildTransaction] Something went wrong while getting building the transaction: ${err.stack}`);
}
return false;
}
}
TransactionBuilder.LOCK_LEAF_SCRIPT = script.compile([
opcodes.OP_FALSE,
opcodes.OP_VERIFY,
]);
TransactionBuilder.MINIMUM_DUST = 50n;