From 717a91a589ecd420198336dc8ec9579ef1dbdf7b Mon Sep 17 00:00:00 2001 From: Raoul Date: Wed, 24 Jun 2026 13:31:08 +0000 Subject: [PATCH 1/4] fix: return traceTransaction result as a JSON array The traceTransaction RPC put the trace file's contents straight into the result as a String, so the response body carried the trace as a single JSON-encoded string instead of structured data. Clients had to re-parse the JSONL string themselves. Parse the JSONL trace file into a JSON array in #respondTrace, so the result is an array with one object per executed instruction (empty when the transaction ran no instructions; null still means no such trace). The on-disk traces/trace_.jsonl format is unchanged. Update the trace tests (empty trace is now [] rather than "") and the README / server / node-semantics / architecture docs to match. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 2 +- docs/architecture.md | 2 +- docs/node-semantics.md | 2 +- docs/server.md | 6 +++-- src/komet_node/kdist/node.md | 34 +++++++++++++++++++++++++--- src/tests/integration/test_server.py | 18 ++++++--------- 6 files changed, 45 insertions(+), 19 deletions(-) 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..f496043 100644 --- a/src/tests/integration/test_server.py +++ b/src/tests/integration/test_server.py @@ -323,9 +323,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: @@ -340,7 +340,7 @@ def test_trace_transaction_missing_hash_returns_invalid_params(server: StellarRp def test_trace_transaction_produces_trace_on_contract_invocation(server: StellarRpcServer) -> None: - """traceTransaction returns non-empty trace JSONL for a submitted contract invocation.""" + """traceTransaction returns a non-empty trace array for a submitted contract invocation.""" keypair = Keypair.random() account = Account(keypair.public_key, sequence=0) @@ -378,14 +378,10 @@ def send(xdr: str) -> str: 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) + # Trace is a JSON array of per-instruction records (not a JSON-encoded string). + assert isinstance(trace, list) + assert len(trace) > 0 + for record in trace: assert 'instr' in record From ff75ef9f7f3b686fdac16d978adbb64a10afd0d6 Mon Sep 17 00:00:00 2001 From: Raoul Date: Wed, 24 Jun 2026 14:39:39 +0000 Subject: [PATCH 2/4] test: cover non-empty traceTransaction traces Trace coverage only ever asserted the empty-trace case ([]) and a weak "len > 0 and every record has an instr key" check on a contract call. Neither pinned the actual trace content or the record structure. Add two stronger tests: - assert foo()'s full instruction trace record-for-record (the exact trace the README documents), which also guards the array-vs-string shape of the result; - invoke a function with arguments (test_integers) and assert every record is a {pos, instr, stack, locals} object, that stack/locals entries are [type, value] pairs, that the decoded arguments are bound as locals 0..3, that intermediate values reach the stack, and that the body returns Void. Factor the create-account -> upload -> deploy -> invoke setup, previously copy-pasted across the trace and args tests, into a _deploy_and_get_invoker helper, and refactor test_call_tx_with_args onto it. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/tests/integration/test_server.py | 155 ++++++++++++++++----------- 1 file changed, 90 insertions(+), 65 deletions(-) diff --git a/src/tests/integration/test_server.py b/src/tests/integration/test_server.py index f496043..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) @@ -339,50 +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 a non-empty trace array 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'] - # Trace is a JSON array of per-instruction records (not a JSON-encoded string). assert isinstance(trace, list) assert len(trace) > 0 for record in trace: - assert 'instr' in record + 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: @@ -391,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( From 49242497de62087eb2d71eb84811c217b4e434d7 Mon Sep 17 00:00:00 2001 From: Raoul Date: Wed, 22 Jul 2026 16:57:45 +0000 Subject: [PATCH 3/4] perf(traceTransaction): embed trace via string concatenation The trace file is JSONL already produced by the semantics, so decoding each record with String2JSON and re-encoding the whole array via JSON2String was a redundant round-trip whose cost scales with the trace (which can be very large). Trust the file and build the result array by concatenating the lines with commas inside brackets, running only the small id through the JSON encoder. --- docs/node-semantics.md | 2 +- src/komet_node/kdist/node.md | 65 +++++++++++++++++++++++++----------- 2 files changed, 47 insertions(+), 20 deletions(-) diff --git a/docs/node-semantics.md b/docs/node-semantics.md index 7ff027c..41397f4 100644 --- a/docs/node-semantics.md +++ b/docs/node-semantics.md @@ -105,7 +105,7 @@ The trace is not part of the receipt — the executing steps already appended it ### traceTransaction -`traceTransaction` is a komet-specific extension, not part of the Stellar RPC specification (see [server.md](server.md#tracetransaction-komet-specific-extension)). It 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. +`traceTransaction` is a komet-specific extension, not part of the Stellar RPC specification (see [server.md](server.md#tracetransaction-komet-specific-extension)). It is a read-only lookup: it takes a `hash` (the same parameter `getTransaction` takes) and responds with the contents of `traces/trace_.jsonl` as a JSON array (one record per executed instruction), or `null` when no trace file exists for that hash. Rather than decoding and re-encoding each record — a full round-trip whose cost scales with the (potentially very large) trace — the semantics trust that the file the tracer wrote is already valid JSON and build the array by string concatenation, joining the lines with commas inside `[` … `]`. Because tracing is always on, every `sendTransaction` writes this file. ### Two ways steps are delivered diff --git a/src/komet_node/kdist/node.md b/src/komet_node/kdist/node.md index 49cd1eb..f4a5b78 100644 --- a/src/komet_node/kdist/node.md +++ b/src/komet_node/kdist/node.md @@ -527,10 +527,17 @@ the spec-mandated `resultXdr`/`resultMetaXdr` base64 XDR fields (K cannot constr 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`. 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. +by `sendTransaction`. The file is JSONL (one JSON record per executed instruction), and each +line is already valid JSON produced by the semantics — so rather than decoding every record +into a `JSON` term and re-encoding the whole array (a full round-trip whose cost scales with +the trace, which can be very large), we build the result array by *string concatenation*: +join the trusted lines with commas and wrap them in `[` … `]`. Responds with that array — +empty when the transaction ran no instructions — or `null` when no trace file exists for that +hash. + +Because the trace body is spliced in as raw text, `#respondTrace` writes the JSON-RPC +envelope directly (mirroring `#respond`) instead of building a `JSON` term for `JSON2String`: +only the small `id` value is serialized through the encoder. ```k rule #dispatchMethod( "traceTransaction", REQ ) @@ -538,36 +545,56 @@ file exists for that hash. ... - rule #respondTrace( ID, HASH ) => #respond( ID, [ #parseTraceLines( {#readFile( #traceFile( HASH ) )}:>String ) ] ) ... + rule #respondTrace( ID, HASH ) + => #writeFile( "response.json", + "{\"jsonrpc\":\"2.0\",\"id\":" + +String JSON2String( ID ) + +String ",\"result\":" + +String #traceArray( {#readFile( #traceFile( HASH ) )}:>String ) + +String "}" ) + ~> #remove( "request.json" ) + ... + + _ => 0 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). +`#traceArray` wraps the JSONL trace text in array brackets; `#traceElems` joins the +newline-delimited records with commas without touching their contents. Empty segments (a +leading/blank line, or the empty tail after the final record's trailing newline) are skipped +via `#maybeComma`, which prefixes a separator only when a following record actually exists — +so a trailing newline never yields a dangling comma, and an empty file yields `[]`. ```k - syntax JSONs ::= #parseTraceLines( String ) [function, symbol(parseTraceLines)] - // ------------------------------------------------------------------------------- - rule #parseTraceLines( "" ) => .JSONs + syntax String ::= #traceArray( String ) [function, symbol(traceArray)] + | #traceElems( String ) [function, symbol(traceElems)] + | #maybeComma( String ) [function, symbol(maybeComma)] + // ----------------------------------------------------------------------- + rule #traceArray( S ) => "[" +String #traceElems( S ) +String "]" + + rule #traceElems( "" ) => "" // No more newlines: the whole remaining string is the final record. - rule #parseTraceLines( S ) => String2JSON( S ) , .JSONs + rule #traceElems( S ) => S 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 ) ) ) + // Split off the first line and recurse; a comma is added only if the tail is non-empty. + rule #traceElems( S ) + => substrString( S, 0, findString( S, "\n", 0 ) ) + +String #maybeComma( #traceElems( 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 ) ) ) + rule #traceElems( S ) + => #traceElems( substrString( S, 1, lengthString( S ) ) ) requires findString( S, "\n", 0 ) ==Int 0 + + rule #maybeComma( "" ) => "" + rule #maybeComma( S ) => "," +String S + requires S =/=String "" ``` ## simulateTransaction From ecb5bc453d7f9ce6d32120149752662e13229fe6 Mon Sep 17 00:00:00 2001 From: Raoul Date: Wed, 22 Jul 2026 17:01:21 +0000 Subject: [PATCH 4/4] test: update trace expectations for call-boundary frames Merging main brought in the enriched tracer (get-events / contract-call features), which now wraps the WebAssembly instruction stream with a callContract entry frame and an endWasm exit frame. Assert the deterministic instruction records exactly and the entry/exit frames structurally, leaving the per-run contract and account ids unpinned. --- src/tests/integration/test_server.py | 59 ++++++++++++++++++++++------ 1 file changed, 48 insertions(+), 11 deletions(-) diff --git a/src/tests/integration/test_server.py b/src/tests/integration/test_server.py index 5b7f079..bf90560 100644 --- a/src/tests/integration/test_server.py +++ b/src/tests/integration/test_server.py @@ -614,19 +614,33 @@ def test_trace_transaction_missing_hash_returns_invalid_params(server: StellarRp def test_trace_transaction_returns_full_instruction_trace_for_foo(server: StellarRpcServer) -> None: - """traceTransaction returns the complete, ordered instruction trace of an invocation. + """traceTransaction returns the complete, ordered trace of an invocation: a ``callContract`` + entry frame, the executed WebAssembly instructions, and an ``endWasm`` exit frame. 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. + instruction records are the contract's global initialisation and the ``block`` is the + function frame. The instruction records are asserted record-for-record (the exact trace + shown in the README) so any drift in format, ordering, or the array-vs-string shape of the + result is caught. The entry/exit frames carry per-run contract and account ids, so they are + checked structurally rather than by value. """ invoke = _deploy_and_get_invoker(server, EMPTY_CONTRACT_WAT) tx_hash = invoke('foo') trace = _rpc(server.port(), 'traceTransaction', {'hash': tx_hash})['result'] - assert trace == [ + # A callContract entry frame opens the trace: the account calls foo() on the contract with + # no arguments at call depth 1. + entry = trace[0] + assert entry['instr'] == ['callContract'] + assert entry['function'] == 'foo' + assert entry['args'] == [] + assert entry['depth'] == 1 + assert entry['from']['addrType'] == 'account' + assert entry['to']['addrType'] == 'contract' + + # The executed WebAssembly instructions, exactly as shown in the README. + assert trace[1:-1] == [ {'pos': 3, 'instr': ['const', 'i32', 1048576], 'stack': [], 'locals': {}}, {'pos': 11, 'instr': ['const', 'i32', 1048576], 'stack': [], 'locals': {}}, {'pos': 19, 'instr': ['const', 'i32', 1048576], 'stack': [], 'locals': {}}, @@ -634,10 +648,18 @@ def test_trace_transaction_returns_full_instruction_trace_for_foo(server: Stella {'pos': 3, 'instr': ['const', 'i64', 2], 'stack': [], 'locals': {}}, ] + # An endWasm exit frame closes the trace: the call succeeded and returned Void. + exit_frame = trace[-1] + assert exit_frame['instr'] == ['endWasm'] + assert exit_frame['success'] is True + assert exit_frame['result'] == {'type': 'void'} + assert exit_frame['depth'] == 1 + 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 + """The trace opens with a ``callContract`` frame that echoes the decoded arguments, and each + WebAssembly instruction record is a ``{pos, instr, stack, locals}`` object. For a call that + takes arguments the 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) @@ -655,7 +677,22 @@ def test_trace_records_have_expected_structure_and_reflect_arguments(server: Ste assert isinstance(trace, list) assert len(trace) > 0 - for record in trace: + + # The callContract entry frame echoes the call target and its decoded arguments. + entry = trace[0] + assert entry['instr'] == ['callContract'] + assert entry['function'] == 'test_integers' + assert entry['args'] == [ + {'type': 'u32', 'value': 42}, + {'type': 'i32', 'value': -7}, + {'type': 'u64', 'value': 100}, + {'type': 'i64', 'value': -200}, + ] + + # The instruction records (everything between the call-boundary frames) share one shape. + instr_records = [record for record in trace if 'locals' in record] + assert instr_records + for record in instr_records: 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'] @@ -667,12 +704,12 @@ def test_trace_records_have_expected_structure_and_reflect_arguments(server: Ste 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']} + locals_seen = {key for record in instr_records 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) + assert any(record['stack'] for record in instr_records) # The function body returns Void: the final instruction pushes the i64 constant 2. - assert trace[-1]['instr'] == ['const', 'i64', 2] + assert instr_records[-1]['instr'] == ['const', 'i64', 2] def test_call_tx_with_args(server: StellarRpcServer) -> None: