UNPKG

@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
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;