Skip to content

Commit b5b0642

Browse files
Merge pull request #45 from runtimeverification/refactor/dependency-inversion
refactor: invert server dependencies onto interfaces (DIP)
2 parents de55339 + 05e3ef5 commit b5b0642

8 files changed

Lines changed: 236 additions & 98 deletions

File tree

src/komet_node/__main__.py

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

33
import argparse
4+
import tempfile
45
from pathlib import Path
56

67
from stellar_sdk import Network
78

9+
from komet_node.interpreter import NodeInterpreter
810
from komet_node.server import StellarRpcServer
11+
from komet_node.store import ChainStore
12+
from komet_node.transaction import TransactionEncoder
913

1014
_DESCRIPTION = 'Komet Node — a local Stellar testnet backed by the K semantics of Soroban.'
1115

@@ -35,14 +39,31 @@ def main() -> None:
3539
)
3640
args = parser.parse_args()
3741

38-
server = StellarRpcServer(
39-
host=args.host,
40-
port=args.port,
41-
io_dir=args.io_dir,
42-
network_passphrase=Network.TESTNET_NETWORK_PASSPHRASE,
43-
)
42+
server = build_server(io_dir=args.io_dir, host=args.host, port=args.port)
4443
server.serve()
4544

4645

46+
def build_server(
47+
*,
48+
io_dir: Path | None = None,
49+
network_passphrase: str = Network.TESTNET_NETWORK_PASSPHRASE,
50+
host: str = 'localhost',
51+
port: int = 8000,
52+
) -> StellarRpcServer:
53+
"""Composition root: build the concrete collaborators and wire up the server.
54+
55+
With no ``io_dir`` the chain runs against a fresh temporary directory — a throwaway chain
56+
that starts empty on every launch and leaves the working directory untouched.
57+
"""
58+
resolved_io_dir = Path(tempfile.mkdtemp(prefix='komet-node-')) if io_dir is None else io_dir
59+
return StellarRpcServer(
60+
interpreter=NodeInterpreter(),
61+
encoder=TransactionEncoder(network_passphrase),
62+
store=ChainStore(resolved_io_dir),
63+
host=host,
64+
port=port,
65+
)
66+
67+
4768
if __name__ == '__main__':
4869
main()

src/komet_node/interfaces.py

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
"""The abstractions the server depends on: the shared data shapes and the collaborator protocols.
2+
3+
:class:`~komet_node.server.StellarRpcServer` is written against these, not against the
4+
concrete `NodeInterpreter` / `TransactionEncoder` / `ChainStore`, so the concretes can be
5+
injected (and substituted in tests) — the dependency-inversion boundary. The concrete classes
6+
declare that they implement the protocols, and a composition root wires them together (see
7+
``build_server`` in ``__main__.py``).
8+
9+
Two kinds of thing live here so both the protocols and the concretes can share them without
10+
either importing the other:
11+
12+
- the request/response and disk data shapes (``TypedDict``);
13+
- the ``Interpreter`` / ``Encoder`` / ``Store`` protocols.
14+
"""
15+
16+
from __future__ import annotations
17+
18+
from typing import TYPE_CHECKING, Any, Protocol, TypedDict
19+
20+
if TYPE_CHECKING:
21+
from collections.abc import Callable, Mapping
22+
from pathlib import Path
23+
24+
from pyk.kast.inner import KInner
25+
26+
27+
# ----------------------------------------------------------------------
28+
# Data shapes (functional TypedDict form: the keys are JSON wire / disk
29+
# names, some camelCase, so they cannot be class attributes).
30+
# ----------------------------------------------------------------------
31+
32+
# The request envelopes consumed by node.md. `steps` is the JSON-encoded operation list; for a
33+
# wasm upload it is empty and the steps ride in the <program> cell instead. Key *order* also
34+
# matters to K's JSON matcher — which a TypedDict cannot express, only the fields and types.
35+
TxRequest = TypedDict(
36+
'TxRequest',
37+
{'method': str, 'id': Any, 'now': str, 'txHash': str, 'envelopeXdr': str, 'steps': list},
38+
)
39+
SimulateRequest = TypedDict(
40+
'SimulateRequest',
41+
{'method': str, 'id': Any, 'now': str, 'steps': list},
42+
)
43+
44+
# The internal simulateTransaction result K emits: always ``latestLedger``, then either
45+
# ``error`` (a failed simulation) or ``returnValue`` (a JSON SCVal). total=False — mutually
46+
# exclusive outcomes.
47+
SimulateResult = TypedDict(
48+
'SimulateResult',
49+
{'latestLedger': int, 'error': str, 'returnValue': Any},
50+
total=False,
51+
)
52+
53+
# The ``ledgers/ledger_<seq>.json`` record for one closed ledger; carries the header XDR
54+
# artifacts (hash/headerXdr/metadataXdr) that only Python can build.
55+
LedgerRecord = TypedDict(
56+
'LedgerRecord',
57+
{
58+
'sequence': int,
59+
'txHash': str,
60+
'closedAt': int,
61+
'hash': str,
62+
'headerXdr': str,
63+
'metadataXdr': str,
64+
},
65+
)
66+
67+
# One entry of an ``events/events_<ledger>.json`` array, in the getEvents Event shape.
68+
EventRecord = TypedDict(
69+
'EventRecord',
70+
{
71+
'type': str,
72+
'ledger': int,
73+
'ledgerClosedAt': str,
74+
'contractId': str,
75+
'id': str,
76+
'inSuccessfulContractCall': bool,
77+
'txHash': str,
78+
'topic': list[str],
79+
'value': str,
80+
},
81+
)
82+
83+
84+
# ----------------------------------------------------------------------
85+
# Collaborator protocols
86+
# ----------------------------------------------------------------------
87+
88+
89+
class Interpreter(Protocol):
90+
"""Runs RPC request envelopes through the K node semantics (see ``NodeInterpreter``)."""
91+
92+
def empty_config(self) -> str: ...
93+
94+
def run(
95+
self,
96+
state_file: Path,
97+
io_dir: Path,
98+
request: Mapping[str, Any],
99+
program_steps: list[KInner] | None = ...,
100+
*,
101+
commit: bool = ...,
102+
) -> str | None: ...
103+
104+
105+
class Encoder(Protocol):
106+
"""Decodes Stellar XDR transactions into node request envelopes (see ``TransactionEncoder``)."""
107+
108+
network_passphrase: str
109+
110+
def build_tx_request(
111+
self, method: str, rpc_id: Any, transaction_xdr: str, now: str
112+
) -> tuple[TxRequest, list[KInner] | None, dict[str, bytes]]: ...
113+
114+
def build_simulate_request(self, rpc_id: Any, transaction_xdr: str, now: str) -> SimulateRequest: ...
115+
116+
117+
class Store(Protocol):
118+
"""Reads and writes the files of a komet-node io-dir (see ``ChainStore``)."""
119+
120+
root: Path
121+
state_file: Path
122+
wasms_dir: Path
123+
124+
def initialize(self, empty_config: Callable[[], str]) -> bool: ...
125+
126+
def latest_ledger(self) -> int: ...
127+
128+
def has_receipt(self, tx_hash: str) -> bool: ...
129+
130+
def read_receipt(self, tx_hash: str) -> dict[str, Any]: ...
131+
132+
def write_receipt(self, tx_hash: str, receipt: dict[str, Any]) -> None: ...
133+
134+
def read_ledger(self, sequence: int) -> LedgerRecord | None: ...
135+
136+
def write_ledger(self, record: LedgerRecord) -> None: ...
137+
138+
def write_wasm(self, wasm_hash: str, wasm: bytes) -> None: ...
139+
140+
def read_staged_event_lines(self) -> list[str]: ...
141+
142+
def clear_staged_events(self) -> None: ...
143+
144+
def write_events(self, ledger: int, events: list[EventRecord]) -> None: ...
145+
146+
def archive_request(self, method: str | None, params: dict[str, Any], request_id: Any) -> None: ...

src/komet_node/interpreter.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
from pyk.utils import check_file_path, run_process_2
1515

1616
from .errors import NodeInterpreterError
17+
from .interfaces import Interpreter
1718
from .utils import simbolik_definition
1819

1920
if TYPE_CHECKING:
@@ -79,7 +80,7 @@ def _set_cell(pattern: Pattern, cell_symbol: str, value: Pattern) -> Pattern:
7980
return pattern
8081

8182

82-
class NodeInterpreter:
83+
class NodeInterpreter(Interpreter):
8384
"""
8485
Runs the K node semantics against a saved KORE world-state configuration.
8586

src/komet_node/server.py

Lines changed: 29 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -5,28 +5,27 @@
55
import logging
66
import re
77
import sys
8-
import tempfile
98
import time
109
import traceback
1110
from datetime import datetime, timezone
1211
from http.server import BaseHTTPRequestHandler, HTTPServer
13-
from pathlib import Path
14-
from typing import TYPE_CHECKING, Any, Final, TypedDict
12+
from typing import TYPE_CHECKING, Any, Final
1513

16-
from stellar_sdk import Network, StrKey, TransactionEnvelope, xdr
14+
from stellar_sdk import StrKey, TransactionEnvelope, xdr
1715

1816
from komet_node.errors import RpcError
19-
from komet_node.interpreter import NodeInterpreter
2017
from komet_node.ledger import build_ledger_artifacts
2118
from komet_node.ledger_entries import InvalidParamsError, format_ledger_entries_response, ledger_key_descriptors
2219
from komet_node.result_xdr import transaction_meta_xdr, transaction_result_xdr
2320
from komet_node.scval import scval_from_json
24-
from komet_node.store import ChainStore, EventRecord, LedgerRecord
25-
from komet_node.transaction import SimulationRejected, TransactionEncoder, malformed_tx_result_xdr
21+
from komet_node.transaction import SimulationRejected, malformed_tx_result_xdr
2622

2723
if TYPE_CHECKING:
2824
from collections.abc import Mapping
2925
from http.server import HTTPServer as HTTPServerType
26+
from pathlib import Path
27+
28+
from komet_node.interfaces import Encoder, EventRecord, Interpreter, LedgerRecord, SimulateResult, Store
3029

3130
_PROTOCOL_VERSION: Final = 22
3231

@@ -103,53 +102,50 @@ def _empty_transaction_data() -> str:
103102

104103
_log = logging.getLogger('komet_node')
105104

106-
# The internal simulateTransaction result K emits (node.md): always ``latestLedger``, then
107-
# either ``error`` (a failed simulation) or ``returnValue`` (a JSON SCVal for a successful
108-
# one). ``total=False`` — the two outcomes are mutually exclusive.
109-
SimulateResult = TypedDict(
110-
'SimulateResult',
111-
{'latestLedger': int, 'error': str, 'returnValue': Any},
112-
total=False,
113-
)
114-
115105

116106
class StellarRpcServer:
117107
"""
118108
Long-running HTTP/JSON-RPC server that wraps the one-shot K node semantics.
119109
120110
The compiled semantics run one request per process invocation and hold no state
121111
between runs, so this server supplies what they lack: it keeps the HTTP socket open,
122-
persists state to disk, and decodes the Stellar XDR envelope (:class:`TransactionEncoder`)
123-
that K cannot parse. It then runs the request envelope through the semantics
124-
(:class:`NodeInterpreter`). All RPC dispatch, receipt bookkeeping, ledger accounting,
125-
and response formatting are performed in K (``node.md``). The on-disk artifacts — input
126-
and output — belong to the :class:`~komet_node.store.ChainStore`, which owns the io-dir
127-
layout; this server holds one and asks it for receipts, ledgers, events, and the ledger
128-
counter rather than touching paths itself.
112+
persists state to disk, and decodes the Stellar XDR envelope (an injected
113+
:class:`~komet_node.interfaces.Encoder`) that K cannot parse. It then runs the request
114+
envelope through the semantics (an :class:`~komet_node.interfaces.Interpreter`). All RPC
115+
dispatch, receipt bookkeeping,
116+
ledger accounting, and response formatting are performed in K (``node.md``). The on-disk
117+
artifacts — input and output — belong to the injected :class:`~komet_node.interfaces.Store`,
118+
which owns the io-dir layout; this server holds one and asks it for receipts, ledgers,
119+
events, and the ledger counter rather than touching paths itself.
120+
121+
The three collaborators (interpreter, encoder, store) are injected rather than constructed
122+
here, so the server depends only on the protocols in :mod:`komet_node.interfaces` and a test
123+
can drive it with fakes. A composition root wires the concretes — see ``build_server`` in
124+
``__main__.py``.
129125
"""
130126

131-
interpreter: NodeInterpreter
132-
encoder: TransactionEncoder
133-
store: ChainStore
127+
interpreter: Interpreter
128+
encoder: Encoder
129+
store: Store
134130
io_dir: Path
135131
state_file: Path
136132

137133
def __init__(
138134
self,
135+
*,
136+
interpreter: Interpreter,
137+
encoder: Encoder,
138+
store: Store,
139139
host: str = 'localhost',
140140
port: int = 8000,
141-
io_dir: Path | None = None,
142-
network_passphrase: str = Network.TESTNET_NETWORK_PASSPHRASE,
143141
) -> None:
142+
self.interpreter = interpreter
143+
self.encoder = encoder
144+
self.store = store
144145
self.host = host
145146
self._port = port
146-
self.interpreter = NodeInterpreter()
147-
self.encoder = TransactionEncoder(network_passphrase)
148147
self._httpd: HTTPServerType | None = None
149148

150-
# With no io-dir given, run against a fresh temporary directory: a throwaway chain
151-
# that starts empty on every launch and leaves the working directory untouched.
152-
self.store = ChainStore(Path(tempfile.mkdtemp(prefix='komet-node-')) if io_dir is None else io_dir)
153149
self._fresh = self.store.initialize(self.interpreter.empty_config)
154150
# state_file and io_dir are the paths the interpreter subprocess resolves against; kept
155151
# as attributes so callers (and tests) can reach the io-dir root directly.

src/komet_node/store.py

Lines changed: 6 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -23,53 +23,24 @@
2323
from __future__ import annotations
2424

2525
import json
26-
from typing import TYPE_CHECKING, Any, TypedDict
26+
from typing import TYPE_CHECKING, Any
27+
28+
from komet_node.interfaces import Store
2729

2830
if TYPE_CHECKING:
2931
from collections.abc import Callable
3032
from pathlib import Path
3133

32-
33-
# The ``ledgers/ledger_<seq>.json`` record for one closed ledger. Carries the ledger-header
34-
# XDR artifacts (hash/headerXdr/metadataXdr) that only Python can build; the semantics read
35-
# them back to serve getTransactions/getLedgers. The functional TypedDict form is used because
36-
# the keys are the spec's camelCase JSON wire names, not Python identifiers.
37-
LedgerRecord = TypedDict(
38-
'LedgerRecord',
39-
{
40-
'sequence': int,
41-
'txHash': str,
42-
'closedAt': int,
43-
'hash': str,
44-
'headerXdr': str,
45-
'metadataXdr': str,
46-
},
47-
)
48-
49-
# One entry of an ``events/events_<ledger>.json`` array, in the getEvents Event shape.
50-
EventRecord = TypedDict(
51-
'EventRecord',
52-
{
53-
'type': str,
54-
'ledger': int,
55-
'ledgerClosedAt': str,
56-
'contractId': str,
57-
'id': str,
58-
'inSuccessfulContractCall': bool,
59-
'txHash': str,
60-
'topic': list[str],
61-
'value': str,
62-
},
63-
)
34+
from komet_node.interfaces import EventRecord, LedgerRecord
6435

6536

6637
# Where the K semantics stage the contract events of the currently executing transaction
6738
# (one JSON record per line, appended by the `contract_event` interception in node.md).
6839
_EVENTS_STAGING = 'events_staged.jsonl'
6940

7041

71-
class ChainStore:
72-
"""Reads and writes the files of a single komet-node io-dir."""
42+
class ChainStore(Store):
43+
"""Reads and writes the files of a single komet-node io-dir (the :class:`Store` concretion)."""
7344

7445
def __init__(self, io_dir: Path) -> None:
7546
self.root = io_dir.resolve()

0 commit comments

Comments
 (0)