diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index edeb0f9..62bf0c3 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -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 \ diff --git a/README.md b/README.md index 9976b3d..71616d2 100644 --- a/README.md +++ b/README.md @@ -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 { diff --git a/docs/architecture.md b/docs/architecture.md index c36ae26..6792650 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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_.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_.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_.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_.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. | diff --git a/docs/node-semantics.md b/docs/node-semantics.md index 6f72b79..122294c 100644 --- a/docs/node-semantics.md +++ b/docs/node-semantics.md @@ -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_.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_.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 diff --git a/docs/server.md b/docs/server.md index 0af41c5..ba54270 100644 --- a/docs/server.md +++ b/docs/server.md @@ -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 -"" +[ + {"pos": 3, "instr": ["const", "i32", 1048576], "stack": [], "locals": {}} +] ``` ### `getTransaction` diff --git a/src/komet_node/kdist/node.md b/src/komet_node/kdist/node.md index 8c0c320..79bc643 100644 --- a/src/komet_node/kdist/node.md +++ b/src/komet_node/kdist/node.md @@ -335,8 +335,10 @@ 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_.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 #dispatchMethod( "traceTransaction", REQ ) @@ -344,12 +346,38 @@ exists for that hash. ... - rule #respondTrace( ID, HASH ) => #respond( ID, {#readFile( #traceFile( HASH ) )}:>String ) ... + rule #respondTrace( ID, HASH ) => #respond( ID, [ #parseTraceLines( {#readFile( #traceFile( HASH ) )}:>String ) ] ) ... requires #fileExists( #traceFile( HASH ) ) rule #respondTrace( ID, HASH ) => #respond( ID, null ) ... 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 ) 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 diff --git a/src/tests/integration/test_server.py b/src/tests/integration/test_server.py index 88bd0e1..52c7fc1 100644 --- a/src/tests/integration/test_server.py +++ b/src/tests/integration/test_server.py @@ -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) @@ -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) @@ -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: @@ -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: @@ -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(