Skip to content

JuLC Compiler Developer Guide

A comprehensive guide for developers who maintain, debug, or extend the JuLC compiler — the Java-to-UPLC compiler for Cardano smart contracts.

This guide covers the internals of the JuLC compiler — the component that transforms a Java source subset into Untyped Plutus Lambda Calculus (UPLC) programs that run on the Cardano blockchain. It is intended for contributors who need to fix bugs, add language features, register new builtins, or understand compilation behavior.

  • Java 21+ features: sealed interfaces, records, pattern matching
  • Basic lambda calculus: application, abstraction, substitution, De Bruijn indices
  • Familiarity with the Cardano transaction model (UTxOs, validators, datums, redeemers)
Guide Audience
docs/getting-started.md First-time users writing validators
docs/api-reference.md On-chain API reference
docs/stdlib-guide.md Standard library usage
docs/library-developer-guide.md Writing @OnchainLibrary modules
docs/for-loop-patterns.md Loop compilation patterns
docs/performance-guide.md Script size and cost optimization
docs/troubleshooting.md Common errors and solutions
docs/advanced-guide.md Low-level programming patterns
docs/type-method-compilation-comparison.md Type method compilation details

2. Conceptual Background: UPLC, PIR, and Data Encoding

Section titled “2. Conceptual Background: UPLC, PIR, and Data Encoding”

Untyped Plutus Lambda Calculus (UPLC) is the on-chain language that Cardano nodes execute. It is a minimal lambda calculus with 10 term variants, defined in julc-core/.../Term.java:

# Variant Description
1 Var(NamedDeBruijn name) Variable reference (De Bruijn indexed)
2 Lam(String paramName, Term body) Lambda abstraction
3 Apply(Term function, Term argument) Function application
4 Force(Term term) Force evaluation of a delayed (polymorphic) term
5 Delay(Term term) Delay evaluation (create a thunk)
6 Const(Constant value) Constant value (integer, bytestring, string, bool, unit, data)
7 Builtin(DefaultFun fun) Reference to one of 101 released builtins or the retained future tag 101
8 Error() Halt evaluation (transaction fails)
9 Constr(long tag, List<Term> fields) Constructor application (Plutus V3, SOPs)
10 Case(Term scrutinee, List<Term> branches) Case/pattern matching (Plutus V3, SOPs)

De Bruijn indexing. Variables are referenced by how many lambda binders you must cross to reach the binding site. Index 1 means “the immediately enclosing lambda”:

\x -> \y -> x ≡ Lam("x", Lam("y", Var(2)))
^^^ cross y(1) then x(2)

Force/Delay. UPLC is strict (call-by-value). Polymorphic builtins like IfThenElse need Force to instantiate type variables, and branches need Delay to prevent premature evaluation:

-- if True then "yes" else "no"
Force(Apply(Apply(Apply(Force(Builtin(IfThenElse)), Const(True)),
Delay(Const("yes"))),
Delay(Const("no"))))

Plutus Intermediate Representation (PIR) is the typed bridge between Java and UPLC. It has 15 term variants defined in julc-compiler/.../pir/PirTerm.java:

# Variant Description
1 Var(String name, PirType type) Named, typed variable
2 Let(String name, PirTerm value, PirTerm body) Let binding
3 LetRec(List<Binding> bindings, PirTerm body) Recursive let (for loops, self-recursion)
4 Lam(String param, PirType paramType, PirTerm body) Typed lambda
5 App(PirTerm function, PirTerm argument) Application
6 Const(Constant value) Constant
7 Builtin(DefaultFun fun) Builtin function
8 IfThenElse(PirTerm cond, PirTerm then, PirTerm else) Conditional
9 DataConstr(int tag, PirType type, List<PirTerm> fields) Data constructor
10 DataMatch(PirTerm scrutinee, List<MatchBranch> branches) Pattern match
11 Error(PirType type) Typed error
12 Trace(PirTerm message, PirTerm body) Trace debug output
13 ListMatch(scrutinee, headName, tailName, nilBranch, consBranch) Native list match; raw head/tail bindings scoped to cons branch
14 PairMatch(scrutinee, pairType, firstName, secondName, body) Proven native pair; raw first/second bindings scoped to body
15 IntegerCase(scrutinee, branches) Generator-local: branch i for native integer scrutinee i, no binders; created only during DataMatch lowering (ADR-041)

ListMatch performs no Data decoding. The for-each producer retains its NullList representation guard and places the match only in the non-empty branch, preserving failures for unchecked non-list casts. Head/tail binding must not move partial element decoders or loop effects. PV11 UPLC branch order is cons first, nil second, with the cons branch applied to head then tail. The guard makes the emitted nil branch unreachable; it is Error. Map/native pair loops and unconditional-break loops without a tail use retain their existing form. NONE/BASELINE produce the historical PIR directly. See ADR-034 for the eligibility and validation contract.

PairDestructuringPass runs immediately before UPLC generation in all compiler entry points. It replaces a strict Let p = UnConstrData(d) only when every use of that lexical binding is a correctly typed direct FstPair(p) or SndPair(p), with both projections present. This covers constructor decomposition in strict record, sum, Boolean and Optional boundaries. UnConstrData remains strict and runs once; PairMatch binds raw tag and fields, leaving all partial decoders, traces and branches in place. The one UPLC branch takes first then second. Aliases, escaping pairs, one-sided projections, unproven producers and Data-encoded pair-like values are excluded. The pass and lowering both require the exact PV11 target and a safe optimization level; NONE/BASELINE retain their historical bytes. Rule provenance is pv11.o4.case-pair (ADR-036).

ADR-038 also constructs PairMatch directly inside generateDataMatch, after preserving the strict once-bound Data scrutinee. This producer proves the native pair by its own UnConstrData; the earlier proof pass cannot see this late expansion. Tag dispatch and branch-local field decoding remain unchanged. The dispatch binders are reserved # names chosen against every branch (ADR-060), so no branch can refer to them; the former fallback for a direct-PIR reference to __match_pair is gone. The producer and PairMatch consumer share the exact O4 gate and rule identity. This changes safe-profile direct-PIR output as well as source switches.

ValueConversionSharingPass (ADR-042, O8) runs just before PairDestructuringPass at the same three entry points. For a scope in which one variable is converted to a native Value at least twice (Builtins.unValueData(x) or NativeValueLib.fromData(x), outside any rebinding of x), it binds the conversion once, let #value-N = C(x) in …, exactly when that conversion is the first non-trivial evaluation on every path through the scope: function position before argument, a Let value before its body, a condition or scrutinee before its branches, with variables, constants, lambdas and under-saturated builtin or once-bound library-lambda spines counted as trivial. Branch bodies, traces, errors, recursive bindings and lambdas never lead, so nothing is hoisted across them and a binding never wraps a lambda. The shared conversion already ran first on every path, so results, traces, failure points and failure text are unchanged; a path that reaches no second occurrence pays one lambda, one application and one variable lookup. Gate: the exact PV11 target and a safe level as for O4/O5, but on VALUE_CONSTANTS instead of CASE_ON_BUILTIN_CONSTANTS; rule provenance is pv11.o8.value-sharing; NONE/BASELINE keep their bytes.

ListIndexPromotionPass (ADR-043, O9) runs between the two passes above and is the first costed-only rule: its gate is the exact PV11 target, OptimizationLevel.pv11CostedRulesEnabled() (the opt-in PV11_COSTED level, independent of cost profiles), ARRAY_CONSTANTS, and both array builtins on the resolved feature profile. For a Let, lambda-parameter or match-field binding of a list variable whose scope holds the recursive JulcList.get lowering (PirHelpers.recursiveListGet, recognised structurally through the shared PirHelpers.RECURSIVE_LIST_GET binding) at two or more sites, or at one site inside a recursive binding value (a loop body or an inlined list-operation callback), it rewrites those sites to IndexArray(a, i) and inserts let a = ListToArray(xs) at the innermost sub-term that contains every use of a and is evaluated at most once per evaluation of the scope: never inside a recursive binding value, and inside a lambda body only along the parameter chain that directly follows the binder. Sites under a rebinding of the variable belong to the inner binding; the loop lowering’s post-loop let xs = xs self-alias is transparent. Only a proven binding is promoted: a Let whose value is a list by construction under the proven-name environment at its binder (producesList: a decode, a list builtin, a proven variable or a full call of a list-returning method whose body the analysis proved), a list-typed match field, a list-match tail, a root parameter, a parameter of a lambda chain applied on the spot whose argument is proven (the validator handler under its method-binding lets), or a method parameter that call-site provenance proves. That analysis runs over the whole term before the rewrite: a parameter of a Let/LetRec-bound lambda chain is proven only while every use of the method’s name is a call that passes it a list by construction (fewer arguments or a non-call use refute what they leave unbound), and a list-typed result only while the body produces a list under that environment; both start optimistic and are refuted to a greatest fixpoint, so recursive helpers and loops that hand their own list or its tail back to themselves keep their proof. A callback lambda’s parameter is never trusted: the list operations apply callbacks to raw Data elements. A cast to JulcList lowers to its Data-typed inner term, so the local it binds is unproven and so is every alias of it, including the loop lowering’s post-loop let xs = xs, and so is every helper parameter or result such a value reaches. ListToArray is total and pure on a list, so placement is a budget decision; an out-of-range index fails at IndexArray instead of inside the traversal (the ADR-043 failure contract, costed profile only). Rule provenance is pv11.o9.list-to-array; NONE/BASELINE/PV11_SAFE keep their bytes, and MultiIndexArray is never emitted.

ADR-044 (O15) generalises ValueConversionSharingPass from one unit class to three, under the same leading rule. A unit is a closed, deterministic computation of one variable x: the native Value conversion of ADR-042; a record field projection D(headList(tailList^k(sndPair(unConstrData(x))))) where D is exactly one PirHelpers.wrapDecode arm (none, unIData, unBData, unListData, unMapData, the Bool form equalsInteger(fstPair(unConstrData(·)), 1) or the String form decodeUtf8(unBData(·))), matched outermost arm first so the raw chain inside a decode is never a unit of its own; and the fields prefix sndPair(unConstrData(x)). Two occurrences are the same unit when the root variable’s name, the depth and the arm agree. Rounds run field chains to a fixed point (a shared inner projection is a variable and roots the outer chains of b.inner().x() / b.inner().y()), then the prefix over whatever distinct chains remain, then Value conversions, which may now apply to a shared projection. Two refinements apply to every class: the body of a single recursive binding of a lambda can lead (building the closure is a fixed number of pure steps, the fact the UPLC optimiser also relies on; the per-site JulcList.get lowering wraps each site this way; the multi-binding lowering never leads), and units inside a lambda binding the live program never references are neither counted nor rewritten, so a repeat that sits only in an uncalled helper records no provenance (every uncalled library method is such a binding; a unit shared in a live helper the optimiser later inlines still records). A matched unit is a leaf for the collector and the rewriter alike: the raw chain inside a decode belongs to the decode, so a field used both raw and decoded in one scope shares each form as its own unit. Root-level projections of an entrypoint’s own record parameters are already bound once by the strict boundary, and a switch pattern variable’s fields reuse the match binders, so neither produces chains; nested projections (ctx.txInfo(), txInfo.outputs() on a local or parameter, loop items, cast locals) do. Gate: the exact PV11 target and a safe level, no capability (the builtins predate PV11; the gate is rollout policy); provenance pv11.o15.projection-sharing. Because O15 binds a projected list once, ADR-043 sees that binding as a proven list and promotes b.items().get(i) sites at the costed profile that it could not reach before.

ADR-045 (O14) adds ValueLiteralFoldPass, which runs first among the PIR-to-PIR passes (its machinery is the abstract LiteralFoldPass, shared with ADR-046’s ArrayLiteralFoldPass below). Builtins.emptyValue() lowers to a UPLC Value constant, typed NativeValueType, gated by the VALUE_CONSTANTS capability; Builtins.singletonValue(...) and lovelaceValue(...) inline InsertCoin into it (intrinsics, so a program that does not call them is unchanged: a library source method would be compiled and bound whether called or not). The pass folds a saturated call of one of the seven Value builtins whose arguments are all literals (constants, or names bound once by a Let to a literal) into the literal it evaluates to, spelled as the bare builtin or through a once-bound wrapper whose body is that builtin over its parameters and constants (every NativeValueLib method). The result comes from NativeValueSemantics in julc-core, the same code ValueBuiltins runs in the VM: a call the semantics reject stays exactly as written, so failure text and failure point are untouched; a trace, an error or a runtime variable in argument position blocks the fold. A wrapper must use every parameter and every call-site argument must be a literal, used or not (the strict application evaluates them all). Objective: the literal’s FLAT encoding must not be longer in bits than the term it replaces, measured as it stands in the artifact: the builtin or, for a wrapper call, the wrapper variable, applied to the call-site arguments, a constant counting as itself and a literal local as a variable reference (its constant lives once in its binding). A local bound directly to a constant whose every remaining occurrence the call consumes dies with the fold and the optimizer drops its binding, so it counts as its constant once; a local still used elsewhere, and an alias of a local (its binding holds only a reference), count as a reference, so a call that would copy the constant stays (toData(emptyValue()), fromData of a Data literal with five or more tokens, a user wrapper that carries constants in its body and a literal scale of a local shared with another call therefore stay). Gate: exact PV11 target, safe level, the capability, the switch; provenance pv11.o14.value-literal-fold. No program compiled before ADR-045 contains a Value or Data literal argument to these builtins, so the rule is additive.

ADR-046 (O10) adds ArrayLiteralFoldPass, the second domain of LiteralFoldPass, run right after the Value domain. JulcArray.of(a, b, ...) lowers in the registry to ListToArray over the JulcList.of list literal (MkCons over wrapEncoded elements; ListToArray is the requirement, PV11 only). The pass reads a list literal whose elements are constants wrapped exactly as wrapEncode wraps them (or a once-bound local holding one) to a list data constant and folds ListToArray of it to an array constant, LengthOfArray of an array constant to its length and IndexArray at a literal index to the element, by ArraySemantics in julc-core (shared with the VM’s ArrayBuiltins); an out-of-range literal index stays and fails at runtime with the builtin’s text. Because get wraps the access in a decode, the pass also folds UnIData/UnBData/UnListData/UnMapData/UnConstrData/FstPair/ DecodeUtf8 and the Bool form’s EqualsInteger only over constants it produced (an identity set), never over a Data constant that was in the program already; a mismatched element keeps its decode. Gate: exact PV11 target, safe level, the ARRAY_CONSTANTS capability, the switch; provenance pv11.o10.array-literal-fold; additive for existing programs.

ADR-047 (O11) adds no pass; it types the BLS12-381 surface. PirType gains NativeG1Type, NativeG2Type, NativeMlResultType and NativeListType(elem) (elem is IntegerType, NativeG1Type or NativeG2Type), all native-opaque like NativeValueType, so the O7 isolation rules (JULC0041/JULC0042), the blueprint schema, the strict boundary and the verification projection treat them the same way; the Java markers are JulcG1, JulcG2, JulcMlResult, JulcScalars, JulcG1Points, JulcG2Points in julc-core. TypeInferenceHelper types every BLS builtin by its group (compress is the only way to a byte string, uncompress the only way back) and a BLS or native-list constant by its universe. The registry’s nativeArgumentSignature table says which native type each argument of a Builtins method must have (Values, points, Miller results, native lists), and validateNativeValueArguments rejects anything else at compile time with the expected type in the message (requires G1, requires NativeList[Integer]); the variadic producers check every element (requires Integer for scalars), the converters check the Data list’s static element type, and PirGenerator.checkNativeBoundary applies the same native comparison to initializers, same-class helper arguments, return expressions against the declared return type (a lambda’s own return is exempt), the else branch of a conditional against its then branch, and, through LoopBodyGenerator, declarations inside a loop body and assignments to accumulators and loop-body locals (a bare nested block is spliced into the loop body with its own declarations renamed apart, which is the lowering of the braceless body byte for byte; an assignment the loop body generators do not bind, in expression position or to an undeclared name, is rejected by the expression generator rather than lowered to its right-hand side), so no route inside a class launders a point into byte[] or Data. The native lists for bls12_381_G1_multiScalarMul and its G2 form come only from the Builtins intrinsics scalars/g1Points/ g2Points (a list constant when every element is a constant, else MkCons over the empty native list constant, bound once so inference reads the list type off the Var) and scalarsFromList/g1PointsFromCompressed/ g2PointsFromCompressed (a LetRec decoding loop applied to the Data list outside the loop’s own binding, so a user variable named like the loop is never captured, decoding each element with UnIData, or UnBData then uncompress); no Data encoder can appear between an element and its list. MSM semantics are the VM’s: all scalars validated first, zip to the shorter list, empty sum is the identity. No fusion of scalarMul/add chains exists (the crossover is seven points on the pinned profile, ADR-047); the producers are intrinsics so programs that do not use them keep their bytes.

Each PIR-to-PIR rule can be switched off on its own: CompilerOptions.disableOptimizationRule(id) for pv11.o8.value-sharing, pv11.o15.projection-sharing, pv11.o9.list-to-array, pv11.o14.value-literal-fold and pv11.o10.array-literal-fold (CompilationContext.switchableOptimizationRules()); any other identifier fails with JULC0043 before compilation, and the rules implemented inside UplcGenerator/UplcOptimizer are selected by the level only. The switch exists so that a rule can be reviewed and measured in isolation (the O9 golden suite pins its bytes with O15 off, the benchmark compares a level with a rule off against the same level with it on); the level remains the supported way to choose a rollout, and the Gradle plugin and CLI do not expose the switch.

ADR-041 replaces the tag dispatch itself under the same gate. When a DataMatch has two or more constructors, generateDataMatch emits a generator-local IntegerCase on the decoded tag binder instead of the EqualsInteger/IfThenElse chain; it lowers to one UPLC Case with one argument-free branch per constructor in tag order, so branch i runs for tag i and any other tag fails at selection without evaluating a branch. Single-constructor matches are unchanged. Rule provenance is pv11.o5.case-integer. The strict typed boundary rejects invalid tags before user dispatch, so the only observable change for validators is the smaller, flat dispatch cost; direct-PIR consumers see the VM’s Case out-of-range failure text instead of the chain’s error term on a malformed tag.

Run ./gradlew :julc-compiler:pairCaseTest for the dedicated Java/Truffle/Scalus pair suite. It is included in check/build; its additional Truffle dependency does not change default VM selection in the existing compiler tests.

Side-by-side compilation example:

// Java
int x = 5;
int y = x + 3;
return y;
// PIR
Let("x", Const(Integer(5)),
Let("y", App(App(Builtin(AddInteger), Var("x", IntegerType)), Const(Integer(3))),
Var("y", IntegerType)))
// UPLC (Let → Apply(Lam, val), names erased to De Bruijn)
Apply(Lam("x",
Apply(Lam("y", Var(1)),
Apply(Apply(Builtin(AddInteger), Var(1)), Const(Integer(3))))),
Const(Integer(5)))

On the Cardano ledger, all values are encoded as Data — a universal representation with 5 constructors:

Constructor Encodes Encode builtin Decode builtin
Constr(tag, fields) Records, sealed interfaces, booleans, Optional ConstrData UnConstrData
Map(pairs) Map<K,V> MapData UnMapData
List(items) List<T> ListData UnListData
I(integer) int, long, BigInteger IData UnIData
B(bytestring) byte[], hash types, TokenName BData UnBData

The compiler inserts encode/decode wrappers at type boundaries. The two key helpers in PirHelpers.java:

wrapDecode(PirTerm data, PirType targetType) — Extract a typed value from raw Data:

  • IntegerType → UnIData(data)
  • ByteStringType → UnBData(data)
  • ListType → UnListData(data)
  • MapType → UnMapData(data)
  • BoolType → EqualsInteger(FstPair(UnConstrData(data)), 1) (tag 1 = True, 0 = False)
  • StringType → DecodeUtf8(UnBData(data))
  • DataType, RecordType, SumType → pass through (already Data)

wrapEncode(PirTerm value, PirType type) — Wrap a typed value back into Data:

  • IntegerType → IData(value)
  • ByteStringType → BData(value)
  • BoolType → IfThenElse(value, ConstrData(1,[]), ConstrData(0,[]))
  • StringType → BData(EncodeUtf8(value))
  • ListType → ListData(value)
  • MapType → MapData(value)
  • DataType, RecordType, SumType → pass through

Boolean encoding: Booleans map to Constr(1, []) (True) and Constr(0, []) (False) — matching the Haskell convention.

Record encoding: A record Point(int x, int y) with x=10, y=20 encodes as:

Constr(0, [I(10), I(20)])

Sealed interface encoding: Variants get ascending tags:

sealed interface Shape {
record Circle(int radius) implements Shape {} // tag 0
record Rect(int w, int h) implements Shape {} // tag 1
}
// Circle(5) → Constr(0, [I(5)])
// Rect(3,4) → Constr(1, [I(3), I(4)])

┌────────────────┐
│ julc-bom │ (Bill of Materials)
└────────────────┘
┌─────────────┐ ┌────────────────┐ ┌──────────────────┐
│ julc-core │ ◄── │ julc-compiler │ ──► │ julc-stdlib │
│ (UPLC AST) │ │ (Java→UPLC) │ │ (StdlibRegistry) │
└──────┬──────┘ └───────┬────────┘ └──────────────────┘
│ │
┌──────┴──────┐ ┌──────┴────────┐ ┌──────────────────┐
│ julc-vm │ │julc-ledger-api│ │ julc-onchain-api │
│ (VM SPI) │ │ (ScriptCtx) │ │ (Annotations) │
└──────┬──────┘ └───────────────┘ └──────────────────┘
│
┌──────┴──────────┐
│ julc-vm-scalus │
│ (Scalus backend)│
└─────────────────┘
┌─────────────────┐ ┌──────────────────────┐ ┌────────────────────┐
│ julc-testkit │ │ julc-annotation-proc │ │ julc-gradle-plugin │
│ (Test framework)│ │ (Build-time compile) │ │ (Gradle integration│
└─────────────────┘ └──────────────────────┘ └────────────────────┘
┌─────────────────┐ ┌──────────────────────┐ ┌────────────────────┐
│ julc-examples │ │ julc-cardano-client │ │ julc-e2e-tests │
│ (Sample code) │ │ (CCL integration) │ │ (E2E tests) │
└─────────────────┘ └──────────────────────┘ └────────────────────┘
┌─────────────────┐
│ julc-plugin-test│
│ (Plugin tests) │
└─────────────────┘
Module Role
julc-core UPLC AST (Term.java), constants, DefaultFun enum (101 released builtins plus one future tag), FLAT/CBOR serialization, Program wrapper
julc-compiler The compiler itself — parsing, validation, type resolution, PIR generation, loop desugaring, pattern matching, UPLC lowering, optimization
julc-stdlib StdlibRegistry — PIR term builders for ~65 stdlib methods (builtins, HOFs, math)
julc-vm VM SPI interface — pluggable execution backend
julc-vm-scalus Scalus-based VM implementation (evaluates UPLC programs)
julc-ledger-api Java records for Cardano ledger types (TxInfo, ScriptContext, TxOut, etc.)
julc-onchain-api Annotations (@SpendingValidator, @MintingValidator, @Entrypoint, @Param, @OnchainLibrary) + off-chain stubs for stdlib classes
julc-testkit ValidatorTest base class, SourceDiscovery for test-time compilation
julc-annotation-processor Java annotation processor — compiles validators at build time
julc-gradle-plugin Gradle plugin wrapping the annotation processor
julc-cardano-client-lib Integration with cardano-client-lib v0.7.1
julc-examples Example validators and library code
julc-e2e-tests End-to-end integration tests (CIP-113, etc.)
julc-plugin-test Tests for the Gradle plugin
julc-bom Bill of Materials for dependency management

Internal Package Structure of julc-compiler

Section titled “Internal Package Structure of julc-compiler”
org.julclang.compiler/
├── JulcCompiler.java # Main pipeline orchestrator
├── LibraryCompiler.java # Library compilation sub-pipeline
├── CompileResult.java # Compilation result record
├── CompilerException.java # Fatal compiler error
├── CompilerOptions.java # Compilation options
├── LibrarySourceResolver.java # Classpath scanning + transitive resolution
├── codegen/ # ValidatorWrapper, strict boundary checks
├── desugar/ # LoopDesugarer, PatternMatchDesugarer
├── error/ # CompilerDiagnostic, DiagnosticCollector
├── pir/ # PIR generation subsystem
│ ├── PirGenerator.java # Core PIR generation
│ ├── LoopBodyGenerator.java # Loop body compilation
│ ├── AccumulatorTypeAnalyzer.java # Accumulator type analysis
│ ├── TypeInferenceHelper.java # Read-only type inference
│ ├── TypeMethodRegistry.java # Instance method dispatch
│ ├── PirHelpers.java # wrapDecode/wrapEncode + utilities
│ ├── PirHofBuilders.java # HOF PIR builders
│ ├── PirTerm.java, PirType.java # PIR AST + type system
│ └── StdlibLookup.java, CompositeStdlibLookup.java # Stdlib resolution
├── resolve/ # TypeResolver, TypeRegistrar, SymbolTable,
│ # LedgerSourceLoader, LibraryMethodRegistry
├── uplc/ # UplcGenerator, UplcOptimizer
└── validate/ # SubsetValidator

The PIR type system is defined as a sealed interface in julc-compiler/.../pir/PirType.java with 13 variants:

Primitive types (6):

Variant Java types On-chain
IntegerType int, long, BigInteger Arbitrary-precision integer
ByteStringType byte[], PubKeyHash, TxId, PolicyId, TokenName, etc. Raw bytes
StringType String UTF-8 text
BoolType boolean, Boolean Constr(0/1)
UnitType void Unit constant
DataType PlutusData, ConstrData, MapData, ListData, IntData, BytesData Raw untyped Data

Container types (4):

Variant Fields Java type
ListType(PirType elemType) Element type List<T>
PairType(PirType first, PirType second) First, second Internal (pair tuples)
MapType(PirType keyType, PirType valueType) Key, value types Map<K,V>
OptionalType(PirType elemType) Element type Optional<T>

Function type (1):

Variant Fields Use
FunType(PirType paramType, PirType returnType) Param, return Method signatures (curried)

Algebraic data types (2):

Variant Fields Java type
RecordType(String name, List<Field> fields) Name, fields record declarations
SumType(String name, List<Constructor> ctors) Name, constructors sealed interface with record variants

Helper records:

  • Field(String name, PirType type) — record field
  • Constructor(String name, int tag, List<Field> fields) — sum type variant

Defined in TypeResolver.resolve(Type):

Java Type PIR Type Notes
boolean BoolType
int, long IntegerType
void UnitType
var DataType Placeholder; PirGenerator infers actual type from initializer
byte[] ByteStringType Special-cased array type
BigInteger IntegerType
String StringType
Boolean BoolType Boxed
PlutusData, ConstrData, MapData, ListData, IntData, BytesData DataType All raw data types
List<T> ListType(resolve(T)) Generic
Map<K,V> MapType(resolve(K), resolve(V)) Generic
Optional<T> OptionalType(resolve(T)) Generic
JulcArray<T> ArrayType(resolve(T)) PV11 only; O(1) random access (CIP-138)
PubKeyHash, ScriptHash, ValidatorHash, PolicyId, TokenName, DatumHash, TxId ByteStringType Ledger hash types
StakingCredential, ScriptPurpose, Vote, Voter, DRep, Delegatee, GovernanceActionId, GovernanceAction, ProposalProcedure, TxCert, Rational, ProtocolVersion, Committee DataType Opaque ledger types
Registered records RecordType(...) User-defined or ledger records
Registered sealed interfaces SumType(...) User-defined or ledger sum types

Types must be registered before they can be resolved. Registration happens in two places:

1. LedgerTypeRegistry.registerAll(TypeResolver) — Pre-registers all Cardano ledger types in 4 tiers:

Tier Types Examples
Tier 1: Simple leaf records Records with only primitive fields TxOutRef, IntervalBound, Value
Tier 2: Sealed interfaces Sum types Credential, OutputDatum, ScriptInfo, IntervalBoundType
Tier 3: Composite records Records referencing Tier 1/2 types Address, TxOut, TxInInfo, Interval
Tier 4: Top-level types Types referencing Tier 3 TxInfo (16 fields), ScriptContext (3 fields)

2. TypeRegistrar.registerAll(List<CompilationUnit>, TypeResolver) — Registers user-defined types via topological sort:

  1. Collect — Scan all compilation units for record and sealed interface declarations
  2. Validate — Check for duplicate type names across compilation units
  3. Build dependency graph — Record fields of type OtherRecord create a dependency edge
  4. Topological sort (Kahn’s algorithm):
    • Compute in-degree for each type
    • Emit types with in-degree 0 (no dependencies)
    • Decrement dependents, repeat
    • If remaining types exist after queue empties → cycle detected, throw CompilerException
  5. Register — Call typeResolver.registerRecord() or registerSealedInterface() in dependency order

DataType is the “untyped” PIR type. The compiler uses it when:

  • The Java type is PlutusData or any of its subtypes
  • The Java type is an opaque ledger type (e.g., StakingCredential)
  • The Java type is var and the initializer type cannot be inferred
  • The type is unknown to the resolver

Pitfalls:

  • DataType values pass through encode/decode unchanged — no type checking
  • Cross-method calls may infer DataType where a specific type was expected (see Known Limitations, Section 25)
  • @Param fields are always raw Data at runtime regardless of declared Java type

The compilation pipeline is orchestrated by JulcCompiler.compile(). Here is the complete flow:

Java Source(s)
│
▼
┌─────────────────────────────────────┐
│ 1. Parse │ JavaParser → CompilationUnit ASTs
│ 2. Validate │ SubsetValidator rejects unsupported constructs
│ 3. Library Check │ Ensure libraries don't contain validator annotations
│ 4. Annotated Class Discovery │ Find class with a supported validator annotation
│ 5. Script Purpose Detection │ Map annotation → SPENDING/MINTING/WITHDRAW/...
│ 6. Type Registration │ LedgerTypeRegistry + TypeRegistrar (topo sort)
│ 7. @Param Field Detection │ Find @Param-annotated fields
│ 8. Static Field Detection │ Find static fields with initializers
│ 9. Entrypoint Discovery │ Find @Entrypoint method
│ 10. Parameter Validation │ Check param count (2 or 3 for spending, 2 others)
│ 11. Library Compilation │ Multi-pass compile library methods to PIR
│ 12. Compose Lookups │ CompositeStdlibLookup(stdlib, libraries)
│ 13. Symbol Table Setup │ Define @Params, static fields, static methods
│ 14. Helper Method Generation │ Generate PIR for all static helper methods
│ 15. Entrypoint Generation │ Generate PIR for the @Entrypoint method
│ 16. Helper Wrapping │ Wrap helpers as Let bindings
│ 17. Static Field Wrapping │ Wrap static field initializers as Let bindings
│ 18. Library Wrapping │ Wrap library methods as Let/LetRec (topo sorted)
│ 19. Validator Wrapping │ Add ScriptContext decoder + bool→unit/error
│ 20. @Param Wrapping │ Add outer lambda per @Param (Data → decoded)
│ 21. UPLC Generation │ UplcGenerator: PIR → UPLC
│ 22. Optimization │ UplcOptimizer: 6 passes, fixpoint
│ 23. Program Creation │ Program.plutusV3(uplcTerm)
│ 24. ParamInfo Creation │ Build param metadata list
└─────────────────────────────────────┘
│
▼
CompileResult(program, params, diagnostics)

The compiler uses JavaParser configured for Java 21 language level:

StaticJavaParser.getParserConfiguration()
.setLanguageLevel(ParserConfiguration.LanguageLevel.JAVA_21);

Each source string is parsed into a CompilationUnit AST. Parse errors are reported as CompilerDiagnostic entries.

SubsetValidator (validate/SubsetValidator.java) extends JavaParser’s VoidVisitorAdapter to walk the AST and reject unsupported Java constructs. It collects non-fatal diagnostics so multiple errors can be reported at once.

Rejected constructs:

Category Construct Error Message Suggestion
Statements try/catch “try/catch is not supported on-chain” “Use if/else checks instead of exception handling”
throw “throw is not supported on-chain” “Return false from the validator to reject a transaction”
synchronized “synchronized is not supported on-chain” “On-chain code is single-threaded”
for(;;) “C-style for loops are not supported” “Use for-each over a list or while loops”
do-while “do-while loops are not supported” “Use while loops or for-each”
break (outside loop) “break is only supported inside for-each or while” —
Expressions null “null is not supported on-chain” “Use Optional<T> to represent absence”
this “‘this’ is not supported on-chain” “Use static methods instead”
super “‘super’ is not supported on-chain” “Use sealed interfaces and pattern matching”
new T[] “arrays are not supported on-chain” “Use List<T> instead”
arr[i] “array access is not supported” “Use List<T> with list operations”
Types float, double “floating point types not supported” “Use BigInteger or Rational”
Classes extends (non-Object) “class inheritance is not supported” “Use sealed interfaces with record variants”

Allowed constructs:

  • for-each loops (desugared to fold)
  • while loops (desugared to recursion)
  • Nested loops (while-in-while, for-each-in-for-each, mixed)
  • break inside for-each/while
  • new RecordType(...) (record construction)
  • switch expressions on sealed interfaces (pattern matching)
  • instanceof with pattern variables

TypeRegistrar.registerAll() processes all compilation units (validator + libraries) together:

1. Collect all record and sealed interface declarations
2. Validate no duplicate type names across CUs
3. Build dependency graph:
- Record field types create edges (e.g., TxOut.address → Address)
- Sealed interface → variant record edges
- Generic type args extracted recursively (List<ProofStep> → ProofStep)
4. Topological sort (Kahn's algorithm):
- Compute in-degree for each type
- Queue types with in-degree 0
- Dequeue → register → decrement dependents → repeat
- Remaining types after queue empties → cycle error
5. Register in dependency order (skip already-registered ledger types)

LedgerTypeRegistry.registerAll() is called before TypeRegistrar to pre-register all Cardano ledger types. The 4-tier ordering ensures types are available when referenced as field types.

Key types registered:

Type Fields Tier
TxOutRef txId: ByteString, index: Integer 1
Value inner: Map<ByteString, Map<ByteString, Integer>> 1
Credential PubKeyCredential(0, hash) / ScriptCredential(1, hash) 2
ScriptInfo MintingScript(0) / SpendingScript(1) / RewardingScript(2) / CertifyingScript(3) / VotingScript(4) / ProposingScript(5) 2
Address credential: Credential, stakingCredential: Data 3
TxOut address: Address, value: Value, datum: OutputDatum, referenceScript: Data 3
TxInInfo outRef: TxOutRef, resolved: TxOut 3
TxInfo 16 fields (inputs, outputs, fee, mint, signatories, …) 4
ScriptContext txInfo: TxInfo, redeemer: Data, scriptInfo: ScriptInfo 4

This is the heart of the compiler. PirGenerator transforms JavaParser AST nodes into PIR terms.

After the ADR-018 refactoring, PirGenerator (2,147 lines) delegates to three extracted helper classes:

  • AccumulatorTypeAnalyzer — Pure AST analysis: detects accumulator variables in loops, refines types (e.g., distinguishing ListType vs MapType accumulators based on pair operation evidence)
  • TypeInferenceHelper — Read-only type inference: resolveExpressionType(), inferPirType(), inferBuiltinReturnType(), etc. Takes read-only dependencies (SymbolTable, TypeResolver, StdlibLookup, TypeMethodRegistry)
  • LoopBodyGenerator — Loop body compilation: 9 body-processing methods across 5 loop paths (single/multi accumulator × break/no-break), plus pack/unpack helpers and nested loop handling

All three are package-private, final classes in the pir/ package.

Constructor dependencies:

PirGenerator(TypeResolver typeResolver,
SymbolTable symbolTable,
StdlibLookup stdlibLookup,
TypeMethodRegistry typeMethodRegistry,
String libraryClassName) // non-null when compiling @OnchainLibrary

Two entry points:

  1. generateMethod(MethodDeclaration) — Compiles an entrypoint or helper method:

    • Pushes a new scope
    • Registers parameters
    • Compiles the method body via generateBlock()
    • Wraps result in nested lambdas: \p1 -> \p2 -> ... -> body
  2. generateExpression(Expression) (public) — Compiles any expression node to a PIR term. Dispatches by expression type to specialized handlers.

Callback surface for LoopBodyGenerator:

LoopBodyGenerator calls back to PirGenerator via 7 methods (1 already public, 6 widened to package-private):

  • generateExpression() (public), generateStatement(), generateWhileStmt(), generateForEachStmt(), inferType(), enrichedError() (static), detectForEachAccumulators()

Error collection:

  • Non-fatal: collectError(msg, suggestion, node) → adds CompilerDiagnostic, returns PirTerm.Error
  • Fatal: enrichedError(msg, suggestion, node) → throws CompilerException immediately
  • Fuzzy method name matching via Levenshtein distance for “did you mean?” suggestions

Literals:

Java PIR
42 / 42L Const(Integer(42))
0xFF Const(Integer(255))
true Const(Bool(true))
"hello" Const(String("hello"))

Variables:

  • name → Var(name, type) where type is looked up from the SymbolTable

Binary operations (type-aware dispatch):

Op Default String ByteString
+ AddInteger AppendString AppendByteString
- SubtractInteger — —
* MultiplyInteger — —
/ QuotientInteger — —
% RemainderInteger — —
== EqualsInteger / EqualsByteString / EqualsString / EqualsData
< LessThanInteger — —
<= LessThanEqualsInteger — —
> Swap operands + LessThanInteger — —
>= Swap operands + LessThanEqualsInteger — —
&& IfThenElse(left, right, false) — —
|| IfThenElse(left, true, right) — —

Unary operations:

  • !x → IfThenElse(x, false, true)
  • -x → SubtractInteger(0, x)

generateMethodCall() implements a 5-level cascading dispatch:

Level 1: Special cases

  • BigInteger.valueOf(n) → identity (pass through)

Level 2: Stdlib static methods

  • Check if scope is a class name (e.g., ListsLib, Builtins, Math)
  • Look up in StdlibLookup → returns PIR term
  • Example: Builtins.headList(xs) → App(Builtin(HeadList), xs)

Level 3: Instance methods via TypeMethodRegistry

  • Resolve scope expression type
  • Dispatch to registered handler
  • Example: list.size() → PirHelpers.generateListLength(list)

Level 4: Record field access (zero-arg methods)

  • Check if scope variable has RecordType or OptionalType(RecordType)
  • Check if field name is in current scope (from pattern destructuring)
  • Otherwise generate field extraction: HeadList(TailList^n(SndPair(UnConstrData(data))))

Level 5: Helper method / library method calls

  • Look up method name in SymbolTable
  • For library classes, try qualified name ClassName.methodName
  • Apply scope + args
  • If not found → error with fuzzy suggestion

Construction:

new Point(10, 20)

→

DataConstr(0, RecordType("Point", [...]), [Const(10), Const(20)])

For sum type variants, the tag comes from the constructor’s position in the sealed interface.

Field access:

point.x() // field 0
point.y() // field 1

→

// Field 0 (x): HeadList after UnConstrData
Let("__fields", SndPair(UnConstrData(point)),
wrapDecode(HeadList(__fields), IntegerType))
// Field 1 (y): TailList then HeadList
Let("__fields", SndPair(UnConstrData(point)),
wrapDecode(HeadList(TailList(__fields)), IntegerType))

If/else:

if (cond) { thenBody } else { elseBody }

→ IfThenElse(cond, thenBody, elseBody)

Fallthrough optimization: If the then-branch returns and there’s no explicit else but following statements exist, the following statements become the else-branch:

if (cond) { return x; }
return y; // ← becomes the else branch

→ IfThenElse(cond, x, y)

instanceof pattern:

if (shape instanceof Circle c) { ... }

→ Tag equality check: EqualsInteger(FstPair(UnConstrData(shape)), circleTag) The pattern variable c and its fields are bound in the then-scope.

Switch expressions:

switch (shape) {
case Circle c -> c.radius();
case Rect r -> r.w() * r.h();
}

→ DataMatch with branches ordered by tag, delegated to PatternMatchDesugarer

Ternary: cond ? a : b → IfThenElse(cond, a, b)

Variable declaration:

int x = 5;
// rest of block...

→ Let("x", Const(5), rest)

Type inference: explicit type if declared, otherwise inferred from initializer expression type, then PIR term type. Uninitialized variables are rejected.

Return/yield:

return expr; // or: yield expr;

→ generateExpression(expr) — becomes the final term in the Let chain

Expression statement:

someCall();
// rest...

→ Let("_", someCall, rest) — evaluate and discard


8.7 Names: generated binders and the method namespace (ADR-060)

Section titled “8.7 Names: generated binders and the method namespace (ADR-060)”

UPLC resolves a variable to its nearest binder, so a binder the compiler wraps around user code must never share a name with anything that code refers to. The rule, from ADR-060, applies to every lowering:

  • A binder you invent is named "#" + name. No Java identifier contains #, so a source name can never resolve to it. Keep existing names one-to-one ("#" + historical name): the name-counting PV11 passes compare names.
  • If terms built elsewhere sit inside the binder, choose the name with PirHelpers.hygienicName("#base", avoid). That means terms from another builder, which may mention the same generated name, or from another frontend. avoid holds the free variables of those terms (PirHelpers.freeVariables) and the names of any caller-supplied binders placed between your binder and your references to it. hygienicName rejects a base without #. A fixed # name is enough when nothing in the scope can mention it, for example Java source only.
  • A loop must not update a shared field. A loop assignment to a static field or @Param rebinds the name only in the rest of the method’s current run, so it is rejected (JULC0056) unless the field is not final, the method is an entry method that no method calls, the loop is not in a lambda, and no other method reads the field. A method refers to a name when the name is free in its generated PIR once let x = x is removed, so a variable that shadows the field is not a read (PirGenerator.requireUnsharedFields). A new pipeline must declare its entry methods with PirGenerator.setEntryMethods, or no method may update a field. A body local that shadows a field is a local, not an accumulator.
  • Choose names while building the term. Never rename afterwards: source maps and Java debug provenance key on PIR node identity.
  • A binder that rebinds a source name keeps it exactly (method parameters, locals, accumulators, Let p = decode(p)); block-local renames use name'N.
  • Methods have their own namespace. Declare them with SymbolTable.declareMethod(sourceName, owner + "." + sourceName, type) and resolve calls with lookupMethodSignature; never look a method up as a variable.
  • Other frontends must not bind or reference # names.

Three tests enforce the rule. GeneratedBinderNameLintTest parses the compiler and stdlib sources and fails on a binder name outside the namespace. Test tasks set julc.verifyBinderNamespace, so every compilation in tests checks each binder that reaches UPLC generation. BinderNameIndependenceTest renames source names to the old internal names and requires identical output.

Loops are transformed into recursive LetRec patterns by LoopDesugarer. The desugarer assigns unique names to each loop function (#loop__forEach__0, #loop__while__1, etc.) to support nesting.

Before choosing a lowering path, LoopBodyGenerator.validateConditionalLocalUpdates checks lexical scopes (ADR-048, #155). An if returns only the loop’s accumulators, so an assignment to a loop-body local declared outside that branch is rejected with a source location and rewrite hints. Bare blocks do not introduce joins; nested loops have their own locals but retain enclosing conditional restrictions. The check emits no PIR and mutates neither AST nor compiler state. A general join-point lowering for these locals remains future work.

generateSwitchExpr also calls validateSwitchExpressionUpdates: each arm may only assign variables it owns, because a switch exports its yielded value rather than enclosing variable rebindings. The check applies even without an enclosing loop or if; nested switch arms start a new boundary. Branch-local pattern bindings are scoped to their valid branch. Switch case-pattern reassignment is rejected (#162) to avoid stale cached field projections; this does not change supported instanceof bindings.

Outside specialized loop-body lowering, generateIfStmt uses its existing return/yield continuation path for branches containing statement-level loops (ADR-050, #161). The continuation is generated in the enclosing scope and bound once as a join lambda before the conditional. Its parameters are the source-order union of enclosing accumulators updated by statement-level loops in either branch; lexical analysis excludes branch/loop locals and pattern/iteration bindings. Each falling-through branch calls it under its final accumulator bindings; an unchanged path passes incoming values. Zero-accumulator joins take a unit argument so the continuation is not evaluated before the branch. Parameters retain native representations without Data packing. Sequential guarded loops therefore grow linearly in serialized size rather than duplicating the entire tail at each if. Branch locals and instanceof pattern bindings reuse the existing deterministic local renaming so they cannot capture join arguments (for example, a branch local shadowing a class field that the other branch updates). It also protects the existing inline return/yield continuation path. Diagnostics use source names. Nested branches propagate the continuation; loops inside expressions belong to their own switch/lambda boundary. No new PIR node is introduced. Branches without owned exits or statement-level loops keep their sequencing shape. Recompiling affected sources can change bytes, hashes and costs at every optimization level.

ADR-048’s outer-loop guard traverses a switch expression’s selector, not its arms. The dedicated arm-ownership check prevents enclosing-variable updates; each loop inside an arm still validates its own body. Arm-local conditionals therefore use ADR-050’s continuation lowering even when the switch is inside an outer loop, without weakening restrictions on actual loop-body locals.

5 compilation paths based on accumulator count and break usage:

Path A: Single accumulator, no break

long sum = 0;
for (var item : items) {
sum = sum + item;
}

→

LetRec([#loop__forEach__0 = \xs \acc ->
IfThenElse(NullList(xs), acc,
Let(item, wrapDecode(HeadList(xs), elemType),
#loop__forEach__0(TailList(xs), acc + item)))
], #loop__forEach__0(items, 0))

Path B: Single accumulator, with break

long result = 0;
for (var item : items) {
if (item > 100) break;
result = result + item;
}

→ Uses a bodyBuilder(continueFn, accVar) function:

  • break → return accVar (no recursion)
  • Continue → continueFn.apply(newAcc) (recurse with loop(TailList(xs), newAcc))

Path C: Multi-accumulator (2+)

long sum = 0, count = 0;
for (var item : items) {
sum = sum + item;
count = count + 1;
}

→ Accumulators packed into a Data list tuple:

pack: MkCons(IData(sum), MkCons(IData(count), MkNilData))
unpack: Let(sum, UnIData(HeadList(tuple)),
Let(count, UnIData(HeadList(TailList(tuple))), body))

Path D: No accumulator → Uses unit accumulator, discards result.

Path E: Multi-accumulator with break → Combines tuple packing (Path C) with break-aware body generation (Path B).

While loops follow the same accumulator detection patterns but use a condition check instead of list exhaustion:

long n = x;
while (n > 0) {
n = n - 1;
}

→

LetRec([#loop__while__0 = \n ->
IfThenElse(n > 0,
#loop__while__0(n - 1),
n)
], #loop__while__0(x))

Accumulator type refinement (AccumulatorTypeAnalyzer.refineAccumulatorTypes()): The compiler distinguishes ListType vs MapType accumulators by looking for evidence (this logic was extracted from PirGenerator into AccumulatorTypeAnalyzer as part of ADR-018):

Evidence Inferred Type
mkNilPairData() assignment MapType
tailList() on unMapData() result MapType
mkCons() with mkPairData() items MapType
fstPair()/sndPair() on headList(cursor) MapType
mkNilData(), mkCons(), tailList() (without pair evidence) ListType

Nested loop unique naming: Each loop gets a unique counter-based name. A loopCounter field in LoopDesugarer increments per loop:

Outer: #loop__forEach__0
Inner: #loop__forEach__1

bodyUsesPairOpsOnCursor fix: The compiler now checks pair operations specifically on the cursor’s headList result variable, not anywhere in the loop body. This prevents incorrect MapType inference when pair operations appear on unrelated variables.


PatternMatchDesugarer transforms Java pattern matching into PIR DataMatch terms.

switch (credential) {
case PubKeyCredential pkc -> pkc.hash();
case ScriptCredential sc -> sc.hash();
}
  1. For each constructor in the SumType, find the matching switch case
  2. Extract binding names from pattern variables
  3. Order branches by constructor tag (tag 0, tag 1, …)
  4. Fill missing branches with Error
  5. Build DataMatch(scrutinee, orderedBranches)

Field bindings use PirHelpers.wrapDecode, the same Data-to-native codec as ordinary record access (ADR-051, #166). This includes Bool and String, not only Integer/ByteString/List/Map. The selected arm strictly decodes its bound fields in order before executing its body; unselected arms remain unevaluated. Keep this codec shared: a cached pattern-field binding must have its declared runtime type. This decoding is not a substitute for strict typed-boundary validation.

UplcGenerator lowers DataMatch to:

Let(pair, UnConstrData(scrutinee),
Let(tag, FstPair(pair),
Let(fields, SndPair(pair),
IfThenElse(tag == 0,
Let(binding0, decode(HeadList(fields)),
Let(__rest, TailList(fields),
Let(binding1, decode(HeadList(__rest)),
body0))),
IfThenElse(tag == 1,
... body1 ...,
Error)))))

That is the NONE/BASELINE shape. Under the PV11 safe profile the pair binding becomes a native pair Case (ADR-038) and, for two or more constructors, the dispatch becomes one integer Case on the tag (ADR-041):

Case(UnConstrData(scrutinee), [\tag -> \fields ->
Case(tag, [
Let(binding0, decode(HeadList(fields)), ... body0 ...),
... body1 ...])])

Branch i is the branch for constructor tag i; a tag outside 0..n-1 fails at selection. The equality chain and its terminal Error are gone, so the dispatch cost no longer grows with the constructor’s position.

if (cred instanceof PubKeyCredential pkc) {
return pkc.hash();
} else if (cred instanceof ScriptCredential sc) {
return sc.hash();
}

Converted to DataMatch with the same structure. Missing variants are filled with the else-branch or Error.


ValidatorWrapper (in codegen/) wraps the compiled entrypoint with ScriptContext decoding and bool-to-unit/error conversion.

Purpose Annotations Param count
SPENDING @SpendingValidator 2 or 3
MINTING @MintingValidator 2
WITHDRAW @WithdrawValidator 2
CERTIFYING @CertifyingValidator 2
VOTING @VotingValidator 2
PROPOSING @ProposingValidator 2

2-Param Wrapper (Non-spending or spending without datum)

Section titled “2-Param Wrapper (Non-spending or spending without datum)”
\#scriptContextData ->
let #ctxFields__ = SndPair(UnConstrData(#scriptContextData))
let redeemer = HeadList(TailList(#ctxFields__)) // field 1
let result = validate(redeemer, #scriptContextData)
in IfThenElse(result, Unit, Error)

Extracts datum from ScriptInfo.SpendingScript:

\#scriptContextData ->
let #ctxFields__ = SndPair(UnConstrData(#scriptContextData))
let redeemer = HeadList(TailList(#ctxFields__)) // field 1
let scriptInfo = HeadList(TailList(TailList(#ctxFields__))) // field 2
let siFields = SndPair(UnConstrData(scriptInfo))
let optDatum = HeadList(TailList(siFields)) // SpendingScript field 1
let datum = HeadList(SndPair(UnConstrData(optDatum))) // unwrap Some
let result = validate(datum, redeemer, #scriptContextData)
in IfThenElse(result, Unit, Error)

Each @Param field gets an outer lambda that accepts raw Data and decodes it:

\#param1__raw -> Let(param1, UnIData(#param1__raw),
\#param2__raw -> Let(param2, UnBData(#param2__raw),
<validator body>))

All validators return boolean in Java. The wrapper converts:

  • true → Unit (script succeeds)
  • false → Error (script fails, transaction rejected)

12. Phase 7: UPLC Generation and Optimization

Section titled “12. Phase 7: UPLC Generation and Optimization”

UplcGenerator lowers PIR to UPLC by eliminating named variables, Let bindings, LetRec, and typed constructs.

Let → Application:

Let(x, val, body) → Apply(Lam("x", body'), val')

LetRec → Z-combinator:

The Z-combinator (strict fixed-point combinator) enables self-recursion:

fix = \f -> (\x -> f (\v -> x x v)) (\x -> f (\v -> x x v))

For LetRec([name = body], expr):

  1. Create the Z-combinator term
  2. Create recursive lambda: Lam(name, body') where body references name
  3. Apply: Apply(Lam(name, expr'), Apply(fix, Lam(name, body')))

Multi-binding LetRec (Bekic’s theorem):

UplcGenerator handles multi-binding LetRec via dependency analysis:

  1. Build a dependency graph between bindings
  2. Topological sort: non-mutual bindings are nested as single LetRec/Let in topo order
  3. For 2-binding mutual recursion, apply Bekic’s theorem to decompose into nested single-binding LetRecs
  4. Limitation: mutual recursion with >2 bindings is not yet supported

IfThenElse → Delay/Force:

IfThenElse(c, t, e) → Force(Apply(Apply(Apply(Force(Builtin(IfThenElse)), c'),
Delay(t')),
Delay(e')))

Both branches are Delayed to prevent premature evaluation (UPLC is strict). The outer Force evaluates the selected branch.

DataConstr → ConstrData:

DataConstr(tag, type, [f1, f2])
→ ConstrData(tag, MkCons(encode(f1), MkCons(encode(f2), MkNilData)))

DataMatch → UnConstrData + tag dispatch: (See Section 10)

De Bruijn computation: Variables are converted from named to De Bruijn indexed. A scope stack tracks active lambda bindings. Index 1 = innermost lambda:

int deBruijnIndex(String name) {
int index = 1;
for (var n : scope) {
if (n.equals(name)) return index;
index++;
}
throw new CompilerException("Unbound variable: " + name);
}

Force count table:

Polymorphic builtins need Force wrappers to instantiate type variables:

Forces Builtins
2 (∀ a b) FstPair, SndPair, ChooseList
1 (∀ a) IfThenElse, ChooseUnit, Trace, ChooseData, MkCons, HeadList, TailList, NullList, DropList, LengthOfArray, ListToArray, IndexArray
0 (mono) All arithmetic, comparisons, Data encode/decode, SerialiseData, crypto, string/bytestring ops

UplcGeneratorTest.ForceCountTable checks every DefaultFun against BuiltinSemantics.Sig.typeArity; the VM’s BuiltinSemanticsCrossCheckTest checks that metadata against runtime signatures. Experimental MultiIndexArray also requires one force, but is unavailable to the current compiler target.

The optimizer runs 6 passes in a fixpoint loop (max 20 iterations, stops when term is unchanged):

Pass 1: Force/Delay cancellation

Force(Delay(t)) → t

Pass 2: Constant folding

Apply(Apply(Builtin(AddInteger), Const(3)), Const(4)) → Const(7)

Supported: AddInteger, SubtractInteger, MultiplyInteger, EqualsInteger, LessThanInteger, LessThanEqualsInteger, EqualsByteString, AppendByteString.

Pass 3: Dead code elimination

Apply(Lam(x, body), val) → body (when x unused in body and val has no side effects)

Side-effect safety: Trace is considered side-effecting. Dead code with trace calls is preserved.

Pass 4: Beta reduction

Apply(Lam(x, body), arg) → substitute(body, arg) (when x used exactly once and arg is simple)

“Simple” = Const, Var, Builtin, or Force of these. Complex args (applications) are not inlined to avoid duplicating computation.

Pass 5: Eta reduction

Lam(x, Apply(f, Var(1))) → f (when x not free in f and f is a value)

The “f is a value” check prevents breaking the Z-combinator. Values = Var, Const, Builtin, Lam, Delay. Non-values = Apply (unevaluated computation).

Pass 6: Constr/Case reduction

Case(Constr(tag, [a, b]), [br0, br1, br2]) → Apply(Apply(br_tag, a), b)

Library methods may depend on each other across compilation units. Library compilation was extracted into LibraryCompiler.java (138 lines) as part of ADR-018. JulcCompiler delegates via new LibraryCompiler(options).compile(...). The strategy is multi-pass:

1. Create progressive LibraryMethodRegistry (starts empty)
2. Create CompositeStdlibLookup(stdlibRegistry, libraryMethodRegistry)
3. For each pass:
a. For each library CU not yet compiled:
- Try to compile all static methods
- If successful, register methods in LibraryMethodRegistry
- If fails (unresolved reference), skip for this pass
b. If no progress this pass (no new CUs compiled), stop
4. Report errors for any remaining uncompiled CUs

This ensures that if library A depends on library B, B will be compiled first (possibly in an earlier pass), and A will succeed in a later pass.

Each compiled library method is stored as a LibraryMethod(className, methodName, type, body). When looked up during validator compilation, it returns a Var reference. The actual PIR body is emitted as a Let (or LetRec for self-recursive methods) binding that wraps the validator term.

Type-aware coercion: When the caller’s argument type doesn’t match the library method’s parameter type, LibraryMethodRegistry.lookup(className, method, args, argTypes) inserts decode wrappers. For example, if the caller has DataType but the library expects IntegerType, it wraps with UnIData.

Library methods are topologically sorted before wrapping as Let bindings:

  1. Build a dependency graph: method A depends on method B if A’s PIR body contains Var("B.methodName")
  2. Kahn’s algorithm sorts dependencies first
  3. Self-recursive methods (detected via containsVarRef) are wrapped in LetRec instead of Let
  4. The outermost binding has no dependencies; the innermost is the validator

LibrarySourceResolver handles automatic discovery of library sources:

  1. scanClasspathSources(ClassLoader) — Scans META-INF/plutus-sources/index.txt for library entries. Falls back to filesystem directory scan in development.
  2. resolve(String source, Map<String,String> availableLibraries) — BFS traversal from root source’s imports, transitively resolving all referenced libraries.

Chains multiple StdlibLookup implementations. First match wins:

new CompositeStdlibLookup(stdlibRegistry, libraryMethodRegistry)

When the compiler encounters SomeClass.method(args):

  1. Try stdlibRegistry first (builtins, HOFs, math)
  2. Try libraryMethodRegistry (compiled @OnchainLibrary methods)
  3. If neither matches, fall through to helper method / error handling

SymbolTable (resolve/SymbolTable.java) manages lexical scoping during PIR generation.

Deque<Map<String, PirType>> scopes; // Variable scope stack
Map<String, MethodInfo> methods; // Helper method registry
Event Operation
Constructor Push global scope
Method entry pushScope() → define parameters
Block statement pushScope() → compile statements → popScope()
Switch case pushScope() → define pattern variable + fields → popScope()
Lambda expression pushScope() → define parameters → popScope()
For-each loop pushScope() → define item + accumulators → popScope()

The global scope is never popped.

  • lookup(name) — Searches from innermost scope outward. Returns Optional<PirType>.
  • lookupCurrentScope(name) — Searches only the innermost scope.

This distinction is critical for the field name collision fix: When accessing txOut.datum(), if there’s an outer parameter named datum, lookup("datum") would find the parameter instead of the record field. lookupCurrentScope ensures only pattern-destructured fields in the current scope are matched.

defineMethod(String name, PirType type, PirTerm body)

Registers a helper method in the methods map and also adds it to the global scope (methods are first-class values in PIR). allMethods() returns all registered methods for final Let wrapping.


TypeMethodRegistry (pir/TypeMethodRegistry.java) maps (PirType, methodName) pairs to instance method handlers.

@FunctionalInterface
interface InstanceMethodHandler {
PirTerm handle(PirTerm scope, List<PirTerm> args,
PirType scopeType, List<PirType> argTypes);
}

Each handler receives:

  • scope — The compiled receiver (e.g., the list, the integer)
  • args — Compiled argument terms
  • scopeType — The receiver’s PIR type
  • argTypes — Argument PIR types
@FunctionalInterface
interface ReturnTypeResolver {
PirType resolve(PirType scopeType);
}

Used for type inference in chained method calls. For example, list.tail() returns the same ListType as the receiver.

IntegerType (15 methods):

Method Implementation
abs() if x < 0 then 0 - x else x
negate() 0 - x
max(other) if a < b then b else a
min(other) if a <= b then a else b
equals(other) EqualsInteger (with Data→Integer coercion)
add(other) AddInteger
subtract(other) SubtractInteger
multiply(other) MultiplyInteger
divide(other) QuotientInteger
remainder(other) RemainderInteger
mod(other) ModInteger
signum() if x < 0 then -1 else if x == 0 then 0 else 1
compareTo(other) if a < b then -1 else if a == b then 0 else 1
intValue() Identity
longValue() Identity

ByteStringType (2 methods):

Method Implementation
length() LengthOfByteString
equals(other) EqualsByteString (with Data→ByteString coercion)

StringType (2 methods):

Method Implementation
length() LengthOfByteString(EncodeUtf8(s))
equals(other) EqualsString

DataType / RecordType / SumType (1 method each):

Method Implementation
equals(other) EqualsData

ListType (16 methods):

Method Implementation Return Type
size() Foldl-based length IntegerType
isEmpty() NullList BoolType
head() wrapDecode(HeadList(list), elemType) elemType
tail() TailList(list) same ListType
get(index) LetRec nth traversal elemType
contains(target) LetRec recursive search BoolType
prepend(elem) MkCons(wrapEncode(elem), list) same ListType
reverse() Foldl-based reversal same ListType
concat(other) Recursive append same ListType
take(n) LetRec traversal same ListType
drop(n) LetRec traversal same ListType
map(f) Delegates to StdlibRegistry HOF builder via lambda inference ListType(DataType)
filter(pred) Delegates to StdlibRegistry HOF builder via lambda inference same ListType
any(pred) Delegates to StdlibRegistry HOF builder via lambda inference BoolType
all(pred) Delegates to StdlibRegistry HOF builder via lambda inference BoolType
find(pred) Delegates to StdlibRegistry HOF builder via lambda inference elemType

OptionalType (3 methods):

Method Implementation Return Type
isPresent() FstPair(UnConstrData(x)) == 0 (Some tag) BoolType
isEmpty() FstPair(UnConstrData(x)) == 1 (None tag) BoolType
get() wrapDecode(HeadList(SndPair(UnConstrData(x))), elemType) elemType

MapType (8 methods):

Method Implementation Return Type
get(key) LetRec lookup, returns Optional OptionalType(valueType)
containsKey(key) LetRec search BoolType
size() generateListLength(UnMapData(map)) IntegerType
isEmpty() NullList(map) BoolType
keys() Foldl-based key extraction ListType(keyType)
values() Foldl-based value extraction ListType(valueType)
insert(k, v) MkCons(MkPairData(k, v), map) same MapType
delete(k) LetRec filtering same MapType

StdlibRegistry (julc-stdlib/.../StdlibRegistry.java) provides PIR term builders for ~65 standard library methods.

The lookup contract includes both untyped and typed lookup methods. This excerpt shows the import-name metadata alongside them; target-aware lookup and legality requirements remain separate.

public interface StdlibLookup {
Optional<PirTerm> lookup(String className, String methodName, List<PirTerm> args);
Optional<PirTerm> lookup(String className, String methodName,
List<PirTerm> args, List<PirType> argTypes);
default Set<String> registeredClassNames() {
return Set.of();
}
}

registeredClassNames() exposes importable FQCNs served by a lookup, excluding simple-name aliases. StdlibRegistry derives these names from registrations; composites union their delegates’ names and decorators should preserve this metadata. Custom lookups with FQCN-only registrations should override it.

Both compiler entry points combine lookup-owned names with parsed source/type names. This matters for the explicit-library-list overloads: they do not scan the classpath, but registry-only methods such as ListsLib.any, ContextsLib.trace, and MathLib.floorDiv must still resolve. Adding a name does not bypass lookup or target-legality checks. See ADR-035 for the contract.

Builtins (raw UPLC operations):

  • List: headList, tailList, nullList, mkCons, mkNilData
  • Pair: fstPair, sndPair, mkPairData, mkNilPairData
  • Data encode: constrData, iData, bData, listData, mapData
  • Data decode: unConstrData, unIData, unBData, unListData, unMapData
  • Comparison: equalsData
  • Error/trace: error, trace
  • ByteString: indexByteString, consByteString, sliceByteString, lengthOfByteString, appendByteString, equalsByteString, lessThanByteString, lessThanEqualsByteString, integerToByteString, byteStringToInteger, encodeUtf8, decodeUtf8, serialiseData, replicateByte, emptyByteString
  • Crypto: sha2_256, blake2b_256, verifyEd25519Signature, sha3_256, blake2b_224, keccak_256, verifyEcdsaSecp256k1Signature, verifySchnorrSecp256k1Signature, ripemd_160
  • Bitwise: andByteString, orByteString, xorByteString, complementByteString, readBit, writeBits, shiftByteString, rotateByteString, countSetBits, findFirstSetBit
  • Data decomposition: constrTag, constrFields
  • Math: expModInteger (PV11)

ListsLib HOF methods (require lambda/LetRec — cannot be compiled from Java source):

Method Signature Implementation
any(predicate, list) (T→Bool, List<T>) → Bool Left fold, short-circuit
all(predicate, list) (T→Bool, List<T>) → Bool Left fold, short-circuit
find(predicate, list) (T→Bool, List<T>) → Optional<T> LetRec recursion
foldl(f, init, list) ((A,T)→A, A, List<T>) → A LetRec recursion
map(f, list) (T→U, List<T>) → List<U> reverse(foldl(...))
filter(pred, list) (T→Bool, List<T>) → List<T> reverse(foldl(...))
zip(a, b) (List<T>, List<U>) → List<Pair<T,U>> LetRec, stops at shorter list

Math delegates (allows Math.abs(x) syntax):

Method Implementation
Math.abs(x) if x < 0 then 0 - x else x
Math.max(a, b) if a < b then b else a
Math.min(a, b) if a <= b then a else b

Example: ListsLib.foldl(fn, init, list) in user Java code:

  1. PirGenerator.generateMethodCall() detects scope = ListsLib (class name)
  2. Level 2 dispatch: Calls stdlibLookup.lookup("ListsLib", "foldl", compiledArgs)
  3. StdlibRegistry finds the registered PirTermBuilder for ListsLib.foldl
  4. Builder generates a LetRec PIR pattern:
    LetRec([go = \acc \lst ->
    IfThenElse(NullList(lst), acc,
    go(App(App(fn, acc), HeadList(lst)), TailList(lst)))
    ], App(App(go, init), list))
  5. UplcGenerator lowers LetRec using Z-combinator
  6. UplcOptimizer applies Force/Delay cancellation, potentially beta-reduces

HOF Compilation Pipeline (Instance Methods)

Section titled “HOF Compilation Pipeline (Instance Methods)”

Instance HOF calls like list.map(x -> x + 1) are compiled via PirHofBuilders.java:

  1. TypeMethodRegistry detects HOF method (map, filter, any, all, find) on a ListType variable
  2. Lambda inference: Lambda parameter type is auto-inferred from the list’s element type
  3. PirHofBuilders generates the PIR pattern (same builders as StdlibRegistry static HOFs)
  4. hofUnwrappedVars: For ByteStringType elements (e.g., JulcList<PubKeyHash>), the lambda param is tracked as pre-unwrapped to prevent double UnBData in .hash() calls
  5. Result: map wraps results to Data; filter/any/all/find preserve element types

foldl is only available as ListsLib.foldl(...) (static), not as an instance method, due to the complexity of 2-parameter lambdas with an init value.


public record CompilerDiagnostic(
Level level, // ERROR, WARNING, INFO
String message,
String fileName,
int line,
int column,
String suggestion // optional
)

Non-fatal errors (recoverable — compilation continues):

// In PirGenerator:
private PirTerm collectError(String message, String suggestion, Node node) {
collectedErrors.add(new CompilerDiagnostic(
Level.ERROR, message, fileName, line, column, suggestion));
return new PirTerm.Error(new PirType.DataType());
}

Returns PirTerm.Error as a placeholder. Multiple errors can be collected and reported to the user in a single compilation run.

Fatal errors (structural — compilation aborts):

private CompilerException enrichedError(String message, String suggestion, Node node) {
throw new CompilerException(message + " at " + fileName + ":" + line + ":" + column
+ (suggestion != null ? " (suggestion: " + suggestion + ")" : ""));
}

Fuzzy method name matching: When a method is not found, the compiler computes Levenshtein edit distance against all known methods and suggests the closest match:

ERROR MyValidator.java:15:8 - Unknown method 'filterr'. Did you mean 'filter'?

Construct-specific suggestions: Each rejected construct in SubsetValidator has a tailored suggestion (e.g., “Use Optional<T> to represent absence of a value” for null).


Goal: Add list.last() returning the last element of a list.

Step 1: Register in TypeMethodRegistry (pir/TypeMethodRegistry.java):

Find the ListType section in defaultRegistry() and add:

reg.register("ListType", "last", (scope, args, scopeType, argTypes) -> {
var lt = (PirType.ListType) scopeType;
// LetRec: go(lst) = if NullList(TailList(lst)) then HeadList(lst) else go(TailList(lst))
var goName = "go__last";
var lstParam = new PirTerm.Var("__lst", lt);
var tail = new PirTerm.App(new PirTerm.Builtin(DefaultFun.TailList), lstParam);
var head = new PirTerm.App(new PirTerm.Builtin(DefaultFun.HeadList), lstParam);
var nullTail = new PirTerm.App(new PirTerm.Builtin(DefaultFun.NullList), tail);
var recurse = new PirTerm.App(new PirTerm.Var(goName, new PirType.FunType(lt, lt.elemType())), tail);
var body = new PirTerm.IfThenElse(nullTail, PirHelpers.wrapDecode(head, lt.elemType()), recurse);
var goLam = new PirTerm.Lam("__lst", lt, body);
return new PirTerm.LetRec(
List.of(new PirTerm.Binding(goName, goLam)),
new PirTerm.App(new PirTerm.Var(goName, new PirType.FunType(lt, lt.elemType())), scope));
}, scopeType -> ((PirType.ListType) scopeType).elemType());

Step 2: Add off-chain stub (julc-onchain-api/.../Builtins.java or the relevant stub class):

public static <T> T last(List<T> list) {
// Off-chain stub for IDE support
return list.get(list.size() - 1);
}

Step 3: Write a test in TypeMethodRegistryTest.java or TypeMethodsTest.java:

@Test
void testListLast() {
var source = """
@SpendingValidator
public class Test {
@Entrypoint
public static boolean validate(PlutusData redeemer, PlutusData ctx) {
var items = Builtins.mkNilData(Builtins.unit());
items = Builtins.mkCons(Builtins.iData(10), items);
items = Builtins.mkCons(Builtins.iData(20), items);
items = Builtins.mkCons(Builtins.iData(30), items);
return Builtins.unIData(items.last()) == 10;
}
}
""";
compileAndAssertTrue(source);
}

Goal: Register a new Plutus builtin (e.g., from a Plutus version upgrade).

Step 1: Add to DefaultFun enum (julc-core/.../DefaultFun.java):

NewBuiltin(102), // CIP-XXX: description

The FLAT code must match the Plutus specification exactly.

Step 2: Set force count (uplc/UplcGenerator.java):

In the forceCount() method, add:

case NewBuiltin -> 0; // or 1 or 2 depending on polymorphism

Step 3: Register in StdlibRegistry (julc-stdlib/.../StdlibRegistry.java):

In registerBuiltins():

reg.register("Builtins", "newBuiltin", args -> {
requireArgs("newBuiltin", args, 2);
return builtinApp2(DefaultFun.NewBuiltin, args.get(0), args.get(1));
});

Step 4: Add off-chain stub (julc-onchain-api/.../Builtins.java):

public static PlutusData newBuiltin(PlutusData a, PlutusData b) {
throw new UnsupportedOperationException("On-chain only");
}

Step 5: Write a compile+eval test verifying the builtin works end-to-end.


Goal: Map a new Java type (e.g., Rational) to a PIR type.

Step 1: Define the PirType variant (if needed) in pir/PirType.java:

record RationalType() implements PirType {}

Or use an existing type — Rational is currently mapped to DataType (opaque).

Step 2: Register in TypeResolver (resolve/TypeResolver.java):

In the resolve() method, add a case:

case "Rational" -> new PirType.DataType(); // or your new type

Step 3: Add wrapDecode/wrapEncode (pir/PirHelpers.java):

case RationalType() -> /* your decode logic */;

Step 4: Handle in UplcGenerator (uplc/UplcGenerator.java):

If the new type needs special DataConstr encoding or DataMatch decoding, add cases in wrapDataEncode() and the DataMatch lowering.

Step 5: Write tests covering construction, field access, and passing the type across function boundaries.


Goal: Create a new on-chain library (e.g., SortLib).

Step 1: Write the on-chain Java source (julc-stdlib/src/main/java/.../onchain/SortLib.java):

@OnchainLibrary
public class SortLib {
public static List<PlutusData> sort(List<PlutusData> items) {
// Implementation using supported Java subset
// Use while loops, for-each, Builtins.* calls
}
}

Step 2: Write the off-chain stub (julc-onchain-api/src/main/java/.../SortLib.java):

@OnchainLibrary
public class SortLib {
public static List<PlutusData> sort(List<PlutusData> items) {
throw new UnsupportedOperationException("On-chain only");
}
}

Step 3: If the library needs HOF methods (lambdas as parameters), register PIR builders in StdlibRegistry:

reg.register("SortLib", "sortBy", args -> {
// Build PIR term with LetRec recursion
});

Step 4: Write tests in StdlibCompileEvalTest.java or StdlibIntegrationTest.java:

@Test
void testSort() {
var source = """
@SpendingValidator
public class Test {
@Entrypoint
public static boolean validate(PlutusData r, PlutusData ctx) {
var items = /* build list */;
var sorted = SortLib.sort(items);
return /* verify sorted order */;
}
}
""";
compileAndAssertTrue(source);
}

Step 5: Ensure the library source is discoverable by adding it to META-INF/plutus-sources/index.txt in the stdlib jar.


Goal: Register a new Cardano ledger type (e.g., from a hard fork).

Step 1: Create the Java record (julc-ledger-api/src/main/java/.../):

public record NewLedgerType(BigInteger field1, byte[] field2) {}

Step 2: Register in LedgerTypeRegistry (resolve/LedgerTypeRegistry.java):

Add to the appropriate tier:

typeResolver.registerLedgerRecord("NewLedgerType", List.of(
new PirType.Field("field1", new PirType.IntegerType()),
new PirType.Field("field2", new PirType.ByteStringType())
));

Step 3: Create off-chain stub (julc-onchain-api/src/main/java/.../):

public record NewLedgerType(BigInteger field1, byte[] field2) {}

Step 4: Write tests verifying field access compiles and evaluates correctly:

@Test
void testNewLedgerTypeAccess() {
// Build ScriptContext with the new type, verify field extraction works
}

Goal: Add a new UPLC optimization pass.

Step 1: Add the pass method in UplcOptimizer (uplc/UplcOptimizer.java):

private Term myNewOptimization(Term term) {
return switch (term) {
case Term.Apply(var f, var a) -> {
var optF = myNewOptimization(f);
var optA = myNewOptimization(a);
// Your pattern matching logic here
yield new Term.Apply(optF, optA);
}
// ... handle all Term variants recursively
default -> term;
};
}

Step 2: Register in the pass list. In runAllPasses(), add your pass in the appropriate position. Consider ordering:

  • Passes that create opportunities for other passes should run earlier
  • Passes that clean up artifacts should run later
  • All passes run in a fixpoint loop, so cascading effects are handled

Step 3: Preserve De Bruijn indices. If your pass removes or adds lambda bindings, use shiftDown()/shiftUp() to adjust variable indices.

Step 4: Check side-effect safety. If your pass removes terms, ensure dropped terms don’t contain Trace (use hasSideEffect()).

Step 5: Write tests in UplcOptimizerTest.java:

@Test
void testMyNewOptimization() {
var input = /* construct UPLC term matching the pattern */;
var expected = /* expected optimized term */;
var result = optimizer.optimize(input);
assertEquals(expected, result);
}

Goal: Understand what happens when a simple validator is compiled.

@SpendingValidator
public class SimpleValidator {
@Entrypoint
public static boolean validate(PlutusData redeemer, PlutusData scriptContext) {
int x = 5;
int y = x + 3;
return y == 8;
}
}

JavaParser produces an AST with:

  • ClassOrInterfaceDeclaration “SimpleValidator” with @SpendingValidator
  • MethodDeclaration “validate” with @Entrypoint
  • Two parameters: redeemer: PlutusData, scriptContext: PlutusData
  • Body: three statements (two var decls, one return)

SubsetValidator walks the AST. No rejected constructs found.

@SpendingValidator -> SPENDING. Two parameters -> 2-param wrapper.

No user-defined records or sealed interfaces. LedgerTypeRegistry pre-registers standard types.

Symbol table has: redeemer: DataType, scriptContext: DataType

Statement 1: int x = 5;

Let("x", Const(Integer(5)),

Statement 2: int y = x + 3;

Let("y", App(App(Builtin(AddInteger), Var("x", IntegerType)), Const(Integer(3))),

Statement 3: return y == 8;

App(App(Builtin(EqualsInteger), Var("y", IntegerType)), Const(Integer(8)))))

Wrapped in entrypoint lambdas:

Lam("redeemer", DataType,
Lam("scriptContext", DataType,
Let("x", Const(5),
Let("y", App(App(Builtin(AddInteger), Var("x")), Const(3)),
App(App(Builtin(EqualsInteger), Var("y")), Const(8))))))

2-param spending wrapper adds ScriptContext decoding and bool→unit/error:

Lam("#scriptContextData", DataType,
Let("#ctxFields__", SndPair(UnConstrData(Var("#scriptContextData"))),
Let("#redeemer__", HeadList(TailList(Var("#ctxFields__"))),
Let("#result__",
App(App(<entrypoint>, Var("#redeemer__")), Var("#scriptContextData")),
IfThenElse(Var("#result__"), Const(Unit), Error)))))

All Let bindings become Apply(Lam(...), ...). Variables become De Bruijn indices:

Lam("__scd",
Apply(Lam("#ctxFields__",
Apply(Lam("__red",
Apply(Lam("__result",
Force(Apply(Apply(Apply(Force(Builtin(IfThenElse)), Var(1)),
Delay(Const(Unit))),
Delay(Error)))),
Apply(Apply(
Lam("redeemer", Lam("scriptContext",
Apply(Lam("x",
Apply(Lam("y",
Apply(Apply(Builtin(EqualsInteger), Var(1)), Const(8))),
Apply(Apply(Builtin(AddInteger), Var(1)), Const(3)))),
Const(5)))),
Var(1)), // __red
Var(3)))), // __scd
Apply(Apply(Force(Force(Builtin(HeadList))), // ... extraction chain

The optimizer applies fixpoint passes:

  • Constant folding: AddInteger(5, 3) → 8
  • Constant folding: EqualsInteger(8, 8) → True
  • Beta reduction: inline single-use variables
  • Dead code elimination: remove unused let bindings
  • Force/Delay cancellation: Force(Delay(True)) → True

After optimization, the validator becomes equivalent to: \ctx -> Unit (always succeeds).


No Return Inside Multi-Accumulator While Body

Section titled “No Return Inside Multi-Accumulator While Body”

Root cause: The multi-accumulator while loop compilation packs accumulators into a tuple and generates a single LetRec. A return inside the body would need to escape the entire loop + tuple unpacking, which the current LetRec pattern doesn’t support.

Workaround: Use break to exit loops early, or restructure to avoid early return.

Root cause: When method A calls helper method B with a long parameter, the compiler may generate EqualsData instead of EqualsInteger because the parameter type is inferred as DataType at the boundary.

Workaround: Use Data-level equality (EqualsData on wrapped values) or ensure helper methods use the same primitive types as callers.

Root cause: @Param values are always raw Data at runtime, but declaring @Param PlutusData.BytesData tells the compiler the value is already a ByteString. This causes double-wrapping with bData() and incorrect cross-library calls.

Workaround: Always use @Param PlutusData for @Param fields. Decode manually with Builtins.unBData() if needed.

Root cause: Variables defined before a multi-accumulator while loop may be corrupted after the loop. The LetRec transformation overwrites variable bindings in the scope.

Workaround: Extract post-loop logic into a separate helper method that receives the needed values as parameters.

Root cause: When calling a stdlib library method that takes BytesData-typed params, if the caller also has a BytesData variable, the compiler sees matching types and skips conversion. But compiled libraries expect Data args at the UPLC boundary.

Workaround: Pass PlutusData (not BytesData) args to cross-library calls. For stdlib methods taking BytesData params, use a same-project local wrapper or ensure call-site variables are typed as PlutusData.


Diagnostics follow the format:

ERROR fileName.java:15:8 - Message text (suggestion: hint text)

The CompileResult contains both the compiled Program (if successful) and a list of CompilerDiagnostic entries (errors + warnings).

Error Likely Cause Fix
“Unknown method ‘X’” Method not in stdlib, TypeMethodRegistry, or helper methods Check spelling, add import, or register method
“Unknown type: ‘X’” Type not registered in TypeResolver Register record/sealed interface, add to LedgerTypeRegistry
“Unbound variable: ‘X’” Variable used outside its scope, or De Bruijn index wrong Check scope push/pop lifecycle
“Unsupported statement” Java construct not in supported subset Rewrite using supported subset (see SubsetValidator)
“Cycle detected in type dependencies” Mutually recursive record types Break cycle with DataType fields

To inspect intermediate PIR, add a breakpoint or print statement after generateMethod() in JulcCompiler. PIR terms have a readable toString():

PirTerm pir = generator.generateMethod(entrypoint);
System.out.println(pir); // Prints PIR structure

Use julc-vm-scalus to evaluate UPLC terms and inspect results:

var result = compiler.compile(source, List.of());
var program = result.program();
// Use VM to evaluate with test data

Track script sizes across changes:

var cbor = program.toCborHex();
System.out.println("Script size: " + cbor.length() / 2 + " bytes");

Test Class Coverage Area
JulcCompilerTest Full pipeline integration
EndToEndTest Complete validator scenarios
PirGeneratorTest PIR generation from Java AST
PirTypeTest PirType equality and structure
PirTermTest PirTerm construction
SubsetValidatorTest Java subset validation
TypeResolverTest Java-to-PIR type mapping
TypeMethodRegistryTest Instance method dispatch
TypeMethodsTest Instance method compilation + eval
UplcGeneratorTest PIR-to-UPLC lowering
UplcOptimizerTest Optimization passes
SealedInterfaceTest Sum type compilation
PatternMatchTest Switch/instanceof compilation
HelperMethodTest Static helper methods
RecursionTest Recursive method compilation
LoopDesugarTest Loop desugaring
StdlibIntegrationTest Stdlib method integration
StdlibCompileEvalTest Stdlib compile + evaluate
LedgerTypeAccessTest Ledger type field access
LambdaExpressionTest Lambda expression compilation
ByteStringLibTest ByteString operations
BigIntegerCompatTest BigInteger method support
ParameterizedValidatorTest @Param validators
MultiFileCompilerTest Multi-file / library compilation
LibrarySourceResolverTest Library source resolution
@Test
void testFeature() {
// 1. Define Java source
var source = """
@SpendingValidator
public class Test {
@Entrypoint
public static boolean validate(PlutusData redeemer, PlutusData ctx) {
// Test logic that should return true
return someCondition;
}
}
""";
// 2. Compile
var result = compiler.compile(source, List.of());
// 3. Assert compilation succeeded
assertNotNull(result.program());
assertTrue(result.diagnostics().stream().noneMatch(CompilerDiagnostic::isError));
// 4. Evaluate with test data
var evalResult = vm.evaluate(result.program(), testScriptContext);
// 5. Check result
assertTrue(evalResult.isSuccess());
}

Unit tests: Test individual components (TypeResolver, SymbolTable, PirHelpers) in isolation.

Integration tests: Compile Java source → assert PIR structure or UPLC evaluation result.

End-to-end tests: Full lifecycle including ScriptContext construction, validator execution, and result verification (see julc-e2e-tests/ for CIP-113 tests).


ADR Title Status
ADR-001 Plutus-Java Architecture Accepted
ADR-002 Milestone 2 — V3 Ledger Types Proposed
ADR-003 Milestone 3 — Compiler MVP Proposed
ADR-004 Milestone 8 — Developer Experience —
ADR-005 Production Readiness & Stdlib Completion Completed
ADR-006 Library Discovery + Stdlib-in-Java Rewrite Accepted
ADR-007 Convert Legacy PIR to Java Source Accepted
ADR-008 Rename to JuLC Proposed
ADR-009 CIP-113 Compiler Fixes Completed
ADR-010 Beta Release Review —
ADR-011 Post-Review Fixes —
ADR-012 Compiler Core Simplification Completed
ADR-013 Beta Release Readiness Review —
ADR-014 Release Readiness Review —
ADR-015 CompileMethod Param Support —
ADR-016 Feature Roadmap Prioritization —
ADR-017 1.0 Release Readiness Assessment —
ADR-018 PirGenerator Refactoring Completed

ADR files are in the adr/ directory at the project root.


Term Definition
UPLC Untyped Plutus Lambda Calculus — the on-chain execution language
PIR Plutus Intermediate Representation — typed bridge between Java and UPLC
De Bruijn Variable indexing scheme where variables reference lambda binders by distance (1 = innermost)
Force Evaluate a delayed/polymorphic term
Delay Defer evaluation (create a thunk)
Data Universal on-chain value encoding with 5 constructors (Constr, Map, List, I, B)
CEK Count-Evaluate-Kont — the Plutus virtual machine (abstract machine)
ScriptContext Cardano ledger data passed to every validator (contains TxInfo, redeemer, script info)
SumType Tagged union — a sealed interface with record variants
RecordType Product type — a record with named typed fields
LetRec Recursive let binding — used for loops and self-recursive functions
Accumulator Variable modified across loop iterations (packed into Data tuples for multi-acc)
wrapDecode PirHelpers method to extract a typed value from raw Data
wrapEncode PirHelpers method to wrap a typed value back into Data
Z-combinator Strict fixed-point combinator enabling recursion in UPLC
Kahn’s algorithm Topological sort algorithm used for type registration and library ordering
StdlibLookup Interface for resolving static method calls to PIR terms
TypeMethodRegistry Registry mapping (PirType, method) pairs to instance method handlers
@OnchainLibrary Annotation marking a class as a reusable on-chain library
@Param Annotation marking a validator field as a deployment-time parameter
@Entrypoint Annotation marking the main validator method
FLAT Binary encoding format for UPLC programs on-chain
SOPs Sums of Products — Plutus V3 constructor/case terms

File Lines Role
julc-compiler/.../JulcCompiler.java 1,221 Main pipeline orchestrator — 24-phase compilation
julc-compiler/.../LibraryCompiler.java 138 Library compilation sub-pipeline (extracted from JulcCompiler, ADR-018)
julc-compiler/.../CompileResult.java — Compilation result (program + diagnostics + params)
julc-compiler/.../CompilerException.java — Fatal compiler error
julc-compiler/.../CompilerOptions.java — Compilation options
julc-compiler/.../LibrarySourceResolver.java — Classpath scanning + transitive BFS library resolution
julc-compiler/.../codegen/ValidatorWrapper.java — ScriptContext decoding + bool→unit/error wrapping
julc-compiler/.../desugar/LoopDesugarer.java — For-each/while → LetRec transformation
julc-compiler/.../desugar/PatternMatchDesugarer.java — Switch/instanceof → DataMatch transformation
julc-compiler/.../error/CompilerDiagnostic.java — Diagnostic record (level, message, location, suggestion)
julc-compiler/.../error/DiagnosticCollector.java — Structured error collection with source location
julc-compiler/.../pir/PirGenerator.java 2,147 Java AST → PIR — core expression/statement compilation
julc-compiler/.../pir/LoopBodyGenerator.java 531 Loop body compilation — 5 paths × break/no-break (extracted, ADR-018)
julc-compiler/.../pir/AccumulatorTypeAnalyzer.java 432 Accumulator type analysis — pair list detection heuristics (extracted, ADR-018)
julc-compiler/.../pir/TypeInferenceHelper.java 282 Read-only type inference — resolveExpressionType, inferPirType (extracted, ADR-018)
julc-compiler/.../pir/TypeMethodRegistry.java 905 Instance method dispatch (~50 methods across 11 types)
julc-compiler/.../pir/PirHelpers.java 356 wrapDecode/wrapEncode + list length/contains/foldl + blockStmts
julc-compiler/.../pir/PirHofBuilders.java 244 HOF PIR builders (map, filter, any, all, find, foldl, zip)
julc-compiler/.../pir/CompositeStdlibLookup.java — Chains multiple StdlibLookup instances (first match wins)
julc-compiler/.../pir/PirTerm.java — PIR term AST (12 variants)
julc-compiler/.../pir/PirType.java — PIR type system (13 variants)
julc-compiler/.../pir/StdlibLookup.java — Interface for stdlib method resolution
julc-compiler/.../pir/PirFormatter.java — PIR pretty-printing
julc-compiler/.../pir/PirSubstitution.java — PIR variable substitution
julc-compiler/.../resolve/TypeResolver.java — Java → PIR type mapping
julc-compiler/.../resolve/TypeRegistrar.java — Topological type registration (Kahn’s algorithm)
julc-compiler/.../resolve/SymbolTable.java — Scope stack for variable/method management
julc-compiler/.../resolve/LedgerSourceLoader.java — Dynamic ledger type loading from META-INF
julc-compiler/.../resolve/LibraryMethodRegistry.java — Compiled library method storage + typed coercion
julc-compiler/.../resolve/ImportResolver.java — Import resolution
julc-compiler/.../uplc/UplcGenerator.java — PIR → UPLC lowering (Let→Apply, LetRec→Z-combinator)
julc-compiler/.../uplc/UplcOptimizer.java — 6-pass UPLC optimizer with fixpoint iteration
julc-compiler/.../validate/SubsetValidator.java — Java subset enforcement
julc-compiler/.../util/MethodDependencyResolver.java — Method dependency graph construction
julc-compiler/.../util/StringUtils.java — String utilities (Levenshtein distance, etc.)
julc-core/.../DefaultFun.java — 101 released Plutus builtins (tags 0-100) plus future MultiIndexArray (tag 101)
julc-core/.../Term.java — UPLC term AST (10 variants)
julc-stdlib/.../StdlibRegistry.java — PIR term builders for ~65 stdlib methods