Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .devcontainer/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,8 @@ RUN . /home/vscode/.nix-profile/etc/profile.d/nix.sh \
&& nix profile install \
nixpkgs#direnv \
nixpkgs#nix-direnv \
nixpkgs#nodejs_22
nixpkgs#nodejs_22 \
nixpkgs#gh

# Claude Code CLI — installed to a per-user prefix so root isn't required.
RUN . /home/vscode/.nix-profile/etc/profile.d/nix.sh \
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ curl -s http://localhost:8000 -H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"traceTransaction","params":{"hash":"c7099cbe10a9bfa1cdf9c9d368e1e1c932f535a70e4403b7aa409ce19fc36805"}}'
```

`traceTransaction` returns the stored trace as its result. The trace is a JSONL string (one JSON record per executed WebAssembly instruction); it is shown decoded here for readability:
`traceTransaction` returns the stored trace as its result: a JSON array with one record per executed WebAssembly instruction.

```jsonc
{
Expand Down
2 changes: 1 addition & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ All of the server's input and output artifacts live in one directory, the *io di
| `state.kore` | persistent | `NodeInterpreter` | the full K world-state configuration — accounts, contract code (including uploaded wasm `ModuleDecl`s), contract storage, ledger metadata — serialized in KORE. Read before each run and rewritten after a successful one. |
| `metadata.json` | persistent | the K semantics | `{"latest_ledger": N}` — the server ledger counter, bumped by 1 per committed transaction. |
| `receipts/receipt_<hash>.json` | persistent | the semantics (on success) or the server (on failure) | one stored receipt per transaction, keyed by tx hash, answering `getTransaction`. Each is `{status, ledger, createdAt, envelopeXdr, resultXdr, resultMetaXdr}`. |
| `traces/trace_<hash>.jsonl` | persistent | the semantics | one execution trace per transaction, keyed by tx hash — the instruction-level records, one JSON object per line. `traceTransaction` returns this file's contents. |
| `traces/trace_<hash>.jsonl` | persistent | the semantics | one execution trace per transaction, keyed by tx hash — the instruction-level records, one JSON object per line. `traceTransaction` returns these records as a JSON array. |
| `requests/request_<n>.json` | persistent | the server | an archive of each incoming JSON-RPC request, numbered by a monotonic counter, kept for debugging. |
| `request.json` | transient | the server | the request envelope for the call in flight (`method`, `id`, `now`, and method-specific fields). The semantics remove it once they respond. |
| `response.json` | transient | the semantics | the JSON-RPC response (`{jsonrpc, id, result}`) for the most recent call. The server reads it back; it is absent when a transaction gets stuck. |
Expand Down
2 changes: 1 addition & 1 deletion docs/node-semantics.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ The trace is not part of the receipt — the executing steps already appended it

### traceTransaction

`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.
`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` parsed into a JSON array (one record per executed instruction), or `null` when no trace file exists for that hash. Because tracing is always on, every `sendTransaction` writes this file.

### Two ways steps are delivered

Expand Down
6 changes: 4 additions & 2 deletions docs/server.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,10 +116,12 @@ All methods are answered by the K semantics and follow the [Stellar RPC specific

### `traceTransaction`

`traceTransaction` retrieves the instruction trace of a previously submitted transaction. It takes a `hash` parameter (the same one `getTransaction` takes) and returns the trace that `sendTransaction` stored on that transaction's receipt. The result is the trace itself: a JSONL string with one record per executed WebAssembly instruction, or `null` when no transaction with that hash exists.
`traceTransaction` retrieves the instruction trace of a previously submitted transaction. It takes a `hash` parameter (the same one `getTransaction` takes) and returns the trace that `sendTransaction` stored for that transaction. The result is a JSON array with one record per executed WebAssembly instruction (empty when the transaction ran no instructions), or `null` when no transaction with that hash exists.

```json
"<jsonl string>"
[
{"pos": 3, "instr": ["const", "i32", 1048576], "stack": [], "locals": {}}
]
```

### `getTransaction`
Expand Down
34 changes: 31 additions & 3 deletions src/komet_node/kdist/node.md
Original file line number Diff line number Diff line change
Expand Up @@ -335,21 +335,49 @@ this point means the steps completed without getting stuck, so the status is `SU

Retrieve the execution trace of a previously submitted transaction, looked up by `hash` (the
same parameter `getTransaction` takes). The trace was written to `traces/trace_<hash>.jsonl`
by `sendTransaction`. Responds with the trace file's contents, or `null` when no trace file
exists for that hash.
by `sendTransaction`. The file is JSONL (one JSON record per executed instruction); we parse
it into a JSON array so the result is structured data rather than an opaque string. Responds
with that array — empty when the transaction ran no instructions — or `null` when no trace
file exists for that hash.

```k
rule <k> #dispatchMethod( "traceTransaction", REQ )
=> #respondTrace( #getJSON( "id", REQ ), #getString( "hash", REQ ) )
...
</k>

rule <k> #respondTrace( ID, HASH ) => #respond( ID, {#readFile( #traceFile( HASH ) )}:>String ) ... </k>
rule <k> #respondTrace( ID, HASH ) => #respond( ID, [ #parseTraceLines( {#readFile( #traceFile( HASH ) )}:>String ) ] ) ... </k>
requires #fileExists( #traceFile( HASH ) )
rule <k> #respondTrace( ID, HASH ) => #respond( ID, null ) ... </k>
requires notBool #fileExists( #traceFile( HASH ) )
```

`#parseTraceLines` turns the JSONL trace text into a `JSONs` list, parsing each newline-
delimited record with `String2JSON`. Empty segments (a leading/blank line, or the empty
tail after the final record's trailing newline) are skipped, so an empty file yields `.JSONs`
(an empty array).

```k
syntax JSONs ::= #parseTraceLines( String ) [function, symbol(parseTraceLines)]
// -------------------------------------------------------------------------------
rule #parseTraceLines( "" ) => .JSONs

// No more newlines: the whole remaining string is the final record.
rule #parseTraceLines( S ) => String2JSON( S ) , .JSONs
requires S =/=String "" andBool findString( S, "\n", 0 ) <Int 0

// Split off the first line and recurse on the rest.
rule #parseTraceLines( S )
=> String2JSON( substrString( S, 0, findString( S, "\n", 0 ) ) )
, #parseTraceLines( substrString( S, findString( S, "\n", 0 ) +Int 1, lengthString( S ) ) )
requires findString( S, "\n", 0 ) >Int 0

// Empty leading line (the string starts with a newline): drop it and recurse.
rule #parseTraceLines( S )
=> #parseTraceLines( substrString( S, 1, lengthString( S ) ) )
requires findString( S, "\n", 0 ) ==Int 0
```

###############################################################################
# Step decoding

Expand Down
169 changes: 95 additions & 74 deletions src/tests/integration/test_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,18 @@
import time
import urllib.request
from pathlib import Path
from typing import Any
from typing import TYPE_CHECKING, Any

import pytest
from stellar_sdk import Account, Keypair, Network, StrKey, TransactionBuilder, xdr
from stellar_sdk.utils import sha256
from stellar_sdk.xdr.sc_val_type import SCValType

from komet_node.server import StellarRpcServer

if TYPE_CHECKING:
from collections.abc import Callable

EMPTY_CONTRACT_WAT = (Path(__file__).parent / 'data' / 'wasm' / 'empty.wat').resolve(strict=True)
ARGS_CONTRACT_WAT = (Path(__file__).parent / 'data' / 'wasm' / 'args.wat').resolve(strict=True)

Expand Down Expand Up @@ -73,6 +77,41 @@ def server(tmp_path: Path):
srv.shutdown()


def _deploy_and_get_invoker(server: StellarRpcServer, wat_path: Path) -> Callable[..., str]:
"""Create an account, upload `wat_path`, and deploy a contract instance from it.

Returns an ``invoke(func, args=None)`` callable that runs a contract function and returns
the executed transaction's hash, asserting the whole setup and each call reaches SUCCESS.
"""
keypair = Keypair.random()
account = Account(keypair.public_key, sequence=0)

def builder() -> TransactionBuilder:
return TransactionBuilder(account, Network.TESTNET_NETWORK_PASSPHRASE)

def send(tb: TransactionBuilder) -> str:
env = tb.set_timeout(30).build()
env.sign(keypair)
res = _rpc(server.port(), 'sendTransaction', {'transaction': env.to_xdr()})
assert res['result']['status'] == 'PENDING'
tx_hash = res['result']['hash']
get_res = _rpc(server.port(), 'getTransaction', {'hash': tx_hash})['result']
assert get_res['status'] == 'SUCCESS', f'Transaction failed: {get_res}'
return tx_hash

send(builder().append_create_account_op(keypair.public_key, '1000'))
wasm_bytecode = wat_to_wasm(wat_path)
send(builder().append_upload_contract_wasm_op(wasm_bytecode))
salt = b'\x00' * 32
send(builder().append_create_contract_op(sha256(wasm_bytecode), keypair.public_key, None, salt))
contract_address = server.encoder.contract_address_from_deployer_address(keypair.public_key, salt)

def invoke(func: str, args: list[xdr.SCVal] | None = None) -> str:
return send(builder().append_invoke_contract_function_op(contract_address, func, args or []))

return invoke


def test_default_io_dir_is_a_fresh_temp_dir() -> None:
"""With no io_dir, the server provisions a fresh temporary directory and seeds it."""
srv = StellarRpcServer(host='localhost', port=0)
Expand Down Expand Up @@ -323,9 +362,9 @@ def test_trace_transaction_retrieves_trace_by_hash(server: StellarRpcServer) ->
assert send_result['status'] == 'PENDING'

# The trace is keyed by the same hash getTransaction uses. A create-account op runs no
# wasm instructions, so the stored trace is the empty string (resolved, not null/NOT_FOUND).
# wasm instructions, so the stored trace is an empty array (resolved, not null/NOT_FOUND).
trace = _rpc(server.port(), 'traceTransaction', {'hash': send_result['hash']})['result']
assert trace == ''
assert trace == []


def test_trace_transaction_unknown_hash_returns_null(server: StellarRpcServer) -> None:
Expand All @@ -339,54 +378,66 @@ def test_trace_transaction_missing_hash_returns_invalid_params(server: StellarRp
assert result['error']['code'] == -32602


def test_trace_transaction_produces_trace_on_contract_invocation(server: StellarRpcServer) -> None:
"""traceTransaction returns non-empty trace JSONL for a submitted contract invocation."""
keypair = Keypair.random()
account = Account(keypair.public_key, sequence=0)
def test_trace_transaction_returns_full_instruction_trace_for_foo(server: StellarRpcServer) -> None:
"""traceTransaction returns the complete, ordered instruction trace of an invocation.

def builder() -> TransactionBuilder:
return TransactionBuilder(account, Network.TESTNET_NETWORK_PASSPHRASE)

def sign_and_xdr(tb: TransactionBuilder) -> str:
env = tb.set_timeout(30).build()
env.sign(keypair)
return env.to_xdr()

def send(xdr: str) -> str:
res = _rpc(server.port(), 'sendTransaction', {'transaction': xdr})
assert res['result']['status'] == 'PENDING'
tx_hash = res['result']['hash']
assert _rpc(server.port(), 'getTransaction', {'hash': tx_hash})['result']['status'] == 'SUCCESS'
return tx_hash

# Set up: create account, upload wasm, deploy contract
send(sign_and_xdr(builder().append_create_account_op(keypair.public_key, '1000')))
empty.wat's ``foo()`` body is a single ``i64.const 2`` (the Void return); the three leading
records are the contract's global initialisation and the ``block`` is the function frame.
This is the exact trace shown in the README, asserted record-for-record so any drift in the
format, ordering, or the array-vs-string shape of the result is caught.
"""
invoke = _deploy_and_get_invoker(server, EMPTY_CONTRACT_WAT)
tx_hash = invoke('foo')

wasm_bytecode = wat_to_wasm(EMPTY_CONTRACT_WAT)
send(sign_and_xdr(builder().append_upload_contract_wasm_op(wasm_bytecode)))
trace = _rpc(server.port(), 'traceTransaction', {'hash': tx_hash})['result']

from stellar_sdk.utils import sha256
assert trace == [
{'pos': 3, 'instr': ['const', 'i32', 1048576], 'stack': [], 'locals': {}},
{'pos': 11, 'instr': ['const', 'i32', 1048576], 'stack': [], 'locals': {}},
{'pos': 19, 'instr': ['const', 'i32', 1048576], 'stack': [], 'locals': {}},
{'pos': None, 'instr': ['block'], 'stack': [], 'locals': {}},
{'pos': 3, 'instr': ['const', 'i64', 2], 'stack': [], 'locals': {}},
]

wasm_hash = sha256(wasm_bytecode)
salt = b'\x00' * 32
send(sign_and_xdr(builder().append_create_contract_op(wasm_hash, keypair.public_key, None, salt)))

# Submit the invocation, then retrieve its trace by hash.
contract_address = server.encoder.contract_address_from_deployer_address(keypair.public_key, salt)
invoke_xdr = sign_and_xdr(builder().append_invoke_contract_function_op(contract_address, 'foo', []))
tx_hash = send(invoke_xdr)
def test_trace_records_have_expected_structure_and_reflect_arguments(server: StellarRpcServer) -> None:
"""Each trace record is a ``{pos, instr, stack, locals}`` object, and for a call that takes
arguments the decoded arguments are bound as locals while intermediate values build up on the
stack — exercising a richer trace than the argument-less ``foo()`` case.
"""
invoke = _deploy_and_get_invoker(server, ARGS_CONTRACT_WAT)
tx_hash = invoke(
'test_integers',
[
xdr.SCVal(type=SCValType.SCV_U32, u32=xdr.Uint32(42)),
xdr.SCVal(type=SCValType.SCV_I32, i32=xdr.Int32(-7)),
xdr.SCVal(type=SCValType.SCV_U64, u64=xdr.Uint64(100)),
xdr.SCVal(type=SCValType.SCV_I64, i64=xdr.Int64(-200)),
],
)

trace = _rpc(server.port(), 'traceTransaction', {'hash': tx_hash})['result']

assert trace is not None
# Trace is newline-separated JSON records; verify each line parses as JSON
lines = [line for line in trace.splitlines() if line.strip()]
assert len(lines) > 0
import json as _json

for line in lines:
record = _json.loads(line)
assert 'instr' in record
assert isinstance(trace, list)
assert len(trace) > 0
for record in trace:
assert set(record) == {'pos', 'instr', 'stack', 'locals'}
assert record['pos'] is None or isinstance(record['pos'], int)
assert isinstance(record['instr'], list) and record['instr']
assert isinstance(record['instr'][0], str) # opcode mnemonic
# stack and locals hold [type, value] pairs.
assert isinstance(record['stack'], list)
assert all(isinstance(e, list) and len(e) == 2 and isinstance(e[0], str) for e in record['stack'])
assert isinstance(record['locals'], dict)
assert all(isinstance(e, list) and len(e) == 2 and isinstance(e[0], str) for e in record['locals'].values())

# The four call arguments are bound as locals 0..3 by the time the body runs.
locals_seen = {key for record in trace for key in record['locals']}
assert {'0', '1', '2', '3'} <= locals_seen
# Intermediate computation puts values on the stack at some point.
assert any(record['stack'] for record in trace)
# The function body returns Void: the final instruction pushes the i64 constant 2.
assert trace[-1]['instr'] == ['const', 'i64', 2]


def test_call_tx_with_args(server: StellarRpcServer) -> None:
Expand All @@ -395,37 +446,7 @@ def test_call_tx_with_args(server: StellarRpcServer) -> None:
Uses a minimal contract (args.wat) whose functions accept various arg types and return
Void. Covers: bool, u32, i32, u64, i64, u128, i128, symbol.
"""
keypair = Keypair.random()
account = Account(keypair.public_key, sequence=0)

def builder() -> TransactionBuilder:
return TransactionBuilder(account, Network.TESTNET_NETWORK_PASSPHRASE)

def send(tb: TransactionBuilder) -> None:
env = tb.set_timeout(30).build()
env.sign(keypair)
res = _rpc(server.port(), 'sendTransaction', {'transaction': env.to_xdr()})
assert res['result']['status'] == 'PENDING'
tx_hash = res['result']['hash']
get_res = _rpc(server.port(), 'getTransaction', {'hash': tx_hash})['result']
assert get_res['status'] == 'SUCCESS', f'Transaction failed: {get_res}'

# Set up: create account, upload args.wat, deploy contract
send(builder().append_create_account_op(keypair.public_key, '1000'))

wasm_bytecode = wat_to_wasm(ARGS_CONTRACT_WAT)
send(builder().append_upload_contract_wasm_op(wasm_bytecode))

from stellar_sdk.utils import sha256

wasm_hash = sha256(wasm_bytecode)
salt = b'\x00' * 32
send(builder().append_create_contract_op(wasm_hash, keypair.public_key, None, salt))

contract_address = server.encoder.contract_address_from_deployer_address(keypair.public_key, salt)

def invoke(func: str, args: list[xdr.SCVal]) -> None:
send(builder().append_invoke_contract_function_op(contract_address, func, args))
invoke = _deploy_and_get_invoker(server, ARGS_CONTRACT_WAT)

invoke('test_bool', [xdr.SCVal(type=SCValType.SCV_BOOL, b=True)])
invoke(
Expand Down