Skip to content

perf: cache provider calls and reduce fetcher and evaluator calls in MeshTxBuilder - #849

Merged
lisicky merged 21 commits into
mainfrom
feat/tx-builder-fetch-efficiency
Nov 1, 2026
Merged

lisicky merged 21 commits into
mainfrom
feat/tx-builder-fetch-efficiency

Conversation

@lisicky

@lisicky lisicky commented Sep 28, 2026 •

Copy link
Copy Markdown
Contributor

What

Caches provider answers that cannot change, cuts the calls MeshTxBuilder.complete() makes, and fixes two builds that could not complete at all.

Contents

mesh-common:

  • CachedFetcher: an IFetcher wrapper that caches only answers that cannot change.
    • fetchUTxOs(hash): a tx's outputs are fixed by its body, so they are fetched once per hash (whole tx, whatever index is asked for). LRU, 1000 txs by default.
    • fetchProtocolParameters / fetchCostModels: the latest values are kept until the current epoch ends. Values for an explicit past epoch are kept for good. Within 5 minutes after a boundary, values are kept only until those 5 minutes are up, while providers catch up.
    • Every other method (fetchAddressUTxOs, fetchAccountInfo, fetchTxInfo, fetchBlockInfo, asset and governance queries) reflects current chain state. Answers are kept for 10 s by default, half of Cardano's 20 s average block time (1 s slots, active slot coefficient 0.05), so a burst of identical queries costs one request. Configurable through volatileTtlMs (0 disables it). clearVolatile() drops them, and MeshTxBuilder.submitTx calls it after submitting.
    • Concurrent calls share one request. Failures, and empty tx outputs, are not cached. get(url) passes straight through.
    • CachedFetcher.wrap(fetcher) returns one shared cache per fetcher instance.
  • resolveEpochEnd(network, ms): the time the current epoch ends, computed from the genesis epoch and slot lengths in SLOT_CONFIG_NETWORK. These change only by hard fork. They were checked against current chain tips: mainnet epoch 661, preprod 318 and preview 1448 all match epoch number and epoch slot.
    • With no network given, CachedFetcher uses the earliest end among mainnet, preprod and preview. "testnet" (custom network) disables epoch caching.

mesh-transaction (MeshTxBuilder):

  • Fetches go through CachedFetcher.wrap(fetcher). Builders and evaluators sharing a provider share answers: cost models are fetched once per epoch instead of once per complete(), and reference-script / input txs once per process instead of once per builder.
    • A mainnet change address pins the epoch boundary to mainnet's.
  • evaluateRedeemers skips evaluateTx and the serialization before it when nothing is witnessed by a Plutus script. Before, it ran once per coin-selection cost estimate on every transaction, including plain ADA sends.
  • Input and reference-script info is looked up first in what the builder already holds: selectUtxosFrom UTxOs, inputForEvaluation UTxOs and chainTx outputs. fetchUTxOs is called only for hashes still unknown.
  • A failed or empty response is no longer remembered, so a later complete() retries it instead of failing with "Couldn't find value information".
  • fetchCostModels runs alongside the UTxO fetches instead of before them.
  • Native (simple) script reference sources are now fetched and completed, including their scriptSize. Before, simpleScriptTxInReference without a hash and size crashed with TypeError: Cannot read properties of undefined (reading 'find'). Native-script inputs given by reference only are now completed too.
  • A reference script's hash is derived from scriptRef when the fetcher does not return scriptHash (e.g. ScalusEmulator).
  • clone() shares the fetched UTxO cache instead of deep-copying it for every coin-selection callback.
  • TxParser fetches missing txs in parallel, through the same cache.

mesh-core-cst (OfflineEvaluatorScalus) and mesh-core-csl (OfflineEvaluator):

  • Inputs are resolved through CachedFetcher.wrap(fetcher), in parallel. Each coin-selection round used to refetch every input, collateral and reference input. If the builder already fetched a tx through the same provider, the evaluator reuses it.
  • OfflineEvaluator no longer appends resolved UTxOs to the caller's additionalUtxos array.

Provider calls, before → after

Measured with ScalusEmulator behind a call-recording proxy (provider-calls.test.ts):

Scenario Before After
ADA send, evaluator set evaluateTx ×1 (×1+k with extra selection rounds) 0
Two script spends, new builder each, same ref-script UTxO fetchCostModels ×2, fetchUTxOs ×2 per tx hash ×1, ×1
Spend of a chainTx output given as hash/index fetchUTxOs for an unsubmitted hash, then "Couldn't find value information" 0 fetches, builds and submits
Native script by reference without hash/size crash 1 fetch per hash, builds and submits
Two script-spend builds with one OfflineEvaluatorScalus fetchUTxOs ×4 ×2
OfflineEvaluator, same tx evaluated twice fetchUTxOs ×10 ×5

No breaking API changes. The protected getUTxOInfo / queryAllTxInfo keep their signatures.

Validation

npm run build:mesh
cd packages/mesh-common && npx jest
cd packages/mesh-transaction && NODE_OPTIONS=--experimental-vm-modules npx jest
cd packages/mesh-core-cst && npm test
cd packages/mesh-scalus-emulator && npm test
cd packages/mesh-core-csl && npx jest
  • mesh-common: 200 tests pass, including cached-fetcher.test.ts and the resolveEpochEnd cases.
  • mesh-transaction: 160 tests pass. script-tx-fetcher and script-tx-fail fail to load under --experimental-vm-modules on main too, because they import dist through require. Both pass without the flag.
  • mesh-core-cst (179 tests), mesh-scalus-emulator (24) and mesh-wallet (32) pass.
  • mesh-core-csl: the new fetch assertions pass. The same 4 tests in offline-providers/evaluator.test.ts fail on main: expected steps differ after the cost-model update (e.g. 164980381 vs 164973765).

@lisicky
lisicky force-pushed the feat/tx-builder-fetch-efficiency branch from 4003da6 to a9875e2 Compare October 12, 2026 14:58
@lisicky lisicky changed the title perf: reduce fetcher and evaluator calls in MeshTxBuilder perf: cache provider calls and reduce fetcher and evaluator calls in MeshTxBuilder Oct 12, 2026
@lisicky
lisicky force-pushed the feat/tx-builder-fetch-efficiency branch from a9875e2 to 6845f09 Compare October 12, 2026 15:04
twwu123 and others added 10 commits October 16, 2026 10:11
…ency

perf: stop wallets and contracts refetching UTxOs they already have
…ange to a fixed point

Coin selection prices redeemers on a skeleton with a 1 000 ADA placeholder fee and a
change output that still holds the fee, so a script that reads the fee or the change
can need other units on chain than the ones declared. complete() now evaluates the
final body, recomputes the fee, moves the difference into the change output with the
most lovelace and repeats until one more pass changes nothing (at most 20 passes,
then it throws). With a change output, the first pass sets the minimum fee; after that
the fee only rises, so a script that gets cheaper with a higher fee cannot flip
between two fees. The collateral is then checked against the settled fee.

Without an evaluator or Plutus scripts nothing changes; a manual fee is kept.
…ted fee

The largest-first selector priced every pass with the 1 000 ADA placeholder fee and
dropped the redeemers it evaluated, so complete() always had to re-evaluate the final
transaction at least twice. Passes after the first now use the fee already computed,
whose change already pays it, and the selector returns its redeemers. When that pass
priced the final transaction, the final check costs one evaluation.
twwu123 and others added 8 commits October 30, 2026 11:53
Switch OfflineEvaluatorScalus from a per-call evalPlutusScripts to one
Scalus TxEvaluator with its own ScriptCache. The evaluator is built on
first use and rebuilt only when slotConfig or costModels change; the
script cache survives rebuilds. UTxOs are passed as the [input, output]
pairs TransactionUnspentOutput already encodes, so the hand-built CBOR
map goes. A runtime that cannot import() Scalus (Jest without
--experimental-vm-modules) gets an error that says so.

MeshTxBuilder evaluates on every coin-selection round, so reuse pays off:
on recorded Midgard requests, TxEvaluator with a ScriptCache takes 0.98x
the time of Aiken WASM, and the cache alone saves about 17-25%.
`evaluator: "scalus"` makes MeshTxBuilder (and MeshTxBuilderV2) evaluate
scripts locally with OfflineEvaluatorScalus, so a script transaction carries
the execution units it really needs. It needs a fetcher. Without an evaluator
the builder behaves as before: no evaluation and no extra fetches. Before each
evaluation the builder gives the Scalus evaluator the network (the `network`
option or setNetwork, else mainnet for an addr1 change address; a validity
interval without known slot times is an error), the fetched cost models, and
the UTxOs it holds for the transaction. Key inputs given only by address and
value are fetched once per complete() for their chain data; their declared
data is used only when the fetcher reports them not found.

clone() passes `network` and keeps track of the Scalus evaluator, since coin
selection evaluates on clones. OfflineEvaluatorScalus suggests another
evaluator when Scalus cannot be imported.
Drop the `"scalus"` string variant of the `evaluator` option. A builder
given a fetcher and no evaluator now evaluates locally with
OfflineEvaluatorScalus, Mesh's default; `evaluator: null` turns
evaluation off, and a clone never makes its own default.

What the builder learns while completing a transaction (the network,
the cost models it fetched) reaches any evaluator through the new
optional `IEvaluator.configure`, and an evaluator that runs locally
says so with `acceptsKnownUtxos` to get every UTxO the builder holds.
The builder no longer treats one evaluator specially.

OfflineEvaluatorScalus takes an optional network, is configured from a
context, keeps a slot config given to its constructor, and checks the
validity interval of the serialized transaction itself, before any
fetch: a zero start the serializer leaves out no longer needs a network.
… evaluation inputs

Evaluators that do not take known UTxOs get the fetched copy of an input
given for evaluation when the builder has one, since a given copy may
lack script bytes. This fixes the provider-calls test "fetches missing
script bytes for a locally known Plutus reference UTxO", which failed on
the base branch.

Two tests met the new default: the balance fixture holds bare scripts in
`scriptRef`, which Scalus cannot decode, so it opts out with
`evaluator: null`; an OfflineEvaluatorScalus given to the builder now
gets the builder's UTxOs and fetches nothing itself, which its test
asserts.

New tests: a given local evaluator gets every held UTxO; a given
evaluator gets the fetched cost models; the evaluator is kept across
reset(); configure() with nothing new keeps the TxEvaluator;
getTransactionValidityInterval. Comments trimmed.
fix(tx-builder): evaluate the final transaction and settle fee and change to a fixed point
feat(tx-builder): opt-in Scalus evaluation with evaluator: "scalus"
feat(tx-builder): evaluate with Scalus by default, through IEvaluator
Resolves the conflicts with #860 (reusable Scalus TxEvaluator, which this
branch carried as a copy) and with the Scalus 1.4.1 test fixes on main:

- offline-evaluator-scalus.ts and its test keep this branch's version,
  which already holds #860's content plus the default-evaluator work
  (#862, #866).
- offline-evaluator-scalus-cases.test.ts keeps its Scalus expectations.
  Main's edits were to the CSL evaluator test it was copied from, so that
  test (mesh-core-csl/test/offline-providers/evaluator.test.ts) is
  restored from main rather than staying deleted.
- script-tx-fail / script-tx-fetcher tests import MeshTxBuilder from
  @meshsdk/transaction, as on main.
@lisicky
lisicky merged commit 6808a68 into main Nov 1, 2026
1 check passed
@lisicky
lisicky deleted the feat/tx-builder-fetch-efficiency branch November 1, 2026 17:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants