.kat — version 1.0The rules of the file type. A Katomic script is a plain-text spelling of a v2 deal policy: every line is one node of the JSON, and the JSON — never the script — is what a dealId hashes.
| media type | text/vnd.katomic; charset=utf-8 — interim text/x-katomic until registered |
|---|---|
| extension | .kat |
| encoding | UTF-8, no BOM; LF line endings, CRLF accepted |
| compiles to | a v2 deal policy (dealkit/v2/policy.js), the document a KEA signer evaluates |
| identity | the dealId = SHA-256 of the compiled policy in JCS (RFC 8785) form. The script is never hashed. |
| conformance | parse(print(P)) canonicalises to the same bytes as P for every valid v2 policy P that print accepts, and print(parse(S)) is stable. Held over every published deal by qa/katomic/roundtrip-tests.js. |
A projection of the v2 policy JSON, not a format with semantics of its own:
Two scripts that differ only in comments, spacing or the order of their sections compile to the same bytes and are the same deal.
#observe, #derive and #require.// begins a comment to the end of the line, except inside a "double", 'single' or `backtick` quoted run. A line whose first character is # followed by a space, or that is just #, is a comment. A memo line is taken verbatim and never has a comment.# immediately followed by one of deal constants inputs observe derive require transaction, at column 0, opens that section. Any order; each at most once; an empty section may be omitted. A non-blank line before the first section is an error.[A-Za-z_][A-Za-z0-9_]*. Inside an expression a name may also contain . (observed.meta.pinSha256), which is one JSONLogic var path.#dealname TEXT
network TEXT
→ name, network (strings, trimmed). v is never written; the compiler emits "v": 2. Both lines are required.
#constantsNAME is VALUE
→ constants[NAME] = VALUE. VALUE is a JSON literal if it begins with ", [ or {, or is exactly true, false or null; a number if it matches -?\d+(\.\d+)?([eE][+-]?\d+)?; otherwise the trimmed text as a string. So 0.0.7718 is a string, 31 is a number, "296" is the string "296". A name may not be defined twice.
#inputsNAME TYPE
→ inputs[NAME] = { "type": T }. TYPE is one of account (hedera.account), txid (hedera.txid), timestamp (hedera.timestamp), integer, string; the long spellings are also accepted. There is no other input type, because the engine has no other input type.
#observeNAME = SOURCE PATH [-> SELECT]
pin: JMESPATH
attest: JMESPATH
→ observe[NAME] = { source, get, select?, pin?, attest? }. SOURCE is a registry name ([a-z][a-z0-9.]*, e.g. hedera.mirror, xproof.xtxt) — never a URL; the verifier's registry decides where a name resolves. PATH is one token without whitespace and may contain {NAME} substitutions, which are the engine's own. SELECT is a JMESPath taken verbatim to the end of the line. Indented continuation lines: -> … sets or extends select; pin: and attest: set those keys; other indented text extends select if present, else get.
#deriveNAME = query JMESPATH
NAME = EXPRESSION
→ derive[NAME]: with query, the rest of the line verbatim as a JMESPath string; otherwise the EXPRESSION compiled to a JSONLogic object (§4). An indented line continues the previous entry, joined by one space. query exists because the JSON holds two expression languages side by side and nothing about a line's shape reliably tells them apart.
#requireREASON
EXPRESSION
→ one element { "reason": REASON, "rule": EXPRESSION } of require, in order. REASON is the margin line, verbatim and trimmed; the rule is the next line, indented. A reason without a rule, or a rule without a reason, is an error. Reasons are inside the JSON and inside the hash: a signer's refusal quotes them back.
#transaction| line | compiles to |
|---|---|
| transfer | "type": "TransferTransaction" |
| type NAME | "type": NAME |
| A sends EXPR TOKEN | tokenTransfers ← { token: TOKEN, account: A, amount: {"-": [0, EXPR]} } |
| A receives EXPR TOKEN | tokenTransfers ← { token: TOKEN, account: A, amount: EXPR } |
| A sends EXPR hbar | hbarTransfers ← { account: A, amount: {"-": [0, EXPR]} } |
| A receives EXPR hbar | hbarTransfers ← { account: A, amount: EXPR } |
| A sends TOKEN #SERIAL to B | nftTransfers ← { token: TOKEN, serial: SERIAL, from: A, to: B } |
| memo TEXT | "memo": TEXT — verbatim to the end of the line, trimmed |
| operator NAME | "operator": NAME |
| valid from +Ns, for Ms | "schedule": { "validStartOffset": N, "validDuration": M } |
| KEY JSON | any other key, its value as one JSON literal |
A, B, TOKEN and NAME are symbolic names the engine resolves (constants or inputs). hbar is case-insensitive. SERIAL is a number if all digits, else a name. Legs keep their order within each of the three arrays. #transaction is required.
| script | JSON |
|---|---|
| name | {"var": "name"} |
| 12 · -3.5 · "text" · true · false · null | the JSON literal |
| a OP b OP c (same OP) | {"OP": [a, b, c]} — one n-ary node |
| (a OP b) OP c | {"OP": [{"OP": [a, b]}, c]} — parentheses nest |
| not x · !x | {"!": [x]} |
| !!x | {"!!": [x]} |
| -x (x not a number literal) | {"-": [x]} |
| f(a, b) | {"f": [a, b]} |
Infix operators, lowest precedence first, all left-associative: or · and · == != · < <= > >= · + - · * / %. Unary binds tighter than any infix. A - directly before a digit, where an operand is expected, is part of a negative number literal; elsewhere it is the operator.
f must be an operation the engine has: digitAfterPoint, tsSeconds, hederaAccount, sha256 (the engine's own), or a json-logic-js built-in (substr in cat if min max merge missing missing_some log). Any other call is a compile error, so a script cannot name an operation no signer could evaluate. The compiler does not check that a name is defined — that is the engine's business at evaluation — though an editor may warn.
print refuses, rather than mis-print, a policy carrying: a top-level key other than the nine; an input with keys other than type; an observation with keys other than source get select pin attest, or a get containing whitespace; a rule with keys other than reason rule, or a reason that is empty, has leading/trailing whitespace, contains // or starts with #; a transfer leg with extra keys, or a symbolic name containing whitespace; a memo that is not a single-line string; a var whose path is not a plain dotted name; a JSONLogic node with more than one key, or a non-array argument list. None of these occur in any deal published as of this version.
This is version 1.0 of the script. The version is not written in the file: a .kat file is version 1.0 if it compiles under these rules. A change that lets a valid 1.0 script compile to a different policy is a new major version and gets a new media type parameter (text/vnd.katomic; version=2). Adding a line form for something the v2 policy JSON already holds is a minor version and needs no marking. The policy JSON is versioned separately (v: 2); a v3 policy would need its own script rules.
The live testnet klotto ticket deal, dealId 0c516f051d7e…, is exactly this script — open it in the editor:
#deal
name KLOTTO ticket — COP$ (testnet CO)
network testnet
#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
#inputs
player account
stake integer
ref string
#observe
lock_now = hedera.mirror /tokens/{LOCK_TOKEN}/nfts/{LOCK_SERIAL} -> account_id
#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
#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
The canonical copy of this document is dealkit/katomic/SPEC.md in hbarlive_hts, next to the implementation. Where the two differ, the one beside the code is right.