UNPKG

@btc-vision/transaction

Version:

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

559 lines (475 loc) 21.3 kB
import { fromHex, Psbt, type PsbtInput, type Script, toSatoshi, toXOnly, Transaction, } from '@btc-vision/bitcoin'; import { type UniversalSigner } from '@btc-vision/ecpair'; import { TransactionType } from '../enums/TransactionType.js'; import { MINIMUM_AMOUNT_REWARD, TransactionBuilder } from './TransactionBuilder.js'; import { HashCommitmentGenerator } from '../../generators/builders/HashCommitmentGenerator.js'; import { CalldataGenerator } from '../../generators/builders/CalldataGenerator.js'; import type { IConsolidatedInteractionParameters, IConsolidatedInteractionResult, IHashCommittedP2WSH, IRevealTransactionResult, ISetupTransactionResult, } from '../interfaces/IConsolidatedTransactionParameters.js'; import type { IP2WSHAddress } from '../mineable/IP2WSHAddress.js'; import { TimeLockGenerator } from '../mineable/TimelockGenerator.js'; import type { IChallengeSolution } from '../../epoch/interfaces/IChallengeSolution.js'; import { EcKeyPair } from '../../keypair/EcKeyPair.js'; import { BitcoinUtils } from '../../utils/BitcoinUtils.js'; import { Compressor } from '../../bytecode/Compressor.js'; import { type Feature, FeaturePriority, Features } from '../../generators/Features.js'; import { AddressGenerator } from '../../generators/AddressGenerator.js'; /** * Consolidated Interaction Transaction * * Drop-in replacement for InteractionTransaction that bypasses BIP110/Bitcoin Knots censorship. * * Uses the same parameters and sends the same data on-chain as InteractionTransaction, * but embeds data in hash-committed P2WSH witnesses instead of Tapscript. * * Data is split into 80-byte chunks (P2WSH stack item limit), with up to 14 chunks * batched per P2WSH output (~1,120 bytes per output). Each output's witness script * commits to all its chunks via HASH160. When spent, all data chunks are revealed * in the witness and verified at consensus level. * * Policy limits respected: * - MAX_STANDARD_P2WSH_STACK_ITEM_SIZE = 80 bytes per chunk * - g_script_size_policy_limit = 1650 bytes total witness size (serialized) * - MAX_STANDARD_P2WSH_STACK_ITEMS = 100 items per witness * * Data integrity is consensus-enforced: if any data is stripped or modified, * HASH160(data) != committed_hash and the transaction is INVALID. * * Capacity: ~1.1KB per P2WSH output, ~220 outputs per reveal tx, ~242KB max. * * Usage: * ```typescript * // Same parameters as InteractionTransaction * const tx = new ConsolidatedInteractionTransaction({ * calldata: myCalldata, * to: contractAddress, * contract: contractSecret, * challenge: myChallenge, * utxos: myUtxos, * signer: mySigner, * network: networks.bitcoin, * feeRate: 10, * priorityFee: 0n, * gasSatFee: 330n, * }); * * const result = await tx.build(); * // Broadcast setup first, then reveal (can use CPFP) * broadcast(result.setup.txHex); * broadcast(result.reveal.txHex); * ``` */ export class ConsolidatedInteractionTransaction extends TransactionBuilder<TransactionType.INTERACTION> { public readonly type: TransactionType.INTERACTION = TransactionType.INTERACTION; /** Random bytes for interaction (same as InteractionTransaction) */ public readonly randomBytes: Uint8Array; /** The contract address (same as InteractionTransaction.to) */ protected readonly contractAddress: string; /** The contract secret - 32 bytes (same as InteractionTransaction) */ protected readonly contractSecret: Uint8Array; /** The compressed calldata (same as InteractionTransaction) */ protected readonly calldata: Uint8Array; /** Challenge solution for epoch (same as InteractionTransaction) */ protected readonly challenge: IChallengeSolution; /** Epoch challenge P2WSH address (same as InteractionTransaction) */ protected readonly epochChallenge: IP2WSHAddress; /** Script signer for interaction (same as InteractionTransaction) */ protected readonly scriptSigner: UniversalSigner; /** Calldata generator - produces same output as InteractionTransaction */ protected readonly calldataGenerator: CalldataGenerator; /** Hash commitment generator for CHCT */ protected readonly hashCommitmentGenerator: HashCommitmentGenerator; /** The compiled operation data - SAME as InteractionTransaction's compiledTargetScript */ protected readonly compiledTargetScript: Uint8Array; /** Generated hash-committed P2WSH outputs */ protected readonly commitmentOutputs: IHashCommittedP2WSH[]; /** Disable auto refund (same as InteractionTransaction) */ protected readonly disableAutoRefund: boolean; /** Maximum chunk size (default: 80 bytes per P2WSH stack item limit) */ protected readonly maxChunkSize: number; /** Cached value per output (calculated once, used by setup and reveal) */ private cachedValuePerOutput: bigint | null = null; constructor(parameters: IConsolidatedInteractionParameters) { super(parameters); // Same validation as InteractionTransaction if (!parameters.to) { throw new Error('Contract address (to) is required'); } if (!parameters.contract) { throw new Error('Contract secret (contract) is required'); } if (!parameters.calldata) { throw new Error('Calldata is required'); } if (!parameters.challenge) { throw new Error('Challenge solution is required'); } this.contractAddress = parameters.to; this.contractSecret = fromHex(parameters.contract.startsWith('0x') ? parameters.contract.slice(2) : parameters.contract); this.disableAutoRefund = parameters.disableAutoRefund || false; this.maxChunkSize = parameters.maxChunkSize ?? HashCommitmentGenerator.MAX_CHUNK_SIZE; // Validate contract secret (same as InteractionTransaction) if (this.contractSecret.length !== 32) { throw new Error('Invalid contract secret length. Expected 32 bytes.'); } // Compress calldata (same as SharedInteractionTransaction) this.calldata = Compressor.compress(parameters.calldata); // Generate random bytes and script signer (same as SharedInteractionTransaction) this.randomBytes = parameters.randomBytes || BitcoinUtils.rndBytes(); this.scriptSigner = EcKeyPair.fromSeedKeyPair(this.randomBytes, this.network); // Generate epoch challenge address (same as SharedInteractionTransaction) this.challenge = parameters.challenge; this.epochChallenge = TimeLockGenerator.generateTimeLockAddress( this.challenge.publicKey.originalPublicKeyBuffer(), this.network, ); // Create calldata generator (same as SharedInteractionTransaction) this.calldataGenerator = new CalldataGenerator( this.signer.publicKey, toXOnly(this.scriptSigner.publicKey), this.network, ); // Compile the target script - SAME as InteractionTransaction if (parameters.compiledTargetScript) { if (parameters.compiledTargetScript instanceof Uint8Array) { this.compiledTargetScript = parameters.compiledTargetScript; } else if (typeof parameters.compiledTargetScript === 'string') { this.compiledTargetScript = fromHex(parameters.compiledTargetScript); } else { throw new Error('Invalid compiled target script format.'); } } else { this.compiledTargetScript = this.calldataGenerator.compile( this.calldata, this.contractSecret, this.challenge, this.priorityFee, this.generateFeatures(parameters), ); } // Create hash commitment generator this.hashCommitmentGenerator = new HashCommitmentGenerator( this.signer.publicKey, this.network, ); // Split compiled data into hash-committed chunks this.commitmentOutputs = this.hashCommitmentGenerator.prepareChunks( this.compiledTargetScript, this.maxChunkSize, ); // Validate output count this.validateOutputCount(); const totalChunks = this.commitmentOutputs.reduce( (sum, output) => sum + output.dataChunks.length, 0, ); this.log( `ConsolidatedInteractionTransaction: ${this.commitmentOutputs.length} outputs, ` + `${totalChunks} chunks from ${this.compiledTargetScript.length} bytes compiled data`, ); this.internalInit(); } /** * Get the compiled target script (same as InteractionTransaction). */ public exportCompiledTargetScript(): Uint8Array { return this.compiledTargetScript; } /** * Get the contract secret (same as InteractionTransaction). */ public getContractSecret(): Uint8Array { return this.contractSecret; } /** * Get the random bytes (same as InteractionTransaction). */ public getRndBytes(): Uint8Array { return this.randomBytes; } /** * Get the challenge solution (same as InteractionTransaction). */ public getChallenge(): IChallengeSolution { return this.challenge; } /** * Get the commitment outputs for the setup transaction. */ public getCommitmentOutputs(): IHashCommittedP2WSH[] { return this.commitmentOutputs; } /** * Get the number of P2WSH outputs. */ public getOutputCount(): number { return this.commitmentOutputs.length; } /** * Get the total number of 80-byte chunks across all outputs. */ public getTotalChunkCount(): number { return this.commitmentOutputs.reduce((sum, output) => sum + output.dataChunks.length, 0); } /** * Build both setup and reveal transactions. * * @returns Complete result with both transactions */ public async build(): Promise<IConsolidatedInteractionResult> { // Build and sign setup transaction using base class flow const setupTx = await this.signTransaction(); const setupTxId = setupTx.getId(); const setup: ISetupTransactionResult = { txHex: setupTx.toHex(), txId: setupTxId, outputs: this.commitmentOutputs, feesPaid: this.transactionFee, chunkCount: this.getTotalChunkCount(), totalDataSize: this.compiledTargetScript.length, }; this.log(`Setup transaction: ${setup.txId}`); // Build reveal transaction const reveal = this.buildRevealTransaction(setupTxId); return { setup, reveal, totalFees: setup.feesPaid + reveal.feesPaid, }; } /** * Build the reveal transaction. * Spends the P2WSH commitment outputs, revealing the compiled data in witnesses. * * Output structure matches InteractionTransaction: * - Output to epochChallenge.address (miner reward) * - Change output (if any) * * @param setupTxId The transaction ID of the setup transaction */ public buildRevealTransaction(setupTxId: string): IRevealTransactionResult { const revealPsbt = new Psbt({ network: this.network }); // Get the value per output (same as used in setup transaction) const valuePerOutput = this.calculateValuePerOutput(); // Add commitment outputs as inputs (from setup tx) for (let i = 0; i < this.commitmentOutputs.length; i++) { const commitment = this.commitmentOutputs[i] as IHashCommittedP2WSH; revealPsbt.addInput({ hash: setupTxId, index: i, witnessUtxo: { script: commitment.scriptPubKey as Script, value: toSatoshi(valuePerOutput), }, witnessScript: commitment.witnessScript, }); } // Calculate input value from commitments const inputValue = BigInt(this.commitmentOutputs.length) * valuePerOutput; // Calculate OPNet fee (same as InteractionTransaction) const opnetFee = this.getTransactionOPNetFee(); const feeAmount = opnetFee < MINIMUM_AMOUNT_REWARD ? MINIMUM_AMOUNT_REWARD : opnetFee; // Add output to epoch challenge address (same as InteractionTransaction) revealPsbt.addOutput({ address: this.epochChallenge.address, value: toSatoshi(feeAmount), }); // Estimate reveal transaction fee const estimatedVBytes = this.estimateRevealVBytes(); const revealFee = BigInt(Math.ceil(estimatedVBytes * this.feeRate)); // Add change output if there's enough left const changeValue = inputValue - feeAmount - revealFee; if (changeValue > TransactionBuilder.MINIMUM_DUST) { const refundAddress = this.getRefundAddress(); revealPsbt.addOutput({ address: refundAddress, value: toSatoshi(changeValue), }); } // Sign all commitment inputs for (let i = 0; i < this.commitmentOutputs.length; i++) { revealPsbt.signInput(i, this.signer); } // Finalize all inputs with hash-commitment finalizer for (let i = 0; i < this.commitmentOutputs.length; i++) { const commitment = this.commitmentOutputs[i] as IHashCommittedP2WSH; revealPsbt.finalizeInput(i, (_inputIndex: number, input: PsbtInput) => { return this.finalizeCommitmentInput(input, commitment); }); } const revealTx: Transaction = revealPsbt.extractTransaction(); const result: IRevealTransactionResult = { txHex: revealTx.toHex(), txId: revealTx.getId(), dataSize: this.compiledTargetScript.length, feesPaid: revealFee, inputCount: this.commitmentOutputs.length, }; this.log(`Reveal transaction: ${result.txId}`); return result; } /** * Get the value per commitment output (for external access). */ public getValuePerOutput(): bigint { return this.calculateValuePerOutput(); } /** * Build the setup transaction. * Creates P2WSH outputs with hash commitments to the compiled data chunks. * This is called by signTransaction() in the base class. */ protected override async buildTransaction(): Promise<void> { // Add funding UTXOs as inputs this.addInputsFromUTXO(); // Calculate value per output (includes reveal fee + OPNet fee) const valuePerOutput = this.calculateValuePerOutput(); // Add each hash-committed P2WSH as an output for (const commitment of this.commitmentOutputs) { this.addOutput({ value: toSatoshi(valuePerOutput), address: commitment.address, }); } // Calculate total spent on commitment outputs const totalCommitmentValue = BigInt(this.commitmentOutputs.length) * valuePerOutput; // Add optional outputs const optionalAmount = this.addOptionalOutputsAndGetAmount(); // Add refund/change output await this.addRefundOutput(totalCommitmentValue + optionalAmount); } /** * Finalize a commitment input. * * Witness stack: [signature, data_1, data_2, ..., data_N, witnessScript] * * The witness script verifies each data chunk against its committed hash. * If any data is wrong or missing, the transaction is INVALID at consensus level. */ private finalizeCommitmentInput( input: PsbtInput, commitment: IHashCommittedP2WSH, ): { finalScriptSig: Uint8Array | undefined; finalScriptWitness: Uint8Array | undefined; } { if (!input.partialSig || input.partialSig.length === 0) { throw new Error('No signature for commitment input'); } if (!input.witnessScript) { throw new Error('No witness script for commitment input'); } // Witness stack for hash-committed P2WSH with multiple chunks // Order: [signature, data_1, data_2, ..., data_N, witnessScript] const witnessStack: Uint8Array[] = [ (input.partialSig[0] as { signature: Uint8Array }).signature, // Signature for OP_CHECKSIG ...commitment.dataChunks, // All data chunks for OP_HASH160 verification input.witnessScript, // The witness script ]; return { finalScriptSig: undefined, finalScriptWitness: TransactionBuilder.witnessStackToScriptWitness(witnessStack), }; } /** * Estimate reveal transaction vBytes. */ private estimateRevealVBytes(): number { // Calculate actual witness weight based on chunks per output let witnessWeight = 0; for (const commitment of this.commitmentOutputs) { // Per input: 41 bytes base (× 4) + witness data // Witness: signature (~72) + chunks (N × 80) + script (N × 23 + 35) + overhead (~20) const numChunks = commitment.dataChunks.length; const chunkDataWeight = numChunks * 80; // actual data const scriptWeight = numChunks * 23 + 35; // witness script const sigWeight = 72; const overheadWeight = 20; witnessWeight += 164 + chunkDataWeight + scriptWeight + sigWeight + overheadWeight; } const weight = 40 + witnessWeight + 200; // tx overhead + witnesses + outputs return Math.ceil(weight / 4); } /** * Calculate the required value per commitment output. * This must cover: dust minimum + share of reveal fee + share of OPNet fee */ private calculateValuePerOutput(): bigint { // Return cached value if already calculated if (this.cachedValuePerOutput !== null) { return this.cachedValuePerOutput; } const numOutputs = this.commitmentOutputs.length; // Calculate OPNet fee const opnetFee = this.getTransactionOPNetFee(); const feeAmount = opnetFee < MINIMUM_AMOUNT_REWARD ? MINIMUM_AMOUNT_REWARD : opnetFee; // Calculate reveal fee const estimatedVBytes = this.estimateRevealVBytes(); const revealFee = BigInt(Math.ceil(estimatedVBytes * this.feeRate)); // Total needed: OPNet fee + reveal fee + dust for change const totalNeeded = feeAmount + revealFee + TransactionBuilder.MINIMUM_DUST; // Distribute across outputs, ensuring at least MIN_OUTPUT_VALUE per output const valuePerOutput = BigInt(Math.ceil(Number(totalNeeded) / numOutputs)); const minValue = HashCommitmentGenerator.MIN_OUTPUT_VALUE; this.cachedValuePerOutput = valuePerOutput > minValue ? valuePerOutput : minValue; return this.cachedValuePerOutput; } /** * Get refund address. */ private getRefundAddress(): string { if (this.from) { return this.from; } return AddressGenerator.generatePKSH(this.signer.publicKey, this.network); } /** * Generate features (same as InteractionTransaction). */ private generateFeatures(parameters: IConsolidatedInteractionParameters): 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, }); } if (parameters.revealMLDSAPublicKey && !parameters.linkMLDSAPublicKeyToAddress) { throw new Error( 'To reveal the MLDSA public key, you must set linkMLDSAPublicKeyToAddress to true.', ); } if (parameters.linkMLDSAPublicKeyToAddress) { this.generateMLDSALinkRequest(parameters, features); } return features; } /** * Validate output count is within standard tx limits. */ private validateOutputCount(): void { const maxInputs = HashCommitmentGenerator.calculateMaxInputsPerTx(); if (this.commitmentOutputs.length > maxInputs) { const maxData = HashCommitmentGenerator.calculateMaxDataPerTx(); throw new Error( `Data too large: ${this.commitmentOutputs.length} P2WSH outputs needed, ` + `max ${maxInputs} per standard transaction (~${Math.floor(maxData / 1024)}KB). ` + `Compiled data: ${this.compiledTargetScript.length} bytes.`, ); } } }