@btc-vision/transaction
Version:
OPNet transaction library allows you to create and sign transactions for the OPNet network.
509 lines • 18.8 kB
JavaScript
import { crypto as bitcoinCrypto, equals, fromHex, opcodes, PaymentType, Psbt, script, toHex, toSatoshi, toXOnly, } from '@btc-vision/bitcoin';
import { TransactionBuilder } from './TransactionBuilder.js';
import { TransactionType } from '../enums/TransactionType.js';
import { MultiSignGenerator } from '../../generators/builders/MultiSignGenerator.js';
import { EcKeyPair } from '../../keypair/EcKeyPair.js';
import {} from '@btc-vision/ecpair';
/**
* Create a multi sign p2tr transaction
* @class MultiSignTransaction
*/
export class MultiSignTransaction extends TransactionBuilder {
static LOCK_LEAF_SCRIPT = script.compile([
opcodes.OP_XOR,
opcodes.OP_NOP,
opcodes.OP_CODESEPARATOR,
]);
static signHashTypesArray = [
//Transaction.SIGHASH_ALL,
//Transaction.SIGHASH_ANYONECANPAY,
];
static numsPoint = fromHex('50929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac0');
type = TransactionType.MULTI_SIG;
targetScriptRedeem = null;
leftOverFundsScriptRedeem = null;
compiledTargetScript;
scriptTree;
publicKeys;
minimumSignatures;
originalInputCount = 0;
requestedAmount;
receiver;
refundVault;
/**
* @description Sign hash types
* @protected
*/
sighashTypes = MultiSignTransaction.signHashTypesArray;
constructor(parameters) {
if (!parameters.refundVault) {
throw new Error('Refund vault is required');
}
if (!parameters.requestedAmount) {
throw new Error('Requested amount is required');
}
if (!parameters.receiver) {
throw new Error('Receiver is required');
}
super({
...parameters,
signer: EcKeyPair.fromPrivateKey(bitcoinCrypto.sha256(new TextEncoder().encode('aaaaaaaa'))),
priorityFee: 0n,
gasSatFee: 0n,
});
if (!parameters.pubkeys) {
throw new Error('Pubkeys are required');
}
if (parameters.psbt) {
this.log(`Using provided PSBT.`);
this.transaction = parameters.psbt;
this.originalInputCount = this.transaction.data.inputs.length;
}
this.refundVault = parameters.refundVault;
this.requestedAmount = parameters.requestedAmount;
this.receiver = parameters.receiver;
this.publicKeys = parameters.pubkeys;
this.minimumSignatures = parameters.minimumSignatures;
this.compiledTargetScript = MultiSignGenerator.compile(parameters.pubkeys, this.minimumSignatures);
this.scriptTree = this.getScriptTree();
this.internalInit();
}
/**
* Generate a multisig transaction from a base64 psbt.
* @param {MultiSignFromBase64Params} params The parameters
* @returns {MultiSignTransaction} The multisig transaction
*/
static fromBase64(params) {
const psbt = Psbt.fromBase64(params.psbt, { network: params.network });
return new MultiSignTransaction({
...params,
psbt,
});
}
/**
* Verify if that public key already signed the transaction
* @param {Psbt} psbt The psbt
* @param {Uint8Array} signerPubKey The signer public key
* @returns {boolean} True if the public key signed the transaction
*/
static verifyIfSigned(psbt, signerPubKey) {
let alreadySigned = false;
for (let i = 1; i < psbt.data.inputs.length; i++) {
const input = psbt.data.inputs[i];
if (!input.finalScriptWitness) {
continue;
}
const decoded = TransactionBuilder.readScriptWitnessToWitnessStack(input.finalScriptWitness);
if (decoded.length < 3) {
continue;
}
for (let j = 0; j < decoded.length - 2; j += 3) {
const pubKey = decoded[j + 2];
if (equals(pubKey, signerPubKey)) {
alreadySigned = true;
break;
}
}
}
return alreadySigned;
}
/**
* Partially sign the transaction
* @returns {boolean} True if the transaction was signed
* @public
*/
static signPartial(psbt, signer, originalInputCount, minimums) {
let signed = false;
let final = true;
for (let i = originalInputCount; i < psbt.data.inputs.length; i++) {
const input = psbt.data.inputs[i];
if (!input.tapInternalKey) {
input.tapInternalKey = toXOnly(MultiSignTransaction.numsPoint);
}
const partialSignatures = [];
if (input.finalScriptWitness) {
const decoded = TransactionBuilder.readScriptWitnessToWitnessStack(input.finalScriptWitness);
input.tapLeafScript = [
{
leafVersion: 192,
script: decoded[decoded.length - 2],
controlBlock: decoded[decoded.length - 1],
},
];
// we must insert all the partial signatures, decoded.length - 2
for (let j = 0; j < decoded.length - 2; j += 3) {
partialSignatures.push({
signature: decoded[j],
leafHash: decoded[j + 1],
pubkey: decoded[j + 2],
});
}
input.tapScriptSig = (input.tapScriptSig || []).concat(partialSignatures);
}
Reflect.deleteProperty(input, 'finalScriptWitness');
const signHashTypes = MultiSignTransaction.signHashTypesArray
? [MultiSignTransaction.calculateSignHash(MultiSignTransaction.signHashTypesArray)]
: [];
try {
MultiSignTransaction.signInput(psbt, input, i, signer, signHashTypes);
signed = true;
}
catch (e) {
console.log(e);
}
if (signed) {
if (!input.tapScriptSig)
throw new Error('No new signatures for input');
if (input.tapScriptSig.length !== minimums[i - originalInputCount]) {
final = false;
}
}
}
return {
signed,
final: !signed ? false : final,
};
}
/**
* Partially finalize a P2TR MS transaction
* @param {number} inputIndex The input index
* @param {PsbtInput} input The input
* @param {Uint8Array[]} partialSignatures The partial signatures
* @param {Uint8Array[]} orderedPubKeys The ordered public keys
* @param {boolean} isFinal If the transaction is final
*/
static partialFinalizer = (inputIndex, input, partialSignatures, orderedPubKeys, isFinal) => {
if (!input.tapLeafScript ||
!input.tapLeafScript[0]?.script ||
!input.tapLeafScript[0]?.controlBlock) {
throw new Error('Tap leaf script is required');
}
if (!input.tapScriptSig) {
throw new Error(`No new signatures for input ${inputIndex}.`);
}
let scriptSolution = [];
if (!isFinal) {
scriptSolution = input.tapScriptSig
.map((sig) => {
return [sig.signature, sig.leafHash, sig.pubkey];
})
.flat();
}
else {
/** We must order the signatures and the pub keys. */
for (const pubKey of orderedPubKeys) {
let found = false;
for (const sig of input.tapScriptSig) {
if (equals(sig.pubkey, toXOnly(pubKey))) {
scriptSolution.push(sig.signature);
found = true;
}
}
if (!found) {
scriptSolution.push(new Uint8Array(0));
}
}
scriptSolution = scriptSolution.reverse();
}
if (partialSignatures.length > 0) {
scriptSolution = scriptSolution.concat(partialSignatures);
}
const tapLeaf = input.tapLeafScript[0];
const witness = scriptSolution.concat(tapLeaf.script).concat(tapLeaf.controlBlock);
return {
finalScriptWitness: TransactionBuilder.witnessStackToScriptWitness(witness),
};
};
/**
* Dedupe signatures
* @param {TapScriptSig[]} original The original signatures
* @param {TapScriptSig[]} partial The partial signatures
* @returns {TapScriptSig[]} The deduped signatures
*/
static dedupeSignatures(original, partial) {
const signatures = new Map();
for (const sig of original) {
signatures.set(toHex(sig.pubkey), sig);
}
for (const sig of partial) {
if (!signatures.has(toHex(sig.pubkey))) {
signatures.set(toHex(sig.pubkey), sig);
}
}
return Array.from(signatures.values());
}
/**
* Attempt to finalize the inputs
* @param {Psbt} psbt The psbt
* @param {number} startIndex The start index
* @param {Uint8Array[]} orderedPubKeys The ordered public keys
* @param {boolean} isFinal If the transaction is final
* @returns {boolean} True if the inputs were finalized
*/
static attemptFinalizeInputs(psbt, startIndex, orderedPubKeys, isFinal) {
let finalizedInputs = 0;
for (let i = startIndex; i < psbt.data.inputs.length; i++) {
try {
const input = psbt.data.inputs[i];
if (!input.tapInternalKey) {
input.tapInternalKey = toXOnly(MultiSignTransaction.numsPoint);
}
const partialSignatures = [];
if (input.finalScriptWitness) {
const decoded = TransactionBuilder.readScriptWitnessToWitnessStack(input.finalScriptWitness);
// we must insert all the partial signatures, decoded.length - 2
for (let j = 0; j < decoded.length - 2; j += 3) {
partialSignatures.push({
signature: decoded[j],
leafHash: decoded[j + 1],
pubkey: decoded[j + 2],
});
}
input.tapLeafScript = [
{
leafVersion: 192,
script: decoded[decoded.length - 2],
controlBlock: decoded[decoded.length - 1],
},
];
input.tapScriptSig = MultiSignTransaction.dedupeSignatures(input.tapScriptSig || [], partialSignatures);
}
Reflect.deleteProperty(input, 'finalScriptWitness');
psbt.finalizeInput(i, (inputIndex, input) => {
return MultiSignTransaction.partialFinalizer(inputIndex, input, [], orderedPubKeys[i - startIndex], isFinal);
});
finalizedInputs++;
}
catch (e) { }
}
return finalizedInputs === psbt.data.inputs.length - startIndex;
}
/**
* Finalize the psbt multisig transaction
*/
finalizeTransactionInputs() {
let finalized = false;
try {
for (let i = this.originalInputCount; i < this.transaction.data.inputs.length; i++) {
this.transaction.finalizeInput(i, this.customFinalizer.bind(this));
}
finalized = true;
}
catch (e) {
this.error(`Error finalizing transaction inputs: ${e.stack}`);
}
return finalized;
}
/**
* @description Signs the transaction
* @public
* @returns {Promise<Psbt>} - The signed transaction in hex format
* @throws {Error} - If something went wrong
*/
async signPSBT() {
if (await this.signTransaction()) {
return this.transaction;
}
throw new Error('Could not sign transaction');
}
/**
* Build the transaction
* @protected
*
* @throws {Error} If the left over funds script redeem is required
* @throws {Error} If the left over funds script redeem version is required
* @throws {Error} If the left over funds script redeem output is required
*/
// eslint-disable-next-line @typescript-eslint/require-await
async buildTransaction() {
const selectedRedeem = this.targetScriptRedeem;
if (!selectedRedeem) {
throw new Error('Left over funds script redeem is required');
}
if (!selectedRedeem.redeemVersion) {
throw new Error('Left over funds script redeem version is required');
}
if (!selectedRedeem.output) {
throw new Error('Left over funds script redeem output is required');
}
this.tapLeafScript = {
leafVersion: selectedRedeem.redeemVersion,
script: selectedRedeem.output,
controlBlock: this.getWitness(),
};
this.addInputsFromUTXO();
const outputLeftAmount = this.calculateOutputLeftAmountFromVaults(this.utxos);
if (outputLeftAmount < 0) {
throw new Error(`Output value left is negative ${outputLeftAmount}.`);
}
this.addOutput({
address: this.refundVault,
value: toSatoshi(outputLeftAmount),
});
this.addOutput({
address: this.receiver,
value: toSatoshi(this.requestedAmount),
});
}
/**
* Builds the transaction.
* @param {Psbt} transaction - The transaction to build
* @param checkPartialSigs
* @protected
* @returns {Promise<boolean>}
* @throws {Error} - If something went wrong while building the transaction
*/
async internalBuildTransaction(transaction, checkPartialSigs = false) {
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);
return this.finalizeTransactionInputs();
}
catch (e) {
const err = e;
this.error(`[internalBuildTransaction] Something went wrong while getting building the transaction: ${err.stack}`);
}
return false;
}
/**
* Sign the inputs
* @protected
*/
async signInputs(_transaction) { }
generateScriptAddress() {
if (this.useP2MR) {
return {
network: this.network,
scriptTree: this.scriptTree,
name: PaymentType.P2MR,
};
}
return {
internalPubkey: toXOnly(MultiSignTransaction.numsPoint),
network: this.network,
scriptTree: this.scriptTree,
name: PaymentType.P2TR,
};
}
generateTapData() {
const selectedRedeem = this.targetScriptRedeem;
if (!selectedRedeem) {
throw new Error('Left over funds script redeem is required');
}
if (!this.scriptTree) {
throw new Error('Script tree is required');
}
if (this.useP2MR) {
return {
network: this.network,
scriptTree: this.scriptTree,
redeem: selectedRedeem,
name: PaymentType.P2MR,
};
}
return {
internalPubkey: toXOnly(MultiSignTransaction.numsPoint),
network: this.network,
scriptTree: this.scriptTree,
redeem: selectedRedeem,
name: PaymentType.P2TR,
};
}
/**
* Generate the script solution
* @param {PsbtInput} input The input
* @protected
*
* @returns {Uint8Array[]} The script solution
*/
getScriptSolution(input) {
if (!input.tapScriptSig) {
return [];
}
return input.tapScriptSig.map((sig) => {
return sig.signature;
});
}
/**
* Get the script tree
* @private
*
* @returns {Taptree} The script tree
*/
getScriptTree() {
this.generateRedeemScripts();
return [
{
output: this.compiledTargetScript,
version: 192,
},
{
output: MultiSignTransaction.LOCK_LEAF_SCRIPT,
version: 192,
},
];
}
getTotalOutputAmount(utxos) {
let total = 0n;
for (const utxo of utxos) {
total += utxo.value;
}
return total;
}
/**
* @description Calculate the amount left to refund to the first vault.
* @private
* @returns {bigint} The amount left
*/
calculateOutputLeftAmountFromVaults(utxos) {
const total = this.getTotalOutputAmount(utxos);
return total - this.requestedAmount;
}
/**
* Transaction finalizer
* @param {number} _inputIndex The input index
* @param {PsbtInput} input The input
*/
customFinalizer = (_inputIndex, input) => {
if (!this.tapLeafScript) {
throw new Error('Tap leaf script is required');
}
const scriptSolution = this.getScriptSolution(input);
const witness = scriptSolution
.concat(this.tapLeafScript.script)
.concat(this.tapLeafScript.controlBlock);
return {
finalScriptWitness: TransactionBuilder.witnessStackToScriptWitness(witness),
};
};
/**
* Generate the redeem scripts
* @private
*
* @throws {Error} If the public keys are required
* @throws {Error} If the leaf script is required
* @throws {Error} If the leaf script version is required
* @throws {Error} If the leaf script output is required
* @throws {Error} If the target script redeem is required
*/
generateRedeemScripts() {
this.targetScriptRedeem = {
name: PaymentType.P2TR,
output: this.compiledTargetScript,
redeemVersion: 192,
};
this.leftOverFundsScriptRedeem = {
name: PaymentType.P2TR,
output: MultiSignTransaction.LOCK_LEAF_SCRIPT,
redeemVersion: 192,
};
}
}
//# sourceMappingURL=MultiSignTransaction.js.map