Katomic v2 — writing a deal by hand

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.

Design note · 20 Sep 2026 · nothing here is built yet

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.

What the 2023 script got right, and what changed underneath it

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.

Rule
The script is a projection of the JSON, not a new format. If something cannot be said in the JSON, it cannot be said in the script. Validation is the engine's own validatePolicy, run on the compiled output — the compiler has no opinions of its own.

The grammar, one section at a time

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 languages

This 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 claim deal, whole

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.

Comments, layout, and why they are free

Printing goes the other way

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.

An editor

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.

ticket-co.katdealId 0c516f05…ce0d38 · valid ✓ · testnetCopy JSONRaise on testnet
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
line 18 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"} ] } },
    …
Mock-up. The problem list is the engine's validation plus the compiler's name check; both point at a line. The dealId updates as you type — a useful reminder that every keystroke in a rule is a different deal.

Where it would live

piecewherewhy there
parse.js, print.jshbarlive_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 testqa/katomic/roundtrip-tests.jsEvery published klotto and xproof deal: print → compile → same id. Runs in test:units.
katomic.cjskea/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.

Three choices worth arguing about

  1. 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.
  2. 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.
  3. Reasons are free text at the margin, not quoted strings. It makes #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.