Your first validator
In this tutorial you create a small validator, read every file that elci init generates, and check, test
and build it. Then you change it to require a second signature and update its tests. You need elci on your
PATH (see Installation).
What a validator does
Section titled “What a validator does”On Cardano, funds can be locked at a script address. Whenever a transaction tries to spend them, the ledger runs the script, called a validator, and the spend only happens if the validator approves. A spending validator receives four things:
- Parameters that you fix when you deploy the script, such as the owner’s key.
- The datum: data stored with the locked output when it was created.
- The redeemer: data that the spending transaction supplies.
- The script context: the whole transaction being validated, including who signed it.
It returns True to approve the transaction. Returning False, or failing during evaluation, rejects it.
Validators can also control minting, reward withdrawals and governance actions; this tutorial sticks to
spending.
Create the project
Section titled “Create the project”elci init my-contractcd my-contractCreated Elci project at .../my-contract with initial spend handler in validator main.Run: elci check .../my-contractelci init creates this layout:
Directorymy-contract/
- elci.toml project configuration
- README.md
- .gitignore ignores build/
Directorysrc/
- Main.elm the validator
Directorytests/
- MainTest.elm two transaction scenarios
The configuration
Section titled “The configuration”[project]source-directories = ["src"]target = "plutus-v3-pv11-uplc-1.1.0"julc-version = "0.1.0-pre20-ebaf028-SNAPSHOT"
# One deployed script. Add handler keys (spend, mint, withdraw, certify, vote,# propose) to let this script serve more purposes; add more [validators.<name>]# tables for independent scripts.[validators.main]boundary = "julc-strict-v1"parameters = ["owner"]spend = "Main.spend"[project]says where your modules live, which on-chain target to compile for (Plutus V3), and which Julc version the compiler must match.[validators.main]declares one script calledmain. Itsspendhandler is the functionspendin moduleMain, and it takes one deployment parameter calledowner.boundary = "julc-strict-v1"selects strict decoding of the data the script receives. It is currently the only boundary policy.
The source code doesn’t say how it is deployed. The configuration does, so the same function could be reused in another validator.
The validator
Section titled “The validator”module Main exposing (spend)
import Julc.Ledger exposing (PubKeyHash, ScriptContext)
{-| Accept only transactions signed by the owner, a deployment parameter.-}spend : PubKeyHash -> () -> () -> ScriptContext -> Boolspend owner _ _ ctx = List.any (\signer -> signer == owner) ctx.txInfo.signatoriesLine by line:
-
module Main exposing (spend)names the module (it must match the file pathsrc/Main.elm) and makesspendvisible to the configuration and to tests. -
import Julc.Ledger exposing (PubKeyHash, ScriptContext)brings in two Cardano ledger types. -
The signature lists the arguments in the order the handler receives them:
Argument Type Meaning ownerPubKeyHashThe ownerparameter: the hash of the owner’s public key_()The datum. ()(unit) means “no information”_()The redeemer, also unit ctxScriptContextThe transaction being validated -
The body reads
ctx.txInfo.signatories, the key hashes that signed the transaction, and usesList.anyto check whether one of them is the owner.\signer -> signer == owneris an anonymous function (a lambda). The coreTxmodule has the same check asTx.signedBy owner ctx, afterimport Tx.
The _ pattern ignores an argument. The datum and redeemer are still decoded strictly before spend runs,
so they must be unit data.
Check it
Section titled “Check it”elci check compiles every validator all the way through the backend, without writing any files:
elci check .validator main (spend): checked; 300 FLAT bytes; plutus-v3-pv11-uplc-1.1.0The line names the validator and its purposes, then gives the size of the compiled script in bytes and the target. Script size matters on Cardano, because the transaction that uses a script pays for its bytes.
The tests
Section titled “The tests”module MainTest exposing (othersAreRejected, ownerSigns)
import Julc.Data as Dataimport Julc.Ledger as Ledger exposing (PubKeyHash)import Scenario exposing (Scenario)
owner : PubKeyHashowner = Scenario.key "owner"
transaction : Scenariotransaction = Scenario.spend "main" (Scenario.outRef "main" 0) Scenario.unit |> Scenario.parameters [ Data.encode owner ]
ownerSigns : ScenarioownerSigns = transaction |> Scenario.signedBy owner
othersAreRejected : ScenarioothersAreRejected = transaction |> Scenario.signedBy (Scenario.key "someone else") |> Scenario.expectFailureA Scenario describes one transaction against a validator of your project:
Scenario.key "owner"derives a deterministic test key hash from a name.Scenario.spend "main" (Scenario.outRef "main" 0) Scenario.unitspends an output locked at validatormain’s address, with a unit datum.Scenario.parameters [ Data.encode owner ]supplies the deployment parameters, in the order declared inelci.toml.Data.encodeturns an Elci value into on-chain data.Scenario.signedByadds a signer, andScenario.expectFailuresays the validator must reject the transaction.
Only the exposed declarations of type Scenario are tests. owner and transaction are helpers.
Run the tests
Section titled “Run the tests”elci test .PASS MainTest.ownerSigns (cpu 7675163, mem 29990)PASS MainTest.othersAreRejected (cpu 8053596, mem 31922)2 passed, 0 failedEach scenario compiles the validator exactly as elci build does, applies the parameters, builds a complete
ledger context for the transaction and runs the script. cpu and mem are the execution budget the script
used. The ledger charges fees for these units and limits how many a transaction may use.
Build it
Section titled “Build it”elci build .validator main (spend): checked; 300 FLAT bytes; plutus-v3-pv11-uplc-1.1.0The build writes one directory per validator:
Directorybuild/
Directoryvalidators/
- elci-outputs.json the files Elci owns
Directorymain/
- abi.json how your types are encoded
- validator.flat the script in FLAT encoding
- validator.plutus a Plutus V3 text envelope
- validator.plutus.json the Julc artifact for Cardano Client Lib
validator.plutus.json is the file your off-chain code loads (the cborHex value is shortened here):
{ "type": "PlutusScriptV3", "purpose": "spending", "description": "main", "cborHex": "59012f59012c010100323232322322323232323293370e...", "hash": "", "params": "owner:PubKeyHash", "sizeBytes": 300}The hash is empty because the script still needs its owner parameter. The hash of a script changes
when you apply parameters to it.
Make it your own
Section titled “Make it your own”Now change the validator so that spending needs two signatures: the owner’s and a guardian’s.
-
Change the handler. Add a
guardianparameter, and move the signature check into a helper function so you can use it twice:src/Main.elm module Main exposing (spend)import Julc.Ledger exposing (PubKeyHash, ScriptContext){-| Accept only transactions signed by both the owner and the guardian.-}spend : PubKeyHash -> PubKeyHash -> () -> () -> ScriptContext -> Boolspend owner guardian _ _ ctx =signedBy owner ctx && signedBy guardian ctxsignedBy : PubKeyHash -> ScriptContext -> BoolsignedBy key ctx =List.any (\signer -> signer == key) ctx.txInfo.signatoriessignedByisn’t exposed, so it stays private to the module. -
Check it. The compiler notices that the handler no longer matches its configuration:
Terminal window elci check ..../my-contract/src/Main.elm:8:1: ELCI2014: Handler Main.spend does not have the spend signature owner -> datum -> redeemer -> ScriptContext -> Bool that [validators.main] needs (1 parameter); its type is PubKeyHash -> PubKeyHash -> () -> () -> ScriptContext -> Bool; it has `()` where the configuration needs `ScriptContext`8| spend : PubKeyHash -> PubKeyHash -> () -> () -> ScriptContext -> Bool^^^^^In .../my-contract/elci.toml:9: [validators.main]For more on ELCI2014, run `elci explain ELCI2014`.The table still declares one parameter, so the handler must take one parameter, then the datum, the redeemer and the context.
spendnow takes two parameters, so its arguments no longer line up.ELCI2014always means that a handler and itselci.tomltable disagree;elci explain ELCI2014says more. -
Declare the new parameter. Parameters are listed in the order the handler receives them:
elci.toml [validators.main]boundary = "julc-strict-v1"parameters = ["owner", "guardian"]spend = "Main.spend"Terminal window elci check .validator main (spend): checked; 323 FLAT bytes; plutus-v3-pv11-uplc-1.1.0 -
Run the old tests. They still supply only one parameter:
Terminal window elci test .FAIL MainTest.ownerSignscannot prepare validator 'main': it takes 2 parameters [owner, guardian], but the scenario gives 1; set them with Scenario.parametersFAIL MainTest.othersAreRejectedcannot prepare validator 'main': it takes 2 parameters [owner, guardian], but the scenario gives 1; set them with Scenario.parameters0 passed, 2 failedBoth scenarios fail before the script even runs, and that includes
othersAreRejected, which expects the validator to reject its transaction. Without this check it could pass for the wrong reason: the script would fail on the missing parameter, not on the missing signature. A scenario withexpectFailureonly proves something when the transaction is otherwise valid, so pair each one with a similar scenario that must succeed. -
Update the tests. Supply both parameters, and cover each way the transaction can be signed:
tests/MainTest.elm module MainTest exposing (bothSign, guardianAloneIsRejected, othersAreRejected, ownerAloneIsRejected)import Julc.Data as Dataimport Julc.Ledger as Ledger exposing (PubKeyHash)import Scenario exposing (Scenario)owner : PubKeyHashowner =Scenario.key "owner"guardian : PubKeyHashguardian =Scenario.key "guardian"transaction : Scenariotransaction =Scenario.spend "main" (Scenario.outRef "main" 0) Scenario.unit|> Scenario.parameters [ Data.encode owner, Data.encode guardian ]bothSign : ScenariobothSign =transaction|> Scenario.signedBy owner|> Scenario.signedBy guardianownerAloneIsRejected : ScenarioownerAloneIsRejected =transaction|> Scenario.signedBy owner|> Scenario.expectFailureguardianAloneIsRejected : ScenarioguardianAloneIsRejected =transaction|> Scenario.signedBy guardian|> Scenario.expectFailureothersAreRejected : ScenarioothersAreRejected =transaction|> Scenario.signedBy (Scenario.key "someone else")|> Scenario.expectFailure -
Run the tests again.
Terminal window elci test .PASS MainTest.bothSign (cpu 11441262, mem 45132)PASS MainTest.ownerAloneIsRejected (cpu 11088065, mem 43199)PASS MainTest.guardianAloneIsRejected (cpu 8425738, mem 34154)PASS MainTest.othersAreRejected (cpu 8425738, mem 34154)4 passed, 0 failedTo run only some tests, pass part of their name with
--match:Terminal window elci test . --match alonePASS MainTest.ownerAloneIsRejected (cpu 11088065, mem 43199)PASS MainTest.guardianAloneIsRejected (cpu 8425738, mem 34154)2 passed, 0 failed -
Build the new script.
Terminal window elci build .validator main (spend): checked; 323 FLAT bytes; plutus-v3-pv11-uplc-1.1.0validator.plutus.jsonnow lists both parameters:"params": "owner:PubKeyHash,guardian:PubKeyHash".
Apply the parameters
Section titled “Apply the parameters”Before you deploy, you apply the real parameter values. elci apply takes each value as Plutus Data in
cardano-cli’s JSON format. A PubKeyHash is 28 bytes, written as hex. These two keys are placeholders:
elci apply . --validator main \ --param 'owner={"bytes": "01000000000000000000000000000000000000000000000000000000"}' \ --param 'guardian={"bytes": "02000000000000000000000000000000000000000000000000000000"}'validator main: applied 2 parameters; hash 7cf11fa222b171e7a3102db8f51aac3f4815ee8256ed47bde39cc6a6; wrote .../my-contract/build/validators/main/applied.plutus.jsonapply first checks each value against the parameter’s type. The applied script has its own hash, which
determines its address on-chain. Parameterized validators explains the format.
What you learned
Section titled “What you learned”elci.tomldecides how functions are deployed:[validators.<name>], handler keys andparameters.- A spend handler receives parameters, datum, redeemer and context, and returns
Bool. elci checkcatches mistakes, including a handler that doesn’t match its configuration.elci testruns transaction scenarios against the exact script thatelci buildwrites.