From 248817b55c072a955d758f9b2a549e53195a57d8 Mon Sep 17 00:00:00 2001 From: Raoul Date: Thu, 2 Jul 2026 18:57:19 +0000 Subject: [PATCH 1/3] test: cover xdrFormat param and JSON-RPC batch requests Encode the expected behavior for the RPC plumbing work: - getTransaction and sendTransaction accept an optional xdrFormat param (per the stellar-rpc protocol structs): 'base64' behaves as the default, 'json' is rejected as unsupported with -32602, and any other value (or a non-string) is -32602. Rejection must happen before the transaction executes. - JSON-RPC 2.0 batch requests: an array of requests yields an array of responses matched by id; an empty array is a single Invalid Request error; invalid batch elements each get a -32600 response with id null; notifications get no response, and an all-notifications batch yields an empty body. The non-object-frame test now uses a JSON string body, since an array body is a batch and no longer an Invalid Request. The new tests fail until the feature lands; the existing suite still passes. --- src/tests/integration/test_server.py | 169 ++++++++++++++++++++++++++- 1 file changed, 166 insertions(+), 3 deletions(-) diff --git a/src/tests/integration/test_server.py b/src/tests/integration/test_server.py index 3e5cfc3..45d406f 100644 --- a/src/tests/integration/test_server.py +++ b/src/tests/integration/test_server.py @@ -48,14 +48,18 @@ def _rpc(port: int, method: str, params: dict[str, Any]) -> dict[str, Any]: return _post(port, body) -def _post(port: int, body: bytes) -> dict[str, Any]: +def _post(port: int, body: bytes) -> Any: + return json.loads(_post_raw(port, body)) + + +def _post_raw(port: int, body: bytes) -> bytes: req = urllib.request.Request( f'http://localhost:{port}', data=body, headers={'Content-Type': 'application/json'}, ) with urllib.request.urlopen(req) as resp: - return json.loads(resp.read()) + return resp.read() @pytest.fixture @@ -138,7 +142,7 @@ def test_malformed_body_returns_parse_error(server: StellarRpcServer) -> None: def test_non_object_frame_returns_invalid_request(server: StellarRpcServer) -> None: - result = _post(server.port(), b'[1, 2, 3]') + result = _post(server.port(), b'"just a string"') assert result['error']['code'] == -32600 @@ -498,3 +502,162 @@ def builder() -> TransactionBuilder: # All four transactions, including the non-Void invocation, advanced the ledger. assert _rpc(server.port(), 'getLatestLedger', {})['result']['sequence'] == 4 + + +# ---------------------------------------------------------------------- +# xdrFormat parameter (getTransaction / sendTransaction) +# +# Real stellar-rpc accepts an optional `xdrFormat` param on both methods +# (protocols/rpc: GetTransactionRequest.Format, SendTransactionRequest.Format) +# and rejects invalid values with InvalidParams (-32602). komet-node supports +# only 'base64' (the default); 'json' is rejected with a clear -32602 error. +# ---------------------------------------------------------------------- + + +def _create_account_xdr() -> str: + """A freshly signed CreateAccount transaction envelope, base64 XDR.""" + keypair = Keypair.random() + account = Account(keypair.public_key, sequence=0) + envelope = ( + TransactionBuilder(account, Network.TESTNET_NETWORK_PASSPHRASE) + .append_create_account_op(destination=keypair.public_key, starting_balance='1000') + .set_timeout(30) + .build() + ) + envelope.sign(keypair) + return envelope.to_xdr() + + +def test_xdr_format_base64_behaves_as_default(server: StellarRpcServer) -> None: + """xdrFormat 'base64' is the explicit spelling of the default on both methods.""" + send_result = _rpc(server.port(), 'sendTransaction', {'transaction': _create_account_xdr(), 'xdrFormat': 'base64'}) + assert send_result['result']['status'] == 'PENDING' + tx_hash = send_result['result']['hash'] + + get_result = _rpc(server.port(), 'getTransaction', {'hash': tx_hash, 'xdrFormat': 'base64'}) + assert get_result['result']['status'] == 'SUCCESS' + + +def test_get_transaction_xdr_format_json_returns_invalid_params(server: StellarRpcServer) -> None: + """komet-node does not support the JSON XDR format: reject with a clear -32602 error.""" + result = _rpc(server.port(), 'getTransaction', {'hash': '0' * 64, 'xdrFormat': 'json'}) + assert result['error']['code'] == -32602 + assert 'json' in result['error']['message'].lower() + + +def test_get_transaction_xdr_format_invalid_value_returns_invalid_params(server: StellarRpcServer) -> None: + result = _rpc(server.port(), 'getTransaction', {'hash': '0' * 64, 'xdrFormat': 'yaml'}) + assert result['error']['code'] == -32602 + + +def test_get_transaction_xdr_format_non_string_returns_invalid_params(server: StellarRpcServer) -> None: + result = _rpc(server.port(), 'getTransaction', {'hash': '0' * 64, 'xdrFormat': 42}) + assert result['error']['code'] == -32602 + + +def test_send_transaction_xdr_format_json_returns_invalid_params_without_executing(server: StellarRpcServer) -> None: + """An unsupported xdrFormat is rejected before the transaction runs: no state change.""" + result = _rpc(server.port(), 'sendTransaction', {'transaction': _create_account_xdr(), 'xdrFormat': 'json'}) + assert result['error']['code'] == -32602 + assert 'json' in result['error']['message'].lower() + + # The transaction must not have executed: no receipt written, ledger not advanced. + assert list((server.io_dir / 'receipts').iterdir()) == [] + assert _rpc(server.port(), 'getLatestLedger', {})['result']['sequence'] == 0 + + +def test_send_transaction_xdr_format_invalid_value_returns_invalid_params(server: StellarRpcServer) -> None: + result = _rpc(server.port(), 'sendTransaction', {'transaction': _create_account_xdr(), 'xdrFormat': 'yaml'}) + assert result['error']['code'] == -32602 + assert list((server.io_dir / 'receipts').iterdir()) == [] + + +# ---------------------------------------------------------------------- +# JSON-RPC 2.0 batch requests +# +# Per JSON-RPC 2.0 section 6: an array of request objects yields an array of +# response objects (matched by id, order not significant); an empty array is a +# single Invalid Request error; invalid batch elements each yield an Invalid +# Request error with id null; notifications (no id) get no response, and a +# batch of only notifications yields no response body at all. +# ---------------------------------------------------------------------- + + +def _batch(port: int, requests: list[dict[str, Any]]) -> Any: + return _post(port, json.dumps(requests).encode()) + + +def test_batch_request_returns_array_of_responses(server: StellarRpcServer) -> None: + responses = _batch( + server.port(), + [ + {'jsonrpc': '2.0', 'id': 1, 'method': 'getHealth', 'params': {}}, + {'jsonrpc': '2.0', 'id': 2, 'method': 'getLatestLedger', 'params': {}}, + ], + ) + assert isinstance(responses, list) + assert len(responses) == 2 + by_id = {response['id']: response for response in responses} + assert set(by_id) == {1, 2} + for response in responses: + assert response['jsonrpc'] == '2.0' + assert by_id[1]['result']['status'] == 'healthy' + assert by_id[2]['result']['sequence'] == 0 + + +def test_empty_batch_returns_single_invalid_request(server: StellarRpcServer) -> None: + """An empty array is not a valid batch: one Invalid Request error object, not an array.""" + result = _post(server.port(), b'[]') + assert isinstance(result, dict) + assert result['error']['code'] == -32600 + assert result['id'] is None + + +def test_batch_of_invalid_elements_returns_error_per_element(server: StellarRpcServer) -> None: + """rpc call with an invalid batch: one Invalid Request response per element, id null.""" + responses = _post(server.port(), b'[1, 2, 3]') + assert isinstance(responses, list) + assert len(responses) == 3 + for response in responses: + assert response['jsonrpc'] == '2.0' + assert response['error']['code'] == -32600 + assert response['id'] is None + + +def test_batch_mixed_valid_and_invalid_elements(server: StellarRpcServer) -> None: + responses = _batch( + server.port(), + [ + {'jsonrpc': '2.0', 'id': 1, 'method': 'getHealth', 'params': {}}, + {'foo': 'boo'}, + {'jsonrpc': '2.0', 'id': 2, 'method': 'noSuchMethod', 'params': {}}, + ], + ) + assert isinstance(responses, list) + assert len(responses) == 3 + by_id = {response['id']: response for response in responses} + assert by_id[1]['result']['status'] == 'healthy' + assert by_id[None]['error']['code'] == -32600 + assert by_id[2]['error']['code'] == -32601 + + +def test_batch_notification_gets_no_response(server: StellarRpcServer) -> None: + """A request without an id is a notification: it is executed but not answered.""" + responses = _batch( + server.port(), + [ + {'jsonrpc': '2.0', 'id': 1, 'method': 'getHealth', 'params': {}}, + {'jsonrpc': '2.0', 'method': 'getHealth', 'params': {}}, # notification + ], + ) + assert isinstance(responses, list) + assert len(responses) == 1 + assert responses[0]['id'] == 1 + assert responses[0]['result']['status'] == 'healthy' + + +def test_batch_of_only_notifications_returns_nothing(server: StellarRpcServer) -> None: + """If every batch element is a notification, the server must not return an empty array.""" + body = json.dumps([{'jsonrpc': '2.0', 'method': 'getHealth', 'params': {}}]).encode() + raw = _post_raw(server.port(), body) + assert raw.strip() == b'' From 3873b5c87c3c36618b120ce4acc6cf2e0793bc04 Mon Sep 17 00:00:00 2001 From: Raoul Date: Thu, 2 Jul 2026 20:29:58 +0000 Subject: [PATCH 2/3] feat: accept xdrFormat and JSON-RPC batch requests Two pieces of RPC plumbing, both in the Python framing layer (the K semantics see one request envelope per invocation either way): - getTransaction and sendTransaction take the spec's optional xdrFormat param. Only 'base64', the default, is supported; 'json' is rejected with -32602 and a message saying it is unsupported, and any other value is -32602 too. The check runs before dispatch, so a bad xdrFormat on sendTransaction never executes the transaction. - JSON-RPC 2.0 batch calls: an array body yields an array of responses, invalid elements each get an Invalid Request error with id null, and an empty array is a single Invalid Request error. Requests without an id are notifications: they run but are not answered, and a body of only notifications produces an empty response. --- src/komet_node/server.py | 85 ++++++++++++++++++++++++++++++++++------ 1 file changed, 73 insertions(+), 12 deletions(-) diff --git a/src/komet_node/server.py b/src/komet_node/server.py index 6399575..0917146 100644 --- a/src/komet_node/server.py +++ b/src/komet_node/server.py @@ -24,6 +24,11 @@ # trace stored on a previously executed transaction's receipt (see _read_only_envelope). _TX_METHODS: Final = ('sendTransaction',) +# Methods whose spec accepts an optional `xdrFormat` param (protocols/rpc: +# GetTransactionRequest.Format, SendTransactionRequest.Format). komet-node supports only +# the default 'base64' format; see _check_xdr_format. +_XDR_FORMAT_METHODS: Final = ('getTransaction', 'sendTransaction') + _log = logging.getLogger('komet_node') @@ -146,33 +151,66 @@ def shutdown(self) -> None: # ------------------------------------------------------------------ def _handle(self, body: bytes) -> bytes: - """Parse a raw JSON-RPC body and return the response bytes (the HTTP entry point).""" + """Parse a raw JSON-RPC body and return the response bytes (the HTTP entry point). + + An array body is a JSON-RPC 2.0 batch; anything else is a single call. The result + may be empty (no response body) when every request was a notification. + """ try: req = json.loads(body.decode('utf-8')) except (json.JSONDecodeError, UnicodeDecodeError): return _error_bytes(None, -32700, 'Parse error') - if not isinstance(req, dict): + if isinstance(req, list): + return self._handle_batch(req) + response = self._handle_single(req) + return b'' if response is None else response.encode('utf-8') + + def _handle_batch(self, batch: list[Any]) -> bytes: + """Answer a JSON-RPC 2.0 batch call: one response per element, in an array. + + Per section 6 of the spec: an empty array is itself a single Invalid Request error; + invalid elements each get their own error response; notifications get no response + entry, and a batch of only notifications gets no response body at all. Elements run + sequentially (the server is single-threaded by design, see serve()). + """ + if not batch: return _error_bytes(None, -32600, 'Invalid Request') + responses = [response for element in batch if (response := self._handle_single(element)) is not None] + if not responses: + return b'' + return ('[' + ','.join(responses) + ']').encode('utf-8') + + def _handle_single(self, req: Any) -> str | None: + """Validate one JSON-RPC request frame and dispatch it. + + Returns the response as a JSON string, or ``None`` for a notification — a valid + request frame without an ``id`` member, which per JSON-RPC 2.0 is executed but + never answered, not even with an error. + """ + if not isinstance(req, dict): + return _error_str(None, -32600, 'Invalid Request') request_id = req.get('id') # Validate the JSON-RPC frame before dispatch (JSON-RPC 2.0): # - wrong/missing protocol version or a non-string method => Invalid Request # - params, if present, must be a structured (object) value => else Invalid params if req.get('jsonrpc') != '2.0' or not isinstance(req.get('method'), str): - return _error_bytes(request_id, -32600, 'Invalid Request') + return _error_str(request_id, -32600, 'Invalid Request') params = req.get('params') if params is None: params = {} - elif not isinstance(params, dict): - return _error_bytes(request_id, -32602, 'Invalid params') - try: - return self.handle_rpc(req['method'], params, request_id).encode('utf-8') - except Exception: - # An unexpected error must never take down the server thread, but it must not - # vanish silently either — log the traceback before returning Internal error. - traceback.print_exc() - return _error_bytes(request_id, -32603, 'Internal error') + if not isinstance(params, dict): + response = _error_str(request_id, -32602, 'Invalid params') + else: + try: + response = self.handle_rpc(req['method'], params, request_id) + except Exception: + # An unexpected error must never take down the server thread, but it must + # not vanish silently either — log the traceback, return Internal error. + traceback.print_exc() + response = _error_str(request_id, -32603, 'Internal error') + return None if 'id' not in req else response def handle_rpc(self, method: str | None, params: dict[str, Any], request_id: Any = None) -> str: """Dispatch a single JSON-RPC call and return the response envelope as a JSON string. @@ -183,6 +221,13 @@ def handle_rpc(self, method: str | None, params: dict[str, Any], request_id: Any _log.info('request: %s (id=%r)', method, request_id) self._archive_request(method, params, request_id) + # Reject an unsupported xdrFormat up front, before the request does anything — + # in particular before a sendTransaction executes and commits state. + if method in _XDR_FORMAT_METHODS: + format_error = _check_xdr_format(params, request_id) + if format_error is not None: + return format_error + if method in _TX_METHODS: transaction = params.get('transaction') if not isinstance(transaction, str): @@ -305,6 +350,22 @@ def _configure_logging() -> None: _log.setLevel(logging.INFO) +def _check_xdr_format(params: dict[str, Any], request_id: Any) -> str | None: + """Validate the optional ``xdrFormat`` param; ``None`` means the request may proceed. + + Only ``'base64'`` (the spec default) is supported. The spec's alternative ``'json'`` + format is not implemented in komet-node, so it gets a dedicated error message; any + other value is rejected as invalid. Both cases are Invalid params (-32602), matching + real stellar-rpc's handling of a bad format value. + """ + xdr_format = params.get('xdrFormat', 'base64') + if xdr_format == 'base64': + return None + if xdr_format == 'json': + return _error_str(request_id, -32602, "Invalid params: xdrFormat 'json' is not supported, use 'base64'") + return _error_str(request_id, -32602, "Invalid params: unknown xdrFormat, expected 'base64'") + + def _error_str(rpc_id: Any, code: int, message: str) -> str: return json.dumps({'jsonrpc': '2.0', 'id': rpc_id, 'error': {'code': code, 'message': message}}) From c69bf787b3242600178b7f1eecafc7bb08129ae1 Mon Sep 17 00:00:00 2001 From: Raoul Date: Thu, 2 Jul 2026 20:30:10 +0000 Subject: [PATCH 3/3] docs: mark traceTransaction as an extension, expand known gaps - Document traceTransaction as a komet-specific extension in server.md, node-semantics.md, and architecture.md. It keeps its plain name: the official spec has no method of that name, so there is nothing to collide with, and a komet_ prefix would break existing clients. - Document the JSON-RPC batch/notification framing and the xdrFormat parameter in server.md. - Rewrite the notes.md known-gaps list to cover the full missing-method list (simulateTransaction, getLedgerEntries, getEvents, getTransactions, getLedgers, getFeeStats, getVersionInfo) and the remaining per-method deviations from the official spec. --- docs/architecture.md | 2 +- docs/node-semantics.md | 2 +- docs/notes.md | 21 +++++++++++++++++++-- docs/server.md | 20 ++++++++++++++++++-- 4 files changed, 39 insertions(+), 6 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index c36ae26..15245c1 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -51,7 +51,7 @@ flowchart TB → **[Detailed documentation](server.md)** -The server implements six RPC methods — `getHealth`, `getNetwork`, `getLatestLedger`, `sendTransaction`, `getTransaction`, and `traceTransaction` — and the K semantics answer all of them. +The server implements six RPC methods — `getHealth`, `getNetwork`, `getLatestLedger`, `sendTransaction`, `getTransaction`, and the komet-specific `traceTransaction` extension — and the K semantics answer all of them. JSON-RPC framing (single calls, batch arrays, notifications) is handled in Python; the semantics see one request envelope per invocation. `sendTransaction` always returns `PENDING` and clients poll `getTransaction` for the result — matching the Stellar RPC async pattern even though the transaction executes synchronously. See [server.md](server.md) for details. diff --git a/docs/node-semantics.md b/docs/node-semantics.md index 6f72b79..9e8aa70 100644 --- a/docs/node-semantics.md +++ b/docs/node-semantics.md @@ -90,7 +90,7 @@ The trace is not part of the receipt — the executing steps already appended it ### traceTransaction -`traceTransaction` is a read-only lookup. It takes a `hash` (the same parameter `getTransaction` takes) and responds with the contents of `traces/trace_.jsonl`, or `null` when no trace file exists for that hash. Because tracing is always on, every `sendTransaction` writes this file. +`traceTransaction` is a 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. ### Two ways steps are delivered diff --git a/docs/notes.md b/docs/notes.md index f1a0b3d..5ec0eb3 100644 --- a/docs/notes.md +++ b/docs/notes.md @@ -33,6 +33,23 @@ The tests do not yet cover `bytes` / `address` SCVal arguments or `SCVec` / `SCM ## Known gaps -- `resultXdr` / `resultMetaXdr` are empty stubs (contract return values not surfaced). +Measured against the official Stellar RPC surface for protocol 22 (the OpenRPC spec plus the `protocols/rpc` structs in go-stellar-sdk, which are what real stellar-rpc emits). + +### Missing methods + +`simulateTransaction`, `getLedgerEntries`, `getEvents`, `getTransactions`, `getLedgers`, `getFeeStats`, and `getVersionInfo` are not implemented and return `-32601` Method not found. TTL/footprint operations are likewise unsupported. + +### Deviations in the implemented methods + +- `getHealth` returns only `{status}`; the spec adds `latestLedger`, `oldestLedger`, and `ledgerRetentionWindow` (numbers). +- `getNetwork` returns `protocolVersion` as a string (`"22"`) where the spec has a number, and `friendbotUrl: null` where real stellar-rpc omits the field entirely. +- `getLatestLedger` returns `protocolVersion` as a string, and `id` is a constant all-zeros hash instead of a per-ledger value. +- `sendTransaction` returns `latestLedger` as a string; the statuses `DUPLICATE`, `ERROR`, and `TRY_AGAIN_LATER` are never returned (resubmitting a transaction re-executes it instead of reporting `DUPLICATE`), and `errorResultXdr` / `diagnosticEventsXdr` are never returned. +- `getTransaction` is missing the required `oldestLedger` / `oldestLedgerCloseTime` (all statuses) and `applicationOrder` / `feeBump` (success/failed); `latestLedger` and `ledger` are strings where the spec has numbers; `resultXdr` / `resultMetaXdr` are empty-string stubs (contract return values are not surfaced); the `hash` parameter is not validated against the 64-lowercase-hex format. + +### Cross-cutting + +- The `xdrFormat` parameter is accepted on `getTransaction` and `sendTransaction`, but only the default `'base64'` is supported; `'json'` is rejected with `-32602`. +- The K semantics' unknown-method fallback answers with `result: null` instead of error `-32601`. This is currently unreachable — the Python layer rejects unknown methods before the semantics run — but latent. - `SCVec` / `SCMap` contract arguments are not yet encoded. -- `simulateTransaction`, `getEvents`, `getLedgerEntries`, `getFeeStats`, and TTL/footprint operations are not implemented. +- `traceTransaction` is a komet-specific extension, absent from real Stellar RPC (see [server.md](server.md#tracetransaction-komet-specific-extension)). diff --git a/docs/server.md b/docs/server.md index 0af41c5..754cf41 100644 --- a/docs/server.md +++ b/docs/server.md @@ -19,6 +19,20 @@ class StellarRpcServer: The server is a plain `http.server.HTTPServer` (not pyk's `JsonRpcServer`). A `BaseHTTPRequestHandler` reads each POST body and calls `_handle`, which parses the JSON-RPC frame and delegates to `handle_rpc`. +### JSON-RPC framing + +The server implements the JSON-RPC 2.0 framing rules, including batch calls: + +- A single request object gets a single response object. +- An **array** body is a batch: each element is validated and dispatched on its own, and the response is an array with one entry per answered element (matched by `id`; invalid elements each get an Invalid Request error with `id: null`). Batch elements run sequentially — the server is single-threaded by design, so a batch is equivalent to sending its elements one at a time. An empty array is answered with a single Invalid Request error object, per the spec. +- A request without an `id` member is a **notification**: it is executed but never answered, not even with an error. A batch of only notifications (or a single notification) produces an empty response body. + +Framing happens entirely in Python (`_handle` / `_handle_batch` / `_handle_single`); the K semantics see one request envelope per invocation regardless of how requests were framed on the wire. + +### The `xdrFormat` parameter + +`getTransaction` and `sendTransaction` accept the spec's optional `xdrFormat` parameter. Only `'base64'`, the spec default, is supported: XDR fields in responses are always base64 strings. The alternative `'json'` format (XDR rendered as JSON objects) is not implemented and is rejected with error `-32602` and a message saying so; any other value is rejected with `-32602` as well. The check runs before anything else, so a `sendTransaction` with a bad `xdrFormat` is rejected without executing the transaction. + ### `handle_rpc(method, params, request_id) -> str` `handle_rpc` is the dispatch entry point; it returns the JSON-RPC response envelope as a string. You can call it **without** the HTTP layer, which is convenient for scripts and tests: @@ -83,7 +97,7 @@ Because these artifacts live on disk, the server can be stopped and restarted wi ## RPC methods -All methods are answered by the K semantics and follow the [Stellar RPC specification](https://developers.stellar.org/docs/data/apis/rpc/methods). +All methods are answered by the K semantics. All follow the [Stellar RPC specification](https://developers.stellar.org/docs/data/apis/rpc/methods) except `traceTransaction`, which is a komet-specific extension (see below). ### `getHealth` @@ -114,7 +128,9 @@ All methods are answered by the K semantics and follow the [Stellar RPC specific { "hash": "<64-char hex>", "status": "PENDING", "latestLedger": "5", "latestLedgerCloseTime": "1716000000" } ``` -### `traceTransaction` +### `traceTransaction` (komet-specific extension) + +`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.