Skip to content

Commit 16d53b7

Browse files
Merge remote-tracking branch 'origin/feat/rpc-plumbing' into feat/get-version-info-fee-stats
# Conflicts: # docs/architecture.md # docs/notes.md # src/tests/integration/test_server.py
2 parents b88979d + 1b5f66c commit 16d53b7

6 files changed

Lines changed: 264 additions & 19 deletions

File tree

docs/architecture.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ flowchart TB
5151

5252
**[Detailed documentation](server.md)**
5353

54-
The server implements eight RPC methods — `getHealth`, `getNetwork`, `getLatestLedger`, `getVersionInfo`, `getFeeStats`, `sendTransaction`, `getTransaction`, and `traceTransaction` — and the K semantics answer all of them.
54+
The server implements eight RPC methods — `getHealth`, `getNetwork`, `getLatestLedger`, `getVersionInfo`, `getFeeStats`, `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.
5555

5656
`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.
5757

docs/node-semantics.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@ The trace is not part of the receipt — the executing steps already appended it
9595

9696
### traceTransaction
9797

98-
`traceTransaction` is a read-only lookup. It takes a `hash` (the same parameter `getTransaction` takes) and responds with the contents of `traces/trace_<hash>.jsonl`, or `null` when no trace file exists for that hash. Because tracing is always on, every `sendTransaction` writes this file.
98+
`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_<hash>.jsonl`, or `null` when no trace file exists for that hash. Because tracing is always on, every `sendTransaction` writes this file.
9999

100100
### Two ways steps are delivered
101101

docs/notes.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ The tests do not yet cover `bytes` / `address` SCVal arguments or `SCVec` / `SCM
3636
- `resultXdr` / `resultMetaXdr` are empty stubs (contract return values not surfaced).
3737
- `sendTransaction`'s `errorResultXdr` is always a generic `txMALFORMED` result (no per-cause codes such as `txBAD_SEQ` — sequence numbers, fees, and signatures are not modelled), and `diagnosticEventsXdr` is never populated (both fields are optional in the spec). `TRY_AGAIN_LATER` is never returned by design: it signals mempool backpressure, which cannot arise in a synchronous node without a mempool.
3838
- `SCVec` / `SCMap` contract arguments are not yet encoded.
39+
- The `xdrFormat` parameter is accepted on `getTransaction` and `sendTransaction`, but only the default `'base64'` is supported; `'json'` is rejected with `-32602`.
3940
- `simulateTransaction`, `getEvents`, `getLedgerEntries`, `getTransactions`, `getLedgers`, and TTL/footprint operations are not implemented.
4041
- `getFeeStats` reports constant distributions (there is no fee market); only its `latestLedger` is live.
4142
- Receipts are not a stable format: older io-dirs store `ledger`/`createdAt` with pre-spec types and lack `applicationOrder`/`feeBump`. Resume only io-dirs written by the same version, or start fresh.

docs/server.md

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,20 @@ class StellarRpcServer:
1919

2020
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`.
2121

22+
### JSON-RPC framing
23+
24+
The server implements the JSON-RPC 2.0 framing rules, including batch calls:
25+
26+
- A single request object gets a single response object.
27+
- 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.
28+
- 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.
29+
30+
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.
31+
32+
### The `xdrFormat` parameter
33+
34+
`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.
35+
2236
### `handle_rpc(method, params, request_id) -> str`
2337

2438
`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
8397

8498
## RPC methods
8599

86-
All methods are answered by the K semantics and follow the [Stellar RPC specification](https://developers.stellar.org/docs/data/apis/rpc/methods), including its serialization quirks: ledger sequence numbers and `protocolVersion` are JSON numbers, while close-time fields (`latestLedgerCloseTime`, `oldestLedgerCloseTime`, `createdAt`) are int64s serialized as JSON strings holding a decimal integer, matching what real stellar-rpc emits.
100+
All methods are answered by the K semantics and follow the [Stellar RPC specification](https://developers.stellar.org/docs/data/apis/rpc/methods) — except `traceTransaction`, which is a komet-specific extension (see below) — including its serialization quirks: ledger sequence numbers and `protocolVersion` are JSON numbers, while close-time fields (`latestLedgerCloseTime`, `oldestLedgerCloseTime`, `createdAt`) are int64s serialized as JSON strings holding a decimal integer, matching what real stellar-rpc emits.
87101

88102
### `getHealth`
89103

@@ -140,7 +154,9 @@ Resubmitting a transaction whose hash already has a receipt returns status `DUPL
140154

141155
A transaction that decodes as XDR but cannot be processed (e.g. it contains an operation the semantics do not support) is rejected at admission time with status `ERROR` and an `errorResultXdr` carrying a `txMALFORMED` `TransactionResult` — mirroring how real stellar-rpc reports core's admission rejections. Such a transaction never reaches the ledger: no receipt is stored (`getTransaction` stays `NOT_FOUND`) and the ledger does not advance. Undecodable XDR remains a plain `-32602 Invalid params` error, as in real stellar-rpc. `TRY_AGAIN_LATER` is never returned: it signals mempool backpressure, and komet-node executes synchronously without a mempool, so the condition it reports cannot arise.
142156

143-
### `traceTransaction`
157+
### `traceTransaction` (komet-specific extension)
158+
159+
`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.
144160

145161
`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.
146162

src/komet_node/server.py

Lines changed: 73 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,11 @@
3939
# trace stored on a previously executed transaction's receipt (see _read_only_envelope).
4040
_TX_METHODS: Final = ('sendTransaction',)
4141

42+
# Methods whose spec accepts an optional `xdrFormat` param (protocols/rpc:
43+
# GetTransactionRequest.Format, SendTransactionRequest.Format). komet-node supports only
44+
# the default 'base64' format; see _check_xdr_format.
45+
_XDR_FORMAT_METHODS: Final = ('getTransaction', 'sendTransaction')
46+
4247
_log = logging.getLogger('komet_node')
4348

4449

@@ -161,33 +166,66 @@ def shutdown(self) -> None:
161166
# ------------------------------------------------------------------
162167

163168
def _handle(self, body: bytes) -> bytes:
164-
"""Parse a raw JSON-RPC body and return the response bytes (the HTTP entry point)."""
169+
"""Parse a raw JSON-RPC body and return the response bytes (the HTTP entry point).
170+
171+
An array body is a JSON-RPC 2.0 batch; anything else is a single call. The result
172+
may be empty (no response body) when every request was a notification.
173+
"""
165174
try:
166175
req = json.loads(body.decode('utf-8'))
167176
except (json.JSONDecodeError, UnicodeDecodeError):
168177
return _error_bytes(None, -32700, 'Parse error')
169-
if not isinstance(req, dict):
178+
if isinstance(req, list):
179+
return self._handle_batch(req)
180+
response = self._handle_single(req)
181+
return b'' if response is None else response.encode('utf-8')
182+
183+
def _handle_batch(self, batch: list[Any]) -> bytes:
184+
"""Answer a JSON-RPC 2.0 batch call: one response per element, in an array.
185+
186+
Per section 6 of the spec: an empty array is itself a single Invalid Request error;
187+
invalid elements each get their own error response; notifications get no response
188+
entry, and a batch of only notifications gets no response body at all. Elements run
189+
sequentially (the server is single-threaded by design, see serve()).
190+
"""
191+
if not batch:
170192
return _error_bytes(None, -32600, 'Invalid Request')
193+
responses = [response for element in batch if (response := self._handle_single(element)) is not None]
194+
if not responses:
195+
return b''
196+
return ('[' + ','.join(responses) + ']').encode('utf-8')
197+
198+
def _handle_single(self, req: Any) -> str | None:
199+
"""Validate one JSON-RPC request frame and dispatch it.
200+
201+
Returns the response as a JSON string, or ``None`` for a notification — a valid
202+
request frame without an ``id`` member, which per JSON-RPC 2.0 is executed but
203+
never answered, not even with an error.
204+
"""
205+
if not isinstance(req, dict):
206+
return _error_str(None, -32600, 'Invalid Request')
171207
request_id = req.get('id')
172208

173209
# Validate the JSON-RPC frame before dispatch (JSON-RPC 2.0):
174210
# - wrong/missing protocol version or a non-string method => Invalid Request
175211
# - params, if present, must be a structured (object) value => else Invalid params
176212
if req.get('jsonrpc') != '2.0' or not isinstance(req.get('method'), str):
177-
return _error_bytes(request_id, -32600, 'Invalid Request')
213+
return _error_str(request_id, -32600, 'Invalid Request')
178214
params = req.get('params')
179215
if params is None:
180216
params = {}
181-
elif not isinstance(params, dict):
182-
return _error_bytes(request_id, -32602, 'Invalid params')
183217

184-
try:
185-
return self.handle_rpc(req['method'], params, request_id).encode('utf-8')
186-
except Exception:
187-
# An unexpected error must never take down the server thread, but it must not
188-
# vanish silently either — log the traceback before returning Internal error.
189-
traceback.print_exc()
190-
return _error_bytes(request_id, -32603, 'Internal error')
218+
if not isinstance(params, dict):
219+
response = _error_str(request_id, -32602, 'Invalid params')
220+
else:
221+
try:
222+
response = self.handle_rpc(req['method'], params, request_id)
223+
except Exception:
224+
# An unexpected error must never take down the server thread, but it must
225+
# not vanish silently either — log the traceback, return Internal error.
226+
traceback.print_exc()
227+
response = _error_str(request_id, -32603, 'Internal error')
228+
return None if 'id' not in req else response
191229

192230
def handle_rpc(self, method: str | None, params: dict[str, Any], request_id: Any = None) -> str:
193231
"""Dispatch a single JSON-RPC call and return the response envelope as a JSON string.
@@ -198,6 +236,13 @@ def handle_rpc(self, method: str | None, params: dict[str, Any], request_id: Any
198236
_log.info('request: %s (id=%r)', method, request_id)
199237
self._archive_request(method, params, request_id)
200238

239+
# Reject an unsupported xdrFormat up front, before the request does anything —
240+
# in particular before a sendTransaction executes and commits state.
241+
if method in _XDR_FORMAT_METHODS:
242+
format_error = _check_xdr_format(params, request_id)
243+
if format_error is not None:
244+
return format_error
245+
201246
if method in _TX_METHODS:
202247
transaction = params.get('transaction')
203248
if not isinstance(transaction, str):
@@ -369,6 +414,22 @@ def _configure_logging() -> None:
369414
_log.setLevel(logging.INFO)
370415

371416

417+
def _check_xdr_format(params: dict[str, Any], request_id: Any) -> str | None:
418+
"""Validate the optional ``xdrFormat`` param; ``None`` means the request may proceed.
419+
420+
Only ``'base64'`` (the spec default) is supported. The spec's alternative ``'json'``
421+
format is not implemented in komet-node, so it gets a dedicated error message; any
422+
other value is rejected as invalid. Both cases are Invalid params (-32602), matching
423+
real stellar-rpc's handling of a bad format value.
424+
"""
425+
xdr_format = params.get('xdrFormat', 'base64')
426+
if xdr_format == 'base64':
427+
return None
428+
if xdr_format == 'json':
429+
return _error_str(request_id, -32602, "Invalid params: xdrFormat 'json' is not supported, use 'base64'")
430+
return _error_str(request_id, -32602, "Invalid params: unknown xdrFormat, expected 'base64'")
431+
432+
372433
def _error_str(rpc_id: Any, code: int, message: str) -> str:
373434
return json.dumps({'jsonrpc': '2.0', 'id': rpc_id, 'error': {'code': code, 'message': message}})
374435

0 commit comments

Comments
 (0)