Skip to content

Commit c1a2ebc

Browse files
Merge pull request #34 from runtimeverification/feat/get-version-info-fee-stats
feat: implement getVersionInfo and getFeeStats
2 parents e51e41c + ef236b8 commit c1a2ebc

7 files changed

Lines changed: 198 additions & 4 deletions

File tree

docs/architecture.md

Lines changed: 2 additions & 2 deletions
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 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.
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

@@ -177,6 +177,6 @@ sequenceDiagram
177177

178178
- `resultXdr` / `resultMetaXdr` in `getTransaction` responses (contract return values)
179179
- `simulateTransaction` (dry-run without state mutation)
180-
- `getEvents`, `getLedgerEntries`, `getFeeStats` and other read-only RPC methods
180+
- `getEvents`, `getLedgerEntries`, `getTransactions`, `getLedgers` and other read-only RPC methods
181181
- `ExtendFootprintTTL` and `RestoreFootprint` operations
182182
- `SCVec` / `SCMap` contract-argument types in the request encoder (`scval_to_json`)

docs/node-semantics.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,8 @@ insert-handleRequestFile → handleRequestFile
3939
4040
#dispatchMethod(method, request) ← routes on the "method" field
4141
42-
├─ getHealth / getNetwork / getLatestLedger / getTransaction / traceTransaction → #respond(...)
42+
├─ getHealth / getNetwork / getLatestLedger / getVersionInfo / getFeeStats
43+
│ / getTransaction / traceTransaction → #respond(...)
4344
4445
└─ sendTransaction → #runTx → run steps
4546
→ #finalizeTx → record receipt + bump ledger → #respond(...)
@@ -61,6 +62,8 @@ If `request.json` is absent, `insert-handleRequestFile` does not fire and K halt
6162
- `getHealth``{ "status": "healthy", "latestLedger": N, "latestLedgerCloseTime": <now>, "oldestLedger": 0, "oldestLedgerCloseTime": "0", "ledgerRetentionWindow": N+1 }` (everything since genesis is retained)
6263
- `getNetwork``{ "passphrase": ..., "protocolVersion": ... }` (passphrase/version come from the request, keeping the semantics network-agnostic; `friendbotUrl` is omitted — no friendbot)
6364
- `getLatestLedger` → reads `metadata.json` and returns `{ "id": #ledgerId(seq), "protocolVersion": ..., "sequence": <latest_ledger> }`; `#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)
65+
- `getVersionInfo` → echoes the version fields the server put into the envelope (`version`, `commitHash`, `buildTimestamp`, `captiveCoreVersion` — package metadata lives on the Python side); `protocolVersion` is a JSON number
66+
- `getFeeStats` → constant `#feeDistribution` objects (komet-node has no fee market) for `sorobanInclusionFee`/`inclusionFee`, plus a live `latestLedger` number from `metadata.json`; all distribution fields except `ledgerCount` are decimal strings, matching real stellar-rpc's Go `,string` encoding
6467
- `getTransaction` → reads the hash's `receipts/receipt_<hash>.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
6568

6669
`#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).

docs/notes.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,5 +37,6 @@ The tests do not yet cover `bytes` / `address` SCVal arguments or `SCVec` / `SCM
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.
3939
- The `xdrFormat` parameter is accepted on `getTransaction` and `sendTransaction`, but only the default `'base64'` is supported; `'json'` is rejected with `-32602`.
40-
- `simulateTransaction`, `getEvents`, `getLedgerEntries`, `getFeeStats`, and TTL/footprint operations are not implemented.
40+
- `simulateTransaction`, `getEvents`, `getLedgerEntries`, `getTransactions`, `getLedgers`, and TTL/footprint operations are not implemented.
41+
- `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: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,22 @@ The node retains every ledger since genesis, so `oldestLedger` is always 0 and t
123123
{ "id": "1715609f7c74...", "protocolVersion": 22, "sequence": 4 }
124124
```
125125

126+
### `getVersionInfo`
127+
128+
`getVersionInfo` reports the komet-node package version as the RPC server version and the komet package (the K semantics of Soroban executing the transactions) as the "Captive Core". komet-node is a Python package, so no commit hash or build timestamp is baked in at build time — those fields are all-zeros / epoch placeholders with the spec-correct types. `protocolVersion` is a JSON number.
129+
130+
```json
131+
{ "version": "0.1.0", "commitHash": "0000...0000", "buildTimestamp": "1970-01-01T00:00:00", "captiveCoreVersion": "komet 0.1.79 (K semantics of Soroban)", "protocolVersion": 22 }
132+
```
133+
134+
### `getFeeStats`
135+
136+
`getFeeStats` returns constant fee distributions: komet-node has no fee market, so every statistic is the network minimum inclusion fee of 100 stroops over an empty sample (`transactionCount: "0"`, `ledgerCount: 0`). Matching real stellar-rpc, every distribution field except `ledgerCount` is a JSON string holding a decimal number; `latestLedger` is a JSON number read live from `metadata.json`.
137+
138+
```json
139+
{ "sorobanInclusionFee": { "max": "100", "min": "100", "mode": "100", "p10": "100", ..., "p99": "100", "transactionCount": "0", "ledgerCount": 0 }, "inclusionFee": { ... }, "latestLedger": 4 }
140+
```
141+
126142
### `sendTransaction`
127143

128144
`sendTransaction` submits a base64-encoded XDR transaction envelope.

src/komet_node/kdist/node.md

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -254,6 +254,62 @@ module) and the oldest by the epoch (`"0"`).
254254
</k>
255255
```
256256

257+
`getVersionInfo` echoes the version strings the Python server put into the request envelope
258+
(the package versions live in Python's package metadata, which K cannot read). Its
259+
`protocolVersion` is a JSON number, per the spec and real stellar-rpc.
260+
261+
```k
262+
rule <k> #dispatchMethod( "getVersionInfo", REQ )
263+
=> #respond( #getJSON( "id", REQ ), {
264+
"version" : #getString( "version", REQ ),
265+
"commitHash" : #getString( "commitHash", REQ ),
266+
"buildTimestamp" : #getString( "buildTimestamp", REQ ),
267+
"captiveCoreVersion" : #getString( "captiveCoreVersion", REQ ),
268+
"protocolVersion" : #getInt( "protocolVersion", REQ )
269+
})
270+
...
271+
</k>
272+
```
273+
274+
`getFeeStats` reports a constant fee distribution: komet-node has no fee market (transactions
275+
execute immediately and pay no fees), so every statistic is the network minimum inclusion fee
276+
of 100 stroops over an empty sample. Real stellar-rpc serialises every distribution field
277+
except `ledgerCount` with Go's `,string` option, so those are JSON strings holding decimal
278+
numbers, while `ledgerCount` and `latestLedger` are JSON numbers. `latestLedger` is read live
279+
from `metadata.json`.
280+
281+
```k
282+
rule <k> #dispatchMethod( "getFeeStats", REQ )
283+
=> #respond( #getJSON( "id", REQ ), {
284+
"sorobanInclusionFee" : #feeDistribution,
285+
"inclusionFee" : #feeDistribution,
286+
"latestLedger" : #getInt( "latest_ledger", String2JSON( {#readFile("metadata.json")}:>String ) )
287+
})
288+
...
289+
</k>
290+
291+
syntax JSON ::= "#feeDistribution" [function, symbol(feeDistribution)]
292+
// ----------------------------------------------------------------------
293+
rule #feeDistribution => {
294+
"max" : "100",
295+
"min" : "100",
296+
"mode" : "100",
297+
"p10" : "100",
298+
"p20" : "100",
299+
"p30" : "100",
300+
"p40" : "100",
301+
"p50" : "100",
302+
"p60" : "100",
303+
"p70" : "100",
304+
"p80" : "100",
305+
"p90" : "100",
306+
"p95" : "100",
307+
"p99" : "100",
308+
"transactionCount" : "0",
309+
"ledgerCount" : 0
310+
}
311+
```
312+
257313
`#ledgerId` derives the ledger's `id` (the hash of the ledger header on a real chain) from
258314
its sequence number. This node has no ledger headers to hash — and the semantics have no
259315
hash hooks — so the id is a deterministic stand-in: the sequence (offset by one so ledger 0

src/komet_node/server.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from __future__ import annotations
22

3+
import importlib.metadata
34
import json
45
import logging
56
import re
@@ -25,6 +26,15 @@
2526
# ^[a-f\d]{64}$). Anything else is rejected as Invalid params before reaching K.
2627
_TX_HASH_RE: Final = re.compile(r'[0-9a-f]{64}')
2728

29+
# getVersionInfo fields. komet-node is a Python package, not a Go binary, so there is no
30+
# commit hash or build timestamp baked in at compile time; report all-zeros / epoch
31+
# placeholders with the correct spec types instead. The "Captive Core" of komet-node is the
32+
# komet package (the K semantics of Soroban that execute the transactions).
33+
_VERSION: Final = importlib.metadata.version('komet-node')
34+
_COMMIT_HASH: Final = '0' * 40
35+
_BUILD_TIMESTAMP: Final = '1970-01-01T00:00:00'
36+
_CAPTIVE_CORE_VERSION: Final = f'komet {importlib.metadata.version("komet")} (K semantics of Soroban)'
37+
2838
# Only sendTransaction executes a transaction. traceTransaction is a read-only lookup of the
2939
# trace stored on a previously executed transaction's receipt (see _read_only_envelope).
3040
_TX_METHODS: Final = ('sendTransaction',)
@@ -288,6 +298,19 @@ def _read_only_envelope(
288298
return {**base, 'passphrase': self.encoder.network_passphrase, 'protocolVersion': _PROTOCOL_VERSION}
289299
if method == 'getLatestLedger':
290300
return {**base, 'protocolVersion': _PROTOCOL_VERSION}
301+
if method == 'getVersionInfo':
302+
# protocolVersion is a JSON number here (a Go uint32 in stellar-rpc), unlike the
303+
# string the older methods still emit.
304+
return {
305+
**base,
306+
'version': _VERSION,
307+
'commitHash': _COMMIT_HASH,
308+
'buildTimestamp': _BUILD_TIMESTAMP,
309+
'captiveCoreVersion': _CAPTIVE_CORE_VERSION,
310+
'protocolVersion': int(_PROTOCOL_VERSION),
311+
}
312+
if method == 'getFeeStats':
313+
return base
291314
if method in ('getTransaction', 'traceTransaction'):
292315
tx_hash = params.get('hash')
293316
if not isinstance(tx_hash, str):

src/tests/integration/test_server.py

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from __future__ import annotations
22

3+
import importlib.metadata
34
import json
45
import re
56
import shutil
@@ -729,6 +730,100 @@ def builder() -> TransactionBuilder:
729730
assert _rpc(server.port(), 'getLatestLedger', {})['result']['sequence'] == 4
730731

731732

733+
def test_get_version_info(server: StellarRpcServer) -> None:
734+
"""getVersionInfo returns exactly the five spec fields with the right JSON types.
735+
736+
Real stellar-rpc (protocol 22+) emits camelCase keys only; the deprecated snake_case
737+
aliases (``commit_hash``, ...) were removed, so an exact key-set check covers both the
738+
required fields and the absence of the legacy ones. ``protocolVersion`` is a Go uint32,
739+
i.e. a JSON number, not a string.
740+
"""
741+
resp = _rpc(server.port(), 'getVersionInfo', {})
742+
assert 'error' not in resp, resp
743+
result = resp['result']
744+
745+
assert set(result) == {'version', 'commitHash', 'buildTimestamp', 'captiveCoreVersion', 'protocolVersion'}
746+
# komet-node reports its own package version as the RPC server version.
747+
assert result['version'] == importlib.metadata.version('komet-node')
748+
assert type(result['commitHash']) is str
749+
assert type(result['buildTimestamp']) is str
750+
assert type(result['captiveCoreVersion']) is str
751+
assert type(result['protocolVersion']) is int # `is int` also rejects booleans
752+
assert result['protocolVersion'] == 22
753+
754+
755+
def test_get_version_info_accepts_omitted_params(server: StellarRpcServer) -> None:
756+
"""getVersionInfo takes no parameters; a request without a params member must succeed."""
757+
resp = _post(server.port(), b'{"jsonrpc": "2.0", "id": 1, "method": "getVersionInfo"}')
758+
assert 'error' not in resp, resp
759+
assert resp['result']['protocolVersion'] == 22
760+
761+
762+
# Every FeeDistribution field except ledgerCount is an unsigned integer serialised with Go's
763+
# `,string` option, i.e. a JSON string holding a decimal number (see the getFeeStats spec
764+
# example: `"transactionCount": "10"` but `"ledgerCount": 50`).
765+
_FEE_DISTRIBUTION_STRING_FIELDS = (
766+
'max',
767+
'min',
768+
'mode',
769+
'p10',
770+
'p20',
771+
'p30',
772+
'p40',
773+
'p50',
774+
'p60',
775+
'p70',
776+
'p80',
777+
'p90',
778+
'p95',
779+
'p99',
780+
'transactionCount',
781+
)
782+
783+
784+
def _assert_fee_distribution(dist: dict[str, Any]) -> None:
785+
"""Check one FeeDistribution object against the stellar-rpc wire format."""
786+
assert set(dist) == {*_FEE_DISTRIBUTION_STRING_FIELDS, 'ledgerCount'}
787+
for field in _FEE_DISTRIBUTION_STRING_FIELDS:
788+
value = dist[field]
789+
assert type(value) is str, f'{field} must be a JSON string, got {type(value).__name__}'
790+
assert value.isdigit(), f'{field} must hold a decimal number, got {value!r}'
791+
assert type(dist['ledgerCount']) is int, 'ledgerCount must be a JSON number'
792+
# The distribution must at least be internally consistent.
793+
assert int(dist['min']) <= int(dist['p50']) <= int(dist['max'])
794+
795+
796+
def test_get_fee_stats(server: StellarRpcServer) -> None:
797+
"""getFeeStats returns both fee distributions and latestLedger with the right JSON types."""
798+
resp = _rpc(server.port(), 'getFeeStats', {})
799+
assert 'error' not in resp, resp
800+
result = resp['result']
801+
802+
assert set(result) == {'sorobanInclusionFee', 'inclusionFee', 'latestLedger'}
803+
_assert_fee_distribution(result['sorobanInclusionFee'])
804+
_assert_fee_distribution(result['inclusionFee'])
805+
assert type(result['latestLedger']) is int # a JSON number, and 0 on a fresh chain
806+
assert result['latestLedger'] == 0
807+
808+
809+
def test_get_fee_stats_latest_ledger_tracks_chain(server: StellarRpcServer) -> None:
810+
"""getFeeStats reports the live ledger sequence, not a constant."""
811+
keypair = Keypair.random()
812+
account = Account(keypair.public_key, sequence=0)
813+
envelope = (
814+
TransactionBuilder(account, Network.TESTNET_NETWORK_PASSPHRASE)
815+
.append_create_account_op(destination=keypair.public_key, starting_balance='1000')
816+
.set_timeout(30)
817+
.build()
818+
)
819+
envelope.sign(keypair)
820+
assert _rpc(server.port(), 'sendTransaction', {'transaction': envelope.to_xdr()})['result']['status'] == 'PENDING'
821+
822+
resp = _rpc(server.port(), 'getFeeStats', {})
823+
assert 'error' not in resp, resp
824+
assert resp['result']['latestLedger'] == 1
825+
826+
732827
# ----------------------------------------------------------------------
733828
# xdrFormat parameter (getTransaction / sendTransaction)
734829
#

0 commit comments

Comments
 (0)