diff --git a/packages/mesh-core-csl/src/offline-providers/offline-evaluator.ts b/packages/mesh-core-csl/src/offline-providers/offline-evaluator.ts index 628b86036..b745e87e4 100644 --- a/packages/mesh-core-csl/src/offline-providers/offline-evaluator.ts +++ b/packages/mesh-core-csl/src/offline-providers/offline-evaluator.ts @@ -17,6 +17,9 @@ import { getTransactionOutputs, } from "../utils"; +/** Most tx hashes an evaluator keeps fetched outputs for, dropping the oldest first */ +const MAX_CACHED_TX_HASHES = 1000; + /** * OfflineEvaluator implements the IEvaluator interface to provide offline evaluation of Plutus scripts. * This class evaluates Plutus scripts contained in Cardano transactions without requiring network connectivity, @@ -74,6 +77,8 @@ export class OfflineEvaluator implements IEvaluator { private readonly network: Network; public slotConfig: Omit, "epochLength">; public costModels: number[][]; + /** Outputs fetched per tx hash. Outputs never change once created, so they are reused across evaluations */ + private readonly fetchedUTxOs = new Map>(); /** * Creates a new instance of OfflineEvaluator. @@ -122,6 +127,8 @@ export class OfflineEvaluator implements IEvaluator { additionalUtxos: UTxO[], additionalTxs: string[], ): Promise[]> { + // Resolved utxos are appended to a copy, leaving the caller's array untouched + additionalUtxos = [...additionalUtxos]; // Track which utxos is resolved const foundUtxos = new Set(); @@ -137,9 +144,14 @@ export class OfflineEvaluator implements IEvaluator { const inputsToResolve = getTransactionInputs(tx).filter( (input) => !foundUtxos.has(`${input.txHash}:${input.outputIndex}`), ); - const txHashesSet = new Set(inputsToResolve.map((input) => input.txHash)); - for (const txHash of txHashesSet) { - const utxos = await this.fetcher.fetchUTxOs(txHash); + const txHashes = Array.from( + new Set(inputsToResolve.map((input) => input.txHash)), + ); + const fetched = await Promise.all( + txHashes.map((txHash) => this.fetchUTxOsOnce(txHash)), + ); + for (const [i, txHash] of txHashes.entries()) { + const utxos = fetched[i]!; for (const utxo of utxos) { if (utxo) if ( @@ -158,6 +170,8 @@ export class OfflineEvaluator implements IEvaluator { (input) => !foundUtxos.has(`${input.txHash}:${input.outputIndex}`), ); if (missing.length > 0) { + // The fetcher may not have seen these outputs yet; ask again next time + missing.forEach((input) => this.fetchedUTxOs.delete(input.txHash)); const missingList = missing .map((m) => `${m.txHash}:${m.outputIndex}`) .join(", "); @@ -173,4 +187,25 @@ export class OfflineEvaluator implements IEvaluator { this.slotConfig, ); } + + /** + * Fetches the outputs of a transaction once: concurrent and later calls for the same hash + * reuse the first request, while a failed or empty response is forgotten. + */ + private fetchUTxOsOnce(txHash: string): Promise { + const cached = this.fetchedUTxOs.get(txHash); + if (cached) return cached; + const request = this.fetcher.fetchUTxOs(txHash); + this.fetchedUTxOs.set(txHash, request); + request.then( + (utxos) => { + if (!utxos || utxos.length === 0) this.fetchedUTxOs.delete(txHash); + }, + () => this.fetchedUTxOs.delete(txHash), + ); + if (this.fetchedUTxOs.size > MAX_CACHED_TX_HASHES) { + this.fetchedUTxOs.delete(this.fetchedUTxOs.keys().next().value!); + } + return request; + } } diff --git a/packages/mesh-core-csl/test/offline-providers/evaluator.test.ts b/packages/mesh-core-csl/test/offline-providers/evaluator.test.ts index 3411e290d..338659751 100644 --- a/packages/mesh-core-csl/test/offline-providers/evaluator.test.ts +++ b/packages/mesh-core-csl/test/offline-providers/evaluator.test.ts @@ -118,8 +118,21 @@ describe("Offline Evaluator", () => { }, }; fetcher.addUTxOs([utxo_1, utxo_2, utxo_3, utxo_4, utxo_5, utxo_6, utxo_7]); + const fetchUTxOs = jest.spyOn(fetcher, "fetchUTxOs"); const res = await evaluator.evaluateTx(txHex, [], []); + // Evaluating again, as the tx builder does per coin selection round, fetches nothing new + expect(await evaluator.evaluateTx(txHex, [], [])).toStrictEqual(res); + const fetchedTxHashes = fetchUTxOs.mock.calls.map(([txHash]) => txHash); + expect(fetchedTxHashes.sort()).toStrictEqual( + Array.from( + new Set( + [utxo_1, utxo_2, utxo_3, utxo_4, utxo_5, utxo_6, utxo_7].map( + (utxo) => utxo.input.txHash, + ), + ), + ).sort(), + ); expect(res).toStrictEqual([ { index: 0, diff --git a/packages/mesh-core-cst/src/offline-providers/offline-evaluator-scalus.ts b/packages/mesh-core-cst/src/offline-providers/offline-evaluator-scalus.ts index 90c5b5fb3..d57423cb3 100644 --- a/packages/mesh-core-cst/src/offline-providers/offline-evaluator-scalus.ts +++ b/packages/mesh-core-cst/src/offline-providers/offline-evaluator-scalus.ts @@ -36,11 +36,16 @@ const MESH_TAG: Record = { Proposing: "PROPOSE", }; +/** Most tx hashes an evaluator keeps fetched outputs for, dropping the oldest first */ +const MAX_CACHED_TX_HASHES = 1000; + export class OfflineEvaluatorScalus implements IEvaluator { private readonly fetcher: IFetcher; private readonly network: Network; public slotConfig: Omit, "epochLength">; public costModels: number[][]; + /** Outputs fetched per tx hash. Outputs never change once created, so they are reused across evaluations */ + private readonly fetchedUTxOs = new Map>(); /** * Creates a new instance of OfflineEvaluatorScalus. @@ -106,10 +111,11 @@ export class OfflineEvaluatorScalus implements IEvaluator { queriesNeeded.add(input.txHash); } const fetchedUtxos: Map = new Map(); - for (const txHash of queriesNeeded) { - const utxos = await this.fetcher.fetchUTxOs(txHash); - fetchedUtxos.set(txHash, utxos); - } + await Promise.all( + Array.from(queriesNeeded, async (txHash) => { + fetchedUtxos.set(txHash, await this.fetchUTxOsOnce(txHash)); + }), + ); for (const input of inputsToResolve) { const utxos = fetchedUtxos.get(input.txHash); @@ -120,6 +126,8 @@ export class OfflineEvaluatorScalus implements IEvaluator { } const utxo = utxos.find((u) => u.input.outputIndex === input.outputIndex); if (!utxo) { + // The fetcher may not have seen this output yet; ask again next time + this.fetchedUTxOs.delete(input.txHash); throw new Error( `UTxO not found for input: ${input.txHash}:${input.outputIndex}`, ); @@ -170,4 +178,25 @@ export class OfflineEvaluatorScalus implements IEvaluator { }; }); } + + /** + * Fetches the outputs of a transaction once: concurrent and later calls for the same hash + * reuse the first request, while a failed or empty response is forgotten. + */ + private fetchUTxOsOnce(txHash: string): Promise { + const cached = this.fetchedUTxOs.get(txHash); + if (cached) return cached; + const request = this.fetcher.fetchUTxOs(txHash); + this.fetchedUTxOs.set(txHash, request); + request.then( + (utxos) => { + if (!utxos || utxos.length === 0) this.fetchedUTxOs.delete(txHash); + }, + () => this.fetchedUTxOs.delete(txHash), + ); + if (this.fetchedUTxOs.size > MAX_CACHED_TX_HASHES) { + this.fetchedUTxOs.delete(this.fetchedUTxOs.keys().next().value!); + } + return request; + } } diff --git a/packages/mesh-transaction/src/mesh-tx-builder/index.ts b/packages/mesh-transaction/src/mesh-tx-builder/index.ts index 77db15266..8d115da45 100644 --- a/packages/mesh-transaction/src/mesh-tx-builder/index.ts +++ b/packages/mesh-transaction/src/mesh-tx-builder/index.ts @@ -36,6 +36,7 @@ import { CredentialType as CstCredentialType, NativeScript as CstNativeScript, Script as CstScript, + getTransactionOutputs, } from "@meshsdk/core-cst"; import { @@ -71,6 +72,10 @@ export class MeshTxBuilder extends MeshTxBuilderCore { protected queriedTxHashes: Set = new Set(); protected queriedUTxOs: { [x: string]: UTxO[] } = {}; protected utxosWithRefScripts: UTxO[] = []; + /** In-flight `fetchUTxOs` calls, keyed by tx hash, so concurrent lookups share one request */ + protected pendingUTxOQueries: Map> = new Map(); + /** UTxOs already known to the builder (selection pool, evaluation inputs, chained txs), keyed by `txHash#index` */ + protected knownUTxOs: Map = new Map(); constructor({ serializer, @@ -195,7 +200,8 @@ export class MeshTxBuilder extends MeshTxBuilderCore { this.setFee(customizedTx.fee); } } - await this.completeCostModels(); + // Cost models don't depend on the rest of the body, so fetch them alongside the UTxOs + const costModelsCompleted = this.completeCostModels(); this.queueAllLastItem(); if (this.verbose) { @@ -214,7 +220,11 @@ export class MeshTxBuilder extends MeshTxBuilderCore { for (let collateral of this.meshTxBuilderBody.collaterals) { collateral.txIn.scriptSize = 0; } - await this.completeTxParts(); + try { + await this.completeTxParts(); + } finally { + await costModelsCompleted; + } await this.sanitizeOutputs(); this.sortTxParts(); @@ -522,11 +532,11 @@ export class MeshTxBuilder extends MeshTxBuilderCore { }; evaluateRedeemers = async () => { - let txHex = this.serializer.serializeTxBody( - this.meshTxBuilderBody, - this._protocolParams, - ); - if (this.evaluator) { + if (this.evaluator && this.hasPlutusScriptWitness()) { + const txHex = this.serializer.serializeTxBody( + this.meshTxBuilderBody, + this._protocolParams, + ); const txEvaluation = await this.evaluator .evaluateTx( txHex, @@ -556,6 +566,25 @@ export class MeshTxBuilder extends MeshTxBuilderCore { } }; + /** + * Whether any part of the transaction is witnessed by a Plutus script, i.e. whether + * there are redeemers for an evaluator to price + */ + protected hasPlutusScriptWitness = (): boolean => { + const { inputs, mints, certificates, withdrawals, votes, proposals } = + this.meshTxBuilderBody; + return ( + inputs.some((input) => input.type === "Script") || + mints.some((mint) => mint.type === "Plutus") || + certificates.some((cert) => cert.type === "ScriptCertificate") || + withdrawals.some( + (withdrawal) => withdrawal.type === "ScriptWithdrawal", + ) || + votes.some((vote) => vote.type === "ScriptVote") || + proposals.some((proposal) => proposal.type === "ScriptProposal") + ); + }; + protected getRedeemerCosts = () => { const meshTxBuilderBody = this.meshTxBuilderBody; const redeemers: Omit[] = []; @@ -658,16 +687,54 @@ export class MeshTxBuilder extends MeshTxBuilderCore { }; /** - * Get the UTxO information from the blockchain - * @param txHash The TxIn object that contains the txHash and txIndex, while missing amount and address information + * Get the UTxO information from the blockchain. + * Concurrent calls for the same hash share one request; a failed or empty + * response is not remembered, so a later `complete()` can retry it. + * @param txHash The hash of the transaction whose outputs are needed */ protected getUTxOInfo = async (txHash: string): Promise => { - let utxos: UTxO[] = []; - if (!this.queriedTxHashes.has(txHash)) { - this.queriedTxHashes.add(txHash); - utxos = (await this.fetcher?.fetchUTxOs(txHash)) || []; - this.queriedUTxOs[txHash] = utxos; + if (this.queriedTxHashes.has(txHash)) return; + let query = this.pendingUTxOQueries.get(txHash); + if (!query) { + query = (async () => { + try { + const utxos = (await this.fetcher?.fetchUTxOs(txHash)) || []; + this.queriedUTxOs[txHash] = utxos; + if (utxos.length > 0) this.queriedTxHashes.add(txHash); + } finally { + this.pendingUTxOQueries.delete(txHash); + } + })(); + this.pendingUTxOQueries.set(txHash, query); } + return query; + }; + + /** + * Index the UTxOs the builder already holds - the selection pool, the inputs given + * for evaluation and the outputs of chained transactions - so they need not be fetched + */ + protected indexKnownUTxOs = () => { + const { extraInputs, inputsForEvaluation, chainedTxs } = + this.meshTxBuilderBody; + const known = new Map(); + const add = (utxo: UTxO) => + known.set(this.makeTxId(utxo.input.txHash, utxo.input.outputIndex), utxo); + extraInputs.forEach(add); + Object.values(inputsForEvaluation).forEach(add); + chainedTxs.forEach((txHex) => getTransactionOutputs(txHex).forEach(add)); + this.knownUTxOs = known; + }; + + /** + * Look up a UTxO among the fetched ones and the ones the builder already holds + */ + protected findUTxO = (txHash: string, index: number): UTxO | undefined => { + return ( + this.queriedUTxOs[txHash]?.find( + (utxo) => utxo.input.outputIndex === index, + ) ?? this.knownUTxOs.get(this.makeTxId(txHash, index)) + ); }; protected queryAllTxInfo = ( @@ -675,29 +742,35 @@ export class MeshTxBuilder extends MeshTxBuilderCore { incompleteScriptSources: ScriptSource[], incompleteSimpleScriptSources: SimpleScriptSourceInfo[], ) => { - const queryUTxOPromises: Promise[] = []; - if ( - (incompleteTxIns.length > 0 || - incompleteScriptSources.length > 0 || - incompleteSimpleScriptSources.length) && - !this.fetcher - ) - throw Error( - "Transaction information is incomplete while no fetcher instance is provided. Provide a `fetcher`.", - ); + const txHashesToQuery = new Set(); + const queryIfUnknown = (txHash: string, index: number) => { + if (!this.findUTxO(txHash, index)) txHashesToQuery.add(txHash); + }; for (let i = 0; i < incompleteTxIns.length; i++) { const currentTxIn = incompleteTxIns[i]!; if (!this.isInputInfoComplete(currentTxIn)) { - queryUTxOPromises.push(this.getUTxOInfo(currentTxIn.txIn.txHash)); + queryIfUnknown(currentTxIn.txIn.txHash, currentTxIn.txIn.txIndex); } } for (let i = 0; i < incompleteScriptSources.length; i++) { const scriptSource = incompleteScriptSources[i]!; if (scriptSource.type === "Inline") { - queryUTxOPromises.push(this.getUTxOInfo(scriptSource.txHash)); + queryIfUnknown(scriptSource.txHash, scriptSource.txIndex); } } - return Promise.all(queryUTxOPromises); + for (let i = 0; i < incompleteSimpleScriptSources.length; i++) { + const simpleScriptSource = incompleteSimpleScriptSources[i]!; + if (simpleScriptSource.type === "Inline") { + queryIfUnknown(simpleScriptSource.txHash, simpleScriptSource.txIndex); + } + } + if (txHashesToQuery.size > 0 && !this.fetcher) + throw Error( + "Transaction information is incomplete while no fetcher instance is provided. Provide a `fetcher`.", + ); + return Promise.all( + Array.from(txHashesToQuery, (txHash) => this.getUTxOInfo(txHash)), + ); }; protected completeTxInformation = (input: TxIn) => { @@ -716,10 +789,7 @@ export class MeshTxBuilder extends MeshTxBuilderCore { }; protected completeInputInfo = (input: TxIn) => { - const utxos: UTxO[] = this.queriedUTxOs[input.txIn.txHash]!; - const utxo = utxos?.find( - (utxo) => utxo.input.outputIndex === input.txIn.txIndex, - ); + const utxo = this.findUTxO(input.txIn.txHash, input.txIn.txIndex); const amount = utxo?.output.amount; const address = utxo?.output.address; if (!amount || amount.length === 0) @@ -741,39 +811,55 @@ export class MeshTxBuilder extends MeshTxBuilderCore { } }; + /** + * Find the UTxO holding a reference script, with the script's hash filled in + * even when the source of the UTxO did not provide it + */ + protected findScriptRefUTxO = ( + txHash: string, + index: number, + ): { scriptRef: string; scriptHash: string } => { + const utxo = this.findUTxO(txHash, index); + if (!utxo) + throw Error(`Couldn't find script reference utxo for ${txHash}#${index}`); + const { scriptRef, scriptHash } = utxo.output; + if (!scriptRef) + throw Error(`Utxo ${txHash}#${index} does not hold a reference script`); + return { + scriptRef, + scriptHash: + scriptHash ?? + CstScript.fromCbor(scriptRef) + .hash() + .toString(), + }; + }; + protected completeScriptInfo = (scriptSource: ScriptSource) => { if (scriptSource?.type != "Inline") return; - const refUtxos = this.queriedUTxOs[scriptSource.txHash]!; - const scriptRefUtxo = refUtxos.find( - (utxo) => utxo.input.outputIndex === scriptSource.txIndex, + const { scriptRef, scriptHash } = this.findScriptRefUTxO( + scriptSource.txHash, + scriptSource.txIndex, ); - if (!scriptRefUtxo) - throw Error( - `Couldn't find script reference utxo for ${scriptSource.txHash}#${scriptSource.txIndex}`, - ); - scriptSource.scriptHash = scriptRefUtxo?.output.scriptHash!; - scriptSource.scriptSize = ( - scriptRefUtxo?.output.scriptRef!.length / 2 - ).toString(); + scriptSource.scriptHash = scriptHash; + scriptSource.scriptSize = (scriptRef.length / 2).toString(); }; protected completeSimpleScriptInfo = ( simpleScript: SimpleScriptSourceInfo, ) => { if (simpleScript.type !== "Inline") return; - const refUtxos = this.queriedUTxOs[simpleScript.txHash]!; - const scriptRefUtxo = refUtxos.find( - (utxo) => utxo.input.outputIndex === simpleScript.txIndex, + const { scriptRef, scriptHash } = this.findScriptRefUTxO( + simpleScript.txHash, + simpleScript.txIndex, ); - if (!scriptRefUtxo) - throw Error( - `Couldn't find script reference utxo for ${simpleScript.txHash}#${simpleScript.txIndex}`, - ); - simpleScript.simpleScriptHash = scriptRefUtxo?.output.scriptHash!; + simpleScript.simpleScriptHash = scriptHash; + simpleScript.scriptSize = (scriptRef.length / 2).toString(); }; protected isInputComplete = (txIn: TxIn): boolean => { - if (txIn.type === "PubKey") return this.isInputInfoComplete(txIn); + if (txIn.type === "PubKey" || txIn.type === "SimpleScript") + return this.isInputInfoComplete(txIn); if (txIn.type === "Script") { const { scriptSource } = txIn.scriptTxIn; return ( @@ -872,6 +958,7 @@ export class MeshTxBuilder extends MeshTxBuilderCore { }; protected completeTxParts = async (): Promise => { + this.indexKnownUTxOs(); // Checking if all inputs are complete const { inputs, collaterals, mints, withdrawals, votes, certificates } = this.meshTxBuilderBody; @@ -1519,12 +1606,7 @@ export class MeshTxBuilder extends MeshTxBuilderCore { txHash: string, index: number, ): CstNativeScript | undefined => { - const utxos = this.queriedUTxOs[txHash]; - if (!utxos) { - return undefined; - } - - const utxo = utxos.find((utxo) => utxo.input.outputIndex === index); + const utxo = this.findUTxO(txHash, index); if (utxo?.output.scriptRef) { const script = CstScript.fromCbor( utxo.output.scriptRef, @@ -1811,9 +1893,12 @@ export class MeshTxBuilder extends MeshTxBuilderCore { newBuilder.txHex = this.txHex; - newBuilder.queriedTxHashes = structuredClone(this.queriedTxHashes); - - newBuilder.queriedUTxOs = structuredClone(this.queriedUTxOs); + // Fetched and known UTxOs are only ever added to, never edited, so clones made + // during coin selection can share them instead of deep-copying every script + newBuilder.queriedTxHashes = this.queriedTxHashes; + newBuilder.queriedUTxOs = this.queriedUTxOs; + newBuilder.pendingUTxOQueries = this.pendingUTxOQueries; + newBuilder.knownUTxOs = this.knownUTxOs; newBuilder.utxosWithRefScripts = structuredClone(this.utxosWithRefScripts); return newBuilder; diff --git a/packages/mesh-transaction/test/mesh-tx-builder/provider-calls.test.ts b/packages/mesh-transaction/test/mesh-tx-builder/provider-calls.test.ts new file mode 100644 index 000000000..066d9bf75 --- /dev/null +++ b/packages/mesh-transaction/test/mesh-tx-builder/provider-calls.test.ts @@ -0,0 +1,320 @@ +import { + IEvaluator, + IFetcher, + mConStr0, + NativeScript, + resolveNativeScriptAddress, + resolveScriptRef, + UTxO, +} from "@meshsdk/core"; +import { + OfflineEvaluatorScalus, + resolvePlutusScriptAddress, + Serialization, +} from "@meshsdk/core-cst"; +import { ScalusEmulator } from "@meshsdk/scalus-emulator"; +import { MeshTxBuilder } from "@meshsdk/transaction"; +import { MeshWallet } from "@meshsdk/wallet"; + +import { alwaysSucceedCbor, alwaysSucceedHash } from "../test-util"; + +/** + * Wraps a provider so every method call is recorded, both in total and per first argument + * (the tx hash, for `fetchUTxOs`). + */ +const recordCalls = (target: T) => { + const calls: Record = {}; + const provider = new Proxy(target, { + get(obj, prop) { + const value = Reflect.get(obj, prop, obj); + if (typeof value !== "function") return value; + return (...args: unknown[]) => { + (calls[String(prop)] ??= []).push(String(args[0])); + return value.apply(obj, args); + }; + }, + }); + const count = (method: string) => calls[method]?.length ?? 0; + const countFor = (method: string, arg: string) => + calls[method]?.filter((a) => a === arg).length ?? 0; + const args = (method: string) => calls[method] ?? []; + return { provider, count, countFor, args }; +}; + +const lovelace = (quantity: string) => [{ unit: "lovelace", quantity }]; + +const scriptAddress = resolvePlutusScriptAddress( + { + code: alwaysSucceedCbor, + version: "V3", + }, + 0, +); +const scriptTxHash = "2".repeat(64); +const refScriptTxHash = "3".repeat(64); +const unitDatum = Serialization.PlutusData.fromCore({ + constructor: 0n, + fields: { items: [] }, +}).toCbor(); + +async function setup(extraUtxos: (address: string) => UTxO[] = () => []) { + const wallet = new MeshWallet({ + networkId: 0, + key: { type: "mnemonic", words: Array(24).fill("solution") }, + }); + await wallet.init(); + const address = (await wallet.getChangeAddress())!; + const emulator = await ScalusEmulator.create([ + { + input: { txHash: "0".repeat(64), outputIndex: 0 }, + output: { address, amount: lovelace("10000000000") }, + }, + { + input: { txHash: "1".repeat(64), outputIndex: 0 }, + output: { address, amount: lovelace("5000000") }, + }, + ...extraUtxos(address), + ]); + const params = await emulator.fetchProtocolParameters(); + const walletUtxos = await emulator.fetchAddressUTxOs(address); + const collateral = walletUtxos.find( + (utxo) => utxo.input.txHash === "1".repeat(64), + )!; + const utxos = walletUtxos.filter((utxo) => utxo !== collateral); + return { wallet, address, emulator, params, utxos, collateral }; +} + +const lockedScriptUtxos = (count: number): UTxO[] => + Array.from({ length: count }, (_, outputIndex) => ({ + input: { txHash: scriptTxHash, outputIndex }, + output: { + address: scriptAddress, + amount: lovelace("20000000"), + plutusData: unitDatum, + }, + })); + +describe("MeshTxBuilder provider calls", () => { + it("does not evaluate a transaction without Plutus scripts", async () => { + const { wallet, address, emulator, params, utxos } = await setup(); + const { provider, count } = recordCalls(emulator); + + const txHex = await new MeshTxBuilder({ + fetcher: provider, + evaluator: provider, + params, + }) + .txOut(address, lovelace("2000000")) + .changeAddress(address) + .selectUtxosFrom(utxos) + .complete(); + + expect(count("evaluateTx")).toBe(0); + expect(count("fetchUTxOs")).toBe(0); + expect(count("fetchCostModels")).toBe(1); + expect(await emulator.submitTx(await wallet.signTx(txHex))).toHaveLength( + 64, + ); + }); + + it("fetches each transaction once for inputs given by reference only", async () => { + const { wallet, address, emulator, params, utxos, collateral } = + await setup(() => lockedScriptUtxos(3)); + const { provider, count, countFor } = recordCalls(emulator); + + const txBuilder = new MeshTxBuilder({ + fetcher: provider, + evaluator: provider, + params, + }); + for (let i = 0; i < 3; i++) { + txBuilder + .spendingPlutusScriptV3() + .txIn(scriptTxHash, i) + .txInInlineDatumPresent() + .txInRedeemerValue(mConStr0([])) + .txInScript(alwaysSucceedCbor); + } + const txHex = await txBuilder + .txInCollateral( + collateral.input.txHash, + collateral.input.outputIndex, + collateral.output.amount, + collateral.output.address, + ) + .changeAddress(address) + .selectUtxosFrom(utxos) + .complete(); + + expect(countFor("fetchUTxOs", scriptTxHash)).toBe(1); + expect(count("fetchUTxOs")).toBe(1); + expect(await emulator.submitTx(await wallet.signTx(txHex))).toHaveLength( + 64, + ); + }); + + it("completes a reference script source whose UTxO lacks a script hash", async () => { + const { wallet, address, emulator, params, utxos, collateral } = + await setup(() => [ + ...lockedScriptUtxos(1), + { + input: { txHash: refScriptTxHash, outputIndex: 0 }, + output: { + address: scriptAddress, + amount: lovelace("5000000"), + scriptRef: resolveScriptRef({ + code: alwaysSucceedCbor, + version: "V3", + }), + }, + }, + ]); + const { provider, count } = recordCalls(emulator); + + const txBuilder = new MeshTxBuilder({ + fetcher: provider, + evaluator: provider, + params, + }); + const txHex = await txBuilder + .spendingPlutusScriptV3() + .txIn(scriptTxHash, 0) + .txInInlineDatumPresent() + .txInRedeemerValue(mConStr0([])) + .spendingTxInReference(refScriptTxHash, 0) + .txInCollateral( + collateral.input.txHash, + collateral.input.outputIndex, + collateral.output.amount, + collateral.output.address, + ) + .changeAddress(address) + .selectUtxosFrom(utxos) + .complete(); + + expect(count("fetchUTxOs")).toBe(2); + const [scriptInput] = txBuilder.meshTxBuilderBody.inputs; + expect( + scriptInput?.type === "Script" && + scriptInput.scriptTxIn.scriptSource?.type === "Inline" && + scriptInput.scriptTxIn.scriptSource.scriptHash, + ).toBe(alwaysSucceedHash); + expect(await emulator.submitTx(await wallet.signTx(txHex))).toHaveLength( + 64, + ); + }); + + it("completes a native reference script source from the fetcher", async () => { + const nativeScript: NativeScript = { type: "all", scripts: [] }; + const { address, emulator, params, utxos } = await setup(() => [ + { + input: { txHash: scriptTxHash, outputIndex: 0 }, + output: { + address: resolveNativeScriptAddress(nativeScript, 0), + amount: lovelace("20000000"), + }, + }, + { + input: { txHash: refScriptTxHash, outputIndex: 0 }, + output: { + address: scriptAddress, + amount: lovelace("2000000"), + scriptRef: resolveScriptRef(nativeScript), + }, + }, + ]); + const { provider, count } = recordCalls(emulator); + + const txHex = await new MeshTxBuilder({ + fetcher: provider, + evaluator: provider, + params, + }) + .txIn(scriptTxHash, 0) + .simpleScriptTxInReference(refScriptTxHash, 0) + .changeAddress(address) + .selectUtxosFrom(utxos) + .complete(); + + expect(count("fetchUTxOs")).toBe(2); + // The script needs no signature, and no wallet input is selected + expect(await emulator.submitTx(txHex)).toHaveLength(64); + }); + + it("resolves inputs from a chained transaction without fetching", async () => { + const { wallet, address, emulator, params, utxos } = await setup(); + const { provider, count } = recordCalls(emulator); + + const parentTx = await wallet.signTx( + await new MeshTxBuilder({ fetcher: provider, params }) + .txOut(address, lovelace("30000000")) + .changeAddress(address) + .selectUtxosFrom(utxos) + .complete(), + ); + const parentHash = Serialization.Transaction.fromCbor( + Serialization.TxCBOR(parentTx), + ) + .getId() + .toString(); + + const childTx = await new MeshTxBuilder({ + fetcher: provider, + evaluator: provider, + params, + }) + .txIn(parentHash, 0) + .txOut(address, lovelace("10000000")) + .changeAddress(address) + .chainTx(parentTx) + .complete(); + + expect(count("fetchUTxOs")).toBe(0); + expect(await emulator.submitTx(parentTx)).toBe(parentHash); + expect(await emulator.submitTx(await wallet.signTx(childTx))).toHaveLength( + 64, + ); + }); + + it("offline evaluator fetches each transaction once across evaluations", async () => { + const { address, emulator, params, utxos, collateral } = await setup(() => + lockedScriptUtxos(1), + ); + const evaluatorFetcher = recordCalls(emulator); + const evaluator: IEvaluator = new OfflineEvaluatorScalus( + evaluatorFetcher.provider, + "preview", + ); + const builderEvaluator = recordCalls(evaluator); + + const build = () => + new MeshTxBuilder({ + fetcher: emulator, + evaluator: builderEvaluator.provider, + params, + }) + .spendingPlutusScriptV3() + .txIn(scriptTxHash, 0) + .txInInlineDatumPresent() + .txInRedeemerValue(mConStr0([])) + .txInScript(alwaysSucceedCbor) + .txInCollateral( + collateral.input.txHash, + collateral.input.outputIndex, + collateral.output.amount, + collateral.output.address, + ) + .changeAddress(address) + .selectUtxosFrom(utxos) + .complete(); + await build(); + // A second build with the same evaluator needs nothing new from the fetcher + await build(); + + expect(builderEvaluator.count("evaluateTx")).toBeGreaterThanOrEqual(2); + const fetchedTxHashes = evaluatorFetcher.args("fetchUTxOs"); + expect(fetchedTxHashes).toContain(scriptTxHash); + expect(fetchedTxHashes).toContain(collateral.input.txHash); + expect(new Set(fetchedTxHashes).size).toBe(fetchedTxHashes.length); + }); +});