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:
toTyped. A script declares the Plutus Core version it is written in, and the bytes can name any version; Scalus decodes them all. The ledger takes only the versions Plutus V3 has at protocol version 11 — 1.0.0 and 1.1.0, which addsconstrandcase— so a script of any other version has no typed program.compiling. The JVM has limits a program can exceed: a method holds at most 64 KB of bytecode and a class at most 65 535 constants, so a class can be too large for a class file — and then there is none.
| 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 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:
let— an immediately applied lambda chain,(λx1 … λxn. body) a1 … an;if—force (ifThenElse c (delay a) (delay b)), and the same idiom forchooseList,chooseUnitandchooseData;- builtin calls — a builtin applied to exactly its forces and arguments;
- direct calls — of
let-bound functions and oflet-bound, already forced builtins; - recursion —
let f = λself … in … [f f] …, the knot Scalus ties for a recursive function: whenfandselfare only ever applied to themselves,selfalways holds the function, and its calls are direct calls too; - multi-argument calls —
case (constr k a1 … an) [… f …], the shape the Scalus compiler gives a call with several arguments (onecasestep applies them all), whenfis a lambda or a known function; force (delay t)— justt.
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:
- Closure conversion. Each lambda chain or
delaybecomes a static method; a closure captures exactly its free variables, in an order fixed by the source program, so the runtime can read the layout back. - Representation. Typed values stay in unboxed locals and on the operand stack; boxing happens only where typed code meets dynamic code.
- Direct calls. A saturated builtin is a plain call of the function Scalus'
own
CardanoBuiltinswraps (scalus.uplc.builtin.Builtins), on unboxed operands where its signature allows; calls of known functions areinvokestatics of their body methods. - Method size. Once a method has inlined enough code, the next
letbody moves to a method of its own (the JVM caps a method at 64 KB of bytecode). - The script, embedded. The static initialiser holds the script as Base64
string constants;
lowering.reverseGetreads them back out of the class.
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):
- CEK machine — Scalus'
PlutusVM.evaluateScripton the script applied to its context, with no budget accounting (NoBudgetSpender). The generated code meters nothing either; with the restricting budget spender the ledger uses, the CEK machine would do a little more work per step. - generated class — compiled and loaded once, in setup. Each operation evaluates the script and applies it to the context. The compiler does no optimisation (Limits): the same operations run, as JVM code.
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
- No optimisation. The compiler translates UPLC; it does not optimise it.
There is no constant folding, inlining, dead-code elimination,
common-subexpression elimination, specialisation or tail-call elimination:
every operation the program performs still happens at run time, in the order
the CEK machine performs it. (The demo's own Scala code is another matter: it
is built with Scala 3's bytecode optimizer,
-opt, inlining from everything but the Scala library.) What the typer recognises —let,if, known calls — are encodings it reads back, not changes to the program. The speed-ups above come from the translation alone: JVM bytecode under the JIT, with values unboxed and calls made directly where the types allow. - No budget metering. Results and failures are compared, execution budgets are not, so an out-of-budget script — which the ledger rejects — is accepted here. Metering the same machine-step costs the CEK machine charges is the natural next step.
- The JVM stack. Compiling cannot overflow it, however deep the program:
decoding, typing and lowering make plain calls while they are shallow, and
from 128 calls deep they continue on the heap, through
cats.Eval(trampolining every node instead made translation twice as slow). Scalus' flat decoder and encoder, which reading and writing a script go through, do recurse: on a 1 MB stack they overflow between 2 000 and 20 000 levels deep, depending on how warm the JIT is. Generated code recurses on the JVM stack where the CEK machine uses the heap;Execruns evaluations on a thread with a 512 MB stack. - Method splitting happens at
letboundaries only — the shape compilers produce — not inside one huge expression. A class that still exceeds a JVM limit has no class file: that is whycompilingis a prism.
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.