From 022dd9533961370407ac8f7ebd2793afcd1f3a06 Mon Sep 17 00:00:00 2001 From: Raoul Date: Thu, 2 Jul 2026 13:43:53 +0000 Subject: [PATCH 1/4] test: assert spec-conformant response shapes for the implemented RPC methods Tighten the integration tests to the official stellar-rpc serialization rules (go-stellar-sdk protocols/rpc structs): ledger sequences and protocolVersion as JSON numbers, close times as string-encoded int64s, friendbotUrl omitted rather than null, and 64-hex hashes. New coverage: getHealth ledger range fields, non-constant getLatestLedger id, getTransaction oldestLedger/oldestLedgerCloseTime plus applicationOrder and feeBump, sendTransaction DUPLICATE without re-execution, hash format validation (-32602), and the K unknown-method fallback answering -32601. All of these fail against the current implementation on purpose; they pin down the target behavior for the spec-shape fixes. --- src/tests/integration/test_server.py | 257 +++++++++++++++++++++++---- 1 file changed, 224 insertions(+), 33 deletions(-) diff --git a/src/tests/integration/test_server.py b/src/tests/integration/test_server.py index 3e5cfc3..81a02dc 100644 --- a/src/tests/integration/test_server.py +++ b/src/tests/integration/test_server.py @@ -1,6 +1,7 @@ from __future__ import annotations import json +import re import shutil import socket import subprocess @@ -58,6 +59,71 @@ def _post(port: int, body: bytes) -> dict[str, Any]: return json.loads(resp.read()) +# Spec-shape helpers. The official serialization rules come from the Go structs in +# stellar/go-stellar-sdk protocols/rpc (what real stellar-rpc emits): ledger sequence +# numbers and protocolVersion are JSON numbers; the close-time fields on the singular +# methods are int64 with Go's `,string` encoding, i.e. JSON strings holding a decimal +# integer; hashes are 64 lowercase hex characters. + +_HEX64_RE = re.compile(r'[0-9a-f]{64}') +_INT_STRING_RE = re.compile(r'-?[0-9]+') + + +def _is_number(value: Any) -> bool: + """True for a JSON number decoded to int (bool is a distinct JSON type).""" + return isinstance(value, int) and not isinstance(value, bool) + + +def _is_int_string(value: Any) -> bool: + """True for a JSON string holding a decimal integer (Go int64 `,string` encoding).""" + return isinstance(value, str) and _INT_STRING_RE.fullmatch(value) is not None + + +def _is_hex64(value: Any) -> bool: + return isinstance(value, str) and _HEX64_RE.fullmatch(value) is not None + + +def _assert_ledger_bounds(result: dict[str, Any]) -> None: + """Check the latest/oldest ledger-range fields required on every getTransaction response.""" + assert _is_number(result['latestLedger']) + assert _is_int_string(result['latestLedgerCloseTime']) + assert _is_number(result['oldestLedger']) + assert _is_int_string(result['oldestLedgerCloseTime']) + assert result['oldestLedger'] <= result['latestLedger'] + + +# The full field surface of GetTransactionResponse (Go struct, v22 + optional v23 extras). +_GET_TRANSACTION_KEYS = { + 'status', + 'txHash', + 'applicationOrder', + 'feeBump', + 'envelopeXdr', + 'resultXdr', + 'resultMetaXdr', + 'diagnosticEventsXdr', + 'events', + 'ledger', + 'createdAt', + 'latestLedger', + 'latestLedgerCloseTime', + 'oldestLedger', + 'oldestLedgerCloseTime', +} + + +def _create_account_xdr(keypair: Keypair, account: Account) -> str: + """Build and sign a minimal create-account transaction, returned as base64 XDR.""" + 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() + + @pytest.fixture def server(tmp_path: Path): port = _find_free_port() @@ -92,24 +158,84 @@ def test_default_io_dir_is_a_fresh_temp_dir() -> None: def test_get_health(server: StellarRpcServer) -> None: - result = _rpc(server.port(), 'getHealth', {}) - assert result['result'] == {'status': 'healthy'} + """getHealth returns the spec shape: status plus the ledger range, all sequences as numbers.""" + result = _rpc(server.port(), 'getHealth', {})['result'] + assert result['status'] == 'healthy' + assert _is_number(result['latestLedger']) + assert _is_number(result['oldestLedger']) + assert _is_number(result['ledgerRetentionWindow']) + assert result['oldestLedger'] <= result['latestLedger'] + assert result['ledgerRetentionWindow'] >= 1 + # The close-time fields are always emitted by real stellar-rpc but are not part of this + # node's required surface; when present they must use the int64-as-string encoding. + for key in ('latestLedgerCloseTime', 'oldestLedgerCloseTime'): + if key in result: + assert _is_int_string(result[key]) + assert set(result) <= { + 'status', + 'latestLedger', + 'latestLedgerCloseTime', + 'oldestLedger', + 'oldestLedgerCloseTime', + 'ledgerRetentionWindow', + } def test_get_network(server: StellarRpcServer) -> None: - result = _rpc(server.port(), 'getNetwork', {}) - assert result['result']['passphrase'] == Network.TESTNET_NETWORK_PASSPHRASE - assert result['result']['protocolVersion'] == '22' + """getNetwork: protocolVersion is a JSON number; friendbotUrl is omitted (no friendbot here).""" + result = _rpc(server.port(), 'getNetwork', {})['result'] + assert result['passphrase'] == Network.TESTNET_NETWORK_PASSPHRASE + assert _is_number(result['protocolVersion']) + assert result['protocolVersion'] == 22 + # friendbotUrl is `omitempty` in real stellar-rpc: unset means absent, not null. + assert 'friendbotUrl' not in result + assert set(result) == {'passphrase', 'protocolVersion'} def test_get_latest_ledger_initial(server: StellarRpcServer) -> None: - result = _rpc(server.port(), 'getLatestLedger', {}) - assert result['result']['sequence'] == 0 + """getLatestLedger on a fresh chain: sequence 0, protocolVersion as number, 64-hex id.""" + result = _rpc(server.port(), 'getLatestLedger', {})['result'] + assert result['sequence'] == 0 + assert _is_number(result['sequence']) + assert _is_number(result['protocolVersion']) + assert result['protocolVersion'] == 22 + assert _is_hex64(result['id']) + # closeTime/headerXdr/metadataXdr are protocol-23 extras; when present, closeTime uses + # the int64-as-string encoding. + if 'closeTime' in result: + assert _is_int_string(result['closeTime']) + assert set(result) <= {'id', 'protocolVersion', 'sequence', 'closeTime', 'headerXdr', 'metadataXdr'} + + +def test_get_latest_ledger_id_changes_per_ledger(server: StellarRpcServer) -> None: + """The ledger id is not a constant: each ledger reports its own hash.""" + keypair = Keypair.random() + account = Account(keypair.public_key, sequence=0) + + first = _rpc(server.port(), 'getLatestLedger', {})['result'] + _rpc(server.port(), 'sendTransaction', {'transaction': _create_account_xdr(keypair, account)}) + second = _rpc(server.port(), 'getLatestLedger', {})['result'] + + assert second['sequence'] == first['sequence'] + 1 + assert _is_hex64(first['id']) + assert _is_hex64(second['id']) + assert first['id'] != second['id'] def test_get_transaction_not_found(server: StellarRpcServer) -> None: - result = _rpc(server.port(), 'getTransaction', {'hash': '0' * 64}) - assert result['result']['status'] == 'NOT_FOUND' + """A NOT_FOUND response still carries the full ledger range, with spec-conformant types.""" + result = _rpc(server.port(), 'getTransaction', {'hash': '0' * 64})['result'] + assert result['status'] == 'NOT_FOUND' + _assert_ledger_bounds(result) + assert set(result) <= _GET_TRANSACTION_KEYS + + +def test_get_transaction_malformed_hash_returns_invalid_params(server: StellarRpcServer) -> None: + """The hash param must be a 64-character hex string; anything else is Invalid params.""" + for bad_hash in ('deadbeef', '0' * 63, '0' * 65, 'x' * 64, '0' * 63 + 'g'): + response = _rpc(server.port(), 'getTransaction', {'hash': bad_hash}) + assert 'result' not in response, f'expected an error for hash {bad_hash!r}' + assert response['error']['code'] == -32602, f'hash {bad_hash!r}' def test_unknown_method_returns_method_not_found(server: StellarRpcServer) -> None: @@ -117,6 +243,22 @@ def test_unknown_method_returns_method_not_found(server: StellarRpcServer) -> No assert result['error']['code'] == -32601 +def test_k_unknown_method_fallback_returns_method_not_found(server: StellarRpcServer) -> None: + """The K semantics' own unknown-method fallback answers with JSON-RPC error -32601. + + The Python layer filters unknown methods before they reach K, so this drives the + interpreter directly with an envelope for a method the semantics do not implement. + The fallback must produce an error response, not ``result: null``. + """ + envelope = {'method': 'noSuchMethod', 'id': 7, 'now': str(int(time.time()))} + raw = server.interpreter.run(server.state_file, server.io_dir, envelope, None) + assert raw is not None + response = json.loads(raw) + assert 'result' not in response + assert response['error']['code'] == -32601 + assert response['id'] == 7 + + def test_send_transaction_missing_params_returns_invalid_params(server: StellarRpcServer) -> None: result = _rpc(server.port(), 'sendTransaction', {}) assert result['error']['code'] == -32602 @@ -163,28 +305,63 @@ def test_non_object_params_returns_invalid_params(server: StellarRpcServer) -> N def test_send_transaction_and_get_result(server: StellarRpcServer) -> None: - """Send a CreateAccount transaction through the HTTP server and poll for the result.""" + """Send a CreateAccount transaction through the HTTP server and poll for the result. + + Asserts the exact spec shape of both responses: ledger sequences are JSON numbers, + close times are string-encoded int64s, and the receipt carries the transaction details + required for a SUCCESS status (ledger, createdAt, applicationOrder, feeBump). + """ keypair = Keypair.random() account = Account(keypair.public_key, sequence=0) + xdr_str = _create_account_xdr(keypair, account) - 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) - xdr_str = envelope.to_xdr() - - # sendTransaction always returns PENDING - send_result = _rpc(server.port(), 'sendTransaction', {'transaction': xdr_str}) - assert send_result['result']['status'] == 'PENDING' - tx_hash = send_result['result']['hash'] + # sendTransaction returns PENDING for a fresh transaction + send_result = _rpc(server.port(), 'sendTransaction', {'transaction': xdr_str})['result'] + assert send_result['status'] == 'PENDING' + assert _is_hex64(send_result['hash']) + assert _is_number(send_result['latestLedger']) + assert _is_int_string(send_result['latestLedgerCloseTime']) + assert set(send_result) == {'hash', 'status', 'latestLedger', 'latestLedgerCloseTime'} + tx_hash = send_result['hash'] # since the interpreter runs synchronously, the result is already stored - get_result = _rpc(server.port(), 'getTransaction', {'hash': tx_hash}) - assert get_result['result']['status'] == 'SUCCESS' - assert get_result['result']['envelopeXdr'] == xdr_str + get_result = _rpc(server.port(), 'getTransaction', {'hash': tx_hash})['result'] + assert get_result['status'] == 'SUCCESS' + assert get_result['envelopeXdr'] == xdr_str + _assert_ledger_bounds(get_result) + assert _is_number(get_result['ledger']) + assert get_result['ledger'] == 1 + # createdAt is a string on getTransaction (singular) — a known quirk of real stellar-rpc. + assert _is_int_string(get_result['createdAt']) + assert _is_number(get_result['applicationOrder']) + assert get_result['applicationOrder'] == 1 + assert get_result['feeBump'] is False + assert set(get_result) <= _GET_TRANSACTION_KEYS + + +def test_send_transaction_duplicate_is_not_reexecuted(server: StellarRpcServer) -> None: + """Resubmitting an already-executed transaction returns DUPLICATE and leaves the chain alone.""" + keypair = Keypair.random() + account = Account(keypair.public_key, sequence=0) + xdr_str = _create_account_xdr(keypair, account) + + first = _rpc(server.port(), 'sendTransaction', {'transaction': xdr_str})['result'] + assert first['status'] == 'PENDING' + assert _rpc(server.port(), 'getLatestLedger', {})['result']['sequence'] == 1 + + second = _rpc(server.port(), 'sendTransaction', {'transaction': xdr_str})['result'] + assert second['status'] == 'DUPLICATE' + assert second['hash'] == first['hash'] + assert _is_number(second['latestLedger']) + assert _is_int_string(second['latestLedgerCloseTime']) + assert set(second) == {'hash', 'status', 'latestLedger', 'latestLedgerCloseTime'} + + # The duplicate was not re-executed: the ledger did not advance and the original + # SUCCESS receipt is untouched. + assert _rpc(server.port(), 'getLatestLedger', {})['result']['sequence'] == 1 + get_result = _rpc(server.port(), 'getTransaction', {'hash': first['hash']})['result'] + assert get_result['status'] == 'SUCCESS' + assert get_result['ledger'] == 1 def test_io_dir_splits_into_per_item_files(server: StellarRpcServer) -> None: @@ -231,15 +408,25 @@ def test_failed_transaction_records_failed_receipt(server: StellarRpcServer) -> envelope.sign(keypair) xdr_str = envelope.to_xdr() - # sendTransaction still returns PENDING, even though the tx will fail. - send_result = _rpc(server.port(), 'sendTransaction', {'transaction': xdr_str}) - assert send_result['result']['status'] == 'PENDING' - tx_hash = send_result['result']['hash'] + # sendTransaction still returns PENDING, even though the tx will fail. The response + # keeps the spec types: latestLedger a number, latestLedgerCloseTime a string. + send_result = _rpc(server.port(), 'sendTransaction', {'transaction': xdr_str})['result'] + assert send_result['status'] == 'PENDING' + assert _is_number(send_result['latestLedger']) + assert _is_int_string(send_result['latestLedgerCloseTime']) + tx_hash = send_result['hash'] - # The synthesised receipt is FAILED and echoes the envelope. + # The synthesised receipt is FAILED and echoes the envelope; the ledger-range fields + # are required for every status, and any transaction details keep the spec types. get_result = _rpc(server.port(), 'getTransaction', {'hash': tx_hash})['result'] assert get_result['status'] == 'FAILED' assert get_result['envelopeXdr'] == xdr_str + _assert_ledger_bounds(get_result) + if 'ledger' in get_result: + assert _is_number(get_result['ledger']) + if 'createdAt' in get_result: + assert _is_int_string(get_result['createdAt']) + assert set(get_result) <= _GET_TRANSACTION_KEYS # A failed transaction must not advance the ledger. assert _rpc(server.port(), 'getLatestLedger', {})['result']['sequence'] == 0 @@ -330,8 +517,12 @@ def test_trace_transaction_retrieves_trace_by_hash(server: StellarRpcServer) -> def test_trace_transaction_unknown_hash_returns_null(server: StellarRpcServer) -> None: - """traceTransaction returns null when no transaction with that hash exists.""" - result = _rpc(server.port(), 'traceTransaction', {'hash': 'deadbeef'})['result'] + """traceTransaction returns null when no transaction with that hash exists. + + Uses a well-formed (64-hex) hash so this stays a lookup-miss test regardless of any + hash-format validation on the shared hash parameter. + """ + result = _rpc(server.port(), 'traceTransaction', {'hash': 'ab' * 32})['result'] assert result is None From 0665cfbf640ef819c945826cce02afa765f44ced Mon Sep 17 00:00:00 2001 From: Raoul Date: Thu, 2 Jul 2026 20:32:27 +0000 Subject: [PATCH 2/4] fix: align RPC response shapes with the Stellar RPC spec Match what real stellar-rpc emits (go-stellar-sdk protocols/rpc structs) on the five implemented methods: - Ledger sequences and protocolVersion are JSON numbers everywhere; close-time fields stay int64-as-string. - getHealth reports the ledger range and retention window. - getNetwork omits friendbotUrl instead of sending null. - getLatestLedger derives a distinct per-ledger id from the sequence instead of a constant all-zeros hash. - getTransaction carries oldestLedger/oldestLedgerCloseTime on every status, and receipts record applicationOrder and feeBump. - sendTransaction answers DUPLICATE for a hash that already has a receipt, without re-executing the transaction or advancing the ledger. - The hash param of getTransaction/traceTransaction must be 64 hex chars; anything else is rejected with -32602. - The K unknown-method fallback answers with a proper -32601 error via a new #respondError helper instead of result: null. Receipts are an internal format and are not migrated: io-dirs written by older versions store ledger/createdAt with pre-spec types and lack the new fields, so start fresh after upgrading (documented in the notes). --- docs/node-semantics.md | 16 ++-- docs/notes.md | 2 + docs/server.md | 34 ++++--- src/komet_node/kdist/node.md | 166 +++++++++++++++++++++++++++++------ src/komet_node/server.py | 28 ++++-- 5 files changed, 196 insertions(+), 50 deletions(-) diff --git a/docs/node-semantics.md b/docs/node-semantics.md index 6f72b79..5815f32 100644 --- a/docs/node-semantics.md +++ b/docs/node-semantics.md @@ -56,14 +56,14 @@ If `request.json` is absent, `insert-handleRequestFile` does not fire and K halt ## Dispatch and the read-only methods -`#dispatch` reads the `method` field and routes to a per-method rule. The read-only methods answer directly from constants and the bookkeeping files: +`#dispatch` reads the `method` field and routes to a per-method rule. The read-only methods answer directly from constants and the bookkeeping files. Response shapes follow real stellar-rpc's serialization: ledger sequences and `protocolVersion` are JSON numbers; close-time fields are int64-as-string (JSON strings holding a decimal integer). -- `getHealth` → `{ "status": "healthy" }` -- `getNetwork` → `{ "friendbotUrl": null, "passphrase": ..., "protocolVersion": ... }` (passphrase/version come from the request, keeping the semantics network-agnostic) -- `getLatestLedger` → reads `metadata.json` and returns `{ "id": <64 zeros>, "protocolVersion": ..., "sequence": }` -- `getTransaction` → reads the hash's `receipts/receipt_.json` file; returns the stored receipt merged with the current `latestLedger`/`latestLedgerCloseTime`, or `{ "status": "NOT_FOUND", ... }` when the file is absent +- `getHealth` → `{ "status": "healthy", "latestLedger": N, "latestLedgerCloseTime": , "oldestLedger": 0, "oldestLedgerCloseTime": "0", "ledgerRetentionWindow": N+1 }` (everything since genesis is retained) +- `getNetwork` → `{ "passphrase": ..., "protocolVersion": ... }` (passphrase/version come from the request, keeping the semantics network-agnostic; `friendbotUrl` is omitted — no friendbot) +- `getLatestLedger` → reads `metadata.json` and returns `{ "id": #ledgerId(seq), "protocolVersion": ..., "sequence": }`; `#ledgerId` derives a deterministic per-ledger 64-hex id from the sequence (the semantics have no hash hooks, so it is a fixed odd-constant multiply mod 2^256, which is injective) +- `getTransaction` → reads the hash's `receipts/receipt_.json` file; returns the stored receipt merged with the ledger-range fields (`latestLedger`/`latestLedgerCloseTime`/`oldestLedger`/`oldestLedgerCloseTime`), or `{ "status": "NOT_FOUND", ... }` (with the same ledger-range fields) when the file is absent -`#respond(ID, RESULT)` is the shared terminal: it writes the JSON-RPC envelope to `response.json`, removes `request.json`, and sets the exit code to 0. +`#respond(ID, RESULT)` is the shared terminal: it writes the JSON-RPC envelope to `response.json`, removes `request.json`, and sets the exit code to 0. `#respondError(ID, CODE, MSG)` is its error counterpart, writing `{jsonrpc, id, error: {code, message}}` instead; the unknown-method fallback uses it to answer `-32601 Method not found` (a safety net — the Python server filters unknown methods before they reach K). --- @@ -83,9 +83,11 @@ If `request.json` is absent, `insert-handleRequestFile` does not fire and K halt 1. writes `metadata.json` with `latest_ledger + 1`, 2. writes the receipt to `receipts/receipt_.json`: - `{ status: "SUCCESS", ledger, createdAt, envelopeXdr, resultXdr: "", resultMetaXdr: "" }`, + `{ status: "SUCCESS", applicationOrder: 1, feeBump: false, envelopeXdr, resultXdr: "", resultMetaXdr: "", ledger, createdAt }`, 3. responds with `{hash, status: "PENDING", latestLedger, latestLedgerCloseTime}`. +A `sendTransaction` whose hash already has a receipt never reaches `#runTx`: dispatch answers `{hash, status: "DUPLICATE", latestLedger, latestLedgerCloseTime}` directly, without executing anything or advancing the ledger. (For wasm uploads the Python server also suppresses the `` injection on duplicates, since those steps would run before dispatch.) + The trace is not part of the receipt — the executing steps already appended it to `traces/trace_.jsonl`. Reaching `#finalizeTx` means the steps completed without getting stuck, so the status is `SUCCESS`. A failed transaction gets stuck before this point, `response.json` is never written, and the Python server records the `FAILED` receipt instead. ### traceTransaction diff --git a/docs/notes.md b/docs/notes.md index f1a0b3d..a771f3d 100644 --- a/docs/notes.md +++ b/docs/notes.md @@ -34,5 +34,7 @@ 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). +- `sendTransaction` never returns `ERROR` or `TRY_AGAIN_LATER` (a failed transaction is reported as `PENDING`, then `FAILED` by `getTransaction`); `errorResultXdr` / `diagnosticEventsXdr` are never returned. - `SCVec` / `SCMap` contract arguments are not yet encoded. - `simulateTransaction`, `getEvents`, `getLedgerEntries`, `getFeeStats`, and TTL/footprint operations are not implemented. +- 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. diff --git a/docs/server.md b/docs/server.md index 0af41c5..dbcca7c 100644 --- a/docs/server.md +++ b/docs/server.md @@ -28,7 +28,7 @@ server = StellarRpcServer(io_dir=Path('out')) server.handle_rpc('sendTransaction', {'transaction': xdr}) ``` -For `sendTransaction` it builds the request envelope with `encoder.build_tx_request` and runs it with `interpreter.run`; for the read-only methods (`getHealth`, `getNetwork`, `getLatestLedger`, `getTransaction`, `traceTransaction`) it builds a small envelope and runs it. In every case the *content* of the response is produced by the semantics (`node.md`), not by Python — the one exception is the failure fallback (below). Each call is logged to stderr. +For `sendTransaction` it builds the request envelope with `encoder.build_tx_request` and runs it with `interpreter.run` (skipping the `` wasm injection when the hash already has a receipt, so a duplicate is not re-executed — the semantics answer `DUPLICATE`); for the read-only methods (`getHealth`, `getNetwork`, `getLatestLedger`, `getTransaction`, `traceTransaction`) it builds a small envelope and runs it. In every case the *content* of the response is produced by the semantics (`node.md`), not by Python — the one exception is the failure fallback (below). Each call is logged to stderr. --- @@ -83,24 +83,30 @@ 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 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. ### `getHealth` -`getHealth` returns `{"status": "healthy"}`. +```json +{ "status": "healthy", "latestLedger": 4, "latestLedgerCloseTime": "1716000000", "oldestLedger": 0, "oldestLedgerCloseTime": "0", "ledgerRetentionWindow": 5 } +``` + +The node retains every ledger since genesis, so `oldestLedger` is always 0 and the retention window covers all ledgers so far. Per-ledger close times are not recorded: the latest close time is approximated by the request time and the oldest by the epoch. ### `getNetwork` ```json -{ "friendbotUrl": null, "passphrase": "Test SDF Network ; September 2015", "protocolVersion": "22" } +{ "passphrase": "Test SDF Network ; September 2015", "protocolVersion": 22 } ``` +`friendbotUrl` is omitted (not `null`) because the node runs no friendbot — matching real stellar-rpc's `omitempty` serialization. + ### `getLatestLedger` -`getLatestLedger` returns the current ledger sequence (the `latest_ledger` from `metadata.json`), which increments by 1 per successfully committed transaction. +`getLatestLedger` returns the current ledger sequence (the `latest_ledger` from `metadata.json`), which increments by 1 per successfully committed transaction. The `id` is a deterministic per-ledger stand-in for the ledger-header hash, derived from the sequence (see `#ledgerId` in [node-semantics.md](node-semantics.md)). ```json -{ "id": "0000...0000", "protocolVersion": "22", "sequence": 4 } +{ "id": "1715609f7c74...", "protocolVersion": 22, "sequence": 4 } ``` ### `sendTransaction` @@ -111,9 +117,11 @@ All methods are answered by the K semantics and follow the [Stellar RPC specific **Response**: ```json -{ "hash": "<64-char hex>", "status": "PENDING", "latestLedger": "5", "latestLedgerCloseTime": "1716000000" } +{ "hash": "<64-char hex>", "status": "PENDING", "latestLedger": 5, "latestLedgerCloseTime": "1716000000" } ``` +Resubmitting a transaction whose hash already has a receipt returns status `DUPLICATE` without re-executing it: the ledger does not advance and the stored receipt is untouched. + ### `traceTransaction` `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. @@ -124,7 +132,7 @@ All methods are answered by the K semantics and follow the [Stellar RPC specific ### `getTransaction` -`getTransaction` reads the hash's `receipts/receipt_.json` file. +`getTransaction` reads the hash's `receipts/receipt_.json` file. The `hash` parameter must be a 64-character hex string; anything else is rejected with `-32602 Invalid params` (this and `traceTransaction` share the validation). | Status | Meaning | |---|---| @@ -135,13 +143,17 @@ All methods are answered by the K semantics and follow the [Stellar RPC specific **`SUCCESS` response**: ```json { - "status": "SUCCESS", "ledger": "5", "createdAt": "1716000000", + "status": "SUCCESS", "applicationOrder": 1, "feeBump": false, "envelopeXdr": "", "resultXdr": "", "resultMetaXdr": "", - "latestLedger": "5", "latestLedgerCloseTime": "1716000000" + "ledger": 5, "createdAt": "1716000000", + "latestLedger": 5, "latestLedgerCloseTime": "1716000000", + "oldestLedger": 0, "oldestLedgerCloseTime": "0" } ``` -`resultXdr` and `resultMetaXdr` are currently empty stubs. The receipt carries no trace — use `traceTransaction` with the same hash to fetch it. +The ledger-range fields (`latestLedger` through `oldestLedgerCloseTime`) are present on every response, `NOT_FOUND` included. Every ledger contains exactly one transaction, so `applicationOrder` is always 1; fee-bump envelopes are not supported, so `feeBump` is always false. `resultXdr` and `resultMetaXdr` are currently empty stubs. The receipt carries no trace — use `traceTransaction` with the same hash to fetch it. + +Receipts are an internal format, not a stable one: receipts written by older komet-node versions stored `ledger` as a string and lack `applicationOrder`/`feeBump`, and `getTransaction` returns stored receipts verbatim. Start from a fresh io-dir after upgrading rather than resuming one with old receipts. --- diff --git a/src/komet_node/kdist/node.md b/src/komet_node/kdist/node.md index b8f1db2..521c3ed 100644 --- a/src/komet_node/kdist/node.md +++ b/src/komet_node/kdist/node.md @@ -50,11 +50,14 @@ module NODE | #runTx( JSON ) | #finalizeTx( JSON ) | #recordAndRespond( JSON, Int ) - | #respondTx( JSON, Int ) + | #respondTx( JSON, String, Int ) | #enableTrace( String ) | #getTxResult( String, String, JSON, Int ) | #respondTrace( JSON, String ) | #respond( JSON, JSON ) + | #respondError( JSON, Int, String ) + | #respondHealth( JSON, String, Int ) + | #respondLatestLedger( JSON, Int, Int ) syntax Step ::= setLedgerSequence(Int) [symbol(setLedgerSequence)] // ---------------------------------------------------------------------- @@ -158,7 +161,9 @@ bookkeeping. #dispatch reads the `method` field of the request envelope and routes to a per-method rule. `#respond(ID, RESULT)` writes the JSON-RPC envelope to `response.json`, removes -`request.json`, and marks the run successful (exit code 0). +`request.json`, and marks the run successful (exit code 0). `#respondError(ID, CODE, MSG)` +is its error-response counterpart: it writes a JSON-RPC error envelope (with an `error` +member instead of `result`, per JSON-RPC 2.0) and otherwise behaves the same. ```k rule #dispatch( REQ ) => #dispatchMethod( #getString( "method", REQ ), REQ ) ... @@ -173,41 +178,118 @@ rule. `#respond(ID, RESULT)` writes the JSON-RPC envelope to `response.json`, re ... _ => 0 + + rule #respondError( ID, CODE, MSG ) + => #writeFile( "response.json", JSON2String({ + "jsonrpc" : "2.0", + "id" : ID, + "error" : { "code" : CODE, "message" : MSG } + })) + ~> #remove( "request.json" ) + ... + + _ => 0 ``` ############################################################################### ## Read-only methods +The response shapes follow real stellar-rpc's serialization: ledger sequence numbers, +`protocolVersion`, and `ledgerRetentionWindow` are JSON numbers, while close-time fields +are int64s that Go serializes with `,string` — JSON strings holding a decimal integer. +`friendbotUrl` is an `omitempty` field and this node runs no friendbot, so it is omitted +entirely (not `null`). + +This node retains every ledger since genesis, so `getHealth` reports `oldestLedger` 0 and a +retention window covering all ledgers so far. Close times are not recorded per ledger: the +latest close time is approximated by the request time (`now`, as everywhere else in this +module) and the oldest by the epoch (`"0"`). + ```k rule #dispatchMethod( "getHealth", REQ ) - => #respond( #getJSON( "id", REQ ), { "status" : "healthy" } ) + => #respondHealth( + #getJSON( "id", REQ ), + #getString( "now", REQ ), + #getInt( "latest_ledger", String2JSON( {#readFile("metadata.json")}:>String ) ) + ) + ... + + + rule #respondHealth( ID, NOW, LL ) + => #respond( ID, { + "status" : "healthy", + "latestLedger" : LL, + "latestLedgerCloseTime" : NOW, + "oldestLedger" : 0, + "oldestLedgerCloseTime" : "0", + "ledgerRetentionWindow" : LL +Int 1 + }) ... rule #dispatchMethod( "getNetwork", REQ ) => #respond( #getJSON( "id", REQ ), { - "friendbotUrl" : null, "passphrase" : #getString( "passphrase", REQ ), - "protocolVersion" : #getString( "protocolVersion", REQ ) + "protocolVersion" : #getInt( "protocolVersion", REQ ) }) ... rule #dispatchMethod( "getLatestLedger", REQ ) - => #respond( #getJSON( "id", REQ ), { - "id" : "0000000000000000000000000000000000000000000000000000000000000000", - "protocolVersion" : #getString( "protocolVersion", REQ ), - "sequence" : #getInt( "latest_ledger", String2JSON( {#readFile("metadata.json")}:>String ) ) + => #respondLatestLedger( + #getJSON( "id", REQ ), + #getInt( "protocolVersion", REQ ), + #getInt( "latest_ledger", String2JSON( {#readFile("metadata.json")}:>String ) ) + ) + ... + + + rule #respondLatestLedger( ID, PV, SEQ ) + => #respond( ID, { + "id" : #ledgerId( SEQ ), + "protocolVersion" : PV, + "sequence" : SEQ }) ... ``` +`#ledgerId` derives the ledger's `id` (the hash of the ledger header on a real chain) from +its sequence number. This node has no ledger headers to hash — and the semantics have no +hash hooks — so the id is a deterministic stand-in: the sequence (offset by one so ledger 0 +is not the all-zeros hash) multiplied by a fixed odd 256-bit constant, mod 2^256, printed as +64 lowercase hex characters. Multiplication by an odd constant is injective mod a power of +two, so every ledger gets a distinct id. The constant is ⌊2^256/φ⌋ rounded to odd +(0x9e3779b97f4a7c15... — the golden-ratio constant, chosen only to spread the bits). + +```k + syntax String ::= #ledgerId( Int ) [function, total, symbol(ledgerId)] + // ---------------------------------------------------------------------- + rule #ledgerId( SEQ ) + => #padLeftZeros( + Base2String( + ((SEQ +Int 1) *Int 71563446777022297856526126342750658392501306254664949883333486863006233104021) + modInt (2 ^Int 256), + 16 + ), + 64 + ) + + syntax String ::= #padLeftZeros( String, Int ) [function, total, symbol(padLeftZeros)] + // -------------------------------------------------------------------------------------- + rule #padLeftZeros( S, N ) => S requires lengthString(S) >=Int N + rule #padLeftZeros( S, N ) => #padLeftZeros( "0" +String S, N ) requires lengthString(S) .json` file. If the file -exists, return its contents merged with the current `latestLedger`/`latestLedgerCloseTime`; -otherwise return `NOT_FOUND`. +exists, return its contents merged with the ledger-range fields; otherwise return +`NOT_FOUND`. The ledger range (`latestLedger`/`latestLedgerCloseTime`/`oldestLedger`/ +`oldestLedgerCloseTime`) is required on every `getTransaction` response, whatever the +status. Ledger sequences are JSON numbers; the close times use the int64-as-string +encoding (see the read-only methods above) — the oldest ledger is always 0 on this node +and its close time is not recorded, so the constant `"0"` stands in. ```k rule #dispatchMethod( "getTransaction", REQ ) @@ -223,8 +305,10 @@ otherwise return `NOT_FOUND`. rule #getTxResult( HASH, NOW, ID, LL ) => #respond( ID, { #concatJSONs( #recordOf( String2JSON( {#readFile( #receiptFile( HASH ) )}:>String ) ), - ( "latestLedger" : Int2String( LL ) , + ( "latestLedger" : LL , "latestLedgerCloseTime" : NOW , + "oldestLedger" : 0 , + "oldestLedgerCloseTime" : "0" , .JSONs ) )}) ... @@ -234,8 +318,10 @@ otherwise return `NOT_FOUND`. rule #getTxResult( HASH, NOW, ID, LL ) => #respond( ID, { "status" : "NOT_FOUND", - "latestLedger" : Int2String( LL ), - "latestLedgerCloseTime" : NOW + "latestLedger" : LL, + "latestLedgerCloseTime" : NOW, + "oldestLedger" : 0, + "oldestLedgerCloseTime" : "0" }) ... @@ -255,15 +341,34 @@ with `PENDING`. Instruction tracing is always on: the executing steps append to transaction's own `traces/trace_.jsonl` file, which `traceTransaction` (below) later retrieves by hash. The receipt itself does not carry the trace. +A transaction whose hash already has a receipt is a duplicate submission: it is answered +with status `DUPLICATE` and is **not** re-executed — the ledger does not advance and the +stored receipt is left untouched. (On the wasm-upload path the Python server suppresses the +`` injection for duplicates, so nothing runs before dispatch either; see +server.py.) + The steps come either from the `steps` array of the request envelope (the common path) or from the `` cell (the wasm-upload path, where they were pre-injected and have already run by the time we get here, leaving `steps` empty). ```k rule #dispatchMethod( "sendTransaction", REQ ) => #runTx( REQ ) ... + requires notBool #fileExists( #receiptFile( #getString( "txHash", REQ ) ) ) - // Unknown method — respond with a null result. - rule #dispatchMethod( _, REQ ) => #respond( #getJSON( "id", REQ ), null ) ... [owise] + rule #dispatchMethod( "sendTransaction", REQ ) + => #respondTx( REQ, "DUPLICATE", + #getInt( "latest_ledger", String2JSON( {#readFile("metadata.json")}:>String ) ) ) + ... + + requires #fileExists( #receiptFile( #getString( "txHash", REQ ) ) ) + + // Unknown method — the JSON-RPC "Method not found" error. (The Python server filters + // unknown methods before they reach the semantics, so this is a safety net for + // envelopes injected directly.) + rule #dispatchMethod( _, REQ ) + => #respondError( #getJSON( "id", REQ ), -32601, "Method not found" ) + ... + [owise] rule #runTx( REQ ) => #enableTrace( #traceFile( #getString( "txHash", REQ ) ) ) @@ -291,6 +396,13 @@ After the steps run, record the receipt, write the new ledger counter, and respo was already written to its own file during execution, so we only reset ``. Reaching this point means the steps completed without getting stuck, so the status is `SUCCESS`. +The receipt holds the per-transaction half of a `getTransaction` response, with the +serialization getTransaction requires: `ledger` is a JSON number, `createdAt` an +int64-as-string. Every ledger on this node contains exactly one transaction, so +`applicationOrder` is always 1, and fee-bump envelopes are not supported, so `feeBump` is +always false. (The Python failure fallback in server.py writes a `FAILED` receipt with the +same field set — keep the two in sync.) + ```k rule #finalizeTx( REQ ) => #recordAndRespond( @@ -305,26 +417,28 @@ this point means the steps completed without getting stuck, so the status is `SU => #writeFile( "metadata.json", JSON2String({ "latest_ledger" : L +Int 1 }) ) ~> #writeFile( #receiptFile( #getString( "txHash", REQ ) ), JSON2String( #txReceipt( REQ, L +Int 1 ) ) ) - ~> #respondTx( REQ, L +Int 1 ) + ~> #respondTx( REQ, "PENDING", L +Int 1 ) ... syntax JSON ::= #txReceipt( JSON, Int ) [function, symbol(txReceipt)] // --------------------------------------------------------------------- rule #txReceipt( REQ, NEWL ) => { - "status" : "SUCCESS", - "ledger" : Int2String( NEWL ), - "createdAt" : #getString( "now", REQ ), - "envelopeXdr" : #getString( "envelopeXdr", REQ ), - "resultXdr" : "", - "resultMetaXdr" : "" + "status" : "SUCCESS", + "applicationOrder" : 1, + "feeBump" : false, + "envelopeXdr" : #getString( "envelopeXdr", REQ ), + "resultXdr" : "", + "resultMetaXdr" : "", + "ledger" : NEWL, + "createdAt" : #getString( "now", REQ ) } - rule #respondTx( REQ, NEWL ) + rule #respondTx( REQ, STATUS, LL ) => #respond( #getJSON( "id", REQ ), { "hash" : #getString( "txHash", REQ ), - "status" : "PENDING", - "latestLedger" : Int2String( NEWL ), + "status" : STATUS, + "latestLedger" : LL, "latestLedgerCloseTime" : #getString( "now", REQ ) }) ... diff --git a/src/komet_node/server.py b/src/komet_node/server.py index 6399575..4e805f9 100644 --- a/src/komet_node/server.py +++ b/src/komet_node/server.py @@ -2,6 +2,7 @@ import json import logging +import re import sys import tempfile import time @@ -18,7 +19,11 @@ if TYPE_CHECKING: from http.server import HTTPServer as HTTPServerType -_PROTOCOL_VERSION: Final = '22' +_PROTOCOL_VERSION: Final = 22 + +# Transaction hashes are 64 lowercase hex characters (the spec's Hash schema: +# ^[a-f\d]{64}$). Anything else is rejected as Invalid params before reaching K. +_TX_HASH_RE: Final = re.compile(r'[0-9a-f]{64}') # Only sendTransaction executes a transaction. traceTransaction is a read-only lookup of the # trace stored on a previously executed transaction's receipt (see _read_only_envelope). @@ -195,6 +200,12 @@ def handle_rpc(self, method: str | None, params: dict[str, Any], request_id: Any # the client-facing message neutral rather than leaking internal exceptions. traceback.print_exc() return _error_str(request_id, -32602, 'Invalid params: could not process transaction') + # A hash that already has a receipt is a duplicate submission: the semantics + # answer DUPLICATE without running the steps (see node.md). Steps injected into + # the cell (wasm uploads) would execute *before* dispatch, though, so + # they must not be injected for a duplicate. + if (self.receipts_dir / f'receipt_{envelope["txHash"]}.json').exists(): + program_steps = None response = self.interpreter.run(self.state_file, self.io_dir, envelope, program_steps) if response is None: return json.dumps(self._failure_response(request_id, envelope, now)) @@ -229,6 +240,8 @@ def _read_only_envelope( tx_hash = params.get('hash') if not isinstance(tx_hash, str): return _error_str(request_id, -32602, "Invalid params: 'hash' (string) is required") + if _TX_HASH_RE.fullmatch(tx_hash) is None: + return _error_str(request_id, -32602, "Invalid params: 'hash' must be a 64-character hex string") return {**base, 'hash': tx_hash} return None @@ -255,22 +268,25 @@ def _failure_response(self, rpc_id: Any, envelope: dict[str, Any], now: str) -> tx_hash = envelope['txHash'] # This FAILED receipt mirrors the SUCCESS receipt the semantics build in - # `#txReceipt` (kdist/node.md): keep the field set in sync with that rule. Like the - # success path, the receipt carries no trace — any trace lives in its own file. + # `#txReceipt` (kdist/node.md): keep the field set in sync with that rule (ledger a + # JSON number, createdAt an int64-as-string). Like the success path, the receipt + # carries no trace — any trace lives in its own file. receipt = { 'status': 'FAILED', - 'ledger': str(ledger), - 'createdAt': now, + 'applicationOrder': 1, + 'feeBump': False, 'envelopeXdr': envelope['envelopeXdr'], 'resultXdr': '', 'resultMetaXdr': '', + 'ledger': ledger, + 'createdAt': now, } (self.receipts_dir / f'receipt_{tx_hash}.json').write_text(json.dumps(receipt)) result = { 'hash': tx_hash, 'status': 'PENDING', - 'latestLedger': str(ledger), + 'latestLedger': ledger, 'latestLedgerCloseTime': now, } return {'jsonrpc': '2.0', 'id': rpc_id, 'result': result} From c9cb9abc02e7cf4fb194f750667b20eb90c9ad74 Mon Sep 17 00:00:00 2001 From: Raoul Date: Tue, 7 Jul 2026 12:36:46 +0000 Subject: [PATCH 3/4] test: cover sendTransaction ERROR for unsupported operations --- src/tests/integration/test_server.py | 36 +++++++++++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/src/tests/integration/test_server.py b/src/tests/integration/test_server.py index 81a02dc..c77cca2 100644 --- a/src/tests/integration/test_server.py +++ b/src/tests/integration/test_server.py @@ -12,7 +12,7 @@ from typing import Any import pytest -from stellar_sdk import Account, Keypair, Network, StrKey, TransactionBuilder, xdr +from stellar_sdk import Account, Asset, Keypair, Network, StrKey, TransactionBuilder, xdr from stellar_sdk.xdr.sc_val_type import SCValType from komet_node.server import StellarRpcServer @@ -339,6 +339,40 @@ def test_send_transaction_and_get_result(server: StellarRpcServer) -> None: assert set(get_result) <= _GET_TRANSACTION_KEYS +def test_send_transaction_unsupported_operation_returns_error_status(server: StellarRpcServer) -> None: + """A transaction that decodes but cannot be processed is rejected with status ERROR. + + Mirrors real stellar-rpc's admission-time rejection: the response carries a txMALFORMED + TransactionResult in errorResultXdr, the transaction never reaches the ledger (no + receipt, no ledger bump), and getTransaction stays NOT_FOUND. + """ + keypair = Keypair.random() + account = Account(keypair.public_key, sequence=0) + envelope = ( + TransactionBuilder(account, Network.TESTNET_NETWORK_PASSPHRASE) + .append_payment_op(destination=Keypair.random().public_key, asset=Asset.native(), amount='1') + .set_timeout(30) + .build() + ) + envelope.sign(keypair) + + result = _rpc(server.port(), 'sendTransaction', {'transaction': envelope.to_xdr()})['result'] + assert result['status'] == 'ERROR' + assert result['hash'] == envelope.hash_hex() + assert _is_number(result['latestLedger']) + assert _is_int_string(result['latestLedgerCloseTime']) + assert set(result) == {'hash', 'status', 'errorResultXdr', 'latestLedger', 'latestLedgerCloseTime'} + + tx_result = xdr.TransactionResult.from_xdr(result['errorResultXdr']) + assert tx_result.result.code == xdr.TransactionResultCode.txMALFORMED + assert tx_result.fee_charged.int64 == 0 + + # The rejected transaction never reached the ledger. + assert _rpc(server.port(), 'getLatestLedger', {})['result']['sequence'] == 0 + get_result = _rpc(server.port(), 'getTransaction', {'hash': envelope.hash_hex()})['result'] + assert get_result['status'] == 'NOT_FOUND' + + def test_send_transaction_duplicate_is_not_reexecuted(server: StellarRpcServer) -> None: """Resubmitting an already-executed transaction returns DUPLICATE and leaves the chain alone.""" keypair = Keypair.random() From 82a6d2657c17dd55dc5802d6f447b4bbef46a405 Mon Sep 17 00:00:00 2001 From: Raoul Date: Tue, 7 Jul 2026 12:36:46 +0000 Subject: [PATCH 4/4] feat: reject unprocessable transactions with sendTransaction status ERROR A transaction that decodes as XDR but cannot be processed (for example an operation the semantics do not support) previously came back as a generic -32602 JSON-RPC error. Real stellar-rpc reports these admission-time rejections as status ERROR with a txMALFORMED TransactionResult in errorResultXdr, so komet-node now does the same: the transaction never reaches the ledger, no receipt is stored, and getTransaction stays NOT_FOUND. Undecodable XDR remains -32602, matching real stellar-rpc. TRY_AGAIN_LATER stays intentionally unreturned: it signals mempool backpressure, which cannot arise in a synchronous node without a mempool. --- docs/notes.md | 2 +- docs/server.md | 2 ++ src/komet_node/server.py | 37 +++++++++++++++++++++++++++++------ src/komet_node/transaction.py | 14 +++++++++++++ 4 files changed, 48 insertions(+), 7 deletions(-) diff --git a/docs/notes.md b/docs/notes.md index a771f3d..dc5c6dc 100644 --- a/docs/notes.md +++ b/docs/notes.md @@ -34,7 +34,7 @@ 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). -- `sendTransaction` never returns `ERROR` or `TRY_AGAIN_LATER` (a failed transaction is reported as `PENDING`, then `FAILED` by `getTransaction`); `errorResultXdr` / `diagnosticEventsXdr` are never returned. +- `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. - `SCVec` / `SCMap` contract arguments are not yet encoded. - `simulateTransaction`, `getEvents`, `getLedgerEntries`, `getFeeStats`, and TTL/footprint operations are not implemented. - 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. diff --git a/docs/server.md b/docs/server.md index dbcca7c..ba99806 100644 --- a/docs/server.md +++ b/docs/server.md @@ -122,6 +122,8 @@ The node retains every ledger since genesis, so `oldestLedger` is always 0 and t Resubmitting a transaction whose hash already has a receipt returns status `DUPLICATE` without re-executing it: the ledger does not advance and the stored receipt is untouched. +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. + ### `traceTransaction` `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. diff --git a/src/komet_node/server.py b/src/komet_node/server.py index 4e805f9..0747661 100644 --- a/src/komet_node/server.py +++ b/src/komet_node/server.py @@ -11,10 +11,10 @@ from pathlib import Path from typing import TYPE_CHECKING, Any, Final -from stellar_sdk import Network +from stellar_sdk import Network, TransactionEnvelope from komet_node.interpreter import NodeInterpreter -from komet_node.transaction import TransactionEncoder +from komet_node.transaction import TransactionEncoder, malformed_tx_result_xdr if TYPE_CHECKING: from http.server import HTTPServer as HTTPServerType @@ -192,14 +192,21 @@ def handle_rpc(self, method: str | None, params: dict[str, Any], request_id: Any transaction = params.get('transaction') if not isinstance(transaction, str): return _error_str(request_id, -32602, "Invalid params: 'transaction' (XDR string) is required") + # Undecodable XDR is a JSON-RPC client error (-32602, as in real stellar-rpc); + # a transaction that decodes but cannot be processed (e.g. an unsupported + # operation) is instead rejected at admission time with status ERROR below. + try: + parsed = TransactionEnvelope.from_xdr(transaction, self.encoder.network_passphrase) + except Exception: + traceback.print_exc() + return _error_str(request_id, -32602, 'Invalid params: could not decode transaction XDR') try: envelope, program_steps = self.encoder.build_tx_request(method, request_id, transaction, now) except Exception: - # build_tx_request both decodes XDR and validates it (e.g. rejecting - # sub-stroop amounts); either is a client error. Log the detail, but keep - # the client-facing message neutral rather than leaking internal exceptions. + # Log the detail, but keep the client-facing response neutral rather than + # leaking internal exceptions. traceback.print_exc() - return _error_str(request_id, -32602, 'Invalid params: could not process transaction') + return json.dumps(self._error_status_response(request_id, parsed.hash_hex(), now)) # A hash that already has a receipt is a duplicate submission: the semantics # answer DUPLICATE without running the steps (see node.md). Steps injected into # the cell (wasm uploads) would execute *before* dispatch, though, so @@ -256,6 +263,24 @@ def _archive_request(self, method: str | None, params: dict[str, Any], request_i (self.requests_dir / f'request_{self._request_count}.json').write_text(json.dumps(archive)) self._request_count += 1 + def _error_status_response(self, rpc_id: Any, tx_hash: str, now: str) -> dict[str, Any]: + """Synthesise the sendTransaction response for a transaction rejected at admission. + + Mirrors real stellar-rpc: a transaction that decodes as XDR but cannot be processed + gets status ``ERROR`` with a ``txMALFORMED`` ``errorResultXdr``. It never reaches + the ledger, so no receipt is stored (``getTransaction`` stays ``NOT_FOUND``) and the + ledger does not advance. + """ + metadata = json.loads((self.io_dir / 'metadata.json').read_text()) + result = { + 'hash': tx_hash, + 'status': 'ERROR', + 'errorResultXdr': malformed_tx_result_xdr(), + 'latestLedger': metadata.get('latest_ledger', 0), + 'latestLedgerCloseTime': now, + } + return {'jsonrpc': '2.0', 'id': rpc_id, 'result': result} + def _failure_response(self, rpc_id: Any, envelope: dict[str, Any], now: str) -> dict[str, Any]: """Synthesise the sendTransaction response for a transaction that got stuck (failed). diff --git a/src/komet_node/transaction.py b/src/komet_node/transaction.py index a8a82c9..dfc30fe 100644 --- a/src/komet_node/transaction.py +++ b/src/komet_node/transaction.py @@ -23,6 +23,20 @@ _STROOPS_PER_XLM = Decimal('10000000') +def malformed_tx_result_xdr() -> str: + """Base64 ``TransactionResult`` with code ``txMALFORMED`` and no fee charged. + + Returned as ``errorResultXdr`` when ``sendTransaction`` rejects a transaction at + admission time (it decodes as XDR but cannot be processed by the semantics). + """ + result = xdr.TransactionResult( + fee_charged=xdr.Int64(0), + result=xdr.TransactionResultResult(code=xdr.TransactionResultCode.txMALFORMED), + ext=xdr.TransactionResultExt(0), + ) + return result.to_xdr() + + def _xlm_to_stroops(balance: object) -> int: """Convert an XLM amount (which may carry up to 7 decimals) to integer stroops.