Status: Experimental
File extension: .runar.java
Supported compilers: TypeScript, Go, Rust, Python, Zig, Ruby, Java (all seven)
The Java format lets you write Rúnar contracts as plain Java classes extending SmartContract or StatefulSmartContract. Contracts use standard Java syntax with camelCase naming, annotations for Rúnar-specific metadata (@Public, @Readonly), and java.math.BigInteger in place of a bespoke bigint literal.
The parser is built on the standard-JDK javax.tools.JavaCompiler + com.sun.source.tree API, so no third-party parser dependency is required. Non-contract Java constructs (inner classes, lambdas, switch expressions, generic types, try/catch, annotations other than @Readonly / @Public / @Stateful) are rejected at parse time — the parser prefers loud failures over silent divergence from the other compilers.
See docs/java-tier-plan.md for the full roadmap covering compiler, SDK, conformance, and integration milestones.
package runar.examples.p2pkh;
import runar.lang.SmartContract;
import runar.lang.annotations.Public;
import runar.lang.annotations.Readonly;
import runar.lang.types.Addr;
import runar.lang.types.PubKey;
import runar.lang.types.Sig;
import static runar.lang.Builtins.assertThat;
import static runar.lang.Builtins.checkSig;
import static runar.lang.Builtins.hash160;The import lines are consumed by javac for type resolution but do not alter the AST. A typical contract pulls the base class from runar.lang, the annotations from runar.lang.annotations, domain types from runar.lang.types, and static-imports its builtins from runar.lang.Builtins.
class P2PKH extends SmartContract {
@Readonly Addr pubKeyHash;
P2PKH(Addr pubKeyHash) {
super(pubKeyHash);
this.pubKeyHash = pubKeyHash;
}
@Public
void unlock(Sig sig, PubKey pubKey) {
assertThat(hash160(pubKey).equals(pubKeyHash));
assertThat(checkSig(sig, pubKey));
}
}- Extend
SmartContract(stateless) orStatefulSmartContract(stateful) - One contract class per file
- Constructor must call
super(...)as the first statement
Contract classes in .runar.java files are package-private (no public modifier on the class). javac rejects a public class whose compound .runar.java suffix does not match the class's bare simple name, so the class, its constructor, and its methods are declared without public. The contract stays reachable inside its own package for testing and compilation. Cross-package consumers use the typed-wrapper facade emitted by the Rúnar SDK codegen (milestone 10 of the Java tier plan), not the raw contract class.
class Auction extends StatefulSmartContract {
@Readonly PubKey auctioneer; // immutable
PubKey highestBidder; // mutable (stateful)
BigInteger highestBid; // mutable (stateful)
@Readonly BigInteger deadline; // immutable
}- In
SmartContract, all properties are implicitly readonly regardless of whether@Readonlyis present - In
StatefulSmartContract, annotate readonly properties with@Readonlyfromrunar.lang.annotations - Unannotated fields on a
StatefulSmartContractare mutable state fields
Fields use plain Java declarations — no private/public/final decoration is required. Visibility modifiers are ignored by the parser; the @Readonly annotation alone determines mutability.
Fields can carry a default value using a Java field initializer. Only literal values are permitted:
class GameBoard extends StatefulSmartContract {
BigInteger count = BigInteger.ZERO; // mutable with default
@Readonly boolean active = true; // readonly with default
@Readonly ByteString magic = ByteString.fromHex("deadbeef");
@Readonly PubKey owner; // no default — required in constructor
}The parser accepts these literal forms:
BigInteger.ZERO,BigInteger.ONE,BigInteger.TWO,BigInteger.TENBigInteger.valueOf(n)wherenis an integer literaltrue,falseByteString.fromHex("...")andfromHex("...")on any ByteString subtype
Properties with initializers are excluded from the auto-generated constructor. Only properties without defaults need to be passed as constructor arguments — the same rule every other format follows.
| Java syntax | Rúnar visibility |
|---|---|
@Public annotation on the method |
public (spending entry point) |
| No annotation | private (inlined helper) |
@Public
void unlock(Sig sig, PubKey pubKey) {
...
}
BigInteger computeThreshold(BigInteger a, BigInteger b) {
return a * b + BigInteger.ONE;
}Public methods must return void — they are spending entry points whose success is determined by the assertions in their body. Private helpers may declare any Rúnar-legal return type.
None. Java identifiers are already camelCase, so the parser passes names through unchanged. Contrast this with Python, which converts pub_key_hash to pubKeyHash during parsing. A Java contract's pubKeyHash field arrives in the AST as pubKeyHash, and checkSig stays checkSig.
Special identifiers:
- The constructor method name becomes
constructorin the AST this.foobecomesPropertyAccessExpr("foo")super(...)is preserved as aCallExpragainst the identifiersuper
| Java Type | Rúnar AST Type |
|---|---|
BigInteger / Bigint |
bigint |
boolean / Boolean |
boolean |
Addr |
Addr |
Sig |
Sig |
PubKey |
PubKey |
ByteString |
ByteString |
Point |
Point |
P256Point |
P256Point |
P384Point |
P384Point |
Sha256Digest |
Sha256 |
SigHashPreimage |
SigHashPreimage |
RabinSig |
RabinSig |
RabinPubKey |
RabinPubKey |
Ripemd160 / Hash160 |
Ripemd160 |
OpCodeType |
OpCodeType |
@Readonly |
Marks property readonly: true |
The other eight surfaces spell a fixed-size array with the length carried in
the type — FixedArray<bigint, 9> (TypeScript), FixedArray[Bigint, 9]
(Python, Ruby), [9]runar.Bigint (Go), [Bigint; 9] (Rust), [9]i64 (Zig),
T[N] (Solidity-like), FixedArray<T, N> (Move-like). The Java surface has
no working equivalent, and a Java contract cannot currently declare a
FixedArray property.
The reason is a hard one: the Java frontend is built on javax.tools.JavaCompiler,
so a Rúnar Java contract must first be syntactically valid Java — and an integer
literal in a type-argument list is not. It fails in javac's parse phase, before
any type resolution, so no annotation, import, or compiler flag can get past it:
$ javac -XDshould-stop.at=PARSE Probe.java
Probe.java:2: error: illegal start of type
FixedArray<Bigint, 9> board;
^
That leaves no spelling the toolchain accepts. All three candidates are dead:
| Spelling | Result |
|---|---|
FixedArray<Bigint, 9> |
Java tier: illegal start of type (javac). The other six tiers parse it fine. |
FixedArray<Bigint> |
All seven tiers: FixedArray requires 2 type arguments (element, length) |
Bigint[] |
Java tier: unsupported type node ARRAY_TYPE. Go/Zig: property initializer must be a literal value. Would not reach expand_fixed_arrays even if it parsed, so byte parity would fail. |
JavaParser#parseFixedArrayLength requires a LiteralTree in the second
type-argument position — a tree javac's parser can never produce — so that
branch is unreachable, and the arity check above it is the only path a
FixedArray type ever takes.
The decision is made: FixedArray is unsupported on the Java surface, and the
exclusion is recorded here the way the Go-only crypto families are recorded in
the root CLAUDE.md. The alternative — introducing length-marker types
(FixedArray<Bigint, N9>) and teaching all seven Java-surface parsers to resolve
them — would widen the language for a feature that has never once worked. Nothing
can depend on it: every spelling was rejected by all seven compilers from the day
the Java surface landed.
What that removed:
runar.lang.types.FixedArrayno longer ships inpackages/runar-java. The artifact-sideRunarArtifact.FixedArrayMetaandStateSerializer'sparseFixedArrayDims/unwrapFixedArrayLeafstay — the Java SDK still deploys and drives artifacts compiled from the other eight surfaces, which do haveFixedArraystate slots.examples/.../tic-tac-toe-v2/TicTacToe.v2.runar.javaandexamples/.../fixed-array-nested/Grid2x2.v2.runar.javaare deleted. Neither ever compiled; their JUnit tests exercised the classes as ordinary Java objects and never calledCompileCheck, which is why two permanently-broken contracts sat in a green suite.packages/runar-compiler/src/__tests__/java-parser-examples.test.tsnow runs everyexamples/javacontract through parse → validate → typecheck, so a.runar.javafile no compiler accepts fails the build.
Port array-shaped contracts to Java by declaring the elements as individual scalar
properties — which is what expand_fixed_arrays produces anyway, so the emitted
script is byte-identical. See
examples/java/src/main/java/runar/examples/tic-tac-toe/TicTacToe.runar.java,
whose nine c0–c8 properties compile to the same script as the
FixedArray-backed versions on the other eight surfaces.
Rúnar Java contracts use Java's native literal forms, with two recognised calls promoted to AST literals:
| Java source | AST literal |
|---|---|
7, 42L |
BigIntLiteral(BigInteger.valueOf(...)) |
BigInteger.valueOf(7) |
BigIntLiteral(7) |
BigInteger.ZERO / ONE / TWO / TEN |
BigIntLiteral(0..10) |
true / false |
BoolLiteral |
ByteString.fromHex("deadbeef") |
ByteStringLiteral("deadbeef") |
PubKey.fromHex("...") (and other ByteString subtypes) |
ByteStringLiteral(...) |
Bare String literals are rejected — use ByteString.fromHex("...") or a subtype's fromHex to author raw bytes. char, float, double, and null literals are all rejected at parse time.
Integer literals auto-promote to BigIntLiteral, so writing count + 1 in a Java contract yields the same AST as count + BigInteger.ONE.
Java has no operator overloading, so ByteString and its subtypes expose .equals(...) for value comparison. The parser recognises the .equals member-access followed by a call and treats the result as strict equality — it compiles to the same === AST node that == produces in TypeScript:
assertThat(hash160(pubKey).equals(pubKeyHash)); // strict ByteString equalityFor BigInteger and boolean values, write == directly — the validator routes == to the correct semantics based on operand type:
assertThat(x == BigInteger.valueOf(7)); // bigint comparison
assertThat(active == true); // boolean comparisonThere is no Runar.eq(...) helper and no operator overloading — arithmetic on bigints uses +, -, *, /, % directly.
| Java | AST / Bitcoin Script |
|---|---|
+ / - / * / / / % |
ADD / SUB / MUL / DIV / MOD |
== / != |
=== / !== (strict equality) |
< / <= / > / >= |
comparison |
&& / || / ! |
short-circuit logical |
& / | / ^ / ~ |
bitwise (bigint or ByteString) |
<< / >> |
OP_LSHIFT / OP_RSHIFT |
cond ? a : b |
ternary expression |
unary -x / +x |
NEG / identity |
++x / x++ / --x / x-- |
increment / decrement (pre- and post-) |
Assertions are function calls on assertThat from runar.lang.Builtins:
assertThat(checkSig(sig, pubKey));
assertThat(count > BigInteger.ZERO);Java does not have a first-class assertion statement in the Rúnar subset — always use the assertThat(...) form, and static-import it at the top of your file.
Only bounded for loops with a literal iteration count are supported:
for (int i = 0; i < 5; i = i + 1) {
...
}The loop must declare exactly one loop variable and have exactly one update expression. while, do/while, for-each, and switch are rejected at parse time.
package runar.examples.p2pkh;
import runar.lang.SmartContract;
import runar.lang.annotations.Public;
import runar.lang.annotations.Readonly;
import runar.lang.types.Addr;
import runar.lang.types.PubKey;
import runar.lang.types.Sig;
import static runar.lang.Builtins.assertThat;
import static runar.lang.Builtins.checkSig;
import static runar.lang.Builtins.hash160;
// Contract classes in .runar.java files are package-private so that javac
// accepts the compound .runar.java suffix (which does not match a bare
// public class name). Cross-package consumers use the typed wrappers
// emitted by the Rúnar SDK codegen (milestone 10).
class P2PKH extends SmartContract {
@Readonly Addr pubKeyHash;
P2PKH(Addr pubKeyHash) {
super(pubKeyHash);
this.pubKeyHash = pubKeyHash;
}
@Public
void unlock(Sig sig, PubKey pubKey) {
assertThat(hash160(pubKey).equals(pubKeyHash));
assertThat(checkSig(sig, pubKey));
}
}package runar.examples.counter;
import java.math.BigInteger;
import runar.lang.StatefulSmartContract;
import runar.lang.annotations.Public;
import static runar.lang.Builtins.assertThat;
class Counter extends StatefulSmartContract {
BigInteger count;
Counter(BigInteger count) {
super(count);
this.count = count;
}
@Public
void increment() {
this.count = this.count + BigInteger.ONE;
}
@Public
void decrement() {
assertThat(this.count > BigInteger.ZERO);
this.count = this.count - BigInteger.ONE;
}
}count has no @Readonly annotation, so it is a mutable state field on StatefulSmartContract. Assignments to this.count generate the state continuation output when the method returns.
Java contracts are tested with JUnit 5 against the contract's native Java business logic:
package runar.examples.p2pkh;
import org.junit.jupiter.api.Test;
import runar.lang.types.Addr;
import runar.lang.types.PubKey;
import runar.lang.types.Sig;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static runar.lang.Builtins.hash160;
import static runar.lang.testing.Mocks.mockPubKey;
import static runar.lang.testing.Mocks.mockSig;
class P2PKHTest {
@Test
void unlocksWithMatchingPubKey() {
PubKey pk = mockPubKey();
Addr pkh = hash160(pk).asAddr();
P2PKH c = new P2PKH(pkh);
c.unlock(mockSig(), pk);
}
@Test
void rejectsWrongPubKey() {
PubKey pk = mockPubKey();
PubKey wrong = PubKey.fromHex("03" + "00".repeat(32));
P2PKH c = new P2PKH(hash160(pk).asAddr());
assertThrows(AssertionError.class, () -> c.unlock(mockSig(), wrong));
}
}Mock crypto functions (checkSig, checkPreimage, verifyWOTS, etc.) always return true for business-logic testing. Hash functions (hash160, hash256, sha256, ripemd160) use real implementations.
A runar.lang.CompileCheck helper that runs the contract source through parse → validate → typecheck ships with milestone 11 — the off-chain simulator of the Java tier plan. Until then, tests instantiate the contract and verify business logic as native Java; the Rúnar compile pipeline is exercised via ./gradlew :compiler:test rather than per-test hooks.
The runar-java package (packages/runar-java/) provides:
- Base classes:
SmartContract,StatefulSmartContract - Annotations:
@Public,@Readonly,@Stateful(all inrunar.lang.annotations) - Types:
Addr,Sig,PubKey,ByteString,Point,P256Point,P384Point,Sha256Digest,SigHashPreimage,RabinSig,RabinPubKey,Ripemd160,OpCodeType— all inrunar.lang.types.FixedArrayis not among them: it is unsupported on this surface — seeFixedArrayis not supported on the Java surface. - Builtins:
Builtins.assertThat,Builtins.hash160,Builtins.checkSig, and peers (static methods) - Off-chain simulator:
runar.lang.runtime(milestone 11) - SDK:
RunarContract,Provider,Signer, transaction builders,PreparedCall(milestones 8–10)
Requires JDK 17 as the compile target (JDK 21 LTS works for local development). The toolchain is Gradle 8; no Spring, Jakarta EE, Guice, or Guava.
- Source locations are approximate. Every AST node currently reports
line 0, column 0for its source location. Error messages from validate/typecheck/lower passes name the file but cannot point at the offending line yet. A polishing pass attaches real positions by tracking line breaks across the source string. - Cross-compiler parity via milestone 7. Today only the Java compiler can parse
.runar.java. The TypeScript, Go, Rust, Python, Zig, and Ruby compilers gain hand-written.runar.javaparsers in milestone 7 of the tier plan, at which point the format joins the shared conformance matrix. - Package-private contracts only. The compound
.runar.javafilename forces contract classes to be package-private. Cross-package consumption depends on the typed-wrapper codegen (milestone 10). - No string literals in contract source. Use
ByteString.fromHex("...")for raw bytes. FixedArraycannot be declared at all. Not a "length must be a literal" restriction — an integer literal in a Java type-argument list is a javac syntax error, so no spelling works. It is unsupported on this surface by decision; seeFixedArrayis not supported on the Java surface. Use individual scalar properties, which emit byte-identical script.- No nested blocks, try/catch, lambdas, switch expressions, or non-Rúnar annotations. The parser rejects anything outside the frozen Rúnar subset.