From 21d26c077f2cc0aacb9c4a2b0476f3c7e2e88da8 Mon Sep 17 00:00:00 2001 From: Raoul Date: Tue, 28 Jul 2026 10:04:29 +0000 Subject: [PATCH] feat: decode composite (vec/map) call arguments; raise recursion limit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit komet-node rejected any Vec/Map — and thus any user enum/struct/tuple — contract call argument at admission: scval_to_json raised NotImplementedError on SCV_VEC/SCV_MAP and #decodeArg had only scalar rules, so the transaction never ran. Add recursive vec/map support on both sides so composite arguments are decoded and executed: - scval.py: scval_to_json emits {"type":"vec","value":[...]} and {"type":"map","value":[{"key":..,"val":..},..]}, recursing element-wise. - node.md: #decodeArg vec/map rules — ScVec(#decodeArgList(...)) and ScMap(#decodeMapEntries(...)). Enums, structs, and tuples all reduce to vec/map at the XDR level, so these cover every composite call argument. - args.wat / test_server.py: a call carrying flat, nested (Vec<(enum,i128)> with an Address variant and a negative i128), map, and map-in-vec arguments reaches SUCCESS and its trace's callContract frame round-trips the exact SCVals sent. Also raise the Python recursion limit — large real contracts produce a KORE world-state term far deeper than CPython's default 1000, which surfaced as a RecursionError mid-request during pyk parsing / config traversal: - __init__.py: sys.setrecursionlimit(10**7), matching the rest of the K tooling (pyk sets 10**7; komet sets its own limit at import). - server.py: run the blocking serve loop on a worker thread with a 512 MB stack, so a deep term raises a catchable RecursionError instead of overflowing the 8 MB default stack into a SIGSEGV. - test_scval.py: unit tests pinning the vec/map JSON shape (order-sensitive, since #decodeArg matches on member order) and that a deeply nested value encodes without hitting the recursion limit. --- src/komet_node/__init__.py | 13 +++ src/komet_node/kdist/node.md | 17 +++ src/komet_node/scval.py | 14 +++ src/komet_node/server.py | 21 +++- src/tests/integration/data/wasm/args.wat | 11 ++ src/tests/integration/test_server.py | 76 +++++++++++++ src/tests/unit/test_scval.py | 134 +++++++++++++++++++++++ 7 files changed, 285 insertions(+), 1 deletion(-) create mode 100644 src/tests/unit/test_scval.py 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..88adfcc 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 @@ -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.""" 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..4b80a74 100644 --- a/src/tests/integration/test_server.py +++ b/src/tests/integration/test_server.py @@ -612,6 +612,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. 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'}