UNPKG

@btc-vision/transaction

Version:

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

370 lines (313 loc) 13.3 kB
import { concat, fromHex, Psbt, type PsbtInput, toXOnly } from '@btc-vision/bitcoin'; import type { UTXO } from '../../utxo/interfaces/IUTXO.js'; import { TransactionType } from '../enums/TransactionType.js'; import type { IInteractionParameters } from '../interfaces/ITransactionParameters.js'; import { TransactionBuilder } from './TransactionBuilder.js'; import { MessageSigner } from '../../keypair/MessageSigner.js'; import { Compressor } from '../../bytecode/Compressor.js'; import { P2WDAGenerator } from '../../generators/builders/P2WDAGenerator.js'; import { type Feature, FeaturePriority, Features } from '../../generators/Features.js'; import { BitcoinUtils } from '../../utils/BitcoinUtils.js'; import { EcKeyPair } from '../../keypair/EcKeyPair.js'; import type { IChallengeSolution } from '../../epoch/interfaces/IChallengeSolution.js'; import { type UniversalSigner } from '@btc-vision/ecpair'; import { P2WDADetector } from '../../p2wda/P2WDADetector.js'; import type { IP2WSHAddress } from '../mineable/IP2WSHAddress.js'; import { TimeLockGenerator } from '../mineable/TimelockGenerator.js'; /** * P2WDA Interaction Transaction * * This transaction type uses the exact same operation data as regular interactions * (via CalldataGenerator), but embeds it in the witness field instead of a taproot script. * This achieves 75% cost reduction through the witness discount. */ export class InteractionTransactionP2WDA extends TransactionBuilder<TransactionType.INTERACTION> { private static readonly MAX_WITNESS_FIELDS = 10; private static readonly MAX_BYTES_PER_WITNESS = 80; public readonly type: TransactionType.INTERACTION = TransactionType.INTERACTION; protected readonly epochChallenge: IP2WSHAddress; /** * Disable auto refund * @protected */ protected readonly disableAutoRefund: boolean; private readonly contractSecret: Uint8Array; private readonly calldata: Uint8Array; private readonly challenge: IChallengeSolution; private readonly randomBytes: Uint8Array; private p2wdaGenerator: P2WDAGenerator; private scriptSigner: UniversalSigner; private p2wdaInputIndices: Set<number> = new Set(); /** * The compiled operation data from CalldataGenerator * This is exactly what would go in a taproot script, but we put it in witness instead */ private readonly compiledOperationData: Uint8Array | null = null; public constructor(parameters: IInteractionParameters) { super(parameters); if (!parameters.to) { throw new Error('Contract address (to) is required'); } if (!parameters.contract) { throw new Error('Contract secret is required'); } if (!parameters.calldata) { throw new Error('Calldata is required'); } if (!parameters.challenge) { throw new Error('Challenge solution is required'); } this.disableAutoRefund = parameters.disableAutoRefund || false; this.contractSecret = fromHex(parameters.contract.startsWith('0x') ? parameters.contract.slice(2) : parameters.contract); this.calldata = Compressor.compress(parameters.calldata); this.challenge = parameters.challenge; this.randomBytes = parameters.randomBytes || BitcoinUtils.rndBytes(); // Create the script signer (same as SharedInteractionTransaction does) this.scriptSigner = this.generateKeyPairFromSeed(); // Create the P2WDA generator instead of CalldataGenerator // P2WDA needs a different data format optimized for witness embedding this.p2wdaGenerator = new P2WDAGenerator( this.signer.publicKey, this.scriptSignerXOnlyPubKey(), this.network, ); // Validate contract secret if (this.contractSecret.length !== 32) { throw new Error('Invalid contract secret length. Expected 32 bytes.'); } this.epochChallenge = TimeLockGenerator.generateTimeLockAddress( this.challenge.publicKey.originalPublicKeyBuffer(), this.network, ); // Validate P2WDA inputs this.validateP2WDAInputs(); if (parameters.compiledTargetScript) { if (parameters.compiledTargetScript instanceof Uint8Array) { this.compiledOperationData = parameters.compiledTargetScript; } else if (typeof parameters.compiledTargetScript === 'string') { this.compiledOperationData = fromHex(parameters.compiledTargetScript); } else { throw new Error('Invalid compiled target script format.'); } } else { this.compiledOperationData = this.p2wdaGenerator.compile( this.calldata, this.contractSecret, this.challenge, this.priorityFee, this.generateFeatures(parameters), ); } // Validate size early this.validateOperationDataSize(); this.internalInit(); } /** * Get random bytes (for compatibility if needed elsewhere) */ public getRndBytes(): Uint8Array { return this.randomBytes; } /** * Get the challenge (for compatibility if needed elsewhere) */ public getChallenge(): IChallengeSolution { return this.challenge; } /** * Get contract secret (for compatibility if needed elsewhere) */ public getContractSecret(): Uint8Array { return this.contractSecret; } /** * Build the transaction */ protected async buildTransaction(): Promise<void> { if (!this.regenerated) { this.addInputsFromUTXO(); } // Add refund await this.createMineableRewardOutputs(); } protected async createMineableRewardOutputs(): Promise<void> { if (!this.to) throw new Error('To address is required'); const amountSpent: bigint = this.getTransactionOPNetFee(); this.addFeeToOutput(amountSpent, this.to, this.epochChallenge, false); const amount = this.addOptionalOutputsAndGetAmount(); if (!this.disableAutoRefund) { await this.addRefundOutput(amountSpent + amount); } } /** * Sign inputs with P2WDA-specific handling */ protected override async signInputs(transaction: Psbt): Promise<void> { // Sign all inputs for (let i = 0; i < transaction.data.inputs.length; i++) { await this.signInput( transaction, transaction.data.inputs[i] as PsbtInput, i, this.signer, ); } // Finalize with appropriate finalizers for (let i = 0; i < transaction.data.inputs.length; i++) { if (this.p2wdaInputIndices.has(i)) { if (i === 0) { transaction.finalizeInput(i, this.finalizePrimaryP2WDA.bind(this)); } else { transaction.finalizeInput(i, this.finalizeSecondaryP2WDA.bind(this)); } } else { transaction.finalizeInput(i, this.customFinalizerP2SH.bind(this)); } } this.finalized = true; } /** * Generate features array (same as InteractionTransaction) */ private generateFeatures(parameters: IInteractionParameters): Feature<Features>[] { const features: Feature<Features>[] = []; if (parameters.loadedStorage) { features.push({ priority: FeaturePriority.ACCESS_LIST, opcode: Features.ACCESS_LIST, data: parameters.loadedStorage, }); } const submission = parameters.challenge.getSubmission(); if (submission) { features.push({ priority: FeaturePriority.EPOCH_SUBMISSION, opcode: Features.EPOCH_SUBMISSION, data: submission, }); } return features; } /** * Generate keypair from seed (same as SharedInteractionTransaction) */ private generateKeyPairFromSeed(): UniversalSigner { return EcKeyPair.fromSeedKeyPair(this.randomBytes, this.network); } /** * Get script signer x-only pubkey (same as SharedInteractionTransaction) */ private scriptSignerXOnlyPubKey(): Uint8Array { return toXOnly(this.scriptSigner.publicKey); } /** * Validate that input 0 is P2WDA */ private validateP2WDAInputs(): void { if (this.utxos.length === 0 || !P2WDADetector.isP2WDAUTXO(this.utxos[0] as UTXO)) { throw new Error('Input 0 must be a P2WDA UTXO'); } // Track all P2WDA inputs for (let i = 0; i < this.utxos.length; i++) { if (P2WDADetector.isP2WDAUTXO(this.utxos[i] as UTXO)) { this.p2wdaInputIndices.add(i); } } for (let i = 0; i < this.optionalInputs.length; i++) { const actualIndex = this.utxos.length + i; if (P2WDADetector.isP2WDAUTXO(this.optionalInputs[i] as UTXO)) { this.p2wdaInputIndices.add(actualIndex); } } } /** * Validate the compiled operation data will fit in witness fields */ private validateOperationDataSize(): void { if (!this.compiledOperationData) { throw new Error('Operation data not compiled'); } // The data that goes in witness: COMPRESS(signature + compiledOperationData) // Signature is 64 bytes const estimatedSize = this.compiledOperationData.length; if (!P2WDAGenerator.validateWitnessSize(estimatedSize)) { const signatureSize = 64; const totalSize = estimatedSize + signatureSize; const compressedEstimate = Math.ceil(totalSize * 0.7); const requiredFields = Math.ceil( compressedEstimate / InteractionTransactionP2WDA.MAX_BYTES_PER_WITNESS, ); throw new Error( `Please dont use P2WDA for this operation. Data too large. Raw size: ${estimatedSize} bytes, ` + `estimated compressed: ${compressedEstimate} bytes, ` + `needs ${requiredFields} witness fields, max is ${InteractionTransactionP2WDA.MAX_WITNESS_FIELDS}`, ); } } /** * Finalize primary P2WDA input with the operation data * This is where we create the signature and compress everything */ private finalizePrimaryP2WDA( inputIndex: number, input: PsbtInput, ): { finalScriptSig: Uint8Array | undefined; finalScriptWitness: Uint8Array | undefined; } { 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}`); } if (!this.compiledOperationData) { throw new Error('Operation data not compiled'); } const txSignature = (input.partialSig[0] as { signature: Uint8Array }).signature; const messageToSign = concat([txSignature, this.compiledOperationData]); const signedMessage = MessageSigner.signMessage( this.signer as UniversalSigner, messageToSign, ); const schnorrSignature = signedMessage.signature; // Combine and compress: COMPRESS(signature + compiledOperationData) const fullData = concat([schnorrSignature, this.compiledOperationData]); const compressedData = Compressor.compress(fullData); // Split into chunks const chunks = this.splitIntoWitnessChunks(compressedData); if (chunks.length > InteractionTransactionP2WDA.MAX_WITNESS_FIELDS) { throw new Error( `Compressed data needs ${chunks.length} witness fields, max is ${InteractionTransactionP2WDA.MAX_WITNESS_FIELDS}`, ); } // Build witness stack const witnessStack: Uint8Array[] = [txSignature]; // Add exactly 10 data fields // Bitcoin stack is reversed! for (let i = 0; i < InteractionTransactionP2WDA.MAX_WITNESS_FIELDS; i++) { witnessStack.push(i < chunks.length ? (chunks[i] as Uint8Array) : new Uint8Array(0)); } witnessStack.push(input.witnessScript as Uint8Array); return { finalScriptSig: undefined, finalScriptWitness: TransactionBuilder.witnessStackToScriptWitness(witnessStack), }; } /** * Split data into 80-byte chunks */ private splitIntoWitnessChunks(data: Uint8Array): Uint8Array[] { const chunks: Uint8Array[] = []; let offset = 0; while (offset < data.length) { const size = Math.min( InteractionTransactionP2WDA.MAX_BYTES_PER_WITNESS, data.length - offset, ); chunks.push(data.slice(offset, offset + size)); offset += size; } return chunks; } }