diff --git a/README.md b/README.md index 54dbfd4..2224abb 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 f3b646d..88739ca 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 and the server (on success — the server rewrites the semantics' internal `returnValue` field into `resultXdr`/`resultMetaXdr`), or the server alone (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. | | `ledgers/ledger_.json` | persistent | the server (on success) | one record per closed ledger — `{sequence, txHash, closedAt, hash, headerXdr, metadataXdr}` — the ledger→transaction index behind `getTransactions` and `getLedgers`. Written in Python because the header artifacts are XDR, which K cannot construct. | | `requests/request_.json` | persistent | the server | an archive of each incoming JSON-RPC request, numbered by a monotonic counter, kept for debugging. | | `wasms/.wasm` | persistent | the server | the raw bytes of each successfully uploaded wasm module, keyed by hex sha256. The K state stores modules parsed (`ModuleDecl`), so `getLedgerEntries` CONTRACT_CODE entries read the original bytes from here. | diff --git a/docs/node-semantics.md b/docs/node-semantics.md index c8d9931..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`, 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/docs/server.md b/docs/server.md index e177055..2938653 100644 --- a/docs/server.md +++ b/docs/server.md @@ -185,10 +185,12 @@ Failures are reported in the result body, matching real stellar-rpc; only an und `traceTransaction` is **not part of the Stellar RPC specification** — it exists only on komet-node, and clients must not expect it from real Stellar RPC endpoints. It keeps its plain name rather than a vendor-prefixed one (`komet_traceTransaction`): the official spec has no method of that name and none is announced, so there is no collision to avoid, and renaming would break every existing client for no gain. If stellar-rpc ever claims the name, the method will be renamed with a prefix. -`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 1224b1d..f4a5b78 100644 --- a/src/komet_node/kdist/node.md +++ b/src/komet_node/kdist/node.md @@ -527,8 +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`. 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), 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 ) @@ -536,12 +545,58 @@ exists for that hash. ... - rule #respondTrace( ID, HASH ) => #respond( ID, {#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 ) ) ``` +`#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 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 #traceElems( S ) => S + requires S =/=String "" andBool findString( S, "\n", 0 ) 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 #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 Run a single contract invocation against the current world state *without committing diff --git a/src/tests/integration/test_server.py b/src/tests/integration/test_server.py index 24b7a1f..bf90560 100644 --- a/src/tests/integration/test_server.py +++ b/src/tests/integration/test_server.py @@ -14,12 +14,13 @@ import pytest from stellar_sdk import Account, Address, Asset, 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 Iterator + from collections.abc import Callable, Iterator from stellar_sdk import TransactionEnvelope @@ -152,6 +153,41 @@ def server(tmp_path: Path) -> Iterator[StellarRpcServer]: 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) @@ -557,9 +593,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: @@ -577,54 +613,103 @@ 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 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 test_trace_transaction_returns_full_instruction_trace_for_foo(server: StellarRpcServer) -> None: + """traceTransaction returns the complete, ordered trace of an invocation: a ``callContract`` + entry frame, the executed WebAssembly instructions, and an ``endWasm`` exit frame. - 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 + empty.wat's ``foo()`` body is a single ``i64.const 2`` (the Void return); the three leading + 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') - # Set up: create account, upload wasm, deploy contract - send(sign_and_xdr(builder().append_create_account_op(keypair.public_key, '1000'))) + trace = _rpc(server.port(), 'traceTransaction', {'hash': tx_hash})['result'] - wasm_bytecode = wat_to_wasm(EMPTY_CONTRACT_WAT) - send(sign_and_xdr(builder().append_upload_contract_wasm_op(wasm_bytecode))) + # 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': {}}, + {'pos': None, 'instr': ['block'], 'stack': [], 'locals': {}}, + {'pos': 3, 'instr': ['const', 'i64', 2], 'stack': [], 'locals': {}}, + ] - from stellar_sdk.utils import sha256 + # 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 - 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: + """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) + 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 + assert isinstance(trace, list) + assert len(trace) > 0 + + # 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}, + ] - for line in lines: - record = _json.loads(line) - assert 'instr' in record + # 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'] + 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 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 instr_records) + # The function body returns Void: the final instruction pushes the i64 constant 2. + assert instr_records[-1]['instr'] == ['const', 'i64', 2] def test_call_tx_with_args(server: StellarRpcServer) -> None: @@ -633,37 +718,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(