diff --git a/src/komet_node/__init__.py b/src/komet_node/__init__.py index e69de29..ae4f646 100644 --- a/src/komet_node/__init__.py +++ b/src/komet_node/__init__.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +import sys + +# Parsing and traversing the KORE world-state configuration (via pyk's recursive-descent +# KORE parser and the recursive cell rewrites in ``interpreter.py``) recurses with the depth +# and size of the term. Large real contracts produce configurations far deeper than CPython's +# default recursion limit (1000), which otherwise surfaces as a ``RecursionError`` mid-request. +# Raise the ceiling to match the rest of the K tooling (pyk sets 10**7; komet sets its own +# limit at import). This is the sole cross-cutting entry point, so setting it here covers the +# server process, direct interpreter use, and the encoders. server.py backs this with a large +# serve-thread stack so a deep term raises a catchable error rather than a SIGSEGV. +sys.setrecursionlimit(10**7) diff --git a/src/komet_node/kdist/node.md b/src/komet_node/kdist/node.md index f4a5b78..938b778 100644 --- a/src/komet_node/kdist/node.md +++ b/src/komet_node/kdist/node.md @@ -1164,6 +1164,23 @@ SCVal arg encoding (key order also significant): rule #decodeArg({ "type" : "bytes" , "value" : V:String }) => ScBytes(HexBytes(V)) rule #decodeArg({ "type" : "address" , "addrType" : "account" , "value" : V:String }) => ScAddress(Account(HexBytes(V))) rule #decodeArg({ "type" : "address" , "addrType" : "contract" , "value" : V:String }) => ScAddress(Contract(HexBytes(V))) + + // Composite arguments. A vec reuses #decodeArgList (which already yields a List of + // ScVal); a map decodes its entries into a Map from ScVal keys to ScVal values. + // Enums, structs, and tuples all bottom out in vecs and maps, so these two rules + // cover every composite call argument. Encoded by scval_to_json as + // { "type": "vec", "value": [ , ... ] } + // { "type": "map", "value": [ { "key": , "val": }, ... ] } + rule #decodeArg({ "type" : "vec" , "value" : [ ELEMS:JSONs ] }) => ScVec(#decodeArgList(ELEMS)) + rule #decodeArg({ "type" : "map" , "value" : [ ENTRIES:JSONs ] }) => ScMap(#decodeMapEntries(ENTRIES)) + + syntax Map ::= #decodeMapEntries(JSONs) [function] + rule #decodeMapEntries(.JSONs) => .Map + rule #decodeMapEntries(E:JSON, ES:JSONs) + => #decodeMapEntry(E) #decodeMapEntries(ES) + + syntax Map ::= #decodeMapEntry(JSON) [function] + rule #decodeMapEntry({ "key" : K:JSON , "val" : V:JSON }) => #decodeArg(K) |-> #decodeArg(V) ``` `uncheckedCallTx` is like komet's `callTx` but it does not entail a return value check. diff --git a/src/komet_node/scval.py b/src/komet_node/scval.py index 36d21ed..29b9683 100644 --- a/src/komet_node/scval.py +++ b/src/komet_node/scval.py @@ -58,6 +58,20 @@ def scval_to_json(scval: SCVal) -> dict: return {'type': 'address', 'addrType': 'account', 'value': raw.hex()} assert addr.contract_id is not None return {'type': 'address', 'addrType': 'contract', 'value': addr.contract_id.contract_id.hash.hex()} + case SCValType.SCV_VEC: + # A vec recurses element-wise. User enums and tuples reduce to vecs at + # the XDR level, so this also covers those composite arguments. + assert scval.vec is not None + return {'type': 'vec', 'value': [scval_to_json(v) for v in scval.vec.sc_vec]} + case SCValType.SCV_MAP: + # A map recurses over its entries. Structs reduce to symbol-keyed maps at + # the XDR level. Key order follows the XDR entry order, which the SDK keeps + # sorted; the K side rebuilds a Map so ordering there is immaterial. + assert scval.map is not None + return { + 'type': 'map', + 'value': [{'key': scval_to_json(e.key), 'val': scval_to_json(e.val)} for e in scval.map.sc_map], + } case _: raise NotImplementedError(f'Unsupported SCVal type for JSON encoding: {scval.type}') diff --git a/src/komet_node/server.py b/src/komet_node/server.py index d5f27b4..4ec56e7 100644 --- a/src/komet_node/server.py +++ b/src/komet_node/server.py @@ -5,6 +5,7 @@ import logging import re import sys +import threading import time import traceback from datetime import datetime, timezone @@ -21,7 +22,7 @@ from komet_node.transaction import SimulationRejected, malformed_tx_result_xdr if TYPE_CHECKING: - from collections.abc import Mapping + from collections.abc import Iterable, Iterator, Mapping from http.server import HTTPServer as HTTPServerType from pathlib import Path @@ -100,6 +101,13 @@ def _empty_transaction_data() -> str: # the default 'base64' format; see _require_supported_xdr_format. _XDR_FORMAT_METHODS: Final = ('getTransaction', 'sendTransaction') +# The request path drives deep Python recursion (pyk's recursive-descent KORE parser and the +# recursive cell rewrites in interpreter.py) proportional to the world-state term. komet_node +# raises the recursion *limit* (see __init__.py) so large real contracts do not hit CPython's +# default 1000; this backs that limit with a matching C stack, run on a dedicated serve thread, +# so a deep term raises a catchable error rather than overflowing an 8 MB stack into a SIGSEGV. +_SERVE_STACK_SIZE: Final = 512 * 1024 * 1024 + _log = logging.getLogger('komet_node') @@ -177,7 +185,18 @@ def log_message(self, *args: Any) -> None: # switch to ThreadingHTTPServer without reworking that file protocol. self._httpd = HTTPServer((self.host, int(self._port)), Handler) self._log_ready() - self._httpd.serve_forever() + + # Run the (blocking) serve loop on a worker thread with a large stack so the raised + # recursion limit is usable: the request handler recurses on this thread, and a big + # C stack is what keeps a deep world-state term from segfaulting. stack_size is a + # no-op fallback (default stack) on the rare platform that does not support it. + try: + threading.stack_size(_SERVE_STACK_SIZE) + except (ValueError, RuntimeError): + pass + worker = threading.Thread(target=self._httpd.serve_forever, name='komet-node-serve') + worker.start() + worker.join() def _log_ready(self) -> None: """Announce, once the socket is bound, where the server listens and how it started.""" @@ -296,6 +315,8 @@ def _dispatch(self, method: str | None, params: dict[str, Any], request_id: Any, return self._handle_simulate(params, request_id, now) if method == 'getLedgerEntries': return self._get_ledger_entries(params, request_id, now) + if method == 'traceTransaction': + return self._trace_transaction(params, request_id) envelope = self._read_only_envelope(method, params, request_id, now) response = self.interpreter.run(self.state_file, self.io_dir, envelope, None) @@ -378,6 +399,98 @@ def _get_ledger_entries(self, params: dict[str, Any], request_id: Any, now: str) raise RpcError.internal() return format_ledger_entries_response(response, self.store.wasms_dir) + def _trace_transaction(self, params: dict[str, Any], request_id: Any) -> str: + """Serve a transaction's execution trace directly from its JSONL file. + + The trace was streamed to ``traces/trace_.jsonl`` during ``sendTransaction`` — one + already-valid JSON record per line — so the result array is assembled here in a single + linear pass (join the lines with commas, wrap in brackets). This deliberately bypasses + the interpreter: the semantics reassembled the array by recursively copying the whole + remaining tail once per line, which is O(n^2) in time and memory and OOM-killed the + interpreter on multi-hundred-MB traces. Hash validation mirrors the read-only path. + + Each served record is additionally stamped with an ``"executingContract"`` field naming the + contract whose code is executing at that record, reconstructed from the trace's own call-boundary + markers by walking a stack of contract ids (the debug adapter needs it because a callee's + small ``pos`` values collide with the caller's and must be mapped against the right binary): + + * a ``callContract`` record (``instr[0] == 'callContract'``) PUSHes ``to.value`` before + tagging, so the record and its whole callee span are tagged with the callee; + * an exit marker (``instr[0]`` starting with ``'endWasm'`` — success ``endWasm`` and trap + ``endWasm-error`` alike) is tagged with the current top, THEN pops (guarded against + underflow); + * every other record is tagged with the current top, or JSON ``null`` when the stack is + empty (records before any ``callContract``). + + The root ``callContract`` may have no matching ``endWasm``; its span simply runs to the end. + The annotation is byte-preserving: original record bytes are untouched (the tag is injected + before the closing brace) and only the handful of boundary-candidate lines are ever parsed, + so peak memory stays proportional to the trace size — the property this path exists to keep. + """ + tx_hash = params.get('hash') + if not isinstance(tx_hash, str): + raise RpcError.invalid_params("'hash' (string) is required") + if _TX_HASH_RE.fullmatch(tx_hash) is None: + raise RpcError.invalid_params("'hash' must be a 64-character hex string") + trace_file = self.io_dir / 'traces' / f'trace_{tx_hash}.jsonl' + if not trace_file.is_file(): + return '{"jsonrpc":"2.0","id":' + json.dumps(request_id) + ',"result":null}' + text = trace_file.read_text() + body = ','.join(self._annotate_trace_lines(text.split('\n'))) + return '{"jsonrpc":"2.0","id":' + json.dumps(request_id) + ',"result":[' + body + ']}' + + @staticmethod + def _annotate_trace_lines(lines: Iterable[str]) -> Iterator[str]: + """Yield each non-empty trace line with an ``"executingContract"`` tag injected, tracking + the call-boundary stack across the whole trace. See :meth:`_trace_transaction` for the + rules. + + The tag is deliberately named ``executingContract`` rather than ``contract``: a + ``contractData`` trace record already carries its own documented top-level ``"contract"`` + field (an address object naming the storage-target contract), so injecting our own + ``"contract"`` would duplicate and clobber it — ``executingContract`` avoids the collision. + + Boundary detection is cheap: a line is ``json.loads``-parsed only when it contains the + substring ``"callContract"`` or ``"endWasm`` (a handful of lines out of the whole trace) — + confirmed against the parsed ``instr[0]``; every other line is tagged with the current top + of stack without being parsed. The stack holds contract-id strings; an empty stack tags a + record with JSON ``null``. A ``callContract`` record's callee id is read defensively (a + malformed record missing ``to``/``value`` pushes ``None`` rather than raising and 500-ing + the served file), so push/pop balance with the ``endWasm*`` markers is preserved and the + malformed span is simply tagged ``executingContract: null``. The tag is injected before the + record's closing brace so the original bytes survive verbatim; a line that does not end in + ``}`` (never a valid JSONL record) is left untouched. + """ + stack: list[str | None] = [] + for line in lines: + if not line: + continue + pop_after = False + # Only parse boundary CANDIDATES: 'callContract' opens a call, 'endWasm'/'endWasm-error' + # close one. Both endWasm spellings share the '"endWasm' prefix. + if '"callContract"' in line or '"endWasm' in line: + record = json.loads(line) + instr = record.get('instr') if isinstance(record, dict) else None + op = instr[0] if isinstance(instr, list) and instr else None + if op == 'callContract': + # Push before tagging: this record and its callee span carry the callee. + # Read 'to.value' defensively so a malformed record still pushes (as None), + # keeping push/pop balance with the endWasm* markers intact. + to = record.get('to') + addr = to.get('value') if isinstance(to, dict) else None + stack.append(addr) + elif isinstance(op, str) and op.startswith('endWasm'): + # Tag with the finishing callee (still on top), then pop after tagging. + pop_after = True + top = stack[-1] if stack else None + stripped = line.rstrip() + if stripped.endswith('}'): + yield stripped[:-1] + ',"executingContract":' + json.dumps(top) + '}' + else: + yield line + if pop_after and stack: # guard against underflow on an unmatched exit marker + stack.pop() + def _read_only_envelope( self, method: str | None, params: dict[str, Any], request_id: Any, now: str ) -> dict[str, Any]: diff --git a/src/tests/integration/data/wasm/args.wat b/src/tests/integration/data/wasm/args.wat index 03f0937..e14d8d4 100644 --- a/src/tests/integration/data/wasm/args.wat +++ b/src/tests/integration/data/wasm/args.wat @@ -23,6 +23,15 @@ ;; _ (Soroban ABI stub) (func (;4;) (type 0)) + ;; test_vec / test_map: accept 1 composite arg (a HostVal object handle), + ;; return Void. Declared last and referenced by symbolic id so their function + ;; indices (and the exports below) do not depend on declaration order — + ;; wat2wasm numbers functions by position, ignoring the ;;(;N;) comments. + (func $test_vec (type 1) (param i64) (result i64) + i64.const 2) + (func $test_map (type 1) (param i64) (result i64) + i64.const 2) + (memory (;0;) 16) (global (;0;) (mut i32) (i32.const 1048576)) (global (;1;) i32 (i32.const 1048576)) @@ -34,6 +43,8 @@ (export "test_wide_integers" (func 2)) (export "test_symbol" (func 3)) (export "_" (func 4)) + (export "test_vec" (func $test_vec)) + (export "test_map" (func $test_map)) (export "__data_end" (global 1)) (export "__heap_base" (global 2)) ) diff --git a/src/tests/integration/test_server.py b/src/tests/integration/test_server.py index 783c7ac..68cf669 100644 --- a/src/tests/integration/test_server.py +++ b/src/tests/integration/test_server.py @@ -485,6 +485,8 @@ def test_trace_transaction_returns_full_instruction_trace_for_foo(server: Stella 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. + + CI-only: deploys a real WAT, so it needs ``wat2wasm`` on PATH and cannot run where it is absent. """ invoke = deploy_and_get_invoker(server, EMPTY_CONTRACT_WAT) tx_hash = invoke('foo') @@ -501,21 +503,56 @@ def test_trace_transaction_returns_full_instruction_trace_for_foo(server: Stella assert entry['from']['addrType'] == 'account' assert entry['to']['addrType'] == 'contract' - # The executed WebAssembly instructions, exactly as shown in the README. + # Every record is stamped with the contract whose code is executing: here a single deployed + # contract runs the whole trace, so that id (the callContract's callee) tags every record. + contract_id = entry['to']['value'] + + # The executed WebAssembly instructions, exactly as shown in the README, each tagged with the + # executing contract. assert trace[1:-1] == [ - {'pos': 3, 'instr': ['const', 'i32', 1048576], 'stack': [], 'locals': {}, 'mem': None}, - {'pos': 11, 'instr': ['const', 'i32', 1048576], 'stack': [], 'locals': {}, 'mem': None}, - {'pos': 19, 'instr': ['const', 'i32', 1048576], 'stack': [], 'locals': {}, 'mem': None}, - {'pos': None, 'instr': ['block'], 'stack': [], 'locals': {}, 'mem': None}, - {'pos': 3, 'instr': ['const', 'i64', 2], 'stack': [], 'locals': {}, 'mem': None}, + { + 'pos': 3, + 'instr': ['const', 'i32', 1048576], + 'stack': [], + 'locals': {}, + 'mem': None, + 'executingContract': contract_id, + }, + { + 'pos': 11, + 'instr': ['const', 'i32', 1048576], + 'stack': [], + 'locals': {}, + 'mem': None, + 'executingContract': contract_id, + }, + { + 'pos': 19, + 'instr': ['const', 'i32', 1048576], + 'stack': [], + 'locals': {}, + 'mem': None, + 'executingContract': contract_id, + }, + {'pos': None, 'instr': ['block'], 'stack': [], 'locals': {}, 'mem': None, 'executingContract': contract_id}, + { + 'pos': 3, + 'instr': ['const', 'i64', 2], + 'stack': [], + 'locals': {}, + 'mem': None, + 'executingContract': contract_id, + }, ] - # An endWasm exit frame closes the trace: the call succeeded and returned Void. + # An endWasm exit frame closes the trace: the call succeeded and returned Void. The exit frame + # is tagged with the finishing contract (the current top of stack) before its pop. 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 + assert exit_frame['executingContract'] == contract_id def test_trace_records_have_expected_structure_and_reflect_arguments(server: StellarRpcServer) -> None: @@ -523,6 +560,8 @@ def test_trace_records_have_expected_structure_and_reflect_arguments(server: Ste 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. + + CI-only: deploys a real WAT, so it needs ``wat2wasm`` on PATH and cannot run where it is absent. """ invoke = deploy_and_get_invoker(server, ARGS_CONTRACT_WAT) tx_hash = invoke( @@ -555,7 +594,7 @@ def test_trace_records_have_expected_structure_and_reflect_arguments(server: Ste 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', 'mem'} + assert set(record) == {'pos', 'instr', 'stack', 'locals', 'mem', 'executingContract'} assert record['pos'] is None or isinstance(record['pos'], int) # mem is null when linear memory is unchanged since the previous record, else a list of runs. assert record['mem'] is None or isinstance(record['mem'], list) @@ -612,6 +651,82 @@ def assert_args_round_trip(func: str, args: list[xdr.SCVal]) -> None: assert_args_round_trip('test_symbol', [xdr.SCVal(type=SCValType.SCV_SYMBOL, sym=xdr.SCSymbol(sc_symbol=b'hello'))]) +def test_call_tx_with_composite_args(server: StellarRpcServer) -> None: + """The scval_to_json / #decodeArg pipeline decodes composite (vec / map) call args. + + Regression test for the composite-argument blocker: komet-node used to decode only + scalar SCVals in call arguments (``scval_to_json`` raised on SCV_VEC/SCV_MAP, and the + ``#decodeArg`` rules had no vec/map cases), so a Vec/Map argument was rejected at + admission and never ran. Both sides now recurse, so a contract call carrying vec and + map arguments reaches SUCCESS (asserted by ``invoke``) and — like ``test_call_tx_with_args`` + — the arguments echoed in the trace's ``callContract`` frame round-trip back to the exact + SCVals sent, so a decoding bug is caught even when the transaction still succeeds. + + User enums, structs, and tuples all reduce to vec/map at the XDR level, so the nested + ``Vec<(enum, i128)>`` case below (with an Address-carrying variant and a negative i128) + stands in for the real ``Vec<(AssetKey, i128)>`` motivating argument. + """ + invoke = deploy_and_get_invoker(server, ARGS_CONTRACT_WAT) + + def assert_args_round_trip(func: str, args: list[xdr.SCVal]) -> None: + tx_hash = invoke(func, args) + trace = _rpc(server.port(), 'traceTransaction', {'hash': tx_hash})['result'] + # A composite argument is allocated as a host object first, so the callContract + # frame is not necessarily trace[0] (unlike the scalar-only case): find it. + entry = next(record for record in trace if record.get('instr') == ['callContract']) + assert entry['function'] == func + assert [scval_from_json(arg) for arg in entry['args']] == args + + def sym(name: str) -> xdr.SCVal: + return xdr.SCVal(type=SCValType.SCV_SYMBOL, sym=xdr.SCSymbol(sc_symbol=name.encode())) + + def i128(value: int) -> xdr.SCVal: + # Two's-complement split into (hi: signed int64, lo: unsigned int64) so negative + # and high-bit values round-trip, not just small positive ones. + unsigned = value & ((1 << 128) - 1) + hi = unsigned >> 64 + lo = unsigned & ((1 << 64) - 1) + if hi >= (1 << 63): + hi -= 1 << 64 + return xdr.SCVal(type=SCValType.SCV_I128, i128=xdr.Int128Parts(hi=xdr.Int64(hi), lo=xdr.Uint64(lo))) + + def u32(value: int) -> xdr.SCVal: + return xdr.SCVal(type=SCValType.SCV_U32, u32=xdr.Uint32(value)) + + def vec(elems: list[xdr.SCVal]) -> xdr.SCVal: + return xdr.SCVal(type=SCValType.SCV_VEC, vec=xdr.SCVec(elems)) + + def mp(entries: list[tuple[xdr.SCVal, xdr.SCVal]]) -> xdr.SCVal: + return xdr.SCVal(type=SCValType.SCV_MAP, map=xdr.SCMap([xdr.SCMapEntry(key=k, val=v) for k, v in entries])) + + address = Address(Keypair.random().public_key).to_xdr_sc_val() + + # A flat vec of scalars. + assert_args_round_trip('test_vec', [vec([u32(1), u32(2), u32(3)])]) + + # The nested motivating case: Vec<(enum, i128)> mirroring Vec<(AssetKey, i128)> — a unit + # variant (Native), an Address-carrying variant (Stellar(addr)), and a positive and a + # negative i128, exercising SCV_ADDRESS nested in a composite and the full signed i128 range. + assert_args_round_trip( + 'test_vec', + [ + vec( + [ + vec([vec([sym('Native')]), i128(1000)]), + vec([vec([sym('Stellar'), address]), i128(-5)]), + ] + ) + ], + ) + + # A map from symbol keys to scalar values (a struct at the XDR level). Keys are sent in + # sorted order ('amount' < 'nonce') to match the canonical SCMap ordering the trace echoes. + assert_args_round_trip('test_map', [mp([(sym('amount'), i128(500)), (sym('nonce'), u32(7))])]) + + # A map nested inside a vec — composites compose in both directions. + assert_args_round_trip('test_vec', [vec([mp([(sym('k'), u32(1))])])]) + + def test_call_tx_with_return_value(server: StellarRpcServer) -> None: """A contract invocation that returns a non-Void value succeeds. @@ -1680,3 +1795,386 @@ def test_get_transaction_not_found_omits_transaction_fields(server: StellarRpcSe assert get_result['status'] == 'NOT_FOUND' for field in ('ledger', 'createdAt', 'envelopeXdr', 'resultXdr', 'resultMetaXdr', 'returnValue'): assert field not in get_result, f'NOT_FOUND response must omit {field}' + + +def test_trace_transaction_served_from_file_without_interpreter(server: StellarRpcServer) -> None: + """traceTransaction is a pure read of ``traces/trace_.jsonl`` and must NOT invoke the + interpreter. + + The trace is already valid JSONL on disk (one record per line); reassembling it into a JSON + array is a linear string operation the Python layer can do directly. Routing it through the + semantics instead made the interpreter join the lines with a recursive per-line tail-copy — + O(n^2) in time and memory — which OOM-killed the interpreter on multi-hundred-MB traces. This + test pins the record content AND that no interpreter subprocess is spawned to serve the trace. + """ + tx_hash = 'a' * 64 + contract_id = 'ab' * 32 + # The stored records as written to disk: the server adds the per-record ``executingContract`` + # tag on the serve path, so the on-disk records carry no ``executingContract`` field of their own. + records = [ + { + 'pos': 0, + 'instr': ['callContract'], + 'function': 'f', + 'to': {'type': 'address', 'addrType': 'contract', 'value': contract_id}, + }, + {'pos': 1, 'instr': ['const', 'i32', 1]}, + {'pos': None, 'instr': ['endWasm'], 'success': True}, + ] + (server.io_dir / 'traces' / f'trace_{tx_hash}.jsonl').write_text('\n'.join(json.dumps(r) for r in records) + '\n') + + # Every served record is stamped with the executing contract, reconstructed from the + # call-boundary markers: the callContract pushes contract_id, so the whole single-call span + # (call frame, the instruction, and the closing endWasm) is tagged with it. + expected = [{**record, 'executingContract': contract_id} for record in records] + + calls: list[Any] = [] + original_run = server.interpreter.run + + def _spy(*args: Any, **kwargs: Any) -> Any: + calls.append(args) + return original_run(*args, **kwargs) + + server.interpreter.run = _spy # type: ignore[method-assign] + try: + response = json.loads(server.handle_rpc('traceTransaction', {'hash': tx_hash})) + finally: + server.interpreter.run = original_run # type: ignore[method-assign] + + assert response['result'] == expected + assert calls == [], 'traceTransaction must not invoke the interpreter' + + +def test_trace_transaction_missing_file_returns_null_without_interpreter(server: StellarRpcServer) -> None: + """A hash with no trace file yields ``result: null`` — again without touching the interpreter.""" + calls: list[Any] = [] + original_run = server.interpreter.run + + def _spy(*args: Any, **kwargs: Any) -> Any: + calls.append(args) + return original_run(*args, **kwargs) + + server.interpreter.run = _spy # type: ignore[method-assign] + try: + response = json.loads(server.handle_rpc('traceTransaction', {'hash': '0' * 64})) + finally: + server.interpreter.run = original_run # type: ignore[method-assign] + + assert response['result'] is None + assert calls == [], 'traceTransaction must not invoke the interpreter' + + +# --------------------------------------------------------------------------- +# Per-record contract annotation on the file-serve path +# +# traceTransaction stamps every served record with an ``executingContract`` field naming the +# contract whose code is executing at that record, reconstructed from the trace's own +# call-boundary markers (no interpreter involvement). The debug adapter needs this because a +# callee's small ``pos`` values collide with the caller's and must be mapped against the right +# binary. The field is deliberately named ``executingContract`` (not ``contract``) so it never +# collides with the DOCUMENTED top-level ``contract`` address object that ``contractData`` records +# already carry to name their storage-target contract. +# +# Reconstruction walks the records maintaining a stack of contract ids: +# * callContract (instr[0] == 'callContract'): PUSH to.value; the record itself is tagged with +# that pushed callee. +# * any exit marker (instr[0].startswith('endWasm') — success ``endWasm`` and trap +# ``endWasm-error`` alike): tag the record with the CURRENT top, THEN pop. +# * every other record: tag with the current top. +# * before any callContract (empty stack): tag ``None``. +# The root callContract may never close (execution can end mid-call); its span simply runs to +# the end of the trace. +# +# These tests are HERMETIC: they write a synthetic ``traces/trace_.jsonl`` and serve it +# directly through ``server.handle_rpc`` — no wat2wasm, no interpreter subprocess. +# --------------------------------------------------------------------------- + +# Distinct 64-hex contract ids standing in for real callee contract ids. +_CONTRACT_A = 'a1' * 32 +_CONTRACT_B = 'b2' * 32 +_CONTRACT_C = 'c3' * 32 + + +def _call_record(to: str, *, function: str = 'f', depth: int = 1) -> dict[str, Any]: + """A ``callContract`` boundary marker targeting contract ``to`` (verbatim in ``to.value``).""" + return { + 'pos': None, + 'instr': ['callContract'], + 'from': {'type': 'address', 'addrType': 'account', 'value': 'G' + 'A' * 55}, + 'to': {'type': 'address', 'addrType': 'contract', 'value': to}, + 'function': function, + 'args': [], + 'depth': depth, + 'storage': [], + } + + +def _instr_record(pos: int) -> dict[str, Any]: + """A plain WebAssembly instruction record.""" + return {'pos': pos, 'instr': ['const', 'i32', 1048576], 'stack': [], 'locals': {}, 'mem': None} + + +def _end_record(*, depth: int = 1) -> dict[str, Any]: + """A success ``endWasm`` exit marker.""" + return {'pos': None, 'instr': ['endWasm'], 'success': True, 'depth': depth, 'result': {'type': 'void'}} + + +def _end_error_record(*, depth: int = 1) -> dict[str, Any]: + """A trap ``endWasm-error`` exit marker (still a pop; keys only on the ``endWasm`` prefix).""" + return {'pos': None, 'instr': ['endWasm-error'], 'success': False, 'depth': depth} + + +def _contract_data_record(target: str, *, args: list[dict[str, Any]] | None = None) -> dict[str, Any]: + """A ``contractData`` storage record (emitted on any storage put/del). + + Per the trace METADATA it carries a DOCUMENTED top-level ``contract`` field: an ADDRESS OBJECT + naming the storage-TARGET contract — not a string, and not the executing contract. It is NOT a + call-boundary marker (``instr[0] == 'contractData'``), so it must leave the reconstruction stack + untouched. The serve-path annotation must preserve this ``contract`` object verbatim and add its + own ``executingContract`` string under the distinct key. + """ + return { + 'pos': None, + 'instr': ['contractData', 'put', 'temporary'], + 'contract': {'type': 'address', 'addrType': 'contract', 'value': target}, + 'args': args if args is not None else [{'type': 'symbol', 'value': 'foo'}, {'type': 'u32', 'value': 123456789}], + } + + +def _serve_trace(server: StellarRpcServer, records: list[dict[str, Any]]) -> tuple[list[dict[str, Any]], list[Any]]: + """Write ``records`` as the trace JSONL for a fresh hash, serve it through ``handle_rpc``, and + return ``(served_result, interpreter_calls)``. The interpreter's ``run`` is spied so callers + can assert the annotation happens purely on the file-serve path.""" + tx_hash = 'f' * 64 + (server.io_dir / 'traces' / f'trace_{tx_hash}.jsonl').write_text('\n'.join(json.dumps(r) for r in records) + '\n') + + calls: list[Any] = [] + original_run = server.interpreter.run + + def _spy(*args: Any, **kwargs: Any) -> Any: + calls.append(args) + return original_run(*args, **kwargs) + + server.interpreter.run = _spy # type: ignore[method-assign] + try: + response = json.loads(server.handle_rpc('traceTransaction', {'hash': tx_hash})) + finally: + server.interpreter.run = original_run # type: ignore[method-assign] + + return response['result'], calls + + +def test_trace_contract_annotation_nested_balanced(server: StellarRpcServer) -> None: + """Nested balanced calls: the root A never closes, while B and C each open and close. Each + record is tagged with the contract executing at that point; a callee's span (its own + callContract through its endWasm inclusive) is tagged with the callee, and control returns to + the caller after the pop. + """ + records = [ + _call_record(_CONTRACT_A), # push A -> A + _instr_record(1), # -> A + _call_record(_CONTRACT_B), # push B -> B + _instr_record(2), # -> B + _end_record(), # top B, pop -> B + _instr_record(3), # -> A (back in the caller) + _call_record(_CONTRACT_C), # push C -> C + _instr_record(4), # -> C + _end_record(), # top C, pop -> C + _instr_record(5), # -> A (root still open, runs to the end) + ] + expected = [ + _CONTRACT_A, + _CONTRACT_A, + _CONTRACT_B, + _CONTRACT_B, + _CONTRACT_B, + _CONTRACT_A, + _CONTRACT_C, + _CONTRACT_C, + _CONTRACT_C, + _CONTRACT_A, + ] + + result, _calls = _serve_trace(server, records) + + assert [record['executingContract'] for record in result] == expected + # The annotation is additive: every original field of each record survives verbatim. + for served, original in zip(result, records, strict=True): + assert {key: served[key] for key in original} == original + + +def test_trace_contract_annotation_trap_exit_pops(server: StellarRpcServer) -> None: + """A trap exit (``endWasm-error``) pops the callee just like a success ``endWasm``: the pop + keys on ``instr[0].startswith('endWasm')``. B's span — including the trapping record itself — + is tagged B, and records after it fall back to the caller A. + """ + records = [ + _call_record(_CONTRACT_A), # push A -> A + _call_record(_CONTRACT_B), # push B -> B + _instr_record(1), # -> B + _end_error_record(), # top B, pop -> B + _instr_record(2), # -> A + ] + expected = [_CONTRACT_A, _CONTRACT_B, _CONTRACT_B, _CONTRACT_B, _CONTRACT_A] + + result, _calls = _serve_trace(server, records) + + assert [record['executingContract'] for record in result] == expected + + +def test_trace_contract_annotation_root_left_open(server: StellarRpcServer) -> None: + """A single root call with no matching ``endWasm`` (execution ended deep, mid-call): its span + runs to the end of the trace and every record is tagged with the root contract. + """ + records = [_call_record(_CONTRACT_A), _instr_record(1), _instr_record(2)] + + result, _calls = _serve_trace(server, records) + + assert [record['executingContract'] for record in result] == [_CONTRACT_A, _CONTRACT_A, _CONTRACT_A] + + +def test_trace_contract_annotation_degenerate_no_call(server: StellarRpcServer) -> None: + """Degenerate guard: with no ``callContract`` ever seen the stack stays empty, so every record + is tagged ``contract: null``. (Real traces always open with a callContract.) + """ + records = [_instr_record(1), _instr_record(2), _instr_record(3)] + + result, _calls = _serve_trace(server, records) + + assert [record['executingContract'] for record in result] == [None, None, None] + + +def test_trace_contract_annotation_does_not_invoke_interpreter(server: StellarRpcServer) -> None: + """The contract annotation is computed purely on the file-serve path; serving a trace that + needs annotation must still NOT spawn the interpreter subprocess. + """ + records = [ + _call_record(_CONTRACT_A), + _call_record(_CONTRACT_B), + _end_record(), + _instr_record(1), + ] + + result, calls = _serve_trace(server, records) + + assert [record['executingContract'] for record in result] == [_CONTRACT_A, _CONTRACT_B, _CONTRACT_B, _CONTRACT_A] + assert calls == [], 'traceTransaction must not invoke the interpreter' + + +def test_trace_contract_data_documented_contract_field_not_clobbered(server: StellarRpcServer) -> None: + """Blocker regression: a ``contractData`` record carries a DOCUMENTED top-level ``contract`` + field — an ADDRESS OBJECT naming its storage-target contract. The executing-contract annotation + must NOT collide with it. It lives under the distinct key ``executingContract`` (a string), so + the storage-target ``contract`` object is left byte-for-byte intact and the served JSON line + carries no duplicate ``contract`` key. + """ + records = [ + _call_record(_CONTRACT_A), # push A -> executing A + _contract_data_record(_CONTRACT_B), # storage target B; still executing A; NOT a marker + _instr_record(1), # -> executing A + _end_record(), # top A, pop -> A + ] + tx_hash = 'e' * 64 + (server.io_dir / 'traces' / f'trace_{tx_hash}.jsonl').write_text('\n'.join(json.dumps(r) for r in records) + '\n') + + raw = server.handle_rpc('traceTransaction', {'hash': tx_hash}) + result = json.loads(raw)['result'] + + data_record = result[1] + # The documented storage-target field is UNCHANGED: still the ADDRESS OBJECT, not a string. + assert data_record['contract'] == {'type': 'address', 'addrType': 'contract', 'value': _CONTRACT_B} + # The executing-contract annotation is added under its own distinct key. + assert data_record['executingContract'] == _CONTRACT_A + # And the whole span is tagged with the executing contract A (the storage target never affects it). + assert [record['executingContract'] for record in result] == [ + _CONTRACT_A, + _CONTRACT_A, + _CONTRACT_A, + _CONTRACT_A, + ] + + # The served line round-trips with NO duplicate ``contract`` key: a strict parse that rejects + # duplicate keys still yields the address OBJECT for ``contract`` (a clobbering string injection + # would either duplicate the key or overwrite the object). + def _reject_dupes(pairs: list[tuple[str, Any]]) -> dict[str, Any]: + seen: dict[str, Any] = {} + for key, value in pairs: + assert key not in seen, f'duplicate key {key!r} in served record' + seen[key] = value + return seen + + strict = json.loads(raw, object_pairs_hook=_reject_dupes) + served_data = strict['result'][1] + assert served_data['contract'] == {'type': 'address', 'addrType': 'contract', 'value': _CONTRACT_B} + assert served_data['executingContract'] == _CONTRACT_A + + +def test_trace_contract_annotation_end_underflow_is_guarded(server: StellarRpcServer) -> None: + """Stack-machine guard: an ``endWasm`` with an empty stack (no prior ``callContract``) must be a + no-op pop, not an exception. The exit marker and the following instruction both tag ``null``. + """ + records = [_end_record(), _instr_record(1)] + + result, _calls = _serve_trace(server, records) + + assert [record['executingContract'] for record in result] == [None, None] + + +def test_trace_contract_annotation_three_deep_nesting(server: StellarRpcServer) -> None: + """Three-deep nesting A->B->C then two exits: each marker tags its OWN contract (the current top + before the pop), so C's ``endWasm`` tags C and B's ``endWasm`` tags B, with control returning to + A for the trailing instruction. + """ + records = [ + _call_record(_CONTRACT_A), # push A -> A + _call_record(_CONTRACT_B), # push B -> B + _call_record(_CONTRACT_C), # push C -> C + _end_record(), # top C, pop -> C + _end_record(), # top B, pop -> B + _instr_record(1), # -> A + ] + expected = [_CONTRACT_A, _CONTRACT_B, _CONTRACT_C, _CONTRACT_C, _CONTRACT_B, _CONTRACT_A] + + result, _calls = _serve_trace(server, records) + + assert [record['executingContract'] for record in result] == expected + + +def test_trace_contract_annotation_sibling_root_calls(server: StellarRpcServer) -> None: + """Two SIBLING root-level calls: each opens and closes at the root (the stack empties between + them), so A's span tags A and B's span tags B — no leakage across the sibling boundary. + """ + records = [ + _call_record(_CONTRACT_A), # push A -> A + _end_record(), # top A, pop -> empty + _call_record(_CONTRACT_B), # push B -> B + _end_record(), # top B, pop -> empty + ] + expected = [_CONTRACT_A, _CONTRACT_A, _CONTRACT_B, _CONTRACT_B] + + result, _calls = _serve_trace(server, records) + + assert [record['executingContract'] for record in result] == expected + + +def test_trace_contract_annotation_marker_lookalike_arg_is_not_a_marker(server: StellarRpcServer) -> None: + """False-positive guard: a ``contractData`` record whose ``args`` contains a symbol VALUE literally + equal to a marker mnemonic (``endWasm``) is NOT a boundary marker — classification keys on + ``instr[0] == 'contractData'``, never on payload substrings. The stack stays untouched, a following + instruction is still tagged with the current contract, and the record keeps its own storage-target + ``contract`` object while also gaining ``executingContract``. + """ + lookalike = _contract_data_record(_CONTRACT_B, args=[{'type': 'symbol', 'value': 'endWasm'}]) + records = [ + _call_record(_CONTRACT_A), # push A -> A + lookalike, # NOT a marker; stack unchanged -> A + _instr_record(1), # -> A (still in A) + ] + + result, _calls = _serve_trace(server, records) + + assert [record['executingContract'] for record in result] == [_CONTRACT_A, _CONTRACT_A, _CONTRACT_A] + served_data = result[1] + assert served_data['contract'] == {'type': 'address', 'addrType': 'contract', 'value': _CONTRACT_B} + assert served_data['args'] == [{'type': 'symbol', 'value': 'endWasm'}] + assert served_data['executingContract'] == _CONTRACT_A diff --git a/src/tests/unit/test_scval.py b/src/tests/unit/test_scval.py new file mode 100644 index 0000000..162c88c --- /dev/null +++ b/src/tests/unit/test_scval.py @@ -0,0 +1,134 @@ +"""Unit tests for ``scval_to_json`` — the SCVal -> request-envelope JSON encoder. + +These are pure-Python tests (no K, no kdist build). They pin two things: + +* the JSON *shape* the K ``#decodeArg`` rules pattern-match on for composite + (vec / map) call arguments — key order is significant, so the expected dicts + are compared verbatim; and +* that encoding a deeply nested composite value does not blow Python's default + recursion limit (blocker #2). ``scval_to_json`` recurses with the value's + structure, so a deep value is a deterministic proxy for the large-real-contract + recursion that komet-node previously died on. +""" + +from __future__ import annotations + +import json + +from stellar_sdk import xdr +from stellar_sdk.xdr.sc_val_type import SCValType + +from komet_node.scval import scval_to_json + + +def _sym(name: str) -> xdr.SCVal: + return xdr.SCVal(type=SCValType.SCV_SYMBOL, sym=xdr.SCSymbol(sc_symbol=name.encode())) + + +def _i128(value: int) -> xdr.SCVal: + return xdr.SCVal(type=SCValType.SCV_I128, i128=xdr.Int128Parts(hi=xdr.Int64(0), lo=xdr.Uint64(value))) + + +def _u32(value: int) -> xdr.SCVal: + return xdr.SCVal(type=SCValType.SCV_U32, u32=xdr.Uint32(value)) + + +def _vec(elems: list[xdr.SCVal]) -> xdr.SCVal: + return xdr.SCVal(type=SCValType.SCV_VEC, vec=xdr.SCVec(elems)) + + +def _map(entries: list[tuple[xdr.SCVal, xdr.SCVal]]) -> xdr.SCVal: + return xdr.SCVal( + type=SCValType.SCV_MAP, + map=xdr.SCMap([xdr.SCMapEntry(key=k, val=v) for k, v in entries]), + ) + + +def test_scval_to_json_vec_of_scalars() -> None: + """A vec encodes as ``{'type': 'vec', 'value': [, ...]}``. + + Key *order* is significant: the K ``#decodeArg`` rules pattern-match on JSON + member order, so this pins the exact serialization (a dict ``==`` compare is + order-insensitive and would not catch a reordering), not just the key/values. + """ + encoded = scval_to_json(_vec([_sym('Native'), _i128(1000)])) + assert encoded == { + 'type': 'vec', + 'value': [ + {'type': 'symbol', 'value': 'Native'}, + {'type': 'i128', 'value': 1000}, + ], + } + assert json.dumps(encoded) == ( + '{"type": "vec", "value": [{"type": "symbol", "value": "Native"}, ' '{"type": "i128", "value": 1000}]}' + ) + + +def test_scval_to_json_empty_vec() -> None: + assert scval_to_json(_vec([])) == {'type': 'vec', 'value': []} + + +def test_scval_to_json_map() -> None: + """A map encodes as ``{'type': 'map', 'value': [{'key': .., 'val': ..}, ..]}``.""" + encoded = scval_to_json(_map([(_sym('amount'), _u32(7))])) + assert encoded == { + 'type': 'map', + 'value': [ + {'key': {'type': 'symbol', 'value': 'amount'}, 'val': {'type': 'u32', 'value': 7}}, + ], + } + # Order-sensitive check: 'type' before 'value', and 'key' before 'val'. + assert json.dumps(encoded) == ( + '{"type": "map", "value": [{"key": {"type": "symbol", "value": "amount"}, ' + '"val": {"type": "u32", "value": 7}}]}' + ) + + +def test_scval_to_json_empty_map() -> None: + assert scval_to_json(_map([])) == {'type': 'map', 'value': []} + + +def test_scval_to_json_nested_composite_supply_shape() -> None: + """The real motivating case: ``Vec<(AssetKey, i128)>`` with a unit-enum variant. + + A unit enum variant (``AssetKey::Native``) is itself a single-element vec of a + symbol at the XDR level, and a tuple is a vec — so the whole argument is nested + vecs bottoming out in scalars. Encoding must recurse through every level. + """ + request = _vec([_vec([_vec([_sym('Native')]), _i128(1000)])]) + assert scval_to_json(request) == { + 'type': 'vec', + 'value': [ + { + 'type': 'vec', + 'value': [ + {'type': 'vec', 'value': [{'type': 'symbol', 'value': 'Native'}]}, + {'type': 'i128', 'value': 1000}, + ], + }, + ], + } + + +def test_scval_to_json_deeply_nested_vec_survives_recursion_limit() -> None: + """Encoding a deeply nested value must not raise ``RecursionError`` (blocker #2). + + ``scval_to_json`` recurses with the value's depth. Python's default recursion + limit (1000) is well below what a large real contract's values reach, so + komet-node raises the limit at import time. A 2000-deep vec is a deterministic + proxy: it exceeds the default limit but stays within the process stack. Without + the raised limit this raises ``RecursionError``; with it, it encodes cleanly. + """ + depth = 2000 + value = _sym('leaf') + for _ in range(depth): + value = _vec([value]) + + encoded = scval_to_json(value) + + # Peel the encoded structure back down and confirm it is intact to the leaf. + for _ in range(depth): + assert encoded['type'] == 'vec' + assert len(encoded['value']) == 1 + encoded = encoded['value'][0] + assert encoded == {'type': 'symbol', 'value': 'leaf'}