Singular Demo 1, step by step¶
Follow Alice as she registers a key, changes its payload and permanently terminates it. Carol starts the registry; Bob folds the registration and termination.
Registry lifecycle¶
flowchart TD
N[Unregistered key] -->|Alice books| P[Registration pending]
P -->|Bob folds| H[Holding]
P -->|Requester reclaims| R[Key stays unregistered]
P -->|Cleanup after expiry| R
H -->|Controller updates payload| U[Updated holding]
U -->|Controller donates deposit| T[Termination pending]
T -->|Anyone folds and burns tokens| X[Terminated key]
A terminated key cannot be registered again. Carol creates the registry before Alice begins.
Every block has a copy button: copy it, paste it into the terminal, run the next one. The blocks are meant to be pasted in order into one terminal. A page with bigger buttons and a done mark per step is the same tutorial.
Release v1.1.0. Everything runs on a private test chain. The project is at https://github.com/lambdasistemi/singular and its documentation at https://lambdasistemi.github.io/singular/.
Before you start: open the shell¶
Everything below is typed in one terminal. The programs used are singular, open-datum, devnet and jq; the shell below puts them on the PATH and sets $blueprint, the compiled contracts the commands need. Use the nix shell, or the Ubuntu kit if the machine has no Nix.
With Nix, one command opens the shell. The first run fetches about 190 MiB from the binary caches.
nix develop github:lambdasistemi/singular-demo --accept-flake-config
Without Nix, unpack the kit from the release page and use its folder:
cd singular-demo-v1.1.0-2-x86_64-linux
export PATH="$PWD/bin:$PATH"
export blueprint="$PWD/share/plutus.json"
Before you start: two programs, one registry¶
Carol, Alice and Bob use two programs to run a registry on a private Cardano chain. Each actor keeps their own wallet and journal.
singular provides wallet creation, wallet signing and transaction submission. Its generic registry creator takes an application policy; this demo uses the application-aware open-datum create with its processing and reclaim windows.
open-datum books a registration with insert, folds the pending requests it finds on the chain with fold, directly updates a holding with update, and books its permanent termination with terminate. A pending registration can be taken back by its requester with reclaim, or cleaned up by anyone after expiry with cleanup.
A registration becomes a holding when it is folded. Its controller may change the payload directly. Termination donates the protected deposit to the treasury; its later fold burns the active token and approval. A terminated key cannot be registered again.
Anyone can read a key back from the chain, with no wallet: open-datum inspect shows a live holding with its payload and deposit, or a pending termination; singular registry inspect answers active, terminal or never registered and names the application. Both exit with status 0 for an answer, 1 when the chain cannot be read and 2 for a refusal. The steps read the key after each change.
The trie library's proofs are assumed sound.
Before you start: two ways to sign¶
A vault is an encrypted key file. Most commands in Demo1 use the actor's own vault to build, sign and submit a transaction, then wait for its confirmation. Each actor keeps their vault, settings, journal and receipts in their own directory.
Bob also uses external signing: the application command builds an unsigned bundle, singular wallet sign writes its witnesses, then singular submit sends it and waits for confirmation. The unsigned builder returns exit status 19, meaning waiting for signatures. Building and signing alone send nothing to the chain.
Before you start: three wallets and a private chain¶
A wallet here is a vault: an encrypted key file, opened by a passphrase. The key never leaves the vault. Carol, Alice and Bob each get one. Nothing here touches a public network.
On a private chain the passphrase comes from the environment variable SINGULAR_PASSPHRASE, so no command asks for it. All three vaults here use demo, a throwaway word. The program accepts the variable on a private chain only.
Running this a second time? A vault from an earlier try is refused ("vault already exists"), and it belongs to the earlier chain's addresses. Start clean first: stop the old devnet and play again in a fresh run folder. Skip this the first time.
kill "$devnet_pid" 2>/dev/null
A folder of your own. The run folder holds one directory for Alice and one for Bob, each with the folders for their receipts, bundles and witnesses and an empty settings file of their own (alice/alice.json, bob/bob.json), and the shell setup every window starts with. The set-up script made them before the first page.
Load the shell setup in each window. One block per window sources it: the run folder, the programs on PATH, the passphrase, the helpers (koios_url, wait_for_slot, wallet_show), the window's settings file, and the actor words carol, alice and bob, which select that actor's SINGULAR_CONFIG. Everything below relies only on what these four blocks load.
. ../stepper.sh
. ../stepper.sh
. ../stepper.sh
. ./stepper.sh
Now Alice.
alice
Alice's vault. singular wallet create makes the vault file and prints her address. The $(...) keeps the printed address in alice_addr, and tee also keeps it in the file alice.address, which the chain window and the later pages read.
--vault alice.vault: the file to create, in her own directory--network-magic 42: the number of this private test chain (preprod is 1, mainnet 764824073)
alice_addr=$(singular wallet create --vault alice.vault --network-magic 42 | tee alice.address)
Now Bob.
bob
Bob's vault, the same way, in his own directory:
bob_addr=$(singular wallet create --vault bob.vault --network-magic 42 | tee bob.address)
Now Carol.
carol
Carol's own vault:
carol_addr=$(singular wallet create --vault carol.vault --network-magic 42 | tee carol.address)
Now Chain.
chain
The private chain, started in the background. devnet runs a real Cardano node with a Koios-style web API in front of it. It only ever sees the addresses, never the keys.
--fund-address ADDRESS:6:2000000000: gives the address 6 coins of 2000 ada each (2000000000 lovelace)$(cat alice/alice.address): the address, read from the file Alice's window wrote (Bob's the same way)> devnet.out: its one line of settings goes to this file, its messages todevnet.err&: runs it in the backgrounddevnet_pid=$!: remembers its process group. A trap stops the whole group when this shell exits, so an unattended run leaves nothing behind (the node outlives its wrapper); the last step shows the manual stop
devnet --fund-address "$(cat alice/alice.address):6:2000000000" --fund-address "$(cat bob/bob.address):6:2000000000" --fund-address "$(cat carol/carol.address):6:2000000000" > devnet.out 2> devnet.err &
devnet_pid=$!
trap 'kill -TERM -- -"$devnet_pid" 2>/dev/null' EXIT
Wait until the settings line has appeared, then show it. providerUrl is where the chain's web API answers. If the devnet dies first, the wait stops at once and shows the devnet's messages instead of hanging.
devnet_gone=0
until jq -e .providerUrl devnet.out >/dev/null 2>&1; do
kill -0 "$devnet_pid" 2>/dev/null || { devnet_gone=1; break; }
sleep 1
done
if [ "$devnet_gone" = 1 ]; then echo "the devnet died before its settings line appeared; its messages:"; cat devnet.err; false; else jq . devnet.out; fi
Step 1: Carol creates the registry¶
Carol starts a registry for the group. Its token identifies it for everyone; creating it gives Carol no exclusive right to fold requests. Each actor signs in their own directory. The processing and reclaim windows are fixed when the registry is created.
Now Carol.
carol
jq --arg blueprint "$blueprint" '{"koios-url": .providerUrl, "network-time": .networkTimeDirectory, "network-magic": 42, "blueprint": $blueprint, "state-dir": "carol-journal"}' ../devnet.out > carol.json
process_ms=${PROCESS_TIME_MS:-180000}; retract_ms=${RETRACT_TIME_MS:-60000}
open-datum create --process-time "$process_ms" --reclaim-time "$retract_ms" --booking-width 60000 --vault carol.vault > receipts/01-create.json
jq '{outcome, registry, stateToken, root, submissions}' receipts/01-create.json
jq -er .stateToken receipts/01-create.json > ../registry.token
Step 2: join using the public token¶
Carol publishes only the registry token. Alice and Bob each make their own settings file, with their own journal. The token and the chain's web API are all a reader needs. The private chain has no public explorer; the recorded preprod run links its public transaction IDs to an explorer.
Now Alice.
alice
jq --arg blueprint "$blueprint" --arg token "$(cat ../registry.token)" '{"koios-url": .providerUrl, "network-time": .networkTimeDirectory, "network-magic": 42, "blueprint": $blueprint, "state-dir": "alice-journal", "state-token": $token}' ../devnet.out > alice.json
jq . alice.json
Now Bob.
bob
jq --arg blueprint "$blueprint" --arg token "$(cat ../registry.token)" '{"koios-url": .providerUrl, "network-time": .networkTimeDirectory, "network-magic": 42, "blueprint": $blueprint, "state-dir": "bob-journal", "state-token": $token}' ../devnet.out > bob.json
jq . bob.json
Step 3: Alice books her key¶
Alice registers the key alice with a payload and a protected deposit of 3 ada. The booking creates a pending request; it does not change the registry yet. She sends nothing to a folder: the request is public on the chain. It must be folded before its processing deadline, on the next page.
Now Alice.
alice
printf '{"answer":42}\n' > payload.json
open-datum insert --key alice --payload payload.json --deposit 3000000 --vault alice.vault > receipts/03-insert-alice.json
jq '{outcome, pending, deposit, processDeadline, reclaimEnd}' receipts/03-insert-alice.json
Step 4: Bob folds Alice's booking¶
Bob needs the public token, chain data and his own wallet. He does not need to know which requests are pending, and he receives no file from Alice's wallet or journal: open-datum fold finds the registry's pending requests on the chain and folds the ones it can. The fold creates Alice's holding with its payload and deposit. The receipt lists what was folded, what was skipped and why, and how many foldable requests were left. Here it folds Alice's registration, skips nothing and leaves nothing.
Now Bob.
bob
open-datum fold --vault bob.vault > receipts/04-fold-insert.json
jq '{outcome, folded, skipped, left, root, stateOutput}' receipts/04-fold-insert.json
Step 5: Alice reads her key¶
Alice reads her key back from the chain. open-datum inspect answers live: the holding carries her payload and her protected deposit. The read signs nothing and needs only the registry token and the chain's web API, from her settings file. Its outputRef is the holding she spends to change the payload.
Now Alice.
alice
open-datum inspect --key alice --json > receipts/05-read-alice.json
jq '{answer, payload, deposit, holding: .outputRef}' receipts/05-read-alice.json
jq -er .outputRef receipts/05-read-alice.json > holding.ref
Step 6: Alice changes the payload¶
The controller signs a direct update of the holding. It spends the previous holding and creates its replacement with the same protected control and deposit. No request is booked and no fold is needed.
Now Alice.
alice
printf '{"answer":43}\n' > payload-new.json
open-datum update --holding "$(cat holding.ref)" --payload payload-new.json --vault alice.vault > receipts/06-update.json
jq '{outcome, holding, treasuryDonation, submissions}' receipts/06-update.json
jq -er .holding receipts/06-update.json > holding.new && mv holding.new holding.ref
Step 7: read the key again¶
Alice reads the key again. It is still live, with the new payload and the same deposit, in the holding the update created. The previous holding has been spent.
Now Alice.
alice
open-datum inspect --key alice --json > receipts/07-read-alice.json
jq '{answer, payload, deposit, holding: .outputRef}' receipts/07-read-alice.json
Step 8: Alice books permanent termination¶
Termination spends Alice's holding and donates its protected deposit to the treasury. It creates a pending termination carrying the key's active token and approval. This is permanent, has no reclaim window and cannot be undone. A later fold burns those tokens; the key can never be registered again.
Now Alice.
alice
open-datum terminate --holding "$(cat holding.ref)" --vault alice.vault > receipts/08-terminate.json
jq '{outcome, pending, treasuryDonation, submissions}' receipts/08-terminate.json
Step 9: Bob folds the termination¶
Anyone can fold the public termination request. Bob first reads the key: open-datum inspect answers pending-termination, at the request Alice booked. Then he folds whatever is pending, as on step 4: the fold finds the termination, burns its approval and active token, and Bob receives the request's value after his fee. Alice's protected deposit has already gone to the treasury.
Now Bob.
bob
open-datum inspect --key alice --json > receipts/09-read-alice.json
jq '{answer, request: .outputRef}' receipts/09-read-alice.json
open-datum fold --vault bob.vault > receipts/09-fold-terminate.json
jq '{outcome, folded, skipped, left, root, stateOutput}' receipts/09-fold-terminate.json
Step 10: read the terminated key¶
Alice reads her key with singular registry inspect, which knows no application. It answers terminal: the key was registered and terminated, and can never be registered again. A key nobody registered answers never-registered. Both answers are read from the registry's state output on the chain.
Now Alice.
alice
singular registry inspect --key alice --json > receipts/10-read-alice.json
jq '{answer, stateOutput, root}' receipts/10-read-alice.json
singular registry inspect --key nobody --json > receipts/10-read-nobody.json
jq '{answer, stateOutput, root}' receipts/10-read-nobody.json
Step 11: clean up an expired registration¶
Alice books a different key, late, and nobody folds it: no fold runs between this booking and its cleanup, so no fold finds it while it can still be folded. After both its processing and reclaim windows expire, anyone may clean it up. The refund goes to the registration's refund address. Cleanup is a separate command from reclaim and applies to registrations, not permanent termination.
Now Alice.
alice
open-datum insert --deposit 3000000 --key late --payload payload.json --vault alice.vault > receipts/11a-insert-late.json
jq -er .pending receipts/11a-insert-late.json > ../late.pending
jq -er .reclaimEnd receipts/11a-insert-late.json > ../late.expires
Now Bob.
bob
wait_until_ms "$(cat ../late.expires)"
open-datum cleanup --pending "$(cat ../late.pending)" --vault bob.vault > receipts/11d-cleanup.json
jq '{outcome, pending, returned, locked, submissions}' receipts/11d-cleanup.json
Step 12: Bob books his own key with external signing¶
Bob prepares a registration for the key bob, signs its bundle with his own wallet, then submits it. Building alone sends nothing; exit status 19 means the bundle is waiting for signatures. Each part stays in Bob's directory.
Now Bob.
bob
Build the registration.
printf '{"answer":42}\n' > payload.json
jq 'del(.["state-token"])' bob.json > wallet.json
open-datum insert --deposit 3000000 --key bob --payload payload.json --wallet-address "$bob_addr" --bundle bundles/insert-bob.bundle > receipts/12-insert-bob.json
Sign with Bob's own vault. The generic wallet settings omit the application-specific state token; the application command already put its transaction in the bundle.
singular wallet sign --config wallet.json --vault bob.vault --out witnesses/insert-bob bundles/insert-bob.bundle
Submit the signed bundle and wait for its confirmation.
singular submit --config wallet.json --witnesses witnesses/insert-bob --receipt receipts/12-insert-bob.submitted.json bundles/insert-bob.bundle
jq '{outcome, submissions}' receipts/12-insert-bob.submitted.json
Step 13: Alice folds Bob's booking¶
Alice folds with her own wallet whatever is pending, which is Bob's registration. The fold is open to everyone and does not need Bob's key, payload file or journal, nor the reference of his booking.
Now Alice.
alice
open-datum fold --vault alice.vault > receipts/13-fold-bob.json
jq '{outcome, folded, skipped, left, root}' receipts/13-fold-bob.json
Step 14: Alice takes back an unprocessed registration¶
Alice books the key unfolded, and nobody folds it: no fold runs before the reclaim, so no fold finds it. Once its processing deadline passes, the refund address can sign a reclaim during its reclaim window. This request disappears without ever registering the key, and its locked value returns to Alice.
Now Alice.
alice
open-datum insert --deposit 3000000 --key unfolded --payload payload.json --vault alice.vault > receipts/14a-insert-unfolded.json
wait_until_ms "$(jq -er .processDeadline receipts/14a-insert-unfolded.json)"
open-datum reclaim --pending "$(jq -er .pending receipts/14a-insert-unfolded.json)" --vault alice.vault > receipts/14c-reclaim.json
jq '{outcome, pending, returned, locked, submissions}' receipts/14c-reclaim.json
Step 15: book one more key¶
The reclaimed request was never folded. Alice now books a fresh key, dave, so Bob can demonstrate an externally signed fold.
Now Alice.
alice
open-datum insert --deposit 3000000 --key dave --payload payload.json --vault alice.vault > receipts/15-insert-dave.json
Step 16: Bob folds the final registration with external signing¶
Bob builds a fold of whatever is pending, which is the registration of dave, signs its bundle with his own wallet, then submits it. The receipt records the confirmed transaction and its outputs.
Now Bob.
bob
Build the fold. Exit status 19 means waiting for signatures; nothing has been sent.
open-datum fold --wallet-address "$bob_addr" --bundle bundles/fold-dave.bundle > receipts/16a-fold-dave.json
Sign with Bob's own vault.
singular wallet sign --config wallet.json --vault bob.vault --out witnesses/fold-dave bundles/fold-dave.bundle
Submit and wait for confirmation.
singular submit --config wallet.json --witnesses witnesses/fold-dave --receipt receipts/16a-fold-dave.submitted.json bundles/fold-dave.bundle
jq '{outcome, expected}' receipts/16a-fold-dave.json
jq '{outcome, submissions}' receipts/16a-fold-dave.submitted.json
Where this design stops¶
This tutorial is for people exploring a registry on a private chain. It shows registration, a direct payload update, permanent termination, reading a key back after each change, a requester reclaim and public cleanup after expiry. It does not establish mainnet readiness.
The trie library's proofs are assumed sound.
Folding is open and optional. A registration needs a fold before its processing deadline. A fold finds the pending requests on the chain and folds every one it can fit in one transaction; --limit caps how many, and repeated --pending names an exact batch instead. An unprocessed registration can be reclaimed by the refund address in its reclaim window, or cleaned up by anyone after that window ends. A termination has no reclaim path.
Termination is permanent. Its protected deposit goes to the treasury when termination is booked. The fold burns the active token and approval. The key cannot be registered again.
Receipts and reads say different things. A receipt reports what a command submitted and observed when it ran. A read reports the key's state when it runs, from the chain through the provider. A read the provider cannot answer exits 1 and says why.
An interrupted send needs reconciliation. Keep its receipt and journal. No step retries an uncertain submission automatically.
Private-chain evidence and preprod evidence are separate. The recorded preprod run uses released binaries and public explorer links. Local tests do not establish hosted CI, deployment, independent audit or acceptance.
Extra: look at the wallets¶
Read the addresses. They were written to files when the wallets were made. Read them into variables, in this window:
alice_addr=$(cat ../alice/alice.address); bob_addr=$(cat ../bob/bob.address)
The pages that follow are optional extras. Skip them in a short demo; the journey is complete without them.
A wallet holds outputs: coins at its address, each one named by the transaction that created it and an index. The devnet's web API lists them. Run this now to see what each wallet starts with, and again whenever you like, for instance after step 16, to see what the journey cost: deposits locked in requests, fees paid, tokens received.
wallet_show ADDRESS prints a short form: total_ada is what the wallet holds; each line of utxos is one output: the transaction that made it, its index, its ada and any tokens.
Alice's wallet, from her public address. No passphrase is needed to look. At the start she holds six outputs of 2000 ada.
wallet_show "$alice_addr"
Bob's wallet, the same way:
wallet_show "$bob_addr"
The chain's own clock, for reference. It is what the program uses to judge windows: the slot is the chain's unit of time.
curl -fsS "$(koios_url)/tip" | jq '.[0] | {abs_slot, block_no}'
Extra: look inside the transactions¶
Everything the demo sent is a real transaction on the devnet, and each can be examined: what the program prepared, which transaction the fold reported, and what the chain says about it. Run these any time after step 16.
Now Bob.
bob
What a prepared transaction is for. A bundle is a JSON file written by a command run with --bundle. Its effects say in plain words what each transaction does, and deadline is the slot by which it must be sent.
jq '.transactions[] | {effects, deadline}' bundles/fold-dave.bundle
Which transaction landed, and where. The fold receipt names each transaction by its hash and records whether inclusion and the expected output were observed.
jq '.submissions[]? | {transaction: .identity, chainPoint, confirmations, claims}' receipts/16a-fold-dave.submitted.json
Every transaction of the demo, in the order of the receipts. The loop walks all receipts and prints the transactions each one landed (tx-info.json is the chain's answer about one of them, kept below, not a receipt, so the loop leaves it out; that also keeps this observation true when it runs again at the end of the path).
for f in receipts/*.json; do [ "$f" = receipts/tx-info.json ] && continue; jq -r --arg f "$f" '.submissions[]? | "\($f) \(.identity) confirmations \(.confirmations)"' "$f"; done
What the chain says about one of them. The devnet's web API answers the same query a public Koios does. Take the first transaction of the step 16 fold and ask the chain about it. The full answer is kept in receipts/tx-info.json.
tx=$(jq -r '.submissions[0].identity' receipts/16a-fold-dave.submitted.json)
curl -fsS -X POST "$(koios_url)/tx_info" -H 'content-type: application/json' -d "{\"_tx_hashes\":[\"$tx\"]}" > receipts/tx-info.json
The short form shows where the money comes from and goes to, which tokens move, and which outputs carry data:
jq '.[0] | {tx_hash, block_height, absolute_slot, inputs: [.inputs[] | {from: .payment_addr.bech32[0:16], ada: ((.value|tonumber)/1000000)}], outputs: [.outputs[] | {to: .payment_addr.bech32[0:16], ada: ((.value|tonumber)/1000000), tokens: [.asset_list[].asset_name[0:8]], has_data: (.inline_datum != null)}]}' receipts/tx-info.json
jq . receipts/tx-info.json prints all of it. The data on an output is shown as raw bytes; open-datum inspect decodes a holding's data, as in step 5. This devnet's web API does not report fees or times.
Extra: a new terminal in the middle¶
Return to the actor's own folder and source the shared setup again. The settings, receipts and vault remain in that folder. Do not copy another actor's files.
cd ~/singular-demo-run/alice
. ../stepper.sh
alice
alice_addr=$(cat alice.address)
Finish: stop the devnet¶
The devnet was started in the background at the beginning. In an unattended run it stops with the tutorial's own shells; the receipts stay in receipts/, the bundles in bundles/, the signatures in witnesses/. Whoever keeps the windows (interactive play) stops it with:
kill "$devnet_pid"