@btc-vision/transaction
Version:
OPNet transaction library allows you to create and sign transactions for the OPNet network.
652 lines (651 loc) • 25.2 kB
JavaScript
import { Logger } from '@btc-vision/logger';
import { address as bitAddress, crypto as bitCrypto, getFinalScripts, isP2A, isP2MS, isP2PK, isP2PKH, isP2SHScript, isP2TR, isP2WPKH, isP2WSHScript, isUnknownSegwitVersion, opcodes, payments, PaymentType, script, toXOnly, varuint, } from '@btc-vision/bitcoin';
import { TweakedSigner } from '../../signer/TweakedSigner.js';
import { canSignNonTaprootInput, isTaprootInput, pubkeyInScript, } from '../../signer/SignerUtils.js';
import { TransactionBuilder } from '../builders/TransactionBuilder.js';
import { Buffer } from 'buffer';
import { P2WDADetector } from '../../p2wda/P2WDADetector.js';
export var TransactionSequence;
(function (TransactionSequence) {
TransactionSequence[TransactionSequence["REPLACE_BY_FEE"] = 4294967293] = "REPLACE_BY_FEE";
TransactionSequence[TransactionSequence["FINAL"] = 4294967295] = "FINAL";
})(TransactionSequence || (TransactionSequence = {}));
export var CSVModes;
(function (CSVModes) {
CSVModes[CSVModes["BLOCKS"] = 0] = "BLOCKS";
CSVModes[CSVModes["TIMESTAMPS"] = 1] = "TIMESTAMPS";
})(CSVModes || (CSVModes = {}));
const CSV_ENABLED_BLOCKS_MASK = 0x3fffffff;
export class TweakedTransaction extends Logger {
constructor(data) {
super();
this.logColor = '#00ffe1';
this.finalized = false;
this.signed = false;
this.scriptData = null;
this.tapData = null;
this.inputs = [];
this.sequence = TransactionSequence.REPLACE_BY_FEE;
this.tapLeafScript = null;
this.isBrowser = false;
this.csvInputIndices = new Set();
this.anchorInputIndices = new Set();
this.regenerated = false;
this.ignoreSignatureErrors = false;
this.noSignatures = false;
this.txVersion = 2;
this.customFinalizerP2SH = (inputIndex, input, scriptA, isSegwit, isP2SH, isP2WSH) => {
const inputDecoded = this.inputs[inputIndex];
if (isP2SH && input.partialSig && inputDecoded && inputDecoded.redeemScript) {
const signatures = input.partialSig.map((sig) => sig.signature) || [];
const scriptSig = script.compile([...signatures, inputDecoded.redeemScript]);
return {
finalScriptSig: scriptSig,
finalScriptWitness: undefined,
};
}
if (this.anchorInputIndices.has(inputIndex)) {
return {
finalScriptSig: undefined,
finalScriptWitness: Buffer.from([0]),
};
}
if (isP2WSH && isSegwit && input.witnessScript) {
if (!input.partialSig || input.partialSig.length === 0) {
throw new Error(`No signatures for P2WSH input #${inputIndex}`);
}
const isP2WDA = P2WDADetector.isP2WDAWitnessScript(input.witnessScript);
if (isP2WDA) {
return this.finalizeSecondaryP2WDA(inputIndex, input);
}
const isCSVInput = this.csvInputIndices.has(inputIndex);
if (isCSVInput) {
const witnessStack = [input.partialSig[0].signature, input.witnessScript];
return {
finalScriptSig: undefined,
finalScriptWitness: TransactionBuilder.witnessStackToScriptWitness(witnessStack),
};
}
}
return getFinalScripts(inputIndex, input, scriptA, isSegwit, isP2SH, isP2WSH, true, this.unlockScript);
};
this.signer = data.signer;
this.network = data.network;
this.noSignatures = data.noSignatures || false;
this.nonWitnessUtxo = data.nonWitnessUtxo;
this.unlockScript = data.unlockScript;
this.isBrowser = typeof window !== 'undefined';
if (data.txVersion) {
this.txVersion = data.txVersion;
}
}
static readScriptWitnessToWitnessStack(Buffer) {
let offset = 0;
function readSlice(n) {
const slice = Buffer.subarray(offset, offset + n);
offset += n;
return slice;
}
function readVarInt() {
const varint = varuint.decode(Buffer, offset);
offset += varint.bytes;
return varint.numberValue || 0;
}
function readVarSlice() {
const len = readVarInt();
return readSlice(len);
}
function readVector() {
const count = readVarInt();
const vector = [];
for (let i = 0; i < count; i++) {
vector.push(readVarSlice());
}
return vector;
}
return readVector();
}
static preEstimateTaprootTransactionFees(feeRate, numInputs, numOutputs, numWitnessElements, witnessElementSize, emptyWitness, taprootControlWitnessSize = 32n, taprootScriptSize = 139n) {
const txHeaderSize = 10n;
const inputBaseSize = 41n;
const outputSize = 68n;
const taprootWitnessBaseSize = 1n;
const baseTxSize = txHeaderSize + inputBaseSize * numInputs + outputSize * numOutputs;
const witnessSize = numInputs * taprootWitnessBaseSize +
numWitnessElements * witnessElementSize +
taprootControlWitnessSize * numInputs +
taprootScriptSize * numInputs +
emptyWitness;
const weight = baseTxSize * 3n + (baseTxSize + witnessSize);
const vSize = weight / 4n;
return vSize * feeRate;
}
static signInput(transaction, input, i, signer, sighashTypes) {
if (sighashTypes && sighashTypes[0])
input.sighashType = sighashTypes[0];
transaction.signInput(i, signer, sighashTypes.length ? sighashTypes : undefined);
}
static calculateSignHash(sighashTypes) {
if (!sighashTypes) {
throw new Error('Sighash types are required');
}
let signHash = 0;
for (const sighashType of sighashTypes) {
signHash |= sighashType;
}
return signHash || 0;
}
ignoreSignatureError() {
this.ignoreSignatureErrors = true;
}
getScriptAddress() {
if (!this.scriptData || !this.scriptData.address) {
throw new Error('Tap data is required');
}
return this.scriptData.address;
}
getTransaction() {
return this.transaction.extractTransaction(false);
}
getTapAddress() {
if (!this.tapData || !this.tapData.address) {
throw new Error('Tap data is required');
}
return this.tapData.address;
}
disableRBF() {
if (this.signed)
throw new Error('Transaction is already signed');
this.sequence = TransactionSequence.FINAL;
for (const input of this.inputs) {
if (this.csvInputIndices.has(this.inputs.indexOf(input))) {
continue;
}
input.sequence = TransactionSequence.FINAL;
}
}
getTweakerHash() {
return this.tapData?.hash;
}
preEstimateTransactionFees(feeRate, numInputs, numOutputs, numSignatures, numPubkeys) {
const txHeaderSize = 10n;
const inputBaseSize = 41n;
const outputSize = 68n;
const signatureSize = 144n;
const pubkeySize = 34n;
const baseTxSize = txHeaderSize + inputBaseSize * numInputs + outputSize * numOutputs;
const redeemScriptSize = 1n + numPubkeys * (1n + pubkeySize) + 1n + numSignatures;
const witnessSize = numSignatures * signatureSize + numPubkeys * pubkeySize + redeemScriptSize;
const weight = baseTxSize * 3n + (baseTxSize + witnessSize);
const vSize = weight / 4n;
return vSize * feeRate;
}
generateTapData() {
return {
internalPubkey: this.internalPubKeyToXOnly(),
network: this.network,
name: PaymentType.P2TR,
};
}
generateScriptAddress() {
return {
internalPubkey: this.internalPubKeyToXOnly(),
network: this.network,
name: PaymentType.P2TR,
};
}
getSignerKey() {
return this.signer;
}
async signInput(transaction, input, i, signer, reverse = false, errored = false) {
if (this.anchorInputIndices.has(i))
return;
const publicKey = signer.publicKey;
let isTaproot = isTaprootInput(input);
if (reverse) {
isTaproot = !isTaproot;
}
let signed = false;
let didError = false;
if (isTaproot) {
try {
await this.attemptSignTaproot(transaction, input, i, signer, publicKey);
signed = true;
}
catch (e) {
this.error(`Failed to sign Taproot script path input ${i} (reverse: ${reverse}): ${e.message}`);
didError = true;
}
}
else {
if (!reverse ? canSignNonTaprootInput(input, publicKey) : true) {
try {
await this.signNonTaprootInput(signer, transaction, i);
signed = true;
}
catch (e) {
this.error(`Failed to sign non-Taproot input ${i}: ${e.stack}`);
didError = true;
}
}
}
if (!signed) {
if (didError && errored) {
throw new Error(`Failed to sign input ${i} with the provided signer.`);
}
try {
await this.signInput(transaction, input, i, signer, true, didError);
}
catch {
throw new Error(`Cannot sign input ${i} with the provided signer.`);
}
}
}
splitArray(arr, chunkSize) {
if (chunkSize <= 0) {
throw new Error('Chunk size must be greater than 0.');
}
const result = [];
for (let i = 0; i < arr.length; i += chunkSize) {
result.push(arr.slice(i, i + chunkSize));
}
return result;
}
async signInputs(transaction) {
if ('multiSignPsbt' in this.signer) {
await this.signInputsWalletBased(transaction);
return;
}
await this.signInputsNonWalletBased(transaction);
}
async signInputsNonWalletBased(transaction) {
const txs = transaction.data.inputs;
const batchSize = 20;
const batches = this.splitArray(txs, batchSize);
if (!this.noSignatures) {
for (let i = 0; i < batches.length; i++) {
const batch = batches[i];
const promises = [];
const offset = i * batchSize;
for (let j = 0; j < batch.length; j++) {
const index = offset + j;
const input = batch[j];
try {
promises.push(this.signInput(transaction, input, index, this.signer));
}
catch (e) {
this.log(`Failed to sign input ${index}: ${e.stack}`);
}
}
await Promise.all(promises);
}
}
for (let i = 0; i < transaction.data.inputs.length; i++) {
transaction.finalizeInput(i, this.customFinalizerP2SH.bind(this));
}
this.finalized = true;
}
internalPubKeyToXOnly() {
return toXOnly(Buffer.from(this.signer.publicKey));
}
internalInit() {
this.scriptData = payments.p2tr(this.generateScriptAddress());
this.tapData = payments.p2tr(this.generateTapData());
}
tweakSigner() {
if (this.tweakedSigner)
return;
this.tweakedSigner = this.getTweakedSigner(true);
}
getTweakedSigner(useTweakedHash = false, signer = this.signer) {
const settings = {
network: this.network,
};
if (useTweakedHash) {
settings.tweakHash = this.getTweakerHash();
}
if (!('privateKey' in signer)) {
return;
}
return TweakedSigner.tweakSigner(signer, settings);
}
generateP2SHRedeemScript(customWitnessScript) {
const p2wsh = payments.p2wsh({
redeem: { output: customWitnessScript },
network: this.network,
});
const p2sh = payments.p2sh({
redeem: p2wsh,
network: this.network,
});
return p2sh.output;
}
generateP2SHRedeemScriptLegacy(inputAddr) {
const pubKeyHash = bitCrypto.hash160(this.signer.publicKey);
const redeemScript = script.compile([
opcodes.OP_DUP,
opcodes.OP_HASH160,
pubKeyHash,
opcodes.OP_EQUALVERIFY,
opcodes.OP_CHECKSIG,
]);
const redeemScriptHash = bitCrypto.hash160(redeemScript);
const outputScript = script.compile([
opcodes.OP_HASH160,
redeemScriptHash,
opcodes.OP_EQUAL,
]);
const p2wsh = payments.p2wsh({
redeem: { output: redeemScript },
network: this.network,
});
const p2sh = payments.p2sh({
redeem: p2wsh,
network: this.network,
});
const address = bitAddress.fromOutputScript(outputScript, this.network);
if (address === inputAddr && p2sh.redeem && p2sh.redeem.output) {
return {
redeemScript,
outputScript: p2sh.redeem.output,
};
}
return;
}
generateP2SHP2PKHRedeemScript(inputAddr) {
const pubkey = Buffer.isBuffer(this.signer.publicKey)
? this.signer.publicKey
: Buffer.from(this.signer.publicKey, 'hex');
const w = payments.p2wpkh({
pubkey: pubkey,
network: this.network,
});
const p = payments.p2sh({
redeem: w,
network: this.network,
});
const address = p.address;
const redeemScript = p.redeem?.output;
if (!redeemScript) {
throw new Error('Failed to generate P2SH-P2WPKH redeem script');
}
if (address === inputAddr && p.redeem && p.redeem.output && p.output) {
return {
redeemScript: p.redeem.output,
outputScript: p.output,
};
}
return;
}
generatePsbtInputExtended(utxo, i, _extra = false) {
const scriptPub = Buffer.from(utxo.scriptPubKey.hex, 'hex');
const input = {
hash: utxo.transactionId,
index: utxo.outputIndex,
sequence: this.sequence,
witnessUtxo: {
value: Number(utxo.value),
script: scriptPub,
},
};
if (isP2PKH(scriptPub)) {
if (utxo.nonWitnessUtxo) {
input.nonWitnessUtxo = Buffer.isBuffer(utxo.nonWitnessUtxo)
? utxo.nonWitnessUtxo
: Buffer.from(utxo.nonWitnessUtxo, 'hex');
}
else {
throw new Error('Missing nonWitnessUtxo for P2PKH UTXO');
}
}
else if (isP2WPKH(scriptPub) || isUnknownSegwitVersion(scriptPub)) {
}
else if (isP2WSHScript(scriptPub)) {
this.processP2WSHInput(utxo, input, i);
}
else if (isP2SHScript(scriptPub)) {
let redeemScriptBuf;
if (utxo.redeemScript) {
redeemScriptBuf = Buffer.isBuffer(utxo.redeemScript)
? utxo.redeemScript
: Buffer.from(utxo.redeemScript, 'hex');
}
else {
if (!utxo.scriptPubKey.address) {
throw new Error('Missing redeemScript and no address to regenerate it for P2SH UTXO');
}
const legacyScripts = this.generateP2SHP2PKHRedeemScript(utxo.scriptPubKey.address);
if (!legacyScripts) {
throw new Error('Missing redeemScript for P2SH UTXO and unable to regenerate');
}
redeemScriptBuf = legacyScripts.redeemScript;
}
input.redeemScript = redeemScriptBuf;
const payment = payments.p2sh({ redeem: { output: input.redeemScript } });
if (!payment.redeem) {
throw new Error('Failed to extract redeem script from P2SH UTXO');
}
const redeemOutput = payment.redeem.output;
if (!redeemOutput) {
throw new Error('Failed to extract redeem output from P2SH UTXO');
}
if (utxo.nonWitnessUtxo) {
input.nonWitnessUtxo = Buffer.isBuffer(utxo.nonWitnessUtxo)
? utxo.nonWitnessUtxo
: Buffer.from(utxo.nonWitnessUtxo, 'hex');
}
if (isP2WPKH(redeemOutput)) {
delete input.nonWitnessUtxo;
}
else if (isP2WSHScript(redeemOutput)) {
delete input.nonWitnessUtxo;
this.processP2WSHInput(utxo, input, i);
}
else {
delete input.witnessUtxo;
}
}
else if (isP2TR(scriptPub)) {
if (this.sighashTypes) {
const inputSign = TweakedTransaction.calculateSignHash(this.sighashTypes);
if (inputSign)
input.sighashType = inputSign;
}
this.tweakSigner();
input.tapInternalKey = this.internalPubKeyToXOnly();
}
else if (isP2A(scriptPub)) {
this.anchorInputIndices.add(i);
input.isPayToAnchor = true;
}
else if (isP2PK(scriptPub) || isP2MS(scriptPub)) {
if (utxo.nonWitnessUtxo) {
input.nonWitnessUtxo = Buffer.isBuffer(utxo.nonWitnessUtxo)
? utxo.nonWitnessUtxo
: Buffer.from(utxo.nonWitnessUtxo, 'hex');
}
else {
throw new Error('Missing nonWitnessUtxo for P2PK or P2MS UTXO');
}
}
else {
this.error(`Unknown or unsupported script type for output: ${utxo.scriptPubKey.hex}`);
}
if (i === 0) {
if (this.tapLeafScript) {
input.tapLeafScript = [this.tapLeafScript];
}
if (this.nonWitnessUtxo) {
input.nonWitnessUtxo = this.nonWitnessUtxo;
}
}
return input;
}
processP2WSHInput(utxo, input, i) {
if (!utxo.witnessScript) {
throw new Error('Missing witnessScript for P2WSH UTXO');
}
input.witnessScript = Buffer.isBuffer(utxo.witnessScript)
? utxo.witnessScript
: Buffer.from(utxo.witnessScript, 'hex');
const decompiled = script.decompile(input.witnessScript);
if (decompiled && this.isCSVScript(decompiled)) {
const decompiled = script.decompile(input.witnessScript);
if (decompiled && this.isCSVScript(decompiled)) {
this.csvInputIndices.add(i);
const csvBlocks = this.extractCSVBlocks(decompiled);
console.log('csvBlocks', csvBlocks);
input.sequence = this.setCSVSequence(csvBlocks, this.sequence);
}
}
}
secondsToCSVTimeUnits(seconds) {
return Math.floor(seconds / 512);
}
createTimeBasedCSV(seconds) {
const timeUnits = this.secondsToCSVTimeUnits(seconds);
if (timeUnits > 0xffff) {
throw new Error(`Time units ${timeUnits} exceeds maximum of 65,535`);
}
return timeUnits | (1 << 22);
}
isCSVEnabled(sequence) {
return (sequence & (1 << 31)) === 0;
}
extractCSVValue(sequence) {
return sequence & 0x0000ffff;
}
finalizeSecondaryP2WDA(inputIndex, input) {
if (!input.partialSig || input.partialSig.length === 0) {
throw new Error(`No signature for P2WDA input #${inputIndex}`);
}
if (!input.witnessScript) {
throw new Error(`No witness script for P2WDA input #${inputIndex}`);
}
const witnessStack = P2WDADetector.createSimpleP2WDAWitness(input.partialSig[0].signature, input.witnessScript);
return {
finalScriptSig: undefined,
finalScriptWitness: TransactionBuilder.witnessStackToScriptWitness(witnessStack),
};
}
async signInputsWalletBased(transaction) {
const signer = this.signer;
await signer.multiSignPsbt([transaction]);
for (let i = 0; i < transaction.data.inputs.length; i++) {
transaction.finalizeInput(i, this.customFinalizerP2SH.bind(this));
}
this.finalized = true;
}
isCSVScript(decompiled) {
return decompiled.some((op) => op === opcodes.OP_CHECKSEQUENCEVERIFY);
}
setCSVSequence(csvBlocks, currentSequence) {
if (this.txVersion < 2) {
throw new Error('CSV requires transaction version 2 or higher');
}
if (csvBlocks > 0xffff) {
throw new Error(`CSV blocks ${csvBlocks} exceeds maximum of 65,535`);
}
const isTimeBased = (csvBlocks & (1 << 22)) !== 0;
let sequence = csvBlocks & 0x0000ffff;
if (isTimeBased) {
sequence |= 1 << 22;
}
if (currentSequence === TransactionSequence.REPLACE_BY_FEE) {
sequence |= 1 << 25;
}
sequence = sequence & 0x7fffffff;
return sequence;
}
getCSVType(csvValue) {
return csvValue & (1 << 22) ? CSVModes.TIMESTAMPS : CSVModes.BLOCKS;
}
extractCSVBlocks(decompiled) {
for (let i = 0; i < decompiled.length; i++) {
if (decompiled[i] === opcodes.OP_CHECKSEQUENCEVERIFY && i > 0) {
const csvValue = decompiled[i - 1];
if (Buffer.isBuffer(csvValue)) {
return script.number.decode(csvValue);
}
else if (typeof csvValue === 'number') {
if (csvValue === opcodes.OP_0 || csvValue === opcodes.OP_FALSE) {
return 0;
}
else if (csvValue === opcodes.OP_1NEGATE) {
return -1;
}
else if (csvValue >= opcodes.OP_1 && csvValue <= opcodes.OP_16) {
return csvValue - opcodes.OP_1 + 1;
}
else {
throw new Error(`Unexpected raw number in script: ${csvValue}`);
}
}
}
}
return 0;
}
async attemptSignTaproot(transaction, input, i, signer, publicKey) {
const isScriptSpend = this.isTaprootScriptSpend(input, publicKey);
if (isScriptSpend) {
await this.signTaprootInput(signer, transaction, i);
}
else {
let tweakedSigner;
if (signer !== this.signer) {
tweakedSigner = this.getTweakedSigner(true, signer);
}
else {
if (!this.tweakedSigner)
this.tweakSigner();
tweakedSigner = this.tweakedSigner;
}
if (tweakedSigner) {
try {
await this.signTaprootInput(tweakedSigner, transaction, i);
}
catch (e) {
tweakedSigner = this.getTweakedSigner(false, this.signer);
if (!tweakedSigner) {
throw new Error(`Failed to obtain tweaked signer for input ${i}.`);
}
await this.signTaprootInput(tweakedSigner, transaction, i);
}
}
else {
this.error(`Failed to obtain tweaked signer for input ${i}.`);
}
}
}
isTaprootScriptSpend(input, publicKey) {
if (input.tapLeafScript && input.tapLeafScript.length > 0) {
for (const tapLeafScript of input.tapLeafScript) {
if (pubkeyInScript(publicKey, tapLeafScript.script)) {
return true;
}
}
}
return false;
}
async signTaprootInput(signer, transaction, i, tapLeafHash) {
if ('signTaprootInput' in signer) {
try {
await signer.signTaprootInput(transaction, i, tapLeafHash);
}
catch {
throw new Error('Failed to sign Taproot input with provided signer.');
}
}
else {
transaction.signTaprootInput(i, signer);
}
}
async signNonTaprootInput(signer, transaction, i) {
if ('signInput' in signer) {
await signer.signInput(transaction, i);
}
else {
transaction.signInput(i, signer);
}
}
}