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

Low-Level Builtins

Instead of using high-level prelude types like ScriptContext and List[A], you can use builtins and primitive data types (Data, BuiltinList, ByteString) directly inside compile {}. This gives you manual control over Data access and makes the resulting Scala code effectively a “compiled assembler” — it maps almost 1:1 to UPLC builtins.

Let’s see the difference on a real validator. Here’s a high-level PreimageValidator that checks a hash preimage and verifies a signatory:

import scalus.*, scalus.compiler.compile import scalus.uplc.builtin.{ByteString, Data} import scalus.uplc.builtin.Builtins.* import scalus.cardano.onchain.plutus.v2.* import scalus.cardano.onchain.plutus.prelude.* @Compile object PreimageValidator { def preimageValidator(datum: Data, redeemer: Data, ctxData: Data): Unit = { val (hash, pkh) = datum.to[(ByteString, ByteString)] val preimage = redeemer.toByteString val ctx = ctxData.to[ScriptContext] ctx.txInfo.signatories.find(_.hash == pkh).orFail("Not signed") require(sha2_256(preimage) == hash, "Wrong preimage") } }

Where does the cost come from?

  • V3 Lowering backend: types stay as Data internally, so .to[ScriptContext] is cheap. The cost is in navigating nested Data constructors to reach the signatories field — chained tailList calls through 8 unused fields.
  • Template-based backends: .to[ScriptContext] Scott-encodes or reconstructs the entire structure including all unused fields — significantly more expensive.

Now the same logic using direct builtins:

import scalus.*, scalus.compiler.compile import scalus.uplc.builtin.Builtins.* import scalus.uplc.builtin.{BuiltinList, ByteString, Data} import scalus.cardano.onchain.plutus.v2.ScriptContext import scalus.cardano.onchain.plutus.prelude.require @Compile object OptimizedPreimageValidator { def preimageValidator(datum: Data, redeemer: Data, ctxData: Data): Unit = { // Manual Data deconstruction instead of .to[T] val pair = datum.toConstr.snd inline def hash = pair.head.toByteString val pkh = pair.tail.head // kept as Data for equalsData inline def preimage = redeemer.toByteString // Walk signatories with BuiltinList[Data] def checkSignatories(sigs: BuiltinList[Data]): Unit = if sigs.head == pkh then () else checkSignatories(sigs.tail) // Direct field access -- skip unused TxInfo fields inline def sigs = ctxData.field[ScriptContext](_.txInfo.signatories).toList checkSignatories(sigs) require(sha2_256(preimage) == hash) } }

Direct Script Context Access

In this example we can see the use of datum.toConstr.snd — deconstructing Data manually instead of .to[T]. Since we know the Data representation (a Constr with fields as a list), we can use builtins directly: unConstrData returns a pair of (constructor tag, fields list), and .snd gives us the fields.

Scalus provides two macros for type-safe field access without hardcoding offsets:

fieldAsData macro (and its shorthand .field) — generates a chain of headList/tailList calls to extract exactly the field you need. Works with any protocol version:

import scalus.compiler.fieldAsData import scalus.uplc.builtin.Data // Both forms are equivalent: val signatories = fieldAsData[ScriptContext](_.txInfo.signatories)(ctxData) val signatories2 = ctxData.field[ScriptContext](_.txInfo.signatories)

offsetOf macro (PV11+) — returns the 0-based field index as BigInt at compile time. Use it with the dropList builtin to skip N fields in one call instead of chaining tailList:

import scalus.compiler.offsetOf import scalus.uplc.builtin.Builtins.* val txInfoFields = ctxData.toConstr.snd.head.toConstr.snd // offsetOf[TxInfo](_.signatories) expands to BigInt(8) at compile time val sigs = dropList(offsetOf[TxInfo](_.signatories), txInfoFields).head.toList

Using direct builtins you also have control over Plutus version details — like using dropList (PV11) instead of chained tailList, or Case on BuiltinList instead of chooseList. Here’s what the same validator looks like with PV11 features:

import scalus.*, scalus.compiler.{compile, offsetOf} import scalus.uplc.builtin.Builtins.* import scalus.uplc.builtin.{BuiltinList, Data} import scalus.cardano.onchain.plutus.v2.TxInfo import scalus.cardano.onchain.plutus.prelude.require @Compile object OptimizedPreimageValidatorV4 { def preimageValidator(datum: Data, redeemer: Data, ctxData: Data): Unit = { val pair = datum.toConstr.snd inline def hash = pair.head.toByteString val pkh = pair.tail.head inline def preimage = redeemer.toByteString // Pattern match on BuiltinList -- compiles to Case on List (PV11) def checkSignatories(sigs: BuiltinList[Data]): Unit = (sigs: @unchecked) match case BuiltinList.Cons(h, t) => if h == pkh then () else checkSignatories(t) // dropList + offsetOf: skip directly to signatories field val txInfoFields = ctxData.toConstr.snd.head.toConstr.snd inline def sigs = dropList(offsetOf[TxInfo](_.signatories), txInfoFields).head.toList checkSignatories(sigs) require(sha2_256(preimage) == hash) } }

@unchecked on the BuiltinList match omits the Nil branch. If the list is empty, the VM throws CaseListBranchError. Use this when you know the list is non-empty or when failure on empty is acceptable.

Budget Comparison

Here’s the same PreimageValidator across styles and protocol versions (1 signatory):

VariantFlat SizeCPUMemoryExec FeeTx Fee (44 lovelace/byte)
High-level, PV9496 B6,594,49321,9621,74323,567 lovelace
High-level, PV11448 B4,821,78217,4101,35321,065 lovelace
Direct builtins, PV9426 B5,529,72613,9081,20219,946 lovelace
Direct builtins, PV11374 B4,161,6959,99087717,333 lovelace

Key observations:

  • PV11 with zero code changes (high-level PV9 vs PV11): 27% CPU savings — just switch targetProtocolVersion
  • Direct builtins (PV11 builtins vs PV11 high-level): further 14% CPU, 43% memory savings from manual Data access
  • Transaction fee difference is smaller than execution fee difference because the size component (44 lovelace/byte) dominates. Still, direct builtins on PV11 save 26% on total transaction fee compared to high-level PV9.

The PreimageBudgetComparisonTest includes additional variants — signatory index lookup (O(1) instead of linear search) and direct UPLC construction. Run it yourself:

sbtn "scalusExamplesJVM/testOnly scalus.examples.PreimageBudgetComparisonTest"

What’s Next?

  • Lowering Backends — control how your remaining high-level types are encoded in UPLC with @UplcRepr.
  • UPLC Term DSL — go one level deeper and hand-craft the UPLC AST directly when builtins still aren’t enough.
Last updated on