cats-eo

UPLC → JVM bytecode

A demo, not a module. The code lives in demos/uplc-jvm and is not published. It shows what optics buy a real program: a compiler from Untyped Plutus Core (UPLC) — the language Cardano smart contracts run in — to JVM class files, checked against the Scalus CEK machine on every program it compiles.

The idea: a compiler is a chain of optics

A compiler is usually a function: script in, class file out. It becomes an isomorphism as soon as its output remembers its input. Here every stage keeps the script it came from — the typed program holds its source, and the class embeds the script in its constants — so every arrow can be walked back:

CborScript ⇀ TypedProgram ≅ JvmClass ⇀ JvmClassScript
           toTyped        lowering   compiling

The middle arrow is an iso. The other two are prisms — each walks back from everything it reaches, but not every value has somewhere to go:

type what it is
CborScript a script as the ledger stores it: the flat-encoded program in a CBOR bytestring
TypedProgram the program read back as a typed, Scala-like expression
JvmClass the script's JVM class in memory: ASM's ClassNode
JvmClassScript a class file the compiler wrote

CborScript and JvmClassScript are opaque types over Scalus' ByteString, so the two byte encodings can't be swapped by accident, and the round-trip laws can compare them with ==. None of the four types hands out what it holds — their bytes and tree are copies — so a value cannot change after the compiler made or checked it, and the way back always works. The optics a caller wants all start at the script, and are compositions of the stages with andThen — a prism, an iso and a prism make a prism:

import dev.constructive.eo.demo.uplc.*

val toTyped = UplcJvm.toTyped                            // CborScript ⇀ TypedProgram
val toJvmClass = UplcJvm.toTyped.andThen(UplcJvm.lowering) // CborScript ⇀ JvmClass
val toBytecode = toJvmClass.andThen(UplcJvm.compiling)    // CborScript ⇀ JvmClassScript

val script = CborScript.of(validator.program)  // or CborScript.parse(onChainBytes)
val compiled = toBytecode.getOption(script)     // the compiler; None: no class file
compiled.map(toBytecode.reverseGet)             // Some(script): the class hands its script back
compiled.map(Exec.validate(_, scriptContext))   // Some(Right(traces) | Left(reason))

toJvmClass.getOption(script).map(UplcJvm.compile) is the same compiler, with the limit a class exceeds as a Left.

Two properties follow from the shape, and the suite checks both.

The laws hold. lowering is a lawful iso on what the compiler produces: there and back is the identity, both ways. toTyped and toBytecode are lawful prisms: getOption(reverseGet(a)) == Some(a) for every typed program or class file a, and a script that gets there comes back out. The suite checks both with cats-eo's own PrismTests, and checks the round trips on every program it compiles, the 943 conformance programs included.

The square commutes. The semantic property is the one a round trip can't see: running toBytecode.getOption(s) gives what a reference evaluator gives for s. The demo checks exactly that, against the Scalus CEK machine, for every program in its test-suite (Correctness).

Back and forth

The UPLC to JVM round trip Top row, the optics: a script goes through toTyped to a typed expression, through lowering to its JVM class in memory, and through compiling to a class file; the middle arrow is an iso and the other two prisms, each walked back by reverseGet. Bottom row, the check: the class file is loaded and run, its value is discharged back to a UPLC term, and that term must be alpha-equivalent to the term the Scalus CEK machine computes from the same bytes. eo optics · CborScript ⇀ TypedProgram ≅ JvmClass ⇀ JvmClassScript script CborScript typed expr TypedProgram JVM class JvmClass class file JvmClassScript UPLC term CEK result must agree α-equal, or both fail UPLC term discharged runtime value closure, constant, … toTyped lowering compiling load + run discharge Scalus CEK machine forth along the top, back along the bottom

The listings in this section are printed, verbatim, by the demo's walkthrough (give it a directory and it also writes the class file there, for javap):

sbt "demoUplcJvm/Test/runMain dev.constructive.eo.demo.uplc.Demo [dir-for-the-class-file]"

Forth: a script to a typed expression (toTyped)

UPLC has no let, no if and no multi-argument call; compilers encode them in lambdas, delay/force and builtins. let x = 6 in let y = x * x in x + y arrives as:

(program 1.1.0 [(lam x [(lam y [(builtin addInteger) x y]) [(builtin multiplyInteger) x x]]) (con integer 6)])

toTyped.getOption checks the script's Plutus Core version, decodes it into Scalus' own Term tree and reads it back as a typed, Scala-like expression; toTyped.reverseGet is the script it was read from:

val v0: BigInt = 6
val v1: BigInt = multiplyInteger(v0, v0)
addInteger(v0, v1)

The typer (Typer.scala) recognises the encodings UPLC compilers emit and records them as what they mean:

Each rewrite is exact under the CEK machine's evaluation order: it only removes steps nothing can observe (building a closure that is applied at once, delaying a branch that is forced at once). Anything the typer doesn't recognise stays a dynamic application and runs exactly as the machine would run it.

Hybrid typing. UPLC is untyped, yet most of a program's scalars are provable locally — constants, typed builtin results, lets of those, branches that agree. A type is a Scalus DefaultUni, and a builtin's signature is Scalus' own TypeScheme for it, instantiated at its arguments' types — fstPair of the pair integer (list data) that unConstrData returns is an integer, so the case that matches a constructor tag on it compiles to a JVM switch. Integers, booleans, bytestrings and strings get Scala types (BigInt, Boolean, ByteString, String) and an unboxed JVM representation. Everything else — functions, constructors, Data, whatever flows through a lambda parameter — is a Value: a boxed Scalus Constant or a runtime closure.

On real code — the Fibonacci puzzle validator, written in Scala and compiled to UPLC by the Scalus plugin — the typed expression makes the program's structure readable again:

val v0: Value = builtin.fstPair.force.force
val v1: Value = builtin.headList.force
val v2: Value = builtin.sndPair.force.force
val v3: Value = builtin.tailList.force
val v4: Value = {
  val v4: Value = (v4: Value, v5: Value, v6: Value, v7: Value) =>
    if lessThanEqualsInteger(v5, 0) then v6
    else v4(v4, subtractInteger(v5, 1), v7, addInteger(v6, v7))
  v4(v4)
}
(v5: Value) =>
  val v6: Value = sndPair(unConstrData(v5))
  val v7: Value = unConstrData(headList(dropList(2, v6)))
  fstPair(v7) match
    case 0 => error()
    case 1 => if equalsInteger(Constr0(unIData(headList(tailList(v6))), 0, 1) match
        case 0 => v4, unIData({
        val v8: Value = unConstrData(headList(tailList(sndPair(v7))))
        fstPair(v8) match
          case 0 => headList(sndPair(v8))
          case 1 => error()
      })) then ()
      else error()
    case 2 => error()
    case 3 => error()
    case 4 => error()
    case 5 => error()

The self-recursive helper v4 is plain if … then … else v4(v4, …), and its recursive call is a direct JVM call; the unConstrData / sndPair / headList chains are the script context being decoded, and the matches on fstPair are switches on constructor tags. The one call into v4 goes through a value the typer cannot see into (v4(v4), a partial application), so it stays a dynamic case — fields first, then the branch — exactly as the machine runs it.

Down: lowering (lowering)

lowering.get (Lower.lower) writes bytecode straight into ASM's tree (ClassNode, with asm-commons' InstructionAdapter) and makes the JVM decisions on the way:

For the small program, the method run is all there is, as ASM prints it; K is the class's table of constants, indexed by UPLC node:

    GETSTATIC dev/constructive/eo/demo/uplc/generated/UplcProgram.K : [Ljava/lang/Object;
    BIPUSH 14
    AALOAD
    INVOKESTATIC dev/constructive/eo/demo/uplc/runtime/Rt.unInt (Ljava/lang/Object;)Lscala/math/BigInt;
    ASTORE 1
    GETSTATIC scalus/uplc/builtin/Builtins$.MODULE$ : Lscalus/uplc/builtin/Builtins$;
    ALOAD 1
    ALOAD 1
    INVOKEVIRTUAL scalus/uplc/builtin/AbstractBuiltins.multiplyInteger (Lscala/math/BigInt;Lscala/math/BigInt;)Lscala/math/BigInt;
    ASTORE 2
    GETSTATIC scalus/uplc/builtin/Builtins$.MODULE$ : Lscalus/uplc/builtin/Builtins$;
    ALOAD 1
    ALOAD 2
    INVOKEVIRTUAL scalus/uplc/builtin/AbstractBuiltins.addInteger (Lscala/math/BigInt;Lscala/math/BigInt;)Lscala/math/BigInt;
    INVOKESTATIC dev/constructive/eo/demo/uplc/runtime/Rt.boxInt (Lscala/math/BigInt;)Ljava/lang/Object;
    ARETURN

The constant is unboxed once, the arithmetic is Scalus' own, and the one box is at the result.

To a class file, and back (compiling)

compiling.getOption (UplcJvm.compile) hands the class's tree to ASM's ClassWriter, which computes the stack map frames and serialises it — unless a method is over 64 KB or the class over 65 535 constants, when getOption is None and compile says which; compiling.reverseGet (UplcJvm.decompile) is ASM's ClassReader. The class has a static method per lambda chain and delay, a switch per call arity for closure dispatch, and the script, re-decoded by the static initialiser. A class file survives being read and written again byte for byte because the generated code has no unreachable instructions — a function that can only fail is never applied, and a call whose argument can only fail after nothing but values is that failure — so ASM has no dead code to blank out:

compiling.getOption: 1844 bytes of class file
and back — toBytecode.reverseGet(that) == script: true

And back again: values to UPLC terms

A UPLC program can evaluate to a closure, and to compare such a result with the CEK machine's it must become a term again. The compiler numbers every UPLC node in pre-order; a runtime closure carries only the number of its source node, plus the values of its free variables. The class re-decodes the program it embeds, so the runtime can discharge a closure — rebuild its source node with every captured variable replaced by its own discharged value — exactly as the CEK machine discharges its results:

(program 1.1.0 [(lam x (lam y x)) (con integer 5)])

run + discharge: (lam i0 (con integer 5))
CEK machine:     (lam i0 (con integer 5))
α-equal:         true

Correctness: the CEK machine as oracle

The demo pins one semantics (Semantics.scala): Plutus V3 at protocol version 11, builtin semantics variant E — the configuration Scalus 1.3.0 runs the Plutus conformance suite under. Builtins without a direct implementation, and builtins used as values, run Scalus' own CEK meanings (BuiltinRuntime); a meaning that hands back one of its arguments (ifThenElse, chooseList, …) hands back the closure or constructor it was given.

sbt demoUplcJvm/test checks the commuting square — and, on every program it compiles, the round trips: the script comes back out of its class, and the class file survives ASM reading and writing it again — on:

suite what result
Plutus conformance the plutus-conformance UPLC evaluation cases, plutus 1.63.0.0 (vendored) 943 / 943 evaluation cases agree; 55 parser-only tests and 1 program with no flat encoding skipped
validators owner + greeting, hash preimage, time lock, 2-of-3 multisig, Fibonacci puzzle, HTLC — accepting and rejecting V3 transactions 20 / 20 scenarios reach the CEK machine's verdict, both compiled once and applied at run time and compiled with the context applied
compiler paths one hand-written program per path, error paths included, a 20 000-deep let chain all agree
optic laws cats-eo-laws' PrismTests for toTyped and toBytecode, over every validator, a program whose value is a closure, a script of an unknown Plutus Core version, and a program too large for a class file all laws hold
stack safety programs nested 100 000 deep — force (delay …), builtin calls, lambdas — typed, lowered and compiled on a 1 MB stack no overflow: a class file, or the JVM limit the class exceeds

The JVM's own verifier checks every generated class as it is loaded, and each conformance case's .expected file pins whether it should succeed or fail — so the harness cannot pass by having both evaluators fail everything. Both sides report in Scalus' types: the generated code fails with a MachineError, as the CEK machine does, and Exec.run returns an evaluation Result.

Benchmarks

JMH, in demos/uplc-jvm-bench. Both evaluators validate the same accepting transaction (setup checks that both accept it before anything is timed):

Average time per operation in µs (lower is better), error = 99.9 % confidence half-width; allocation per operation from -prof gc (gc.alloc.rate.norm). Measured on a 4-vCPU Intel Xeon (2.1 GHz) container, OpenJDK 21.0.12, Scalus 1.3.0, ASM 9.11, the demo built with -opt -opt-inline:**,!scala.**; three forks, 5 × 1 s measurement after 3 × 1 s warm-up (5 × 2 s for the Fibonacci scaling runs, whose slow operations took longer to reach steady state). A shared container is not a quiet benchmark machine: read the ratios, not the absolute values.

Validating a transaction

validator CEK machine (µs) generated class (µs) speed-up CEK alloc/op class alloc/op
helloOwner 4.57 ± 0.14 0.29 ± 0.01 15.7× 13.3 KB 1.0 KB
preimage 6.29 ± 0.35 0.55 ± 0.02 11.4× 17.8 KB 1.7 KB
timeLock 6.42 ± 0.48 0.29 ± 0.01 22.1× 17.7 KB 0.8 KB
multisig 13.3 ± 0.5 0.93 ± 0.05 14.4× 37.4 KB 2.6 KB
fibonacci 31.5 ± 1.3 0.48 ± 0.02 65.6× 76.9 KB 1.7 KB
htlc 8.37 ± 0.49 1.02 ± 0.04 8.2× 23.0 KB 2.9 KB

Most validators spend their time decoding the script context — unConstrData, headList, sndPair and friends. The CEK machine runs each one as a builtin meaning over CEK values, and unConstrData turns a constructor's whole field list into constants. The generated class calls the functions those meanings wrap directly, reads Data lazily — only the fields a script touches become constants — and matches constructor tags with JVM switches: 8–22× faster, with 8–22× less allocation. Where the work is the script's own logic, interpretation is nearly all of the cost: the Fibonacci puzzle (n = 30), a self-recursive function compiled to direct calls on unboxed integers, validates ~66× faster. Against the demo's first, raw-ASM version (same harness, an earlier run), validation is 2.9–7.1× faster.

Rejecting a transaction costs about what accepting one does (0.5–1.6 µs for the rejecting transactions in the test-suite). A failure leaves the generated code as a MachineError thrown without a stack trace — capturing one cost ~1.3 µs, more than validating — and Exec.run turns it into Scalus' Result.Failure. Returning a result value from every call instead would need a check after each one: measured, that leaves validation as fast and makes translation 20–35% slower.

Scaling with the work: the Fibonacci puzzle

n CEK machine (µs) generated class (µs) speed-up CEK alloc/op class alloc/op
10 11.9 ± 0.6 0.25 ± 0.01 47.7× 31.8 KB 0.9 KB
100 92.7 ± 3.0 1.87 ± 0.09 49.6× 235.1 KB 5.8 KB
1000 923.3 ± 78.6 42.3 ± 1.4 21.8× 2,356.0 KB 143.9 KB

The generated code does almost nothing but arithmetic, so the speed-up stays near 50× until the integers grow: at n = 1000 they reach ~700 bits, BigInt addition — identical in both evaluators — is most of the time, and the speed-up settles at ~22×.

What translation costs

validator script class file translate (µs) translate + load (µs) break-even (validations)
helloOwner 149 B 4,560 B 95.8 ± 7.6 487.6 ± 125.5 114
preimage 186 B 5,026 B 130.1 ± 18.0 592.6 ± 90.2 103
timeLock 183 B 5,100 B 126.7 ± 12.8 517.9 ± 71.6 84
multisig 202 B 6,233 B 175.1 ± 15.9 678.9 ± 80.5 55
fibonacci 126 B 4,342 B 81.6 ± 7.7 387.9 ± 57.5 13
htlc 310 B 6,665 B 221.9 ± 22.3 792.9 ± 166.2 108

Translation was measured again once compiling became stack safe (Limits), in a later session on the same kind of container; there, the version before measured 80–186 µs, so stack safety costs 13–19% of translate. translate is toBytecode.getOption, measured warm; translate + load adds defining the class, verifying it, and running its static initialiser, which re-decodes the embedded script. Loading is three quarters of the total. Most of translate is ASM's ClassWriter computing stack map frames; lowering straight into ASM's tree keeps the rest small, and translation costs about 15% more than the demo's first, raw-ASM emitter did — the price of stack safety — and about 40% of what a version built on a higher-level bytecode library (Cojen Maker) cost. Decoding the script is a few microseconds of it: the numbering is a pure fold over Scalus' Term tree, 2–3× slower than writing into arrays, and paid once per class. The break-even column divides translate + load by the time each validation saves in the first table: a script must be validated ~13–115 times before compiling it pays for itself. That fits scripts that run over and over — a node re-validating the same reference scripts, an emulator, a test-suite — rather than one-off evaluation.

Reproduce with sbt "demoUplcJvmBench/Jmh/run -prof gc".

Limits

Scalus has its own experimental JIT (scalus-uplc-jit-compiler, built on Scala 3 staging). This demo is independent of it; its point is the shape — one optic from bytes to bytes, a typed middle you can read — and a differential harness that keeps it honest.

Running it

# the differential suite
sbt demoUplcJvm/test
# the walkthrough above
sbt "demoUplcJvm/Test/runMain dev.constructive.eo.demo.uplc.Demo"
# the benchmarks
sbt "demoUplcJvmBench/Jmh/run -prof gc"
# re-vendor the conformance suite
demos/uplc-jvm/scripts/refresh-conformance.sh [plutus-tag]

The demo and its benchmarks sit outside the root aggregate (Scalus brings JNI crypto bindings with it); CI runs the differential suite as its own step.