@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
text/typescript
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;
}
}