node.md is the K module that implements the entire RPC layer on the K side. It reads request.json, dispatches on the RPC method, reads and updates the bookkeeping files, executes transaction steps via KASMER (Komet's harness for running Soroban operations as Steps), and writes the JSON-RPC response.json. Everything that is part of the Soroban/Stellar protocol — method dispatch, receipt bookkeeping, ledger accounting, status determination, response formatting — lives here rather than in Python.
It is compiled by kdist/plugin.py into the komet-node.simbolik LLVM binary, cached under ~/.cache/kdist-*/komet-node/simbolik/.
The semantics communicate with the Python process through files in the working directory (the io dir), using the file-system hooks. All paths are relative, resolved against the cwd that NodeInterpreter sets before each run. The table below covers only the files the semantics touch through these hooks; for the complete io-dir layout, including state.kore, see architecture.md.
| File | Direction | Contents |
|---|---|---|
request.json |
Python → K | the request envelope (method, id, now, and method-specific fields) |
response.json |
K → Python | the JSON-RPC response ({jsonrpc, id, result}) |
metadata.json |
K ↔ K | {"latest_ledger": N} — the ledger counter |
receipts/receipt_<hash>.json |
K → Python | one stored receipt per transaction, keyed by tx hash |
traces/trace_<hash>.jsonl |
K → Python | one execution trace per transaction (per-instruction records), keyed by tx hash |
The lifecycle fires when K starts in the idle state — <k>, <instrs>, and <program> all empty — and request.json is present.
K starts (idle state read from state.kore)
│
├─ request.json exists? ──no──► halt immediately (idle state is the output)
│
yes
▼
insert-handleRequestFile → handleRequestFile
│
▼
#dispatch(String2JSON(#readFile("request.json")))
│
▼
#dispatchMethod(method, request) ← routes on the "method" field
│
├─ getHealth / getNetwork / getLatestLedger / getTransaction / traceTransaction → #respond(...)
│
└─ sendTransaction → #runTx → run steps
→ #finalizeTx → record receipt + bump ledger → #respond(...)
▼
#respond(id, result)
write response.json {jsonrpc, id, result} ; remove request.json ; exitCode 0
▼
K halts — the updated idle state is the output, saved as state.kore
If request.json is absent, insert-handleRequestFile does not fire and K halts immediately with the idle state.
#dispatch reads the method field and routes to a per-method rule. The read-only methods answer directly from constants and the bookkeeping files:
getHealth→{ "status": "healthy" }getNetwork→{ "friendbotUrl": null, "passphrase": ..., "protocolVersion": ... }(passphrase/version come from the request, keeping the semantics network-agnostic)getLatestLedger→ readsmetadata.jsonand returns{ "id": <64 zeros>, "protocolVersion": ..., "sequence": <latest_ledger> }getTransaction→ reads the hash'sreceipts/receipt_<hash>.jsonfile; returns the stored receipt merged with the currentlatestLedger/latestLedgerCloseTime, or{ "status": "NOT_FOUND", ... }when the file is absent
#respond(ID, RESULT) is the shared terminal: it writes the JSON-RPC envelope to response.json, removes request.json, and sets the exit code to 0.
sendTransaction is the only method that executes a transaction, via #runTx. traceTransaction does not run anything; it reads back the trace sendTransaction already stored (see traceTransaction below).
#runTx(request)
=> #enableTrace(traces/trace_<hash>.jsonl) ← clear the trace file and point <ioDir> at it
~> setLedgerSequence(<latest_ledger from metadata.json>)
~> #decodeSteps(<the "steps" array>) ← KASMER runs each decoded step
~> #finalizeTx(request)
#finalizeTx reads metadata.json, then:
- writes
metadata.jsonwithlatest_ledger + 1, - writes the receipt to
receipts/receipt_<hash>.json:{ status: "SUCCESS", ledger, createdAt, envelopeXdr, resultXdr: "", resultMetaXdr: "" }, - responds with
{hash, status: "PENDING", latestLedger, latestLedgerCloseTime}.
The trace is not part of the receipt — the executing steps already appended it to traces/trace_<hash>.jsonl. Reaching #finalizeTx means the steps completed without getting stuck, so the status is SUCCESS. A failed transaction gets stuck before this point, response.json is never written, and the Python server records the FAILED receipt instead.
traceTransaction is a read-only lookup. It takes a hash (the same parameter getTransaction takes) and responds with the contents of traces/trace_<hash>.jsonl, or null when no trace file exists for that hash. Because tracing is always on, every sendTransaction writes this file.
- JSON steps (the common case): the operations are decoded from the
"steps"array of the request envelope by#decodeSteps/#decodeStep. <program>injection (wasm uploads only): theuploadWasmstep — whoseModuleDeclhas no JSON form — is spliced into the<program>cell byNodeInterpreterbefore the run. KASMER'sload-programrule runs it first; once<program>drains,insert-handleRequestFilefires and the request envelope (with an empty"steps") drives the bookkeeping. Both paths converge on#finalizeTx.
node.md carries a small set of order-independent JSON accessors used for the request envelope and the bookkeeping files:
#getJSON(key, obj[, default]),#getString(key, obj),#getInt(key, obj)— read a field#concatJSONs(a, b)— append object entries (used to mergelatestLedgerfields into a stored receipt)#receiptFile(hash),#traceFile(hash)— build the per-transaction file paths (receipts/receipt_<hash>.json,traces/trace_<hash>.jsonl)
These complement the order-sensitive step decoders below.
Step decoding pattern-matches on the JSON sort. Key order in the step objects must match the order of keys in the K patterns exactly, because K's JSON sort is ordered.
#decodeSteps(S, SS) → #decodeStep(S) #decodeSteps(SS)
#decodeStep({ "op": "setLedgerSequence", ... })→ setLedgerSequence(...)
#decodeStep({ "op": "setAccount", ... })→ setAccount(...)
#decodeStep({ "op": "deployContract", ... })→ deployContract(...)
#decodeStep({ "op": "callTx", ... })→ callTx(...)
SCVal arguments are decoded by #decodeArg, which matches on "type" and produces a K ScVal constructor (SCBool, I32, U32, I64, U64, I128, U128, Symbol, ScBytes, ScAddress).
The steps-done rule (mirroring KASMER's steps-empty but with a ... frame) consumes the final .Steps so the #finalizeTx continuation can proceed.
HexBytes decodes a lowercase hex string to Bytes (big-endian), preserving leading zero bytes via an explicit byte count.
rule HexBytes("") => .Bytes
rule HexBytes(S) => Int2Bytes(lengthString(S) /Int 2, String2Base(S, 16), BE)
requires lengthString(S) >Int 0string2WasmToken wraps a K String into a WasmStringToken (hook(STRING.string2token)). It is required because callTx expects a WasmString for the function name.
fs.md provides #readFile, #writeFile, #appendFile, #fileExists, and #remove as K functions backed by K's built-in I/O hooks (#open, #read, #write, #close). The request/response files, the bookkeeping files, and the tracing rules all use them.
json.md is K Framework's built-in JSON module (not a project file). It provides the JSON sort with String2JSON / JSON2String, which the semantics use to parse request.json and to serialize response.json and the bookkeeping files.
node.md is compiled with md_selector: 'k | k-tracing', which includes the tracing rules from soroban-semantics. They intercept each WebAssembly instruction and append a JSON record to the file named by the <ioDir> cell.
Tracing is always on. Before running the steps, #enableTrace clears the transaction's traces/trace_<hash>.jsonl file and points <ioDir> at it, so the intercepted instructions append to it. After the steps run, #finalizeTx resets <ioDir> to empty; the trace file is left in place for traceTransaction to read.
Trace format (one JSON record per line):
{"pos": 597, "instr": ["local.get", 0], "stack": [["i64", 4]], "locals": {"0": ["i64", 4]}}| Field | Description |
|---|---|
pos |
Byte offset of the instruction in the binary, or null for synthetic instructions |
instr |
Instruction name and operands as a JSON array |
stack |
Value stack at instruction entry, as [type, value] pairs |
locals |
Local variable bindings, keyed by index, as [type, value] pairs |
make kdist-build
# or
uv run kdist build komet-node.simbolikkdist/plugin.py defines the build:
- Backend: LLVM
- Main file:
node.md - Syntax module:
NODE-SYNTAX - MD selector:
k | k-tracing - Depends on:
soroban-semantics.source(the komet repo)