Skip to Content
Scalus Club is now open! Join us to get an early access to new features 🎉

Equality and Lookups

Most validator budget goes to a handful of idioms: comparing a datum, finding one input or output, checking a signature, looking up a token. Several of those idioms have two spellings that read the same and cost differently. This page lists the measured pairs and explains, once per pair, what the generated UPLC does.

All fees are in lovelace at mainnet prices (0.0577 per memory unit, 0.0000721 per CPU step, the same convention as Measuring Performance). Budgets were measured with Options.release on the Plutus V3 backend.

The rules on this page are collected, without the numbers, in the Safe API Cheatsheet.

Datum equality: hasInlineDatum vs inlineOrFail

Two spellings compare a continuing output’s datum with an expected value:

// wrap form: build the expected OutputDatum and compare once require(contractOutput.hasInlineDatum(vestingDatum), InvalidDatum) // decode form: take the datum apart, decode it, compare the decoded value require(contractOutput.datum.inlineOrFail[Config](NotInline) === vestingConfig, InvalidDatum)

Measured on a three-field case-class datum:

Formmemcpuex-unit feescriptfee as reference script
out.hasInlineDatum(x)2 4651 988 17828628 B706
out.datum.inlineOrFail[T](msg) === x4 6252 684 90546145 B1 136

The intuition runs the other way, so the UPLC is worth reading. hasInlineDatum is three builtins around one equalsData: it wraps x as constrData(2, [x]) and compares that with the output’s OutputDatum field. The decode form must first take the OutputDatum apart (unConstrData, fstPair, a three-way case on the tag, sndPair, headList), and then === x on the decoded T does not compare the original Data: the lowering holds the decoded value as its field list and rewraps it, equalsData(constrData(0, sndPair(unConstrData(inner))), x). Nine builtins against three, on the same equalsData, and a fresh constr allocation on every comparison. Memory moves more than CPU (+88% against +19%) for that reason.

Rule: equality on a datum is out.hasInlineDatum(x). Reading the datum’s fields is out.datum.inlineOrFail[T](msg). Do not decode and then compare.

The decode form has one more property that matters for security: the rewrap hard-codes the constructor tag as 0, so a datum with a wrong constructor tag and matching fields compares equal under inlineOrFail[T] === x and unequal under hasInlineDatum. See Datum Validation.

The migrated example: scalus-examples/jvm/src/main/scala/scalus/examples/vesting/VestingValidator.scala.

=== vs toData ==

// canonical require(contractOutput.value === expectedValue, ContinuingValueMismatch) // hand-written, believing === is field-by-field require(contractOutput.value.toData == expectedValue.toData, ContinuingValueMismatch)

Both spellings compile to the same UPLC. Measured on Value.lovelace: 901 mem / 1 653 665 cpu for each. For any Data-backed type, a === b already lowers to equalsData(toData a, toData b); only primitives (BigInt, ByteString, Boolean, String) and @UplcRepr(UplcConstr) types get a different comparison. Writing toData == by hand buys nothing and hides the type.

Rule: write ===. If === does not compile, the type is missing an Eq instance; derive one. The one exception is a type with value-versus-structure semantics such as Rational, where the explicit comparator is the point.

One related trap: a generic === on a BigInt or ByteString behind a type variable also lowers to equalsData, not to equalsInteger / equalsByteString. Measured on a singleton map lookup, the generic form costs 1 761 779 cpu against 832 313 for a concrete-typed clone. Keep key types concrete at the comparison site.

contains vs exists: the Option tax

// intrinsic: a Boolean scan, no Option, no Eq closure require(tx.signatories.contains(owner), NotSigned) // prelude: find(p).isDefined, allocates one Option per call require(tx.signatories.exists(_ === owner), NotSigned)

Option has no native representation on-chain: Some(x) is constrData(0, [x]) and reading it back is unConstrData plus a case. No optimizer pass folds a Case over an Option built from a runtime value. List.contains is intrinsic and compiles to the direct recursion; List.exists is find(p).isDefined and pays the allocation on every call.

Measured per call, V3 backend, contains (direct recursion) against find(...).isDefined:

Outcomecpu savedmem savedlovelace saved
miss326 4831 06484.93
hit564 9962 028157.75

exists measures exactly equal to the find form in every configuration, so every exists call pays that 326 K (miss) / 565 K (hit) cpu. The tax is a fixed per-call cost, not per element: on a List[Data] it is 32% of the total at length 1 and 2% at length 20. The same shape appears in List.isDefinedAt, SortedMap.contains and AssocMap.contains.

Rule: for an equality test use contains. For any other predicate prefer forall on the negated predicate, or a fold, over exists. Any helper that returns Option[A] in a hot path pays the same tax; a …OrFail(msg) helper that returns A does not.

count vs filter(...).length

// one traversal, tail-recursive, no allocation require(tx.outputs.count(_.address.credential === buyerCredential) === BigInt(1), OneBuyerOutput) // two traversals; filter allocates one cons cell per survivor require(tx.outputs.filter(_.address.credential === buyerCredential).length === BigInt(1), OneBuyerOutput)

There is no fusion in the Scalus pipeline: filter is a non-tail foldRight that builds a new list, and length walks it again. count is one tail-recursive pass with an integer accumulator. On the BuiltinList representation neither filter nor length has an intrinsic. This pair has not been pinned with a budget; the difference is structural (two traversals plus k mkCons against one traversal and zero allocations).

Rule: count(p) when a number is needed; isEmpty / nonEmpty when only emptiness is, never length === 0 (length is O(n), isEmpty is one nullList).

findUniqueOrFail vs filter(...).length === 1 plus .head

When exactly one element must match and the validator then uses it, the anti-idiom is:

val own = tx.inputs.filter(_.resolved.address.credential === ownCred) require(own.length === BigInt(1), MultipleScriptInputs) val ownInput = own.head

That is two traversals, k cons cells, a length walk and a head that fails with a generic message. The migrated examples write:

tx.inputs.findUniqueOrFail(_.resolved.address.credential === ownCred, MultipleScriptInputs)

findUniqueOrFail is one pass: it fails as soon as a second match is seen, fails with message if none is, and returns the element. This pair has not been pinned against each other; the findUniqueOrFail call alone pins at 25 694 mem / 4 809 547 cpu (1 830 lovelace) in its test.

The single-own-input guard

The double-satisfaction guard “exactly one input from my own script” has a cheaper and a dearer spelling, and this pair was measured:

// measured cheaper tx.inputs.findUniqueOrFail(_.resolved.address.credential === ownCred, MultipleScriptInputs) // measured dearer require(tx.inputs.count(_.resolved.address.credential === ownCred) === BigInt(1), MultipleScriptInputs)
Inputs in the transactionfindUniqueOrFail feecount(...) === 1 fee
33 1753 307
106 2896 804

count must visit every input and then compare the accumulator; findUniqueOrFail stops at the second match, carries no accumulator, and returns the input for free when the caller needs it. The migrated example: scalus-examples/jvm/src/main/scala/scalus/examples/cape/htlc/HtlcValidator.scala.

SortedMap vs AssocMap

Both are a Data map (a list of pairs) at runtime; the difference is what the operations do with the ordering.

OperationSortedMapAssocMap
getloop over the pair list; stops early when the key sorts before the current entryalways scans to hit or end
unionone linear mergeO(n·m): get per left key plus exists per right key
size / lengthO(n), even though it is inlineO(n)
isEmptyO(1), one nullListO(1)
insert / deleterebuilds the spine up to the keyrebuilds the prefix
Eqderived, one equalsDatanone on purpose; the explicit comparator is itself O(n·m)

Measured SortedMap[BigInt, BigInt] pins, one entry: get hit 5 994 mem / 1 309 043 cpu, get miss 4 429 / 929 240, insert on an empty map 2 464 / 460 969, filter 7 225 / 1 563 239, foldLeft 14 476 / 3 527 562. A whole-expression union of two three-entry maps pins at 54 386 / 14 318 018.

The early exit on get does not help on a present key (the key has to be reached either way); it pays on misses that sort early. Two consequences:

  • Use SortedMap for anything with more than a few entries or any union. The ledger already hands you Value, mint and withdrawals as sorted maps.
  • SortedMap.contains(k) is get(k).isDefined and pays the Option tax above. When the token name is known, value.hasOnly(policy, name, qty) or value.hasNft(policy, name) are the cheaper and stronger checks; both are builtin-backed at PV11.

Phase 2: the migrated examples

The example validators in scalus-examples were rewritten to the operations above (findInputOrFail, findContinuingOutputOrFail, hasInlineDatum, hasNft, validFromOrFail / validToOrFail, valuePaidTo / valueSpentFrom, findUniqueOrFail, onlyBurnsUnder). Every pinned budget in the example test suites moved down. Highlights:

ValidatorChange
Vault, withdrawalbudget -25%
Auction, end with winnerbudget -15%
HTLC (CAPE variant)script 582 B to 548 B
TwoPartyEscrow (CAPE)script 1 174 B to 1 079 B

None of these validators changed what it checks. The savings come from the idioms on this page: one traversal instead of two, one equalsData instead of a decode-and-rewrap, no Option on the hot path.

What’s Next?

Last updated on