Skip to content

JRL (JuLC Rule Language) Guide (Experimental)

Write Cardano smart contracts using a simple, declarative rule language — no Java knowledge required. JRL compiles to UPLC through JuLC, producing the same efficient Plutus V3 scripts as hand-written Java validators.

JRL is a domain-specific language inspired by business rule engines like Drools. Instead of writing imperative code, you declare rules with conditions and actions. The JRL compiler translates your rules into Java, then compiles that Java to UPLC via the JuLC compiler pipeline.

Key design choices:

  • Non-Turing-complete — no loops, no recursion, no mutable variables
  • Declarative — say what should happen, not how
  • Safe by default — the type checker catches errors before compilation
  • Familiar syntax — reads like English: when ... then allow

Compilation pipeline:

YourContract.jrl → parse → type check → Java source → JulcCompiler → UPLC bytecode

The fastest way to start writing JRL contracts is with the julc command-line tool.

Terminal window
# macOS / Linux
brew install bloxbean/tap/julc
# Or download from GitHub Releases:
# https://github.com/bloxbean/julc/releases
Terminal window
julc new my-contract --jrl
cd my-contract

This creates:

my-contract/
├── julc.toml # project config
├── src/
│ └── AlwaysSucceeds.jrl # starter validator
├── test/
│ └── AlwaysSucceedsTest.java
└── .julc/
└── stdlib/ # auto-installed standard library

The generated AlwaysSucceeds.jrl:

contract "AlwaysSucceeds"
version "1.0"
purpose spending
rule "Always allow"
when
Condition( true )
then
allow
default: deny
Terminal window
julc build

Output:

Building my-contract ...
Compiling AlwaysSucceeds.jrl ... OK [67 bytes, a1b2c3d4...]
Build successful: 1 validator(s) compiled to build/plutus/

The compiled script is in build/plutus/AlwaysSucceeds.uplc, and a CIP-57 blueprint is generated at build/plutus/plutus.json.


Every JRL file follows this structure:

contract "<name>"
version "<version>"
purpose <spending | minting | withdraw | certifying | voting | proposing>
[params: ...] -- optional: compile-time parameters
[datum <Type>: ...] -- optional: datum schema (spending only)
[record <Type>: ...] -- optional: helper record types
[redeemer <Type>: ...] -- optional: redeemer schema
rule "<name>" -- one or more rules
when
<patterns>
then
<allow | deny>
default: <allow | deny> -- fallback when no rule matches

The header declares the contract’s name, version, and purpose:

contract "Vesting" version "1.0" purpose spending

Purpose determines what kind of Cardano script is generated:

PurposeCardano Script TypeHas Datum?
spendingSpending validatorYes
mintingMinting policyNo
withdrawWithdrawal validatorNo
certifyingCertifying validatorNo
votingVoting validatorNo
proposingProposing validatorNo

JRL has a focused set of types that map to Cardano on-chain representations:

JRL TypeDescriptionJava Equivalent
IntegerArbitrary-precision integerBigInteger
LovelaceADA amount (1 ADA = 1M)BigInteger
POSIXTimeUnix timestamp (milliseconds)BigInteger
ByteStringRaw bytesbyte[]
PubKeyHashPublic key hashbyte[]
PolicyIdMinting policy IDbyte[]
TokenNameToken/asset namebyte[]
ScriptHashScript hashbyte[]
DatumHashDatum hashbyte[]
TxIdTransaction IDbyte[]
AddressCardano addressAddress
TextUTF-8 stringString
BooleanTrue/falseboolean
List <T>Typed listList<T>
Optional <T>Optional valueOptional<T>

Parameters are compile-time values baked into the script. They make your contract reusable — deploy the same logic with different keys, deadlines, or thresholds.

contract "TimeLock" version "1.0" purpose spending
params:
owner : PubKeyHash
lockTime : POSIXTime

Parameters are referenced by name in rules:

rule "Owner can spend after lock"
when
Transaction( signedBy: owner )
Transaction( validAfter: lockTime )
then allow

When building with julc build, parameter values are applied at deployment time via the CIP-57 blueprint.


Spending validators can declare a datum — structured data stored at the UTxO:

datum VestingDatum:
owner : PubKeyHash
beneficiary : PubKeyHash
deadline : POSIXTime

This declares a record type. In rules, you extract fields using the Datum pattern with $variable bindings:

rule "Beneficiary withdraws after deadline"
when
Datum( VestingDatum( beneficiary: $ben, deadline: $dl ) )
Transaction( signedBy: $ben )
Transaction( validAfter: $dl )
then allow

You can declare helper record types for organizing complex data:

record Payment:
recipient : PubKeyHash
amount : Lovelace

Records can be used as datum field types, redeemer field types, or anywhere a structured type is needed.


The redeemer tells the validator what action the transaction wants to perform. JRL supports two styles:

redeemer SpendAction:
amount : Lovelace
deadline : POSIXTime
redeemer MintAction:
| Mint
| Burn
| Transfer:
recipient : PubKeyHash
amount : Integer

Variant redeemers use | to declare alternatives. Each variant can have zero or more fields. In rules, you match specific variants:

rule "Authority can mint"
when
Redeemer( Mint )
Transaction( signedBy: authority )
then allow
rule "Owner can burn"
when
Redeemer( Burn )
Transaction( signedBy: owner )
then allow

Rules are the heart of JRL. Each rule has:

  1. A name (for documentation and tracing)
  2. Patterns in the when clause (conditions that must all be true)
  3. An action (allow or deny)
rule "Owner can always withdraw"
when
Datum( VestingDatum( owner: $owner ) )
Transaction( signedBy: $owner )
then allow

Rules are evaluated in order. The first rule whose conditions all match determines the result. If no rule matches, the default action applies.

for each rule (in declaration order):
if ALL patterns in the rule match:
return the rule's action (allow → true, deny → false)
return default action

Patterns appear in a rule’s when clause. All patterns in a rule must match for the rule to fire. JRL provides eight pattern types:

Extract and match datum fields (spending validators only):

-- Bind a field to a variable
Datum( MyDatum( owner: $owner ) )
-- Match a literal value
Datum( MyDatum( flag: true ) )
-- Bind multiple fields
Datum( MyDatum( owner: $o, amount: $a, deadline: $d ) )

Match redeemer type and extract fields:

-- Simple variant match (no fields)
Redeemer( Mint )
-- Variant with field extraction
Redeemer( Transfer( recipient: $r, amount: $a ) )
-- Record redeemer field extraction
Redeemer( SpendAction( amount: $amt ) )

Check transaction properties:

-- Check if a public key signed the transaction
Transaction( signedBy: owner )
Transaction( signedBy: $boundVar )
-- Check transaction validity interval
Transaction( validAfter: lockTime ) -- tx valid range starts after lockTime
Transaction( validBefore: deadline ) -- tx valid range ends before deadline
-- Bind the transaction fee to a variable
Transaction( fee: $txFee )

The fee: field binds the transaction fee to a variable for use in conditions:

rule "Fee must be reasonable"
when
Transaction( fee: $f )
Condition( $f <= 2000000 )
then allow

Arbitrary boolean expression:

Condition( true )
Condition( $amount > 1000000 )
Condition( sha2_256($secret) == expectedHash )
Condition( $a + $b >= $threshold )

Check transaction outputs by address, value, and/or datum:

-- Output must go to address with minimum ADA
Output( to: $recipient, value: minADA( $amount ) )
-- Output must contain a specific token
Output( to: $addr, value: contains( $policy, $token, 1 ) )
-- Output must have a specific inline datum
Output( to: receiver, Datum: inline PaymentReceipt( payer: $payer ) )
-- Output with both value and datum checks
Output( to: receiver, value: minADA( 2000000 ), Datum: inline Receipt( ref: refHash ) )

The Datum: field checks the inline datum at the output address. Field values are compared for equality.

Check transaction inputs — useful for verifying the spending UTxO or checking that specific tokens are present in the transaction inputs:

-- Bind the value at the script's own input
Input( from: ownAddress, value: $inputVal )
-- Check that inputs contain a specific token (authorization pattern)
Input( token: contains( authPolicy, "AUTH", 1 ) )

Supported fields:

FieldDescription
from:Address expression (ownAddress or a parameter/variable)
value:Bind own input’s value to a $variable (requires from: ownAddress)
token:Check that total input value contains a token: contains(policy, token, amount) or minADA(amount)

Notes:

  • value: $var binds the value of the script’s own input UTxO, and requires from: ownAddress.
  • token: checks the total valueSpent of all transaction inputs.

Check minting or burning activity in the transaction:

-- Bind minted amount to a variable
Mint( policy: ownPolicyId, token: "MyToken", amount: $amt )
-- Check that tokens are being burned
Mint( policy: ownPolicyId, token: "LPToken", burned )
-- Check that a specific token is being minted (amount > 0)
Mint( policy: ownPolicyId, token: "Ticket" )

Supported fields:

FieldDescription
policy:Policy ID expression (required)
token:Token name expression
amount:Bind minted amount to a $variable
burnedAssert the minted amount is negative (burning)

Constraints:

  • policy: is required (error JRL041 if missing).
  • burned and amount: are mutually exclusive (error JRL045).
  • At least one of token:, amount:, or burned must be present (error JRL044).

Check outputs that go back to the script’s own address. This is the “state continuation” pattern — verifying that the contract re-creates itself with updated state:

-- Check minimum ADA in continuing output
ContinuingOutput( value: minADA( 2000000 ) )
-- Check continuing output has specific datum
ContinuingOutput( Datum: inline StateDatum( counter: 42 ) )
-- Check both value and datum
ContinuingOutput( value: minADA( 2000000 ), Datum: inline StateDatum( counter: $newCount ) )
-- Check continuing output contains a specific token
ContinuingOutput( value: contains( policy, token, 1 ) )

Notes:

  • Only valid in spending purpose (error JRL042 otherwise).
  • Uses getContinuingOutputs(ctx) — outputs that go back to the same script address. No to: field needed since the address is implicit.
  • Checks the first continuing output.

JRL expressions appear inside patterns and conditions. They support:

$a + $b
$a - $b
$a * $b
$a == $b -- equality
$a != $b -- inequality
$a > $b -- greater than
$a >= $b -- greater or equal
$a < $b -- less than
$a <= $b -- less or equal
$a && $b -- logical AND
$a || $b -- logical OR
!$a -- logical NOT
$datum.owner
$datum.deadline

Variables are bound in Datum and Redeemer patterns using $name syntax. Parameters are referenced by their plain name (no $ prefix).

-- $owner is a bound variable from Datum pattern
-- authority is a parameter
Transaction( signedBy: $owner )
Transaction( signedBy: authority )
42 -- integer
"hello" -- string
0xFF -- hex bytes
true / false -- boolean
FunctionDescriptionExample
sha2_256SHA-256 hashsha2_256($secret)
blake2b_256BLAKE2b-256 hashblake2b_256($data)
sha3_256SHA3-256 hashsha3_256($input)
lengthList/bytestring lengthlength($signatories)
ReferenceDescription
ownAddressThe script’s own address
ownPolicyIdThe script’s own policy ID (minting)

Every contract (or purpose section) must end with a default action:

default: deny -- reject if no rule matched (most common)
default: allow -- accept if no rule matched

Best practice: Use default: deny to fail-closed. Only use default: allow when you explicitly want permissive behavior.


Add optional trace messages to rules for debugging:

rule "Owner can withdraw"
when
Transaction( signedBy: owner )
then allow trace "owner-withdraw"

Trace messages appear in transaction logs during script evaluation, useful for debugging failed validations.


-- This is a single-line comment
/* This is a
multi-line comment */

The simplest useful validator — only the designated receiver can spend the UTxO:

contract "SimpleTransfer" version "1.0" purpose spending
params:
receiver : PubKeyHash
rule "Receiver can spend"
when
Transaction( signedBy: receiver )
then allow
default: deny

Lock funds until a specific time. Only the owner can spend, and only after the lock expires:

contract "TimeLock" version "1.0" purpose spending
params:
owner : PubKeyHash
lockTime : POSIXTime
rule "Owner can spend after lock time"
when
Transaction( signedBy: owner )
Transaction( validAfter: lockTime )
then allow
default: deny

Example 3: Vesting (Datum + Multiple Rules)

Section titled “Example 3: Vesting (Datum + Multiple Rules)”

Two spending paths: the owner can always withdraw, but the beneficiary can only withdraw after the deadline:

contract "Vesting" version "1.0" purpose spending
datum VestingDatum:
owner : PubKeyHash
beneficiary : PubKeyHash
deadline : POSIXTime
rule "Owner can always withdraw"
when
Datum( VestingDatum( owner: $owner ) )
Transaction( signedBy: $owner )
then allow
rule "Beneficiary can withdraw after deadline"
when
Datum( VestingDatum( beneficiary: $ben, deadline: $deadline ) )
Transaction( signedBy: $ben )
Transaction( validAfter: $deadline )
then allow
default: deny

Example 4: Multi-Signature Treasury (Two Signers)

Section titled “Example 4: Multi-Signature Treasury (Two Signers)”

Both signers stored in the datum must sign the transaction:

contract "MultiSigTreasury" version "1.0" purpose spending
datum TreasuryDatum:
signer1 : PubKeyHash
signer2 : PubKeyHash
rule "Both signers required"
when
Datum( TreasuryDatum( signer1: $s1, signer2: $s2 ) )
Transaction( signedBy: $s1 )
Transaction( signedBy: $s2 )
then allow
default: deny

Example 5: HTLC — Hash Time-Locked Contract (Variant Redeemer + Crypto)

Section titled “Example 5: HTLC — Hash Time-Locked Contract (Variant Redeemer + Crypto)”

Funds can be claimed by guessing a secret (hash preimage), or reclaimed by the owner after expiry:

contract "HTLC" version "1.0" purpose spending
params:
secretHash : ByteString
expiration : POSIXTime
owner : PubKeyHash
redeemer HtlcAction:
| Guess:
answer : ByteString
| Withdraw
rule "Correct guess unlocks"
when
Redeemer( Guess( answer: $answer ) )
Condition( sha2_256($answer) == secretHash )
then allow
rule "Owner can withdraw after expiry"
when
Redeemer( Withdraw )
Transaction( signedBy: owner )
Transaction( validAfter: expiration )
then allow
default: deny

Example 6: Multi-Sig Minting Policy (Minting + Variant Redeemer)

Section titled “Example 6: Multi-Sig Minting Policy (Minting + Variant Redeemer)”

A minting policy with three paths: authority mint, owner burn, or multi-sig mint:

contract "MultiSigMinting" version "1.0" purpose minting
params:
authority : PubKeyHash
owner : PubKeyHash
cosigner1 : PubKeyHash
cosigner2 : PubKeyHash
redeemer MintAction:
| MintByAuthority
| BurnByOwner
| MintByMultiSig
rule "Authority can mint"
when
Redeemer( MintByAuthority )
Transaction( signedBy: authority )
then allow
rule "Owner can burn"
when
Redeemer( BurnByOwner )
Transaction( signedBy: owner )
then allow
rule "Multi-sig can mint"
when
Redeemer( MintByMultiSig )
Transaction( signedBy: cosigner1 )
Transaction( signedBy: cosigner2 )
then allow
default: deny

Example 7: Output Checking (Value Constraints)

Section titled “Example 7: Output Checking (Value Constraints)”

Ensure the transaction pays the right amount to the right address:

contract "OutputCheck" version "1.0" purpose spending
datum PaymentDatum:
recipient : ByteString
minAmount : Lovelace
rule "Payment meets minimum"
when
Datum( PaymentDatum( recipient: $addr, minAmount: $min ) )
Output( to: $addr, value: minADA( $min ) )
then allow
default: deny

A single JRL file can define multiple validator purposes using purpose sections. This creates a multi-validator script that handles spending, minting, or other purposes in one contract:

contract "TokenMarket" version "1.0"
purpose spending:
redeemer SpendAction:
| Buy
| Withdraw
rule "Anyone can buy"
when
Redeemer( Buy )
Condition( true )
then allow
rule "Owner withdraws"
when
Redeemer( Withdraw )
Transaction( signedBy: owner )
then allow
default: deny
purpose minting:
redeemer MintAction:
| Mint
| Burn
rule "Authority mints"
when
Redeemer( Mint )
Transaction( signedBy: authority )
then allow
default: deny

Each purpose section has its own redeemer, rules, and default action. Shared parameters and datum declarations go at the top level.


The JRL compiler provides clear error messages with source locations and suggestions. Errors are prefixed with codes like JRL001:

CodeCategoryExample
JRL000Syntax errorMissing keyword or unexpected token
JRL001Missing defaultContract has no default: action
JRL002Missing rulesContract or section has no rules
JRL003Duplicate nameTwo rules with the same name
JRL005Unknown typeField uses an undefined type
JRL007Duplicate fieldRecord has two fields with the same name
JRL008Duplicate variantRedeemer has two variants with the same name
JRL009Missing headerContract name or version not specified
JRL010Missing purposeNo purpose in header and no purpose sections
JRL011Unbound variable$var used in condition but never bound
JRL041Mint missing policyMint pattern requires policy: field
JRL042Wrong purposeContinuingOutput only valid in spending
JRL044Mint incompleteMint needs token:, amount:, or burned
JRL045Mutually exclusiveburned and amount: cannot both appear
JRL046Input constraintvalue: $var requires from: ownAddress
JRL047Input uselessInput(from: expr) alone has no effect
JRL050Datum literalLiteral values not allowed in datum patterns

Use JRL when…Use Java when…
Authorization logic (signatures + time)Complex business logic with loops
Output/input/minting checksCustom data transformations
Pattern matching on datum/redeemerMultiple helper methods
State continuation (ContinuingOutput)Fine-grained budget optimization
Quick prototypingIntegration with off-chain Java code
Non-developers writing validatorsAdvanced stdlib usage (maps, lists, HOFs)
Clear, auditable rule setsComplex multi-step state machines

JRL is perfect for validators that follow the pattern: “check conditions, allow or deny.” For complex logic involving iteration, accumulation, or deep data manipulation, use Java directly.

You can mix both in the same project — julc build compiles .java and .jrl files side by side.


Understanding the compilation pipeline helps with debugging:

┌─────────────┐
Vesting.jrl ───→│ JRL Parser │───→ AST (ContractNode)
└──────┬──────┘
┌──────▼──────┐
│ Type Checker │───→ Diagnostics (errors/warnings)
└──────┬──────┘
┌──────▼──────────┐
│ Java Transpiler │───→ Vesting.java (generated)
└──────┬──────────┘
┌──────▼──────────┐
│ JulcCompiler │───→ UPLC Program
└──────┬──────────┘
┌──────▼──────────┐
│ CBOR + FLAT │───→ Plutus script bytes
└─────────────────┘
  1. Parser: ANTLR4-based parser converts JRL syntax into an AST
  2. Type Checker: Validates structure, types, names, and variable scoping
  3. Java Transpiler: Generates a JuLC-compatible Java validator class
  4. JulcCompiler: Compiles the generated Java to UPLC bytecode
  5. Serialization: UPLC is serialized to on-chain format

The intermediate Java source is available for inspection — useful for debugging or understanding what JRL generates.


You can use the JRL compiler directly from Java code:

var compiler = new JrlCompiler();
var result = compiler.compile(jrlSource, "MyContract.jrl");
if (result.hasErrors()) {
for (var diag : result.jrlDiagnostics()) {
System.err.println(diag);
}
} else {
var program = result.compileResult().program();
// Use the UPLC program...
}
var compiler = new JrlCompiler();
var transpiled = compiler.transpile(jrlSource, "MyContract.jrl");
if (!transpiled.hasErrors()) {
String javaSource = transpiled.javaSource();
System.out.println(javaSource);
}
var compiler = new JrlCompiler();
var diagnostics = compiler.check(jrlSource, "MyContract.jrl");
for (var diag : diagnostics) {
if (diag.isError()) {
System.err.println(diag.code() + ": " + diag.message());
}
}
implementation 'com.bloxbean.cardano:julc-jrl-core:<version>'

  1. Start with default: deny — fail-closed is safer. Only allow what you explicitly permit.

  2. Order rules by specificity — put more specific rules (variant redeemer matches) before general ones (plain conditions).

  3. Use parameters for keys and deadlines — don’t hardcode public key hashes or timestamps. Parameters make your contract reusable.

  4. Bind only what you need — in datum/redeemer patterns, only bind fields you actually use in conditions.

  5. One rule per logical path — each rule should represent one clear spending or minting scenario. Multiple patterns in a rule mean AND; multiple rules mean OR.

  6. Test with julc — the CLI compiles and validates your contract instantly, catching type errors and structural issues before deployment.

  7. Inspect generated Java — use the transpile API to see what Java code your JRL produces. This helps understand the mapping and debug issues.