Skip to content

feat(tx-builder): opt-in Scalus evaluation with evaluator: "scalus" - #862

Merged
lisicky merged 3 commits into
MeshJS:feat/tx-builder-fetch-efficiencyfrom
nau:pr-c/default-evaluator
Nov 1, 2026
Merged

lisicky merged 3 commits into
MeshJS:feat/tx-builder-fetch-efficiencyfrom
nau:pr-c/default-evaluator

Conversation

@nau

@nau nau commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

Summary

new MeshTxBuilder({ fetcher, evaluator: "scalus" }) evaluates scripts locally with OfflineEvaluatorScalus, set up by the builder. Script transactions then carry the execution units they really need, without a round trip to a provider. MeshTxBuilderV2 takes the same option.

As agreed with the maintainers, this is opt-in for Mesh 1.x. Scalus evaluation is planned to become the default in Mesh 2.x. A builder without an evaluator behaves exactly as before: no evaluation, and no fetches beyond what #849 makes.

This builds on #849 (provider-call caching in MeshTxBuilder) and on #860 (OfflineEvaluatorScalus reuses one Scalus TxEvaluator and ScriptCache). #849's branch predates b42a31d on main (Scalus 1.4.1, ESM test runner, governance tests), so this PR carries a copy of b42a31d and of the #860 commit. Both drop out when #849 and #860 land. Only the last commit is new here.

Changes

  • MeshTxBuilderOptions.evaluator is now IEvaluator | "scalus":
    • omitted – no evaluation, as before;
    • an evaluator – used as given, with the same arguments as before;
    • "scalus" – an OfflineEvaluatorScalus that the builder configures. It needs a fetcher; without one, the constructor throws an error that names fetcher.
  • Before each evaluation, the builder sets up its Scalus evaluator:
    • Network (slot arithmetic): the new network option or setNetwork(...) (as mesh-contract and the deprecated Transaction call it), else mainnet for an addr1 change address. Both are kept in a new public network field on MeshTxBuilder. The builder does not read CachedFetcher.network, because perf: cache provider calls and reduce fetcher and evaluator calls in MeshTxBuilder #849's cachedFetcher() writes its addr1 guess there for every builder using that fetcher. If the transaction has a validity interval and the network is unknown, has no known slot times ("testnet", whose slot config is all zeros) or is not a network name at all, evaluation throws. The error suggests network (or setNetwork) or another evaluator built for that network. Without a validity interval the slot arithmetic is never used, so evaluation goes ahead.
    • Cost models: the ones the builder fetched (completeCostModels). A version the provider sent no model for keeps the default.
    • UTxOs: the UTxOs the builder holds for the transaction's inputs, collateral and reference inputs. Fetched ones come first, then selectUtxosFrom and inputForEvaluation UTxOs and chained outputs. Key inputs given only by address and value (txIn(hash, i, amount, address, 0)) carry no datum. Their chain UTxOs are fetched once per complete(), alongside the builder's own queries. The declared data is used only for an output the fetcher does not have yet, e.g. a wallet UTxO an indexer has not seen. "Does not have" means an empty result (Kupo, UTxO RPC, the Scalus emulator), an HTTP 404 (Blockfrost, Maestro), or a "not found" error (OfflineFetcher). Any other failure is thrown, including parseHttpError's "{}", which is what every axios-based provider throws for a non-HTTP error. Key inputs that may hold a reference script (scriptSize not 0), and Plutus script inputs, are never taken from declared data. Anything else is left for the evaluator to fetch.
    • Protocol version: fixed at 11 (van Rossem, what mainnet runs), the OfflineEvaluatorScalus default.
      An evaluator you pass yourself still gets only inputForEvaluation UTxOs: a remote one (Blockfrost, Ogmios) may reject additional UTxOs that also exist on chain.
  • clone() also passes network, and keeps track of the Scalus evaluator. clone() runs on every coin-selection round, and evaluation happens on those clones. A clone of an opted-in builder stays opted in; a clone of a builder without an evaluator stays without one.
  • OfflineEvaluatorScalus: the error feat(core-cst): evaluate with a reusable Scalus TxEvaluator #860 gives when Scalus cannot be import()ed (Jest without --experimental-vm-modules) now also suggests giving MeshTxBuilder another evaluator.

Notes

  • Units are only as good as the chain data the fetcher returns. A fetcher that drops datums or reference scripts, such as KupoProvider (its toUTxO keeps only address and value), gives a script a smaller context than the ledger will. The evaluated units can then fall short, and the ledger rejects the transaction. One probe evaluated 86.85M steps where the ledger spent 89.39M. Use another fetcher, or another evaluator, with such providers.
  • KoiosProvider.fetchUTxOs throws "{}" for an unknown transaction (it reads data[0].outputs from an empty answer). So with Koios, an input the indexer has not seen yet fails complete() rather than falling back to its declared data. The fix, returning [] when data[0] is undefined, belongs in the provider package, which is not in this repository.
  • I did not check Yaci's answer for an unknown transaction against its API. If it is not a 404, an unseen input fails complete() there as well.
  • A custom fetcher whose error says "not found" for some other reason would also fall back to declared data. I checked the shipped providers, and none does.
  • The protocol version is fixed at 11. On a network that runs another version, pass an evaluator built for it.
  • A testnet transaction with a validity interval needs network or setNetwork(...). mesh-contract already calls setNetwork. Unlike the body, the network set with setNetwork is kept across reset(). Byron (Ae2, DdzFF) mainnet change addresses do not imply mainnet; pass network with them.
  • @meshsdk/transaction now always references OfflineEvaluatorScalus, so bundlers always emit the lazily loaded Scalus chunk. Scalus is still only loaded when an opted-in builder evaluates a script transaction, and the CommonJS build still loads without it.
  • perf: cache provider calls and reduce fetcher and evaluator calls in MeshTxBuilder #849's cachedFetcher() writes network = "mainnet" into the CachedFetcher shared by every builder using the same fetcher. This PR does not rely on that value.

Tests

  • New scalus-evaluator.test.ts (26 tests), against ScalusEmulator:
    • A script spend declared with { mem: 7, steps: 7 } gets evaluated units with evaluator: "scalus", in MeshTxBuilder and in MeshTxBuilderV2, and the emulator accepts the signed transaction.
    • Without an evaluator, { mem: 7, steps: 7 } stays through coin selection. The builder fetches only what perf: cache provider calls and reduce fetcher and evaluator calls in MeshTxBuilder #849 fetches, and a fetcher failing with "{}" on the collateral does not affect it.
    • evaluator: "scalus" without a fetcher throws, for both builders.
    • An evaluator you pass gets only the inputForEvaluation UTxOs, and the builder fetches nothing extra for it.
    • A wallet UTxO is selected and is not fetched. The script input (given by reference) and the collateral (given by address and value) are fetched, once each.
    • Fetched cost models are used; an empty one falls back to the default.
    • Key inputs by address and value, using a script that serialises its own context, so its cost grows with the context:
      • the chain UTxO has a large inline datum: the units cover it, and the emulator accepts the transaction;
      • not on chain yet: evaluated as declared, and fetched once, even with a further evaluation round on a clone;
      • output Initial Beta Release #1 of a transaction is on chain with a datum and #0 is not: Initial Beta Release #1 keeps its datum in every round;
      • a double-JSON 404 (Blockfrost's shape) or OfflineFetcher's "No UTxOs found": evaluated as declared;
      • a 500 or "{}": complete() rejects with that error;
      • declared with a reference script size, or spending a Plutus script: not taken from declared data.
    • Network: from the option, from setNetwork("preprod") with a validity interval (the mesh-contract flow), and from a mainnet change address. With a validity interval, even one from slot 0, an unknown network, "testnet" or a misspelt name throws. A "mainnet" guess left in the shared cache is not used. Without a validity interval, evaluation works.
    • I broke each behaviour in the code and checked that a test fails: an evaluator made without "scalus", "scalus" accepted without a fetcher, the evaluator or network not passed in clone(), the clone's Scalus evaluator not marked, prefetching without an evaluator, slot 0 not counted as a validity interval, not-found not recognised (404, OfflineFetcher's message), every error treated as not-found, chain data not prefetched, prefetching for a user's evaluator, held UTxOs not used, declared data before chain data, no scriptSize or Plutus-input guard, setNetwork not recorded, zero slot length accepted, network read from the shared cache, empty cost models not replaced. Every one fails a test. Two guards have no test, because breaking them only costs a fetch: prefetching for a transaction without Plutus scripts, and prefetching after builder.evaluator is reassigned.
  • All existing tests pass unchanged; none needed an opt-out.

Results, before and after this commit (both on #849 + #860):

Suite Before After
mesh-transaction 165 passed, 1 failed (166) 191 passed, 1 failed (192)
mesh-core-cst 8 + 179 passed 8 + 179 passed
mesh-scalus-emulator 24 passed 24 passed

The one failure is #849's provider-calls "fetches missing script bytes for a locally known Plutus reference UTxO". It fails the same way with Scalus 1.3.0. That test passes the emulator as an explicit evaluator. The builder fetches the reference script's bytes, but it still hands the emulator the local copy without them, given through inputForEvaluation. The emulator lets additional UTxOs override its own, so it reports "Script not found". The Scalus evaluator avoids this, because it prefers fetched UTxOs. The fix for explicit evaluators belongs in #849.

@Temasar1 Temasar1 mentioned this pull request Oct 27, 2026
28 tasks
twwu123 and others added 2 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%.
@nau
nau force-pushed the pr-c/default-evaluator branch from c4ead22 to ec4d7d8 Compare October 30, 2026 10:55
`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.
@nau
nau force-pushed the pr-c/default-evaluator branch from ec4d7d8 to 5b2c645 Compare October 30, 2026 14:44
@nau nau changed the title feat(tx-builder): evaluate with Scalus by default feat(tx-builder): opt-in Scalus evaluation with evaluator: "scalus" Oct 30, 2026

@lisicky lisicky left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for this one too. Merging it as is, then #866 goes on top and changes the shape a bit, so you're not surprised:

  • No evaluator: "scalus". The option stays IEvaluator | null; with a fetcher and no evaluator the builder now defaults to OfflineEvaluatorScalus, null turns evaluation off.
  • What the builder learns goes through the interface: optional IEvaluator.configure(context) (network, cost models) and acceptsKnownUtxos (a local evaluator gets every UTxO the builder holds). The builder no longer special-cases its Scalus instance, so scalusEvaluator / configureScalusEvaluator are gone.
  • The validity-interval / network check moved into OfflineEvaluatorScalus and runs on the serialized tx, before any fetch. invalidBefore(0) no longer needs a network, the serializer drops it anyway.
  • Non-local evaluators get the fetched copy of an inputForEvaluation when the builder has one. That fixed the provider-calls "missing script bytes" test that was failing on the base branch.
  • Your tests are kept, adapted to the new API, plus a few more. All suites green locally with #861 included.

Details in #866.

@lisicky
lisicky merged commit c5a023f into MeshJS:feat/tx-builder-fetch-efficiency Nov 1, 2026
lisicky added a commit that referenced this pull request Nov 1, 2026
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.
@nau
nau deleted the pr-c/default-evaluator branch November 2, 2026 17:21
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