@btc-vision/transaction
Version:
OPNet transaction library allows you to create and sign transactions for the OPNet network.
473 lines (403 loc) • 14.8 kB
text/typescript
import {
type FinalScriptsFunc,
type P2MRPayment,
type P2TRPayment,
PaymentType,
Psbt,
type PsbtInput,
type Script,
type Signer,
type TapScriptSig,
type Taptree,
toXOnly,
} from '@btc-vision/bitcoin';
import { type UniversalSigner } from '@btc-vision/ecpair';
import { isUniversalSigner } from '../../signer/TweakedSigner.js';
import { MINIMUM_AMOUNT_REWARD, TransactionBuilder } from './TransactionBuilder.js';
import type { TapPayment } from '../shared/TweakedTransaction.js';
import { TransactionType } from '../enums/TransactionType.js';
import { CalldataGenerator } from '../../generators/builders/CalldataGenerator.js';
import type { SharedInteractionParameters } from '../interfaces/ITransactionParameters.js';
import { Compressor } from '../../bytecode/Compressor.js';
import { EcKeyPair } from '../../keypair/EcKeyPair.js';
import { BitcoinUtils } from '../../utils/BitcoinUtils.js';
import { UnisatSigner } from '../browser/extensions/UnisatSigner.js';
import { TimeLockGenerator } from '../mineable/TimelockGenerator.js';
import type { IChallengeSolution } from '../../epoch/interfaces/IChallengeSolution.js';
import type { IP2WSHAddress } from '../mineable/IP2WSHAddress.js';
/**
* Shared interaction transaction
* @class SharedInteractionTransaction
*/
export abstract class SharedInteractionTransaction<
T extends TransactionType,
> extends TransactionBuilder<T> {
public static readonly MAXIMUM_CALLDATA_SIZE = 380 * 1024; // 1MB
/**
* Random salt for the interaction
* @type {Uint8Array}
*/
public readonly randomBytes: Uint8Array;
protected targetScriptRedeem: P2TRPayment | null = null;
protected leftOverFundsScriptRedeem: P2TRPayment | null = null;
protected abstract readonly compiledTargetScript: Uint8Array;
protected abstract readonly scriptTree: Taptree;
protected readonly challenge: IChallengeSolution;
protected readonly epochChallenge: IP2WSHAddress;
protected calldataGenerator: CalldataGenerator;
/**
* Calldata for the interaction
* @protected
*/
protected readonly calldata: Uint8Array;
/**
* Contract secret for the interaction
* @protected
*/
protected abstract readonly contractSecret: Uint8Array;
/**
* Script signer for the interaction
* @protected
*/
protected readonly scriptSigner: Signer | UniversalSigner;
/**
* Disable auto refund
* @protected
*/
protected readonly disableAutoRefund: boolean;
protected constructor(parameters: SharedInteractionParameters) {
super(parameters);
if (!parameters.calldata) {
throw new Error('Calldata is required');
}
if (!parameters.challenge) {
throw new Error('Challenge solution is required');
}
this.challenge = parameters.challenge;
this.LOCK_LEAF_SCRIPT = this.defineLockScript();
this.disableAutoRefund = parameters.disableAutoRefund || false;
this.epochChallenge = TimeLockGenerator.generateTimeLockAddress(
this.challenge.publicKey.originalPublicKeyBuffer(),
this.network,
);
this.calldata = Compressor.compress(parameters.calldata);
this.randomBytes = parameters.randomBytes || BitcoinUtils.rndBytes();
this.scriptSigner = this.generateKeyPairFromSeed();
this.calldataGenerator = new CalldataGenerator(
this.signer.publicKey,
this.scriptSignerXOnlyPubKey(),
this.network,
);
}
public exportCompiledTargetScript(): Uint8Array {
return this.compiledTargetScript;
}
/**
* Get the contract secret
* @returns {Uint8Array} The contract secret
*/
public getContractSecret(): Uint8Array {
return this.contractSecret;
}
/**
* Get the random bytes used for the interaction
* @returns {Uint8Array} The random bytes
*/
public getRndBytes(): Uint8Array {
return this.randomBytes;
}
/**
* Get the preimage
*/
public getChallenge(): IChallengeSolution {
return this.challenge;
}
/**
* Get the internal pubkey as an x-only key
* @protected
* @returns {Uint8Array} The internal pubkey as an x-only key
*/
protected scriptSignerXOnlyPubKey(): Uint8Array {
return toXOnly(this.scriptSigner.publicKey);
}
/**
* Generate a key pair from the seed
* @protected
*
* @returns {UniversalSigner} The key pair
*/
protected generateKeyPairFromSeed(): UniversalSigner {
return EcKeyPair.fromSeedKeyPair(this.randomBytes, this.network);
}
/**
* 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
* @throws {Error} If the to address is required
*/
protected override async buildTransaction(): Promise<void> {
const selectedRedeem = this.scriptSigner
? this.targetScriptRedeem
: this.leftOverFundsScriptRedeem;
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(),
};
if (!this.regenerated) {
this.addInputsFromUTXO();
}
await this.createMineableRewardOutputs();
}
/**
* Sign the inputs
* @param {Psbt} transaction The transaction to sign
* @protected
*/
protected override async signInputs(transaction: Psbt): Promise<void> {
if (!this.scriptSigner) {
await super.signInputs(transaction);
return;
}
if ('multiSignPsbt' in this.signer) {
await this.signInputsWalletBased(transaction);
} else {
await this.signInputsNonWalletBased(transaction);
}
}
protected override generateScriptAddress(): TapPayment {
if (this.useP2MR) {
return {
network: this.network,
scriptTree: this.scriptTree,
name: PaymentType.P2MR,
} as P2MRPayment;
}
return {
internalPubkey: this.internalPubKeyToXOnly(),
network: this.network,
scriptTree: this.scriptTree,
name: PaymentType.P2TR,
};
}
protected override generateTapData(): TapPayment {
const selectedRedeem = this.scriptSigner
? this.targetScriptRedeem
: this.leftOverFundsScriptRedeem;
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,
} as P2MRPayment;
}
return {
internalPubkey: this.internalPubKeyToXOnly(),
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
*/
protected getScriptSolution(input: PsbtInput): Uint8Array[] {
if (!input.tapScriptSig) {
throw new Error('Tap script signature is required');
}
return [
this.contractSecret,
(input.tapScriptSig[0] as TapScriptSig).signature,
(input.tapScriptSig[1] as TapScriptSig).signature,
] as Uint8Array[];
}
/**
* Get the script tree
* @private
*
* @returns {Taptree} The script tree
*/
protected getScriptTree(): Taptree {
if (!this.calldata) {
throw new Error('Calldata is required');
}
this.generateRedeemScripts();
return [
{
output: this.compiledTargetScript,
version: 192,
},
{
output: this.LOCK_LEAF_SCRIPT,
version: 192,
},
];
}
/**
* Transaction finalizer
* @param {number} _inputIndex The input index
* @param {PsbtInput} input The input
*/
protected customFinalizer = (_inputIndex: number, input: PsbtInput) => {
if (!this.tapLeafScript) {
throw new Error('Tap leaf script is required');
}
if (!this.contractSecret) {
throw new Error('Contract secret is required');
}
const scriptSolution = this.getScriptSolution(input);
const witness = scriptSolution
.concat(this.tapLeafScript.script)
.concat(this.tapLeafScript.controlBlock);
return {
finalScriptWitness: TransactionBuilder.witnessStackToScriptWitness(witness),
};
};
// custom for interactions
protected override async signInputsWalletBased(transaction: Psbt): Promise<void> {
const signer: UnisatSigner = this.signer as UnisatSigner;
// first, we sign the first input with the script signer.
await this.signInput(
transaction,
transaction.data.inputs[0] as PsbtInput,
0,
this.scriptSigner,
);
// then, we sign all the remaining inputs with the wallet signer.
await signer.multiSignPsbt([transaction]);
// Then, we finalize every input.
for (let i = 0; i < transaction.data.inputs.length; i++) {
if (i === 0) {
transaction.finalizeInput(i, this.customFinalizer.bind(this));
} else {
try {
transaction.finalizeInput(i, this.customFinalizerP2SH.bind(this));
} catch (e) {
transaction.finalizeInput(i);
}
}
}
}
protected override async signInputsNonWalletBased(transaction: Psbt): Promise<void> {
// Input 0: always sequential (needs scriptSigner + main signer, custom finalizer)
await this.signInput(
transaction,
transaction.data.inputs[0] as PsbtInput,
0,
this.scriptSigner,
);
await this.signInput(
transaction,
transaction.data.inputs[0] as PsbtInput,
0,
this.getSignerKey(),
);
transaction.finalizeInput(0, this.customFinalizer.bind(this));
// Inputs 1+: parallel key-path if available, then sequential for remaining
if (this.canUseParallelSigning && isUniversalSigner(this.signer)) {
let parallelSignedIndices = new Set<number>();
try {
const result = await this.signKeyPathInputsParallel(transaction, new Set([0]));
if (result.success) {
parallelSignedIndices = new Set(result.signatures.keys());
}
} catch (e) {
this.error(
`Parallel signing failed, falling back to sequential: ${(e as Error).message}`,
);
}
// Sign remaining inputs 1+ that weren't handled by parallel signing
for (let i = 1; i < transaction.data.inputs.length; i++) {
if (!parallelSignedIndices.has(i)) {
await this.signInput(
transaction,
transaction.data.inputs[i] as PsbtInput,
i,
this.signer,
);
}
}
} else {
for (let i = 1; i < transaction.data.inputs.length; i++) {
await this.signInput(
transaction,
transaction.data.inputs[i] as PsbtInput,
i,
this.signer,
);
}
}
// Finalize inputs 1+
for (let i = 1; i < transaction.data.inputs.length; i++) {
try {
transaction.finalizeInput(
i,
this.customFinalizerP2SH.bind(this) as FinalScriptsFunc,
);
} catch {
transaction.finalizeInput(i);
}
}
this.finalized = true;
}
protected async createMineableRewardOutputs(): Promise<void> {
if (!this.to) throw new Error('To address is required');
const opnetFee = this.getTransactionOPNetFee();
// Add the output to challenge address
this.addFeeToOutput(opnetFee, this.to, this.epochChallenge, false);
// Get the actual amount added to outputs (might be MINIMUM_AMOUNT_REWARD if opnetFee is too small)
const actualOutputAmount =
opnetFee < MINIMUM_AMOUNT_REWARD ? MINIMUM_AMOUNT_REWARD : opnetFee;
const optionalAmount = this.addOptionalOutputsAndGetAmount();
if (!this.disableAutoRefund) {
// Pass the TOTAL amount spent: actual output amount + optional outputs
await this.addRefundOutput(actualOutputAmount + optionalAmount);
}
}
/**
* 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 as Script,
redeemVersion: 192,
};
this.leftOverFundsScriptRedeem = {
name: PaymentType.P2TR,
output: this.LOCK_LEAF_SCRIPT,
redeemVersion: 192,
};
}
}