UNPKG

opnet

Version:

The perfect library for building Bitcoin-based applications.

471 lines (381 loc) 15.8 kB
import { Network, PsbtOutputExtended, Signer } from '@btc-vision/bitcoin'; import { Address, BinaryReader, BufferHelper, ChallengeSolution, IInteractionParameters, InteractionParametersWithoutSigner, LoadedStorage, NetEvent, RawChallenge, SupportedTransactionVersion, TransactionFactory, UTXO, } from '@btc-vision/transaction'; import { ECPairInterface } from 'ecpair'; import { BitcoinFees } from '../block/BlockGasParameters.js'; import { AbstractRpcProvider } from '../providers/AbstractRpcProvider.js'; import { RequestUTXOsParamsWithAmount } from '../utxos/interfaces/IUTXOsManager.js'; import { ContractDecodedObjectResult, DecodedOutput } from './Contract.js'; import { IAccessList } from './interfaces/IAccessList.js'; import { EventList, ICallResultData, RawEventList } from './interfaces/ICallResult.js'; import { OPNetEvent } from './OPNetEvent.js'; import { TransactionHelper } from './TransactionHelpper.js'; const factory = new TransactionFactory(); export interface TransactionParameters { readonly signer?: Signer | ECPairInterface; readonly refundTo: string; readonly priorityFee?: bigint; feeRate?: number; readonly utxos?: UTXO[]; readonly maximumAllowedSatToSpend: bigint; readonly network: Network; readonly extraInputs?: UTXO[]; readonly extraOutputs?: PsbtOutputExtended[]; readonly minGas?: bigint; readonly note?: string | Buffer; readonly p2wda?: boolean; readonly from?: Address; readonly txVersion?: SupportedTransactionVersion; readonly anchor?: boolean; readonly dontIncludeAccessList?: boolean; } export interface InteractionTransactionReceipt { readonly transactionId: string; readonly newUTXOs: UTXO[]; readonly peerAcknowledgements: number; readonly estimatedFees: bigint; readonly challengeSolution: RawChallenge; } /** * Represents the result of a contract call. * @category Contracts */ export class CallResult< T extends ContractDecodedObjectResult = {}, U extends OPNetEvent<ContractDecodedObjectResult>[] = OPNetEvent<ContractDecodedObjectResult>[], > implements Omit<ICallResultData, 'estimatedGas' | 'events' | 'specialGas'> { public readonly result: BinaryReader; public readonly accessList: IAccessList; public readonly revert: string | undefined; public calldata: Buffer | undefined; public loadedStorage: LoadedStorage | undefined; public readonly estimatedGas: bigint | undefined; public readonly refundedGas: bigint | undefined; public properties: T = {} as T; public estimatedSatGas: bigint = 0n; public estimatedRefundedGasInSat: bigint = 0n; public events: U = [] as unknown as U; public to: string | undefined; public address: Address | undefined; #bitcoinFees: BitcoinFees | undefined; readonly #rawEvents: EventList; readonly #provider: AbstractRpcProvider; constructor(callResult: ICallResultData, provider: AbstractRpcProvider) { this.#provider = provider; this.#rawEvents = this.parseEvents(callResult.events); this.accessList = callResult.accessList; this.loadedStorage = callResult.loadedStorage; //this.getValuesFromAccessList(); if (callResult.estimatedGas) { this.estimatedGas = BigInt(callResult.estimatedGas); } if (callResult.specialGas) { this.refundedGas = BigInt(callResult.specialGas); } const revert = typeof callResult.revert === 'string' ? this.base64ToUint8Array(callResult.revert) : callResult.revert; if (revert) { this.revert = CallResult.decodeRevertData(revert); } this.result = typeof callResult.result === 'string' ? new BinaryReader(this.base64ToUint8Array(callResult.result)) : callResult.result; } public get rawEvents(): EventList { return this.#rawEvents; } public static decodeRevertData(revertDataBytes: Uint8Array | Buffer): string { if (this.startsWithErrorSelector(revertDataBytes)) { const decoder = new TextDecoder(); return decoder.decode(revertDataBytes.subarray(8)); } else { return `Unknown Revert: 0x${this.bytesToHexString(revertDataBytes)}`; } } private static startsWithErrorSelector(revertDataBytes: Uint8Array | Buffer) { const errorSelectorBytes = Uint8Array.from([0x63, 0x73, 0x9d, 0x5c]); return ( revertDataBytes.length >= 4 && this.areBytesEqual(revertDataBytes.subarray(0, 4), errorSelectorBytes) ); } private static areBytesEqual(a: Uint8Array | Buffer, b: Uint8Array | Buffer) { if (a.length !== b.length) return false; for (let i = 0; i < a.length; i++) { if (a[i] !== b[i]) { return false; } } return true; } private static bytesToHexString(byteArray: Uint8Array): string { return Array.from(byteArray, function (byte) { return ('0' + (byte & 0xff).toString(16)).slice(-2); }).join(''); } public setTo(to: string, address: Address): void { this.to = to; this.address = address; } /** * Easily create a bitcoin interaction transaction from a simulated contract call. * @param {TransactionParameters} interactionParams - The parameters for the transaction. * @param amountAddition * @returns {Promise<InteractionTransactionReceipt>} The transaction hash, the transaction hex and the UTXOs used. */ public async sendTransaction( interactionParams: TransactionParameters, amountAddition: bigint = 0n, ): Promise<InteractionTransactionReceipt> { if (!this.address) { throw new Error('Contract address not set'); } if (!this.calldata) { throw new Error('Calldata not set'); } if (!this.to) { throw new Error('To address not set'); } if (this.revert) { throw new Error(`Can not send transaction! Simulation reverted: ${this.revert}`); } try { let UTXOs: UTXO[] = interactionParams.utxos || (await this.acquire(interactionParams, amountAddition)); if (interactionParams.extraInputs) { UTXOs = UTXOs.filter((utxo) => { return ( interactionParams.extraInputs?.find((input) => { return ( input.outputIndex === utxo.outputIndex && input.transactionId === utxo.transactionId ); }) === undefined ); }); } if (!UTXOs || UTXOs.length === 0) { throw new Error('No UTXOs found'); } let totalPointers = 0; if (this.loadedStorage) { for (const obj in this.loadedStorage) { totalPointers += obj.length; } } // It's useless to send the access list if we don't load at least 100 pointers. const storage = interactionParams.dontIncludeAccessList === false ? totalPointers > 100 ? this.loadedStorage : undefined : undefined; const priorityFee: bigint = interactionParams.priorityFee || 0n; const challenge: ChallengeSolution = await this.#provider.getChallenge(); const params: IInteractionParameters | InteractionParametersWithoutSigner = { contract: this.address.toHex(), calldata: this.calldata, priorityFee: priorityFee, gasSatFee: this.bigintMax(this.estimatedSatGas, interactionParams.minGas || 0n), feeRate: interactionParams.feeRate || this.#bitcoinFees?.conservative || 10, from: interactionParams.refundTo, utxos: UTXOs, to: this.to, network: interactionParams.network, optionalInputs: interactionParams.extraInputs || [], optionalOutputs: interactionParams.extraOutputs || [], signer: interactionParams.signer as Signer | ECPairInterface, challenge: challenge, loadedStorage: storage, note: interactionParams.note, anchor: interactionParams.anchor || false, txVersion: interactionParams.txVersion || 2, }; const transaction = await factory.signInteraction(params); if (!interactionParams.p2wda) { if (!transaction.fundingTransaction) { throw new Error('Funding transaction not created'); } const tx1 = await this.#provider.sendRawTransaction( transaction.fundingTransaction, false, ); if (!tx1 || tx1.error) { throw new Error(`Error sending transaction: ${tx1?.error || 'Unknown error'}`); } } const tx2 = await this.#provider.sendRawTransaction( transaction.interactionTransaction, false, ); if (!tx2 || tx2.error) { throw new Error(`Error sending transaction: ${tx2?.error || 'Unknown error'}`); } if (!tx2.result) { throw new Error('No transaction ID returned'); } this.#provider.utxoManager.spentUTXO( interactionParams.refundTo, UTXOs, transaction.nextUTXOs, ); return { transactionId: tx2.result, peerAcknowledgements: tx2.peers || 0, newUTXOs: transaction.nextUTXOs, estimatedFees: transaction.estimatedFees, challengeSolution: transaction.challenge, }; } catch (e) { const msgStr = (e as Error).message; if (msgStr.includes('Insufficient funds to pay the fees') && amountAddition === 0n) { return await this.sendTransaction(interactionParams, 200_000n); } // We need to clean up the UTXOs if the transaction fails this.#provider.utxoManager.clean(); throw e; } } public setGasEstimation(estimatedGas: bigint, refundedGas: bigint): void { this.estimatedSatGas = estimatedGas; this.estimatedRefundedGasInSat = refundedGas; } public setBitcoinFee(fees: BitcoinFees): void { this.#bitcoinFees = fees; } public setDecoded(decoded: DecodedOutput): void { this.properties = Object.freeze(decoded.obj) as T; } public setEvents(events: U): void { this.events = events; } public setCalldata(calldata: Buffer): void { this.calldata = calldata; } private async acquire( interactionParams: TransactionParameters, amountAddition: bigint = 0n, ): Promise<UTXO[]> { if (!this.calldata) { throw new Error('Calldata not set'); } if (!interactionParams.feeRate) { interactionParams.feeRate = 1.5; } const feeRate = interactionParams.feeRate; const priority = interactionParams.priorityFee ?? 0n; const addedOuts = interactionParams.extraOutputs ?? []; const totalOuts = BigInt(addedOuts.reduce((s, o) => s + o.value, 0)); const gasFee = this.bigintMax(this.estimatedSatGas, interactionParams.minGas ?? 0n); const preWant = gasFee + priority + amountAddition + totalOuts + interactionParams.maximumAllowedSatToSpend; let utxos = interactionParams.utxos ?? (await this.#fetchUTXOs(preWant, interactionParams)); let refetched = false; while (true) { const miningCost = TransactionHelper.estimateMiningCost( utxos, addedOuts, this.calldata.length + 200, interactionParams.network, feeRate, ); const want = gasFee + priority + amountAddition + totalOuts + miningCost + interactionParams.maximumAllowedSatToSpend; const have = utxos.reduce((s, u) => s + BigInt(u.value), 0n); if (have >= want) break; if (refetched) { throw new Error('Not enough sat to complete transaction'); } utxos = await this.#fetchUTXOs(want, interactionParams); refetched = true; const haveAfter = utxos.reduce((s, u) => s + BigInt(u.value), 0n); if (haveAfter === have) { throw new Error('Not enough sat to complete transaction'); } } return utxos; } private bigintMax(a: bigint, b: bigint): bigint { return a > b ? a : b; } async #fetchUTXOs(amount: bigint, interactionParams: TransactionParameters): Promise<UTXO[]> { if (!interactionParams.refundTo) { throw new Error('Refund address not set'); } const utxoSetting: RequestUTXOsParamsWithAmount = { address: interactionParams.refundTo, amount: amount, throwErrors: true, }; const utxos: UTXO[] = await this.#provider.utxoManager.getUTXOsForAmount(utxoSetting); if (!utxos) { throw new Error('No UTXOs found'); } if (!interactionParams.signer) { throw new Error('Signer not set in interaction parameters'); } if (interactionParams.p2wda) { if (!interactionParams.from) { throw new Error('From address not set in interaction parameters'); } const p2wda = interactionParams.from.p2wda(this.#provider.network); if (p2wda.address === interactionParams.refundTo) { utxos.forEach((utxo) => { utxo.witnessScript = p2wda.witnessScript; }); } } return utxos; } private getValuesFromAccessList(): LoadedStorage { const storage: LoadedStorage = {}; for (const contract in this.accessList) { const contractData = this.accessList[contract]; storage[contract] = Object.keys(contractData); } return storage; } private contractToString(contract: string): string { const addressCa = Address.fromString(contract); return addressCa.p2op(this.#provider.network); } private parseEvents(events: RawEventList): EventList { const eventsList: EventList = {}; for (const [contract, value] of Object.entries(events)) { const events: NetEvent[] = []; for (const event of value) { const eventData = new NetEvent(event.type, Buffer.from(event.data, 'base64')); events.push(eventData); } eventsList[this.contractToString(contract)] = events; } return eventsList; } private base64ToUint8Array(base64: string): Uint8Array { return BufferHelper.bufferToUint8Array(Buffer.from(base64, 'base64')); } }