Skip to content

Commit 3873b5c

Browse files
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.
1 parent 248817b commit 3873b5c

1 file changed

Lines changed: 73 additions & 12 deletions

File tree

src/komet_node/server.py

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

27+
# Methods whose spec accepts an optional `xdrFormat` param (protocols/rpc:
28+
# GetTransactionRequest.Format, SendTransactionRequest.Format). komet-node supports only
29+
# the default 'base64' format; see _check_xdr_format.
30+
_XDR_FORMAT_METHODS: Final = ('getTransaction', 'sendTransaction')
31+
2732
_log = logging.getLogger('komet_node')
2833

2934

@@ -146,33 +151,66 @@ def shutdown(self) -> None:
146151
# ------------------------------------------------------------------
147152

148153
def _handle(self, body: bytes) -> bytes:
149-
"""Parse a raw JSON-RPC body and return the response bytes (the HTTP entry point)."""
154+
"""Parse a raw JSON-RPC body and return the response bytes (the HTTP entry point).
155+
156+
An array body is a JSON-RPC 2.0 batch; anything else is a single call. The result
157+
may be empty (no response body) when every request was a notification.
158+
"""
150159
try:
151160
req = json.loads(body.decode('utf-8'))
152161
except (json.JSONDecodeError, UnicodeDecodeError):
153162
return _error_bytes(None, -32700, 'Parse error')
154-
if not isinstance(req, dict):
163+
if isinstance(req, list):
164+
return self._handle_batch(req)
165+
response = self._handle_single(req)
166+
return b'' if response is None else response.encode('utf-8')
167+
168+
def _handle_batch(self, batch: list[Any]) -> bytes:
169+
"""Answer a JSON-RPC 2.0 batch call: one response per element, in an array.
170+
171+
Per section 6 of the spec: an empty array is itself a single Invalid Request error;
172+
invalid elements each get their own error response; notifications get no response
173+
entry, and a batch of only notifications gets no response body at all. Elements run
174+
sequentially (the server is single-threaded by design, see serve()).
175+
"""
176+
if not batch:
155177
return _error_bytes(None, -32600, 'Invalid Request')
178+
responses = [response for element in batch if (response := self._handle_single(element)) is not None]
179+
if not responses:
180+
return b''
181+
return ('[' + ','.join(responses) + ']').encode('utf-8')
182+
183+
def _handle_single(self, req: Any) -> str | None:
184+
"""Validate one JSON-RPC request frame and dispatch it.
185+
186+
Returns the response as a JSON string, or ``None`` for a notification — a valid
187+
request frame without an ``id`` member, which per JSON-RPC 2.0 is executed but
188+
never answered, not even with an error.
189+
"""
190+
if not isinstance(req, dict):
191+
return _error_str(None, -32600, 'Invalid Request')
156192
request_id = req.get('id')
157193

158194
# Validate the JSON-RPC frame before dispatch (JSON-RPC 2.0):
159195
# - wrong/missing protocol version or a non-string method => Invalid Request
160196
# - params, if present, must be a structured (object) value => else Invalid params
161197
if req.get('jsonrpc') != '2.0' or not isinstance(req.get('method'), str):
162-
return _error_bytes(request_id, -32600, 'Invalid Request')
198+
return _error_str(request_id, -32600, 'Invalid Request')
163199
params = req.get('params')
164200
if params is None:
165201
params = {}
166-
elif not isinstance(params, dict):
167-
return _error_bytes(request_id, -32602, 'Invalid params')
168202

169-
try:
170-
return self.handle_rpc(req['method'], params, request_id).encode('utf-8')
171-
except Exception:
172-
# An unexpected error must never take down the server thread, but it must not
173-
# vanish silently either — log the traceback before returning Internal error.
174-
traceback.print_exc()
175-
return _error_bytes(request_id, -32603, 'Internal error')
203+
if not isinstance(params, dict):
204+
response = _error_str(request_id, -32602, 'Invalid params')
205+
else:
206+
try:
207+
response = self.handle_rpc(req['method'], params, request_id)
208+
except Exception:
209+
# An unexpected error must never take down the server thread, but it must
210+
# not vanish silently either — log the traceback, return Internal error.
211+
traceback.print_exc()
212+
response = _error_str(request_id, -32603, 'Internal error')
213+
return None if 'id' not in req else response
176214

177215
def handle_rpc(self, method: str | None, params: dict[str, Any], request_id: Any = None) -> str:
178216
"""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
183221
_log.info('request: %s (id=%r)', method, request_id)
184222
self._archive_request(method, params, request_id)
185223

224+
# Reject an unsupported xdrFormat up front, before the request does anything —
225+
# in particular before a sendTransaction executes and commits state.
226+
if method in _XDR_FORMAT_METHODS:
227+
format_error = _check_xdr_format(params, request_id)
228+
if format_error is not None:
229+
return format_error
230+
186231
if method in _TX_METHODS:
187232
transaction = params.get('transaction')
188233
if not isinstance(transaction, str):
@@ -305,6 +350,22 @@ def _configure_logging() -> None:
305350
_log.setLevel(logging.INFO)
306351

307352

353+
def _check_xdr_format(params: dict[str, Any], request_id: Any) -> str | None:
354+
"""Validate the optional ``xdrFormat`` param; ``None`` means the request may proceed.
355+
356+
Only ``'base64'`` (the spec default) is supported. The spec's alternative ``'json'``
357+
format is not implemented in komet-node, so it gets a dedicated error message; any
358+
other value is rejected as invalid. Both cases are Invalid params (-32602), matching
359+
real stellar-rpc's handling of a bad format value.
360+
"""
361+
xdr_format = params.get('xdrFormat', 'base64')
362+
if xdr_format == 'base64':
363+
return None
364+
if xdr_format == 'json':
365+
return _error_str(request_id, -32602, "Invalid params: xdrFormat 'json' is not supported, use 'base64'")
366+
return _error_str(request_id, -32602, "Invalid params: unknown xdrFormat, expected 'base64'")
367+
368+
308369
def _error_str(rpc_id: Any, code: int, message: str) -> str:
309370
return json.dumps({'jsonrpc': '2.0', 'id': rpc_id, 'error': {'code': code, 'message': message}})
310371

0 commit comments

Comments
 (0)