From 65af74870437e06a713120fb76d4934ce0d7b958 Mon Sep 17 00:00:00 2001 From: Raoul Date: Thu, 23 Jul 2026 10:06:14 +0000 Subject: [PATCH] refactor(di): invert server dependencies onto interfaces + composition root MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit StellarRpcServer hard-constructed NodeInterpreter, TransactionEncoder, and ChainStore in __init__, so there was no seam to substitute fakes — the server's dispatch/validation/response logic could not be exercised without the compiled K interpreter. - interfaces.py: a new abstractions module holding the shared data shapes (TxRequest, SimulateRequest, SimulateResult, LedgerRecord, EventRecord, moved out of transaction.py/store.py) and the Interpreter / Encoder / Store protocols. - StellarRpcServer.__init__ now takes interpreter/encoder/store injected and typed against those protocols; server.py imports none of the concretes. The three collaborators declare conformance (NodeInterpreter(Interpreter), etc.), so mypy catches drift between a concrete and its protocol. - Composition roots wire the concretes: build_server() in __main__ (production, owns the default temp io-dir), and the conftest server fixture (tests). The server's own network_passphrase/io_dir params are gone — those belong to the encoder and store the root builds. Tests: the two sites reaching through server.encoder for a helper now use a shared conftest.contract_address_from_deployer; the default-io-dir test drives build_server. make check clean, 90 tests pass. --- src/komet_node/__main__.py | 33 ++++-- src/komet_node/interfaces.py | 146 +++++++++++++++++++++++++++ src/komet_node/interpreter.py | 3 +- src/komet_node/server.py | 62 ++++++------ src/komet_node/store.py | 41 ++------ src/komet_node/transaction.py | 21 +--- src/tests/integration/conftest.py | 17 +++- src/tests/integration/test_server.py | 11 +- 8 files changed, 236 insertions(+), 98 deletions(-) create mode 100644 src/komet_node/interfaces.py diff --git a/src/komet_node/__main__.py b/src/komet_node/__main__.py index d95d009..0770c6a 100644 --- a/src/komet_node/__main__.py +++ b/src/komet_node/__main__.py @@ -1,11 +1,15 @@ from __future__ import annotations import argparse +import tempfile from pathlib import Path from stellar_sdk import Network +from komet_node.interpreter import NodeInterpreter from komet_node.server import StellarRpcServer +from komet_node.store import ChainStore +from komet_node.transaction import TransactionEncoder _DESCRIPTION = 'Komet Node — a local Stellar testnet backed by the K semantics of Soroban.' @@ -35,14 +39,31 @@ def main() -> None: ) args = parser.parse_args() - server = StellarRpcServer( - host=args.host, - port=args.port, - io_dir=args.io_dir, - network_passphrase=Network.TESTNET_NETWORK_PASSPHRASE, - ) + server = build_server(io_dir=args.io_dir, host=args.host, port=args.port) server.serve() +def build_server( + *, + io_dir: Path | None = None, + network_passphrase: str = Network.TESTNET_NETWORK_PASSPHRASE, + host: str = 'localhost', + port: int = 8000, +) -> StellarRpcServer: + """Composition root: build the concrete collaborators and wire up the server. + + With no ``io_dir`` the chain runs against a fresh temporary directory — a throwaway chain + that starts empty on every launch and leaves the working directory untouched. + """ + resolved_io_dir = Path(tempfile.mkdtemp(prefix='komet-node-')) if io_dir is None else io_dir + return StellarRpcServer( + interpreter=NodeInterpreter(), + encoder=TransactionEncoder(network_passphrase), + store=ChainStore(resolved_io_dir), + host=host, + port=port, + ) + + if __name__ == '__main__': main() diff --git a/src/komet_node/interfaces.py b/src/komet_node/interfaces.py new file mode 100644 index 0000000..56dc3af --- /dev/null +++ b/src/komet_node/interfaces.py @@ -0,0 +1,146 @@ +"""The abstractions the server depends on: the shared data shapes and the collaborator protocols. + +:class:`~komet_node.server.StellarRpcServer` is written against these, not against the +concrete `NodeInterpreter` / `TransactionEncoder` / `ChainStore`, so the concretes can be +injected (and substituted in tests) — the dependency-inversion boundary. The concrete classes +declare that they implement the protocols, and a composition root wires them together (see +``build_server`` in ``__main__.py``). + +Two kinds of thing live here so both the protocols and the concretes can share them without +either importing the other: + +- the request/response and disk data shapes (``TypedDict``); +- the ``Interpreter`` / ``Encoder`` / ``Store`` protocols. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, Protocol, TypedDict + +if TYPE_CHECKING: + from collections.abc import Callable, Mapping + from pathlib import Path + + from pyk.kast.inner import KInner + + +# ---------------------------------------------------------------------- +# Data shapes (functional TypedDict form: the keys are JSON wire / disk +# names, some camelCase, so they cannot be class attributes). +# ---------------------------------------------------------------------- + +# The request envelopes consumed by node.md. `steps` is the JSON-encoded operation list; for a +# wasm upload it is empty and the steps ride in the cell instead. Key *order* also +# matters to K's JSON matcher — which a TypedDict cannot express, only the fields and types. +TxRequest = TypedDict( + 'TxRequest', + {'method': str, 'id': Any, 'now': str, 'txHash': str, 'envelopeXdr': str, 'steps': list}, +) +SimulateRequest = TypedDict( + 'SimulateRequest', + {'method': str, 'id': Any, 'now': str, 'steps': list}, +) + +# The internal simulateTransaction result K emits: always ``latestLedger``, then either +# ``error`` (a failed simulation) or ``returnValue`` (a JSON SCVal). total=False — mutually +# exclusive outcomes. +SimulateResult = TypedDict( + 'SimulateResult', + {'latestLedger': int, 'error': str, 'returnValue': Any}, + total=False, +) + +# The ``ledgers/ledger_.json`` record for one closed ledger; carries the header XDR +# artifacts (hash/headerXdr/metadataXdr) that only Python can build. +LedgerRecord = TypedDict( + 'LedgerRecord', + { + 'sequence': int, + 'txHash': str, + 'closedAt': int, + 'hash': str, + 'headerXdr': str, + 'metadataXdr': str, + }, +) + +# One entry of an ``events/events_.json`` array, in the getEvents Event shape. +EventRecord = TypedDict( + 'EventRecord', + { + 'type': str, + 'ledger': int, + 'ledgerClosedAt': str, + 'contractId': str, + 'id': str, + 'inSuccessfulContractCall': bool, + 'txHash': str, + 'topic': list[str], + 'value': str, + }, +) + + +# ---------------------------------------------------------------------- +# Collaborator protocols +# ---------------------------------------------------------------------- + + +class Interpreter(Protocol): + """Runs RPC request envelopes through the K node semantics (see ``NodeInterpreter``).""" + + def empty_config(self) -> str: ... + + def run( + self, + state_file: Path, + io_dir: Path, + request: Mapping[str, Any], + program_steps: list[KInner] | None = ..., + *, + commit: bool = ..., + ) -> str | None: ... + + +class Encoder(Protocol): + """Decodes Stellar XDR transactions into node request envelopes (see ``TransactionEncoder``).""" + + network_passphrase: str + + def build_tx_request( + self, method: str, rpc_id: Any, transaction_xdr: str, now: str + ) -> tuple[TxRequest, list[KInner] | None, dict[str, bytes]]: ... + + def build_simulate_request(self, rpc_id: Any, transaction_xdr: str, now: str) -> SimulateRequest: ... + + +class Store(Protocol): + """Reads and writes the files of a komet-node io-dir (see ``ChainStore``).""" + + root: Path + state_file: Path + wasms_dir: Path + + def initialize(self, empty_config: Callable[[], str]) -> bool: ... + + def latest_ledger(self) -> int: ... + + def has_receipt(self, tx_hash: str) -> bool: ... + + def read_receipt(self, tx_hash: str) -> dict[str, Any]: ... + + def write_receipt(self, tx_hash: str, receipt: dict[str, Any]) -> None: ... + + def read_ledger(self, sequence: int) -> LedgerRecord | None: ... + + def write_ledger(self, record: LedgerRecord) -> None: ... + + def write_wasm(self, wasm_hash: str, wasm: bytes) -> None: ... + + def read_staged_event_lines(self) -> list[str]: ... + + def clear_staged_events(self) -> None: ... + + def write_events(self, ledger: int, events: list[EventRecord]) -> None: ... + + def archive_request(self, method: str | None, params: dict[str, Any], request_id: Any) -> None: ... diff --git a/src/komet_node/interpreter.py b/src/komet_node/interpreter.py index e9fb284..05c9767 100644 --- a/src/komet_node/interpreter.py +++ b/src/komet_node/interpreter.py @@ -14,6 +14,7 @@ from pyk.utils import check_file_path, run_process_2 from .errors import NodeInterpreterError +from .interfaces import Interpreter from .utils import simbolik_definition if TYPE_CHECKING: @@ -79,7 +80,7 @@ def _set_cell(pattern: Pattern, cell_symbol: str, value: Pattern) -> Pattern: return pattern -class NodeInterpreter: +class NodeInterpreter(Interpreter): """ Runs the K node semantics against a saved KORE world-state configuration. diff --git a/src/komet_node/server.py b/src/komet_node/server.py index 5cdaba0..d5f27b4 100644 --- a/src/komet_node/server.py +++ b/src/komet_node/server.py @@ -5,28 +5,27 @@ import logging import re import sys -import tempfile import time import traceback from datetime import datetime, timezone from http.server import BaseHTTPRequestHandler, HTTPServer -from pathlib import Path -from typing import TYPE_CHECKING, Any, Final, TypedDict +from typing import TYPE_CHECKING, Any, Final -from stellar_sdk import Network, StrKey, TransactionEnvelope, xdr +from stellar_sdk import StrKey, TransactionEnvelope, xdr from komet_node.errors import RpcError -from komet_node.interpreter import NodeInterpreter from komet_node.ledger import build_ledger_artifacts from komet_node.ledger_entries import InvalidParamsError, format_ledger_entries_response, ledger_key_descriptors from komet_node.result_xdr import transaction_meta_xdr, transaction_result_xdr from komet_node.scval import scval_from_json -from komet_node.store import ChainStore, EventRecord, LedgerRecord -from komet_node.transaction import SimulationRejected, TransactionEncoder, malformed_tx_result_xdr +from komet_node.transaction import SimulationRejected, malformed_tx_result_xdr if TYPE_CHECKING: from collections.abc import Mapping from http.server import HTTPServer as HTTPServerType + from pathlib import Path + + from komet_node.interfaces import Encoder, EventRecord, Interpreter, LedgerRecord, SimulateResult, Store _PROTOCOL_VERSION: Final = 22 @@ -103,15 +102,6 @@ def _empty_transaction_data() -> str: _log = logging.getLogger('komet_node') -# The internal simulateTransaction result K emits (node.md): always ``latestLedger``, then -# either ``error`` (a failed simulation) or ``returnValue`` (a JSON SCVal for a successful -# one). ``total=False`` — the two outcomes are mutually exclusive. -SimulateResult = TypedDict( - 'SimulateResult', - {'latestLedger': int, 'error': str, 'returnValue': Any}, - total=False, -) - class StellarRpcServer: """ @@ -119,37 +109,43 @@ class StellarRpcServer: The compiled semantics run one request per process invocation and hold no state between runs, so this server supplies what they lack: it keeps the HTTP socket open, - persists state to disk, and decodes the Stellar XDR envelope (:class:`TransactionEncoder`) - that K cannot parse. It then runs the request envelope through the semantics - (:class:`NodeInterpreter`). All RPC dispatch, receipt bookkeeping, ledger accounting, - and response formatting are performed in K (``node.md``). The on-disk artifacts — input - and output — belong to the :class:`~komet_node.store.ChainStore`, which owns the io-dir - layout; this server holds one and asks it for receipts, ledgers, events, and the ledger - counter rather than touching paths itself. + persists state to disk, and decodes the Stellar XDR envelope (an injected + :class:`~komet_node.interfaces.Encoder`) that K cannot parse. It then runs the request + envelope through the semantics (an :class:`~komet_node.interfaces.Interpreter`). All RPC + dispatch, receipt bookkeeping, + ledger accounting, and response formatting are performed in K (``node.md``). The on-disk + artifacts — input and output — belong to the injected :class:`~komet_node.interfaces.Store`, + which owns the io-dir layout; this server holds one and asks it for receipts, ledgers, + events, and the ledger counter rather than touching paths itself. + + The three collaborators (interpreter, encoder, store) are injected rather than constructed + here, so the server depends only on the protocols in :mod:`komet_node.interfaces` and a test + can drive it with fakes. A composition root wires the concretes — see ``build_server`` in + ``__main__.py``. """ - interpreter: NodeInterpreter - encoder: TransactionEncoder - store: ChainStore + interpreter: Interpreter + encoder: Encoder + store: Store io_dir: Path state_file: Path def __init__( self, + *, + interpreter: Interpreter, + encoder: Encoder, + store: Store, host: str = 'localhost', port: int = 8000, - io_dir: Path | None = None, - network_passphrase: str = Network.TESTNET_NETWORK_PASSPHRASE, ) -> None: + self.interpreter = interpreter + self.encoder = encoder + self.store = store self.host = host self._port = port - self.interpreter = NodeInterpreter() - self.encoder = TransactionEncoder(network_passphrase) self._httpd: HTTPServerType | None = None - # With no io-dir given, run against a fresh temporary directory: a throwaway chain - # that starts empty on every launch and leaves the working directory untouched. - self.store = ChainStore(Path(tempfile.mkdtemp(prefix='komet-node-')) if io_dir is None else io_dir) self._fresh = self.store.initialize(self.interpreter.empty_config) # state_file and io_dir are the paths the interpreter subprocess resolves against; kept # as attributes so callers (and tests) can reach the io-dir root directly. diff --git a/src/komet_node/store.py b/src/komet_node/store.py index 62b5bb1..7f86ae8 100644 --- a/src/komet_node/store.py +++ b/src/komet_node/store.py @@ -23,44 +23,15 @@ from __future__ import annotations import json -from typing import TYPE_CHECKING, Any, TypedDict +from typing import TYPE_CHECKING, Any + +from komet_node.interfaces import Store if TYPE_CHECKING: from collections.abc import Callable from pathlib import Path - -# The ``ledgers/ledger_.json`` record for one closed ledger. Carries the ledger-header -# XDR artifacts (hash/headerXdr/metadataXdr) that only Python can build; the semantics read -# them back to serve getTransactions/getLedgers. The functional TypedDict form is used because -# the keys are the spec's camelCase JSON wire names, not Python identifiers. -LedgerRecord = TypedDict( - 'LedgerRecord', - { - 'sequence': int, - 'txHash': str, - 'closedAt': int, - 'hash': str, - 'headerXdr': str, - 'metadataXdr': str, - }, -) - -# One entry of an ``events/events_.json`` array, in the getEvents Event shape. -EventRecord = TypedDict( - 'EventRecord', - { - 'type': str, - 'ledger': int, - 'ledgerClosedAt': str, - 'contractId': str, - 'id': str, - 'inSuccessfulContractCall': bool, - 'txHash': str, - 'topic': list[str], - 'value': str, - }, -) + from komet_node.interfaces import EventRecord, LedgerRecord # Where the K semantics stage the contract events of the currently executing transaction @@ -68,8 +39,8 @@ _EVENTS_STAGING = 'events_staged.jsonl' -class ChainStore: - """Reads and writes the files of a single komet-node io-dir.""" +class ChainStore(Store): + """Reads and writes the files of a single komet-node io-dir (the :class:`Store` concretion).""" def __init__(self, io_dir: Path) -> None: self.root = io_dir.resolve() diff --git a/src/komet_node/transaction.py b/src/komet_node/transaction.py index 658e2c2..f59e545 100644 --- a/src/komet_node/transaction.py +++ b/src/komet_node/transaction.py @@ -3,7 +3,7 @@ import hashlib from decimal import Decimal from io import BytesIO -from typing import TYPE_CHECKING, Any, TypedDict +from typing import TYPE_CHECKING, Any from komet.kast.syntax import upload_wasm from pykwasm.wasm2kast import wasm2kast @@ -11,6 +11,7 @@ from stellar_sdk.operation import CreateAccount, InvokeHostFunction from stellar_sdk.utils import sha256 +from komet_node.interfaces import Encoder from komet_node.scval import scval_to_json from .errors import TransactionEncodingError @@ -20,21 +21,9 @@ from stellar_sdk import MuxedAccount, Transaction from stellar_sdk.operation import Operation -_STROOPS_PER_XLM = Decimal('10000000') + from komet_node.interfaces import SimulateRequest, TxRequest -# The request envelopes consumed by node.md. The functional TypedDict form is used because the -# keys are the JSON wire names (some camelCase). `steps` is the JSON-encoded operation list; -# for a wasm upload it is empty and the steps ride in the cell instead (see -# build_tx_request). Key *order* also matters to K's JSON matcher — see _encode_operation — -# which a TypedDict cannot express, only the field names and types. -TxRequest = TypedDict( - 'TxRequest', - {'method': str, 'id': Any, 'now': str, 'txHash': str, 'envelopeXdr': str, 'steps': list}, -) -SimulateRequest = TypedDict( - 'SimulateRequest', - {'method': str, 'id': Any, 'now': str, 'steps': list}, -) +_STROOPS_PER_XLM = Decimal('10000000') def malformed_tx_result_xdr() -> str: @@ -75,7 +64,7 @@ class SimulationRejected(Exception): """ -class TransactionEncoder: +class TransactionEncoder(Encoder): """ Decodes Stellar XDR transactions into the request envelope consumed by ``node.md``. diff --git a/src/tests/integration/conftest.py b/src/tests/integration/conftest.py index ab958c3..88dcc61 100644 --- a/src/tests/integration/conftest.py +++ b/src/tests/integration/conftest.py @@ -22,7 +22,10 @@ from stellar_sdk import Account, Keypair, Network, TransactionBuilder from stellar_sdk.utils import sha256 +from komet_node.interpreter import NodeInterpreter from komet_node.server import StellarRpcServer +from komet_node.store import ChainStore +from komet_node.transaction import TransactionEncoder if TYPE_CHECKING: from collections.abc import Callable, Iterator @@ -115,11 +118,14 @@ def _is_hex64(value: Any) -> bool: @pytest.fixture def server(tmp_path: Path) -> Iterator[StellarRpcServer]: port = _find_free_port() + # The test composition root: wire the concrete collaborators explicitly (the production + # one lives in komet_node.__main__.build_server). srv = StellarRpcServer( + interpreter=NodeInterpreter(), + encoder=TransactionEncoder(PASSPHRASE), + store=ChainStore(tmp_path), host='localhost', port=port, - io_dir=tmp_path, - network_passphrase=PASSPHRASE, ) thread = threading.Thread(target=srv.serve, daemon=True) thread.start() @@ -146,6 +152,11 @@ class Deployed(NamedTuple): wasm_bytecode: bytes +def contract_address_from_deployer(public_key: str, salt: bytes) -> str: + """The deterministic C-address CREATE_CONTRACT assigns for a (deployer account, salt) pair.""" + return TransactionEncoder(PASSPHRASE).contract_address_from_deployer_address(public_key, salt) + + def send_tx(server: StellarRpcServer, keypair: Keypair, tb: TransactionBuilder) -> tuple[str, dict[str, Any]]: """Sign, submit, and confirm a transaction. @@ -186,7 +197,7 @@ def builder() -> TransactionBuilder: wasm_hash = sha256(wasm_bytecode) salt = b'\x00' * 32 send_tx(server, keypair, builder().append_create_contract_op(wasm_hash, keypair.public_key, None, salt)) - address = server.encoder.contract_address_from_deployer_address(keypair.public_key, salt) + address = contract_address_from_deployer(keypair.public_key, salt) return Deployed(address=address, wasm_hash=wasm_hash, wasm_bytecode=wasm_bytecode) diff --git a/src/tests/integration/test_server.py b/src/tests/integration/test_server.py index 5bdc569..bced437 100644 --- a/src/tests/integration/test_server.py +++ b/src/tests/integration/test_server.py @@ -11,8 +11,8 @@ from stellar_sdk.utils import sha256 from stellar_sdk.xdr.sc_val_type import SCValType +from komet_node.__main__ import build_server from komet_node.scval import scval_from_json -from komet_node.server import StellarRpcServer from .conftest import ( PASSPHRASE, @@ -22,6 +22,7 @@ _post, _post_raw, _rpc, + contract_address_from_deployer, deploy_and_get_invoker, deploy_contract, fund_account, @@ -33,6 +34,8 @@ if TYPE_CHECKING: from stellar_sdk import TransactionEnvelope + from komet_node.server import StellarRpcServer + EMPTY_CONTRACT_WAT = (Path(__file__).parent / 'data' / 'wasm' / 'empty.wat').resolve(strict=True) ARGS_CONTRACT_WAT = (Path(__file__).parent / 'data' / 'wasm' / 'args.wat').resolve(strict=True) ADDER_CONTRACT_WAT = (Path(__file__).parent / 'data' / 'wasm' / 'adder.wat').resolve(strict=True) @@ -82,8 +85,8 @@ def _create_account_xdr(keypair: Keypair, account: Account) -> str: def test_default_io_dir_is_a_fresh_temp_dir() -> None: - """With no io_dir, the server provisions a fresh temporary directory and seeds it.""" - srv = StellarRpcServer(host='localhost', port=0) + """With no io_dir, the composition root provisions a fresh temporary directory and seeds it.""" + srv = build_server(port=0) try: assert srv.io_dir.exists() assert srv.io_dir.resolve() != Path.cwd() @@ -1577,7 +1580,7 @@ def send(tb: TransactionBuilder) -> dict[str, Any]: salt = b'\x00' * 32 send(builder().append_create_contract_op(wasm_hash, keypair.public_key, None, salt)) - contract_address = server.encoder.contract_address_from_deployer_address(keypair.public_key, salt) + contract_address = contract_address_from_deployer(keypair.public_key, salt) invoke_result = send( builder().append_invoke_contract_function_op( contract_address,