Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions src/komet_node/__init__.py
Original file line number Diff line number Diff line change
@@ -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)
17 changes: 17 additions & 0 deletions src/komet_node/kdist/node.md
Original file line number Diff line number Diff line change
Expand Up @@ -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": [ <scval>, ... ] }
// { "type": "map", "value": [ { "key": <scval>, "val": <scval> }, ... ] }
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.
Expand Down
14 changes: 14 additions & 0 deletions src/komet_node/scval.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}')

Expand Down
21 changes: 20 additions & 1 deletion src/komet_node/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import logging
import re
import sys
import threading
import time
import traceback
from datetime import datetime, timezone
Expand Down Expand Up @@ -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')


Expand Down Expand Up @@ -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."""
Expand Down
11 changes: 11 additions & 0 deletions src/tests/integration/data/wasm/args.wat
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand All @@ -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))
)
76 changes: 76 additions & 0 deletions src/tests/integration/test_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
134 changes: 134 additions & 0 deletions src/tests/unit/test_scval.py
Original file line number Diff line number Diff line change
@@ -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': [<elem>, ...]}``.

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'}
Loading