A plain-text script that compiles to a v2 deal, and a v2 deal that prints back as the script. Looking ahead: this is a design pass, not a shipped tool.
The short version. A v2 deal is already a small program — six sections, two expression languages, no I/O — but it is written in JSON, and JSON is where people stop. Katomic v2 is the same program written the way the 2023 hackathon script wrote a swap: Alice sends 1 hbar. It adds no semantics. Every line is one node of the JSON, comments and layout are thrown away, and the dealId is still the hash of the compiled JSON — so a script can be reformatted, annotated and reordered without changing which deal it is. The acceptance test is a round trip: print every published deal as a script, compile it back, and every id must come out identical.
The hackathon language (topshef/katomia) had three ideas worth keeping: sections introduced by #name, aliases declared with is (Alice is 0.0.12345), and transfers as subject–verb–amount lines (Alice sends 1.2 my_token, Alice sends NFT my_collection #7 to Bob). It compiled to the SDK's transfer lists and checked that fungible legs summed to zero.
What it did not have — because the deal format did not have it — is everything that makes a v2 deal a deal rather than a transaction: typed inputs the raiser supplies, chain reads, derived values, and rules a signer refuses on. Those are now the bulk of any real deal (the klotto claim below is four reads, eight derivations and six rules around a two-leg transfer). So v2 keeps the three ideas and gives each of the six policy sections its own line grammar.
validatePolicy, run on the compiled output — the compiler has no opinions of its own.The running example is klotto's live testnet ticket deal, 0c516f…. Left is the script; right is the JSON it compiles to — the exact bytes that hash to that id.
#deal — the name and the network#deal
name KLOTTO ticket — COP$ (testnet CO)
network testnet
{ "v": 2,
"name": "KLOTTO ticket — COP$ (testnet CO)",
"network": "testnet", …
v is never written; the compiler stamps 2. There is no envelope: a .kat file compiles to the bare deal, and the id is its filename.
#constants — public ids and numbers, by name#constants
POOL_ACCOUNT is 0.0.7718
POOL_TOKEN is 0.0.2340687
LOCK_TOKEN is 0.0.3626892
LOCK_SERIAL is 31
STAKE_MIN is 50000 // smallest units: COP$ has 2 dp, so 500.00
STAKE_MAX is 1000000
"constants": {
"POOL_ACCOUNT": "0.0.7718",
"POOL_TOKEN": "0.0.2340687",
"LOCK_TOKEN": "0.0.3626892",
"LOCK_SERIAL": 31,
"STAKE_MIN": 50000,
"STAKE_MAX": 1000000 }
is survives from 2023. An account id stays a string; a bare number is a number. Nothing is ever scaled: the engine works in smallest units and refuses a non-integer, so the script does too — the comment is where the human reading goes.
#inputs — what the raiser supplies, typed#inputs
player account
stake integer
ref string
"inputs": {
"player": { "type": "hedera.account" },
"stake": { "type": "integer" },
"ref": { "type": "string" } }
Five types, spelt short: account, txid, timestamp (the three hedera.* types), integer, string. That is the whole type system, because that is the engine's whole type system.
#observe — chain reads#observe
lock_now = hedera.mirror /tokens/{LOCK_TOKEN}/nfts/{LOCK_SERIAL}
-> account_id
"observe": {
"lock_now": {
"source": "hedera.mirror",
"get": "/tokens/{LOCK_TOKEN}/nfts/{LOCK_SERIAL}",
"select": "account_id" } }
NAME = SOURCE PATH -> SELECT. The source is a registry name — never a URL, exactly as the engine insists — and {NAME} substitution inside the path is the engine's own. An indented continuation line carries the two optional keys the xproof oracle work added: pin: <jmespath> and attest: <jmespath>.
#derive — worked-out values, in two languagesThis is the one place the JSON has two expression languages side by side — a JMESPath string or a JSONLogic object — and the one place the script needs a marker to keep them apart. From the claim deal:
#derive
ct_ticket = query ticket.consensus_timestamp
payer = query ticket.token_transfers[?token_id=='{POOL_TOKEN}'
&& amount < `0`] | [0].account
jackpot = digitAfterPoint(ct_lastpayout, 4)
payout = ticket_price * PAYOUT_MULTIPLIER
"derive": {
"ct_ticket": "ticket.consensus_timestamp",
"payer": "ticket.token_transfers[?token_id=='{POOL_TOKEN}'
&& amount < `0`] | [0].account",
"jackpot": { "digitAfterPoint": [ { "var": "ct_lastpayout" }, 4 ] },
"payout": { "*": [ { "var": "ticket_price" },
{ "var": "PAYOUT_MULTIPLIER" } ] } }
query means "the rest of this line is a JMESPath, verbatim." Without it, the right-hand side is an ordinary infix expression that compiles to JSONLogic: names become {"var"}, the usual operators (+ - * / == != < <= > >= and or not) become their JSONLogic spellings, and a function call is one of the engine's four custom ops — digitAfterPoint, tsSeconds, hederaAccount, sha256 — or a refusal. The compiler is deliberately unable to invent an operator the engine does not have.
#require — the rules, each with its reason#require
the pot is open (lock NFT at the pool, no draw in progress)
lock_now == POOL_ACCOUNT
stake is at least the minimum
stake >= STAKE_MIN
stake is at most the maximum
stake <= STAKE_MAX
the player is not the pool
player != POOL_ACCOUNT
"require": [
{ "reason": "the pot is open (lock NFT at the pool, no draw in progress)",
"rule": { "==": [ { "var": "lock_now" }, { "var": "POOL_ACCOUNT" } ] } },
{ "reason": "stake is at least the minimum",
"rule": { ">=": [ { "var": "stake" }, { "var": "STAKE_MIN" } ] } },
{ "reason": "stake is at most the maximum",
"rule": { "<=": [ { "var": "stake" }, { "var": "STAKE_MAX" } ] } },
{ "reason": "the player is not the pool",
"rule": { "!=": [ { "var": "player" }, { "var": "POOL_ACCOUNT" } ] } } ]
A rule is two lines: the reason in plain words at the margin, the expression indented under it. The reason is not a comment — it is inside the JSON and inside the hash, and it is the sentence a signer's refusal quotes back. Which is the right incentive: write the reason a player would want to read.
#transaction — where the 2023 verbs live on#transaction
transfer
player sends stake POOL_TOKEN
POOL_ACCOUNT receives stake POOL_TOKEN
memo klotto ticket CO {ref}
operator player
valid from +15s, for 120s
"transaction": {
"type": "TransferTransaction",
"tokenTransfers": [
{ "token": "POOL_TOKEN", "account": "player",
"amount": { "-": [ 0, { "var": "stake" } ] } },
{ "token": "POOL_TOKEN", "account": "POOL_ACCOUNT",
"amount": { "var": "stake" } } ],
"memo": "klotto ticket CO {ref}",
"operator": "player",
"schedule": { "validStartOffset": 15, "validDuration": 120 } }
sends compiles to the negated amount in exactly the shape the published deals already use ({"-": [0, …]}), so the round trip holds. An NFT leg reads as it did in 2023: POOL_ACCOUNT sends LOCK_TOKEN #LOCK_SERIAL to LOCK_ACCOUNT → one nftTransfers entry. The 2023 sum-to-zero check comes back as a compile-time warning, never an error — a deal that deliberately leaves a leg to the operator's account is legal.
The point of the exercise is that a real deal fits on a screen and reads top to bottom. This is klotto's live testnet claim for CO (db1edc3e…), which today is 140 lines of JSON:
#deal
name KLOTTO claim — COP$, 10x (testnet CO)
network testnet
#constants
POOL_ACCOUNT is 0.0.7718
LOCK_ACCOUNT is 0.0.10343
POOL_TOKEN is 0.0.2340687
LOCK_TOKEN is 0.0.3626892
LOCK_SERIAL is 31
PAYOUT_MULTIPLIER is 10
#inputs
txid_ticket txid
#observe
ticket = hedera.mirror /transactions/{txid_ticket} -> transactions[0]
lock_moves = hedera.mirror /tokens/{LOCK_TOKEN}/nfts/{LOCK_SERIAL}/transactions?limit=50 -> transactions
lock_now = hedera.mirror /tokens/{LOCK_TOKEN}/nfts/{LOCK_SERIAL} -> account_id
pool = hedera.mirror /balances?account.id={POOL_ACCOUNT}
-> balances[0].tokens[?token_id=='{POOL_TOKEN}'] | [0].balance
#derive
ct_ticket = query ticket.consensus_timestamp
payer = query ticket.token_transfers[?token_id=='{POOL_TOKEN}' && amount < `0`] | [0].account
receiver = query ticket.token_transfers[?token_id=='{POOL_TOKEN}' && amount > `0`] | [0].account
ticket_price = query ticket.token_transfers[?token_id=='{POOL_TOKEN}' && amount > `0`] | [0].amount
ct_lastpayout = query lock_moves[?receiver_account_id=='{LOCK_ACCOUNT}'] | sort_by(@, &consensus_timestamp) | [-1].consensus_timestamp
jackpot = digitAfterPoint(ct_lastpayout, 4) // the draw: 4th decimal of the last payout's timestamp
ticket_digit = digitAfterPoint(ct_ticket, 4)
payout = ticket_price * PAYOUT_MULTIPLIER
#require
ticket bought after the last payout
ct_lastpayout < ct_ticket
ticket really paid the pool
receiver == POOL_ACCOUNT
ticket price is positive
ticket_price > 0
ticket digit matches the jackpot
ticket_digit == jackpot
no draw in progress (lock NFT at pool)
lock_now == POOL_ACCOUNT
pool can cover the payout
payout <= pool
#transaction
transfer
POOL_ACCOUNT sends payout POOL_TOKEN
payer receives payout POOL_TOKEN
POOL_ACCOUNT sends LOCK_TOKEN #LOCK_SERIAL to LOCK_ACCOUNT
memo klotto claim {txid_ticket}
operator LOCK_ACCOUNT
valid from +300s, for 120s
Forty-odd lines. Every one is one JSON node, and nothing is in the script that is not in the JSON — the comment on jackpot is the only thing the compiler drops.
// starts a comment anywhere on a line. # (hash, space) at the start of a line is also a comment, for the 2023 habit; #word at column 0 is a section, and #7 after a token is a serial, as before.#derive above the ticket deal is not needed).The same module prints a deal as a script — print(policy) → string — so every deal already published gets a readable form for free, and the deal page gains an "as script" view next to "friendly" and "raw JSON". That is also the test: print then compile over every deal klotto and xproof publish, and the id must survive. The four ops, the two amount shapes, the schedule keys — all of it is pinned by that one loop, and a compiler that drifts from the engine fails it on the first run.
Two panes, nothing clever. Script on the left; on the right, the compiled JSON, the dealId it hashes to, and the engine's validation — the same validatePolicy a signer runs — with each problem pointing at a script line. A third button raises it on testnet, which is the existing deal-bytes path with this JSON in place of a published one.
1#deal 2name KLOTTO ticket — COP$ (testnet CO) 3network testnet 4 5#constants 6POOL_ACCOUNT is 0.0.7718 7POOL_TOKEN is 0.0.2340687 8STAKE_MIN is 50000 // 500.00 COP$ 9 10#inputs 11player account 12stake integer 13 14#require 15stake is at least the minimum 16 stake >= STAKE_MIN 17the player is not the pool 18 plyer != POOL_ACCOUNT 19 20#transaction 21transfer 22player sends stake POOL_TOKEN 23POOL_ACCOUNT receives stake POOL_TOKEN
plyer is not a constant, input, observation or derived value — did you mean player?{
"v": 2,
"name": "KLOTTO ticket — COP$ (testnet CO)",
"network": "testnet",
"constants": {
"POOL_ACCOUNT": "0.0.7718",
"POOL_TOKEN": "0.0.2340687",
"STAKE_MIN": 50000
},
"inputs": {
"player": { "type": "hedera.account" },
"stake": { "type": "integer" }
},
"require": [
{ "reason": "stake is at least the minimum",
"rule": { ">=": [ {"var":"stake"}, {"var":"STAKE_MIN"} ] } },
…
| piece | where | why there |
|---|---|---|
| parse.js, print.js | hbarlive_hts dealkit/katomic/ | Beside the v2 engine they target, browser-safe and dependency-free like jcs.js, so the same code runs in the editor, the CLI and a signer. |
| round-trip test | qa/katomic/roundtrip-tests.js | Every published klotto and xproof deal: print → compile → same id. Runs in test:units. |
| katomic.cjs | kea/tools/ | compile deal.kat and print deal.json, next to convert.cjs. What a publisher runs before publish-deals. |
| /deal/edit/ | phpjabbers, at the root beside /deal/ | Deals are v2; the editor is v2. Not under /keaton/, which is the dashboard. |
| "as script" view | /deal/ | Printing is free once print.js exists, and it is the friendliest of the three views. |
Order of work: print.js first, because it costs nothing to be wrong (it changes no deal) and immediately makes every existing deal readable; then parse.js against the round-trip test; then the editor. The 2023 repo is the reference for the transfer lines and nothing else — its parser is a set of regexes over SDK transfer lists, and none of it targets the v2 shape.
query as the JMESPath marker. The alternative is guessing from the shape of the line, which fails the moment a JMESPath contains && or < — and the claim deal's do. One explicit word is cheaper than a heuristic that is right most of the time.is for constants, = for computed things. Two spellings for two ideas: a constant is declared, a derivation is worked out. It also reads aloud. The cost is one more thing to learn.#require read like a list of promises, which is what it is. The cost is that a reason cannot start with four spaces — which nothing will miss.None of the three changes what a deal can say. They are about what a person sees, and that is the whole point of having a script at all.