From 71b90ff5669dc7af92c368719b9b8c984e380c96 Mon Sep 17 00:00:00 2001 From: oscarozaine Date: Sun, 7 Jun 2026 07:25:49 -0700 Subject: [PATCH 1/2] perf(core-csl): resolve evaluator UTxOs in parallel without mutating input OfflineEvaluator.evaluateTx pushed resolved UTxOs into the caller's additionalUtxos array (a surprising side effect that accumulated across repeated calls) and fetched each parent transaction's UTxOs sequentially, so latency scaled linearly with the number of distinct input transactions. It now copies the input array and resolves distinct parent transactions concurrently via Promise.all, leaving the caller's array untouched. The two in-tree callers pass a freshly built array and read only the return value, so behavior is unchanged for them. Adds a no-mutation assertion to the existing evaluator test, which already resolves inputs across several parent transactions (exercising the parallel path). Refs audit findings PERF-001, REL-003 Co-Authored-By: Claude Opus 4.8 --- .../offline-providers/offline-evaluator.ts | 43 +++++++++++-------- .../test/offline-providers/evaluator.test.ts | 9 +++- 2 files changed, 32 insertions(+), 20 deletions(-) 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..5fc25e55b 100644 --- a/packages/mesh-core-csl/src/offline-providers/offline-evaluator.ts +++ b/packages/mesh-core-csl/src/offline-providers/offline-evaluator.ts @@ -122,14 +122,17 @@ export class OfflineEvaluator implements IEvaluator { additionalUtxos: UTxO[], additionalTxs: string[], ): Promise[]> { - // Track which utxos is resolved + // Work on a copy so the caller's `additionalUtxos` array is never mutated. + const resolvedUtxos: UTxO[] = [...additionalUtxos]; + + // Track which utxos are resolved const foundUtxos = new Set(); - for (const utxo of additionalUtxos) { + for (const utxo of resolvedUtxos) { foundUtxos.add(`${utxo.input.txHash}:${utxo.input.outputIndex}`); } - for (const tx of additionalTxs) { - const outputs = getTransactionOutputs(tx); + for (const additionalTx of additionalTxs) { + const outputs = getTransactionOutputs(additionalTx); for (const output of outputs) { foundUtxos.add(`${output.input.txHash}:${output.input.outputIndex}`); } @@ -138,20 +141,24 @@ export class OfflineEvaluator implements IEvaluator { (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); + + // Resolve each distinct parent transaction's UTxOs concurrently. + const fetchedUtxoSets = await Promise.all( + [...txHashesSet].map((txHash) => this.fetcher.fetchUTxOs(txHash)), + ); + for (const utxos of fetchedUtxoSets) { for (const utxo of utxos) { - if (utxo) - if ( - inputsToResolve.find( - (input) => - input.txHash === txHash && - input.outputIndex === utxo.input.outputIndex, - ) - ) { - additionalUtxos.push(utxo); - foundUtxos.add(`${utxo.input.txHash}:${utxo.input.outputIndex}`); - } + if ( + utxo && + inputsToResolve.some( + (input) => + input.txHash === utxo.input.txHash && + input.outputIndex === utxo.input.outputIndex, + ) + ) { + resolvedUtxos.push(utxo); + foundUtxos.add(`${utxo.input.txHash}:${utxo.input.outputIndex}`); + } } } const missing = inputsToResolve.filter( @@ -167,7 +174,7 @@ export class OfflineEvaluator implements IEvaluator { } return evaluateTransaction( tx, - additionalUtxos, + resolvedUtxos, additionalTxs, this.costModels, this.slotConfig, 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..b71390daa 100644 --- a/packages/mesh-core-csl/test/offline-providers/evaluator.test.ts +++ b/packages/mesh-core-csl/test/offline-providers/evaluator.test.ts @@ -1,4 +1,4 @@ -import { SLOT_CONFIG_NETWORK } from "@meshsdk/common"; +import { SLOT_CONFIG_NETWORK, UTxO } from "@meshsdk/common"; import { OfflineEvaluator } from "@meshsdk/core-csl"; import { OfflineFetcher } from "@meshsdk/provider"; @@ -119,7 +119,12 @@ describe("Offline Evaluator", () => { }; fetcher.addUTxOs([utxo_1, utxo_2, utxo_3, utxo_4, utxo_5, utxo_6, utxo_7]); - const res = await evaluator.evaluateTx(txHex, [], []); + // All inputs are resolved from the fetcher (across several parent txs, in + // parallel). The caller's array must not be mutated in the process — the + // previous implementation pushed the resolved UTxOs into it. + const additionalUtxos: UTxO[] = []; + const res = await evaluator.evaluateTx(txHex, additionalUtxos, []); + expect(additionalUtxos).toHaveLength(0); expect(res).toStrictEqual([ { index: 0, From 87e86952e56aea9dc50372bcb6a2614d0e915f94 Mon Sep 17 00:00:00 2001 From: oscarozaine Date: Sun, 7 Jun 2026 21:47:48 -0700 Subject: [PATCH 2/2] fix(contract): use BigInt arithmetic in MeshMarketplaceContract.purchaseAsset Replaces float-based lovelace arithmetic with BigInt throughout purchaseAsset to avoid silent precision loss for NFT prices above Number.MAX_SAFE_INTEGER (~9,007,199 ADA). The previous `as number` casts were TypeScript-only lies that caused a TypeError at runtime; converting via Number() fixed the crash but introduced silent rounding errors for large prices. Changes: - All fee and seller-payment calculations now use BigInt operators; quantities are only converted to string at the txOut boundary. - Ceiling division implemented as (a * b + 9999n) / 10000n instead of Math.ceil(float), which is exact and matches the on-chain semantics. - 1-ADA minimum fee floor is now guarded by priceBig > 0n so free listings (price = 0) no longer incur a spurious mandatory fee. - lovelaceEntry guard replaces the !.quantity non-null assertion with a descriptive error instead of an opaque TypeError. - Constructor validates feePercentageBasisPoint is in [0, 10000] and throws early rather than silently producing unbalanceable transactions. Co-Authored-By: Claude Sonnet 4.6 --- .../mesh-contract/src/marketplace/offchain.ts | 43 +++++++++++++------ 1 file changed, 31 insertions(+), 12 deletions(-) diff --git a/packages/mesh-contract/src/marketplace/offchain.ts b/packages/mesh-contract/src/marketplace/offchain.ts index f7c5c4b39..a5324508b 100644 --- a/packages/mesh-contract/src/marketplace/offchain.ts +++ b/packages/mesh-contract/src/marketplace/offchain.ts @@ -57,6 +57,11 @@ export class MeshMarketplaceContract extends MeshTxInitiator { feePercentageBasisPoint: number, ) { super(inputs); + if (feePercentageBasisPoint < 0 || feePercentageBasisPoint > 10000) { + throw new Error( + `feePercentageBasisPoint must be between 0 and 10000, got ${feePercentageBasisPoint}`, + ); + } this.ownerAddress = ownerAddress; this.feePercentageBasisPoint = feePercentageBasisPoint; @@ -154,9 +159,14 @@ export class MeshMarketplaceContract extends MeshTxInitiator { marketplaceUtxo.output.plutusData!, ); - const inputLovelace = marketplaceUtxo.output.amount.find( + const lovelaceEntry = marketplaceUtxo.output.amount.find( (a) => a.unit === "lovelace", - )!.quantity; + ); + if (!lovelaceEntry) { + throw new Error( + "purchaseAsset: marketplace UTxO is missing a lovelace entry", + ); + } const tx = this.mesh .spendingPlutusScript(this.languageVersion) @@ -178,27 +188,36 @@ export class MeshMarketplaceContract extends MeshTxInitiator { ) .selectUtxosFrom(utxos); - let ownerToReceiveLovelace = - ((inputDatum.fields[1].int as number) * this.feePercentageBasisPoint) / - 10000; - if (this.feePercentageBasisPoint > 0 && ownerToReceiveLovelace < 1000000) { - ownerToReceiveLovelace = 1000000; + // Keep all lovelace arithmetic in BigInt to avoid precision loss for + // prices above Number.MAX_SAFE_INTEGER (~9M ADA). + const priceBig = BigInt(inputDatum.fields[1].int); + const feeBig = BigInt(Math.round(this.feePercentageBasisPoint)); + + // Ceiling division: (a * b + d - 1) / d mirrors Math.ceil without floats. + let ownerToReceiveLovelace = (priceBig * feeBig + 9999n) / 10000n; + // Apply 1-ADA minimum only when the listing has a non-zero price; a free + // listing (price = 0) should not incur a mandatory fee. + if ( + this.feePercentageBasisPoint > 0 && + priceBig > 0n && + ownerToReceiveLovelace < 1000000n + ) { + ownerToReceiveLovelace = 1000000n; } - if (ownerToReceiveLovelace > 0) { + if (ownerToReceiveLovelace > 0n) { const ownerToReceive = [ { unit: "lovelace", - quantity: Math.ceil(ownerToReceiveLovelace).toString(), + quantity: ownerToReceiveLovelace.toString(), }, ]; tx.txOut(this.ownerAddress, ownerToReceive); } - const sellerToReceiveLovelace = - (inputDatum.fields[1].int as number) + Number(inputLovelace); + const sellerToReceiveLovelace = priceBig + BigInt(lovelaceEntry.quantity); - if (sellerToReceiveLovelace > 0) { + if (sellerToReceiveLovelace > 0n) { const sellerAddress = serializeAddressObj( inputDatum.fields[0], this.networkId,