@btc-vision/transaction
Version:
OPNet transaction library allows you to create and sign transactions for the OPNet network.
568 lines (483 loc) • 16.8 kB
text/typescript
import { TransactionType } from '../enums/TransactionType.js';
import type { IDeploymentParameters } from '../interfaces/ITransactionParameters.js';
import {
concat,
crypto as bitCrypto,
type FinalScriptsFunc,
fromHex,
type P2MRPayment,
type P2TRPayment,
PaymentType,
Psbt,
type PsbtInput,
type Script,
type Signer,
type TapScriptSig,
type Taptree,
toHex,
toXOnly,
} from '@btc-vision/bitcoin';
import { TransactionBuilder } from './TransactionBuilder.js';
import type { TapPayment } from '../shared/TweakedTransaction.js';
import type { TapLeafScript } from '../interfaces/Tap.js';
import {
DeploymentGenerator,
versionBuffer,
} from '../../generators/builders/DeploymentGenerator.js';
import { EcKeyPair } from '../../keypair/EcKeyPair.js';
import { BitcoinUtils } from '../../utils/BitcoinUtils.js';
import { Compressor } from '../../bytecode/Compressor.js';
import { SharedInteractionTransaction } from './SharedInteractionTransaction.js';
import { type UniversalSigner } from '@btc-vision/ecpair';
import { isUniversalSigner } from '../../signer/TweakedSigner.js';
import { Address } from '../../keypair/Address.js';
import { UnisatSigner } from '../browser/extensions/UnisatSigner.js';
import { TimeLockGenerator } from '../mineable/TimelockGenerator.js';
import type { IChallengeSolution } from '../../epoch/interfaces/IChallengeSolution.js';
import { type Feature, FeaturePriority, Features } from '../../generators/Features.js';
import type { IP2WSHAddress } from '../mineable/IP2WSHAddress.js';
export class DeploymentTransaction extends TransactionBuilder<TransactionType.DEPLOYMENT> {
public static readonly MAXIMUM_CONTRACT_SIZE = 1536 * 1024;
public type: TransactionType.DEPLOYMENT = TransactionType.DEPLOYMENT;
protected readonly challenge: IChallengeSolution;
protected readonly epochChallenge: IP2WSHAddress;
/**
* The contract address
* @protected
*/
protected readonly _contractAddress: Address;
/**
* The tap leaf script
* @private
*/
protected override tapLeafScript: TapLeafScript | null = null;
private readonly deploymentVersion: number = 0x00;
/**
* The target script redeem
* @private
*/
private targetScriptRedeem: P2TRPayment | null = null;
/**
* The left over funds script redeem
* @private
*/
private leftOverFundsScriptRedeem: P2TRPayment | null = null;
/**
* The compiled target script
* @private
*/
private readonly compiledTargetScript: Uint8Array;
/**
* The script tree
* @private
*/
private readonly scriptTree: Taptree;
/**
* The deployment bitcoin generator
* @private
*/
private deploymentGenerator: DeploymentGenerator;
/**
* The contract seed
* @private
*/
private readonly contractSeed: Uint8Array;
/**
* The contract bytecode
* @private
*/
private readonly bytecode: Uint8Array;
/**
* Constructor calldata
* @private
*/
private readonly calldata?: Uint8Array;
/**
* The contract signer
* @private
*/
private readonly contractSigner: Signer | UniversalSigner;
/**
* The contract public key
* @private
*/
private readonly _contractPubKey: string;
/**
* The contract salt random bytes
* @private
*/
private readonly randomBytes: Uint8Array;
private _computedAddress: string | undefined;
public constructor(parameters: IDeploymentParameters) {
super(parameters);
if (!this.hashedPublicKey) {
throw new Error('MLDSA signer must be defined to deploy a contract.');
}
this.bytecode = Compressor.compress(
new Uint8Array([...versionBuffer, ...parameters.bytecode]),
);
this.verifyBytecode();
if (parameters.calldata) {
this.calldata = parameters.calldata;
this.verifyCalldata();
}
if (!parameters.challenge) throw new Error('Challenge solution is required');
this.randomBytes = parameters.randomBytes || BitcoinUtils.rndBytes();
this.challenge = parameters.challenge;
this.LOCK_LEAF_SCRIPT = this.defineLockScript();
this.epochChallenge = TimeLockGenerator.generateTimeLockAddress(
this.challenge.publicKey.originalPublicKeyBuffer(),
this.network,
);
this.contractSeed = this.getContractSeed();
this.contractSigner = EcKeyPair.fromSeedKeyPair(this.contractSeed, this.network);
this.deploymentGenerator = new DeploymentGenerator(
this.signer.publicKey,
this.contractSignerXOnlyPubKey(),
this.network,
);
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.deploymentGenerator.compile(
this.bytecode,
this.randomBytes,
this.challenge,
this.priorityFee,
this.calldata,
this.generateFeatures(parameters),
);
}
this.scriptTree = this.getScriptTree();
this.internalInit();
this._contractPubKey = '0x' + toHex(this.contractSeed);
this._contractAddress = new Address(this.contractSeed);
}
/**
* Get the contract public key
*/
public get contractPubKey(): string {
return this._contractPubKey;
}
/**
* @description Get the contract address (PKSH)
*/
public get contractAddress(): Address {
return this._contractAddress;
}
/**
* @description Get the P2TR address
*/
public get p2trAddress(): string {
return this.to || this.getScriptAddress();
}
public exportCompiledTargetScript(): Uint8Array {
return this.compiledTargetScript;
}
/**
* Get the random bytes used for the interaction
* @returns {Uint8Array} The random bytes
*/
public getRndBytes(): Uint8Array {
return this.randomBytes;
}
/**
* Get the contract bytecode
* @returns {Uint8Array} The contract bytecode
*/
public getChallenge(): IChallengeSolution {
return this.challenge;
}
public getContractAddress(): string {
if (this._computedAddress) {
return this._computedAddress;
}
this._computedAddress = EcKeyPair.p2op(
this.contractSeed,
this.network,
this.deploymentVersion,
);
return this._computedAddress;
}
/**
* Get the contract signer public key
* @protected
*/
protected contractSignerXOnlyPubKey(): Uint8Array {
return toXOnly(this.contractSigner.publicKey);
}
/**
* Build the transaction
* @protected
*/
protected override async buildTransaction(): Promise<void> {
if (!this.to) {
this.to = this.getScriptAddress();
}
const selectedRedeem = this.contractSigner
? 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(),
};
this.addInputsFromUTXO();
const amountSpent: bigint = this.getTransactionOPNetFee();
this.addFeeToOutput(amountSpent, this.getContractAddress(), this.epochChallenge, true);
await this.addRefundOutput(amountSpent + this.addOptionalOutputsAndGetAmount());
}
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.contractSigner,
);
// 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);
}
}
}
}
/**
* Sign the inputs
* @param {Psbt} transaction The transaction to sign
* @protected
*/
protected override async signInputs(transaction: Psbt): Promise<void> {
if (!this.contractSigner) {
await super.signInputs(transaction);
return;
}
if ('multiSignPsbt' in this.signer) {
await this.signInputsWalletBased(transaction);
return;
}
// Input 0: sequential (contractSigner + main signer, custom finalizer)
transaction.signInput(0, this.contractSigner);
transaction.signInput(0, this.getSignerKey());
transaction.finalizeInput(0, this.customFinalizer.bind(this));
// Inputs 1+: parallel key-path if available, then sequential for remaining
const signedIndices = new Set<number>([0]);
if (this.canUseParallelSigning && isUniversalSigner(this.signer)) {
try {
const result = await this.signKeyPathInputsParallel(transaction, new Set([0]));
if (result.success) {
for (const idx of result.signatures.keys()) signedIndices.add(idx);
}
} catch (e) {
this.error(`Parallel signing failed: ${(e as Error).message}`);
}
}
for (let i = 1; i < transaction.data.inputs.length; i++) {
if (!signedIndices.has(i)) {
transaction.signInput(i, this.getSignerKey());
}
}
// 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);
}
}
}
/**
* Get the tap output
* @protected
*/
protected override generateScriptAddress(): TapPayment {
if (this.useP2MR) {
return {
name: PaymentType.P2MR,
network: this.network,
scriptTree: this.scriptTree,
} as P2MRPayment;
}
return {
name: PaymentType.P2TR,
internalPubkey: this.internalPubKeyToXOnly(),
network: this.network,
scriptTree: this.scriptTree,
};
}
/**
* Generate the tap data
* @protected
*/
protected override generateTapData(): TapPayment {
const selectedRedeem = this.contractSigner
? 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 {
name: PaymentType.P2MR,
network: this.network,
scriptTree: this.scriptTree,
redeem: selectedRedeem,
} as P2MRPayment;
}
return {
name: PaymentType.P2TR,
internalPubkey: this.internalPubKeyToXOnly(),
network: this.network,
scriptTree: this.scriptTree,
redeem: selectedRedeem,
};
}
private generateFeatures(parameters: IDeploymentParameters): Feature<Features>[] {
const features: Feature<Features>[] = [];
const submission = parameters.challenge.getSubmission();
if (submission) {
features.push({
priority: FeaturePriority.MLDSA_LINK_PUBKEY,
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;
}
private verifyCalldata(): void {
if (
this.calldata &&
this.calldata.length > SharedInteractionTransaction.MAXIMUM_CALLDATA_SIZE
) {
throw new Error('Calldata size overflow.');
}
}
private verifyBytecode(): void {
if (!this.bytecode) throw new Error('Bytecode is required');
if (this.bytecode.length > DeploymentTransaction.MAXIMUM_CONTRACT_SIZE) {
throw new Error('Contract size overflow.');
}
}
/**
* Generate the contract seed for the deployment
* @private
*/
private getContractSeed(): Uint8Array {
if (!this.bytecode) {
throw new Error('Bytecode is required');
}
// Concatenate deployer pubkey, salt, and sha256(bytecode)
const deployerPubKey = this.internalPubKeyToXOnly();
const salt = bitCrypto.hash256(this.randomBytes);
const sha256OfBytecode = bitCrypto.hash256(this.bytecode);
const buf = concat([deployerPubKey, salt, sha256OfBytecode]);
return bitCrypto.hash256(buf);
}
/**
* Finalize the transaction
* @param _inputIndex
* @param input
*/
private customFinalizer = (_inputIndex: number, input: PsbtInput) => {
if (!this.tapLeafScript) {
throw new Error('Tap leaf script is required');
}
if (!input.tapScriptSig) {
throw new Error('Tap script signature is required');
}
const scriptSolution = [
this.randomBytes,
(input.tapScriptSig[0] as TapScriptSig).signature,
(input.tapScriptSig[1] as TapScriptSig).signature,
];
const witness = scriptSolution
.concat(this.tapLeafScript.script)
.concat(this.tapLeafScript.controlBlock);
return {
finalScriptWitness: TransactionBuilder.witnessStackToScriptWitness(witness),
};
};
/**
* Generate the redeem scripts
* @private
*/
private generateRedeemScripts(): void {
this.targetScriptRedeem = {
name: PaymentType.P2TR,
//pubkeys: this.getPubKeys(),
output: this.compiledTargetScript as Script,
redeemVersion: 192,
};
this.leftOverFundsScriptRedeem = {
name: PaymentType.P2TR,
//pubkeys: this.getPubKeys(),
output: this.getLeafScript(),
redeemVersion: 192,
};
}
/**
* Get the second leaf script
* @private
*/
private getLeafScript(): Script {
return this.LOCK_LEAF_SCRIPT;
}
/**
* Get the script tree
* @private
*/
private getScriptTree(): Taptree {
if (!this.bytecode) {
throw new Error('Contract bytecode is required');
}
this.generateRedeemScripts();
return [
{
output: this.compiledTargetScript,
version: 192,
},
{
output: this.getLeafScript(),
version: 192,
},
];
}
}