UNPKG

@btc-vision/transaction

Version:

OPNet transaction library allows you to create and sign transactions for the OPNet network.

679 lines (578 loc) 21.5 kB
import { crypto as bitcoinCrypto, opcodes, P2TRPayment, PaymentType, Psbt, PsbtInput, PsbtInputExtended, PsbtOutputExtended, script, Signer, TapScriptSig, Taptree, toXOnly, } from '@btc-vision/bitcoin'; import { TransactionBuilder } from './TransactionBuilder.js'; import { TransactionType } from '../enums/TransactionType.js'; import { ITransactionParameters } from '../interfaces/ITransactionParameters.js'; import { MultiSignGenerator } from '../../generators/builders/MultiSignGenerator.js'; import { UTXO } from '../../utxo/interfaces/IUTXO.js'; import { EcKeyPair } from '../../keypair/EcKeyPair.js'; import { ECPairInterface } from 'ecpair'; export interface MultiSignParameters extends Omit<ITransactionParameters, 'gasSatFee' | 'priorityFee' | 'signer'> { readonly pubkeys: Buffer[]; readonly minimumSignatures: number; readonly from?: undefined; readonly to?: undefined; readonly psbt?: Psbt; readonly receiver: string; readonly requestedAmount: bigint; readonly refundVault: string; } export interface MultiSignFromBase64Params extends Omit<MultiSignParameters, 'psbt'> { readonly psbt: string; } /** * Create a multi sign p2tr transaction * @class MultiSignTransaction */ export class MultiSignTransaction extends TransactionBuilder<TransactionType.MULTI_SIG> { public static readonly LOCK_LEAF_SCRIPT: Buffer = script.compile([ opcodes.OP_XOR, opcodes.OP_NOP, opcodes.OP_CODESEPARATOR, ]); public static readonly signHashTypesArray: number[] = [ //Transaction.SIGHASH_ALL, //Transaction.SIGHASH_ANYONECANPAY, ]; public static readonly numsPoint = Buffer.from( '50929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac0', 'hex', ); public type: TransactionType.MULTI_SIG = TransactionType.MULTI_SIG; protected targetScriptRedeem: P2TRPayment | null = null; protected leftOverFundsScriptRedeem: P2TRPayment | null = null; protected readonly compiledTargetScript: Buffer; protected readonly scriptTree: Taptree; protected readonly publicKeys: Buffer[]; protected readonly minimumSignatures: number; protected readonly originalInputCount: number = 0; protected readonly requestedAmount: bigint; protected readonly receiver: string; protected readonly refundVault: string; /** * @description Sign hash types * @protected */ protected readonly sighashTypes: number[] = MultiSignTransaction.signHashTypesArray; public constructor(parameters: MultiSignParameters) { 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(Buffer.from('aaaaaaaa', 'utf-8')), ), 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 */ public static fromBase64(params: MultiSignFromBase64Params): MultiSignTransaction { 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 {Buffer} signerPubKey The signer public key * @returns {boolean} True if the public key signed the transaction */ public static verifyIfSigned(psbt: Psbt, signerPubKey: Buffer): boolean { let alreadySigned: boolean = false; for (let i = 1; i < psbt.data.inputs.length; i++) { const input: PsbtInput = 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 (pubKey.equals(signerPubKey)) { alreadySigned = true; break; } } } return alreadySigned; } /** * Partially sign the transaction * @returns {boolean} True if the transaction was signed * @public */ public static signPartial( psbt: Psbt, signer: Signer | ECPairInterface, originalInputCount: number, minimums: number[], ): { final: boolean; signed: boolean; } { let signed: boolean = false; let final: boolean = true; for (let i = originalInputCount; i < psbt.data.inputs.length; i++) { const input: PsbtInput = psbt.data.inputs[i]; if (!input.tapInternalKey) { input.tapInternalKey = toXOnly(MultiSignTransaction.numsPoint); } const partialSignatures: TapScriptSig[] = []; 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); } delete input.finalScriptWitness; const signHashTypes: number[] = 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 {Buffer[]} partialSignatures The partial signatures * @param {Buffer[]} orderedPubKeys The ordered public keys * @param {boolean} isFinal If the transaction is final */ public static partialFinalizer = ( inputIndex: number, input: PsbtInput, partialSignatures: Buffer[], orderedPubKeys: Buffer[], isFinal: boolean, ) => { 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: Buffer[] = []; 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 (sig.pubkey.equals(toXOnly(pubKey))) { scriptSolution.push(sig.signature); found = true; } } if (!found) { scriptSolution.push(Buffer.alloc(0)); } } scriptSolution = scriptSolution.reverse(); } if (partialSignatures.length > 0) { scriptSolution = scriptSolution.concat(partialSignatures); } const witness = scriptSolution .concat(input.tapLeafScript[0].script) .concat(input.tapLeafScript[0].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 */ public static dedupeSignatures( original: TapScriptSig[], partial: TapScriptSig[], ): TapScriptSig[] { const signatures = new Map<string, TapScriptSig>(); for (const sig of original) { signatures.set(sig.pubkey.toString('hex'), sig); } for (const sig of partial) { if (!signatures.has(sig.pubkey.toString('hex'))) { signatures.set(sig.pubkey.toString('hex'), sig); } } return Array.from(signatures.values()); } /** * Attempt to finalize the inputs * @param {Psbt} psbt The psbt * @param {number} startIndex The start index * @param {Buffer[]} orderedPubKeys The ordered public keys * @param {boolean} isFinal If the transaction is final * @returns {boolean} True if the inputs were finalized */ public static attemptFinalizeInputs( psbt: Psbt, startIndex: number, orderedPubKeys: Buffer[][], isFinal: boolean, ): boolean { 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: TapScriptSig[] = []; 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, ); } delete input.finalScriptWitness; psbt.finalizeInput( i, ( inputIndex: number, input: PsbtInput, ): { finalScriptWitness: Buffer | undefined; } => { return MultiSignTransaction.partialFinalizer( inputIndex, input, [], orderedPubKeys[i - startIndex], isFinal, ); }, ); finalizedInputs++; } catch (e) {} } return finalizedInputs === psbt.data.inputs.length - startIndex; } /** * Finalize the psbt multisig transaction */ public finalizeTransactionInputs(): boolean { let finalized: boolean = 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 as Error).stack}`); } return finalized; } /** * @description Signs the transaction * @public * @returns {Promise<Psbt>} - The signed transaction in hex format * @throws {Error} - If something went wrong */ public async signPSBT(): Promise<Psbt> { 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 protected override async buildTransaction(): Promise<void> { 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: Number(outputLeftAmount), }); this.addOutput({ address: this.receiver, value: Number(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 */ protected override async internalBuildTransaction( transaction: Psbt, checkPartialSigs: boolean = false, ): Promise<boolean> { const inputs: PsbtInputExtended[] = this.getInputs(); const outputs: PsbtOutputExtended[] = 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: Error = e as Error; this.error( `[internalBuildTransaction] Something went wrong while getting building the transaction: ${err.stack}`, ); } return false; } /** * Sign the inputs * @protected */ protected override async signInputs(_transaction: Psbt): Promise<void> {} protected override generateScriptAddress(): P2TRPayment { return { internalPubkey: toXOnly(MultiSignTransaction.numsPoint), //this.internalPubKeyToXOnly(), network: this.network, scriptTree: this.scriptTree, name: PaymentType.P2TR, }; } protected override generateTapData(): P2TRPayment { 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'); } return { internalPubkey: toXOnly(MultiSignTransaction.numsPoint), //this.internalPubKeyToXOnly(), network: this.network, scriptTree: this.scriptTree, redeem: selectedRedeem, name: PaymentType.P2TR, }; } /** * Generate the script solution * @param {PsbtInput} input The input * @protected * * @returns {Buffer[]} The script solution */ protected getScriptSolution(input: PsbtInput): Buffer[] { if (!input.tapScriptSig) { return []; } return input.tapScriptSig.map((sig) => { return sig.signature; }); } /** * Get the script tree * @private * * @returns {Taptree} The script tree */ protected getScriptTree(): Taptree { this.generateRedeemScripts(); return [ { output: this.compiledTargetScript, version: 192, }, { output: MultiSignTransaction.LOCK_LEAF_SCRIPT, version: 192, }, ]; } private getTotalOutputAmount(utxos: UTXO[]): bigint { let total = BigInt(0); for (const utxo of utxos) { total += BigInt(utxo.value); } return total; } /** * @description Calculate the amount left to refund to the first vault. * @private * @returns {bigint} The amount left */ private calculateOutputLeftAmountFromVaults(utxos: UTXO[]): bigint { const total = this.getTotalOutputAmount(utxos); return total - this.requestedAmount; } /** * Transaction finalizer * @param {number} _inputIndex The input index * @param {PsbtInput} input The input */ private customFinalizer = (_inputIndex: number, input: PsbtInput) => { 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 */ private generateRedeemScripts(): void { this.targetScriptRedeem = { name: PaymentType.P2TR, output: this.compiledTargetScript, redeemVersion: 192, }; this.leftOverFundsScriptRedeem = { name: PaymentType.P2TR, output: MultiSignTransaction.LOCK_LEAF_SCRIPT, redeemVersion: 192, }; } }