From a4219b6471084258caf92fcc32cfde84b888c48c Mon Sep 17 00:00:00 2001 From: Raoul Date: Thu, 2 Jul 2026 13:42:34 +0000 Subject: [PATCH 1/3] test: add integration tests for getLedgerEntries Cover the official spec surface: ACCOUNT, CONTRACT_CODE, and CONTRACT_DATA (instance and persistent storage) lookups, base64 key/xdr round-trips, numeric latestLedger/lastModifiedLedgerSeq, silent drop of unknown keys, the 200-key cap, key and xdrFormat validation errors, and rejection of the unsupported json xdrFormat. Adds a storage.wat fixture whose store() function writes a persistent contract-data entry via put_contract_data. --- src/tests/integration/data/wasm/storage.wat | 31 +++ src/tests/integration/test_server.py | 235 +++++++++++++++++++- 2 files changed, 265 insertions(+), 1 deletion(-) create mode 100644 src/tests/integration/data/wasm/storage.wat diff --git a/src/tests/integration/data/wasm/storage.wat b/src/tests/integration/data/wasm/storage.wat new file mode 100644 index 0000000..26129ea --- /dev/null +++ b/src/tests/integration/data/wasm/storage.wat @@ -0,0 +1,31 @@ +(module + (type (;0;) (func)) + (type (;1;) (func (result i64))) + (type (;2;) (func (param i64 i64 i64) (result i64))) + + ;; put_contract_data(key, val, storage_type) -> Void + (import "l" "_" (func $put_contract_data (type 2))) + + ;; store: write the persistent storage entry U32(7) -> U32(42), return Void. + ;; u32 HostVals carry the payload in the high 32 bits and tag 4 in the low bits; + ;; the storage type is a raw integer (0: temporary, 1: persistent, 2: instance). + (func $store (type 1) (result i64) + i64.const 30064771076 ;; key U32(7): (7 << 32) | 4 + i64.const 180388626436 ;; val U32(42): (42 << 32) | 4 + i64.const 1 ;; storage type: persistent + call $put_contract_data) + + ;; _ (Soroban ABI stub) + (func $stub (type 0)) + + (memory (;0;) 16) + (global (;0;) (mut i32) (i32.const 1048576)) + (global (;1;) i32 (i32.const 1048576)) + (global (;2;) i32 (i32.const 1048576)) + + (export "memory" (memory 0)) + (export "store" (func $store)) + (export "_" (func $stub)) + (export "__data_end" (global 1)) + (export "__heap_base" (global 2)) +) diff --git a/src/tests/integration/test_server.py b/src/tests/integration/test_server.py index 3e5cfc3..f84c3e1 100644 --- a/src/tests/integration/test_server.py +++ b/src/tests/integration/test_server.py @@ -11,7 +11,7 @@ from typing import Any import pytest -from stellar_sdk import Account, Keypair, Network, StrKey, TransactionBuilder, xdr +from stellar_sdk import Account, Address, Keypair, Network, StrKey, TransactionBuilder, xdr from stellar_sdk.xdr.sc_val_type import SCValType from komet_node.server import StellarRpcServer @@ -19,6 +19,7 @@ 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) +STORAGE_CONTRACT_WAT = (Path(__file__).parent / 'data' / 'wasm' / 'storage.wat').resolve(strict=True) def wat_to_wasm(wat_path: Path) -> bytes: @@ -498,3 +499,235 @@ def builder() -> TransactionBuilder: # All four transactions, including the non-Void invocation, advanced the ledger. assert _rpc(server.port(), 'getLatestLedger', {})['result']['sequence'] == 4 + + +# ---------------------------------------------------------------------- +# getLedgerEntries +# ---------------------------------------------------------------------- +# +# Spec: stellar-docs OpenRPC getLedgerEntries.json + stellar-rpc's Go serialization +# (GetLedgerEntriesResponse). Result is {entries, latestLedger}; latestLedger and +# lastModifiedLedgerSeq are JSON numbers; liveUntilLedgerSeq is optional (omitted when the +# entry has no TTL); only found entries are returned; `key`/`xdr` are base64 LedgerKey / +# LedgerEntryData strings. + + +def _account_ledger_key(public_key: str) -> str: + return xdr.LedgerKey( + type=xdr.LedgerEntryType.ACCOUNT, + account=xdr.LedgerKeyAccount(account_id=Keypair.from_public_key(public_key).xdr_account_id()), + ).to_xdr() + + +def _contract_code_ledger_key(wasm_hash: bytes) -> str: + return xdr.LedgerKey( + type=xdr.LedgerEntryType.CONTRACT_CODE, + contract_code=xdr.LedgerKeyContractCode(hash=xdr.Hash(wasm_hash)), + ).to_xdr() + + +def _contract_data_ledger_key(contract_address: str, key: xdr.SCVal, durability: xdr.ContractDataDurability) -> str: + return xdr.LedgerKey( + type=xdr.LedgerEntryType.CONTRACT_DATA, + contract_data=xdr.LedgerKeyContractData( + contract=Address(contract_address).to_xdr_sc_address(), + key=key, + durability=durability, + ), + ).to_xdr() + + +def _assert_ledger_entry_shape(entry: dict[str, Any], expected_key: str) -> None: + """Assert one entry matches the Go LedgerEntryResult serialization (base64 format).""" + assert {'key', 'xdr', 'lastModifiedLedgerSeq'} <= set(entry) + assert set(entry) <= {'key', 'xdr', 'lastModifiedLedgerSeq', 'liveUntilLedgerSeq'} + assert entry['key'] == expected_key + assert type(entry['xdr']) is str and entry['xdr'] != '' + assert type(entry['lastModifiedLedgerSeq']) is int # JSON number, not string + if 'liveUntilLedgerSeq' in entry: # optional; only Soroban entries carry a TTL + assert type(entry['liveUntilLedgerSeq']) is int + + +def _send_tx(server: StellarRpcServer, keypair: Keypair, tb: TransactionBuilder) -> None: + env = tb.set_timeout(30).build() + env.sign(keypair) + res = _rpc(server.port(), 'sendTransaction', {'transaction': env.to_xdr()}) + assert res['result']['status'] == 'PENDING' + get_res = _rpc(server.port(), 'getTransaction', {'hash': res['result']['hash']})['result'] + assert get_res['status'] == 'SUCCESS', f'Transaction failed: {get_res}' + + +def test_get_ledger_entries_account(server: StellarRpcServer) -> None: + """An ACCOUNT ledger key resolves to an AccountEntry; unknown keys are silently dropped.""" + keypair = Keypair.random() + account = Account(keypair.public_key, sequence=0) + _send_tx( + server, + keypair, + TransactionBuilder(account, Network.TESTNET_NETWORK_PASSPHRASE).append_create_account_op( + destination=keypair.public_key, starting_balance='1000' + ), + ) + + account_key = _account_ledger_key(keypair.public_key) + missing_key = _account_ledger_key(Keypair.random().public_key) + result = _rpc(server.port(), 'getLedgerEntries', {'keys': [account_key, missing_key]})['result'] + + assert set(result) == {'entries', 'latestLedger'} + assert result['latestLedger'] == 1 + assert type(result['latestLedger']) is int # JSON number, not string + + # Only the found entry is returned; the unknown key is not an error, just absent. + assert len(result['entries']) == 1 + entry = result['entries'][0] + _assert_ledger_entry_shape(entry, account_key) + assert 0 <= entry['lastModifiedLedgerSeq'] <= result['latestLedger'] + + data = xdr.LedgerEntryData.from_xdr(entry['xdr']) + assert data.type == xdr.LedgerEntryType.ACCOUNT + assert data.account is not None + assert data.account.account_id == Keypair.from_public_key(keypair.public_key).xdr_account_id() + assert data.account.balance.int64 == 10_000_000_000 # 1000 XLM in stroops + + +def test_get_ledger_entries_contract_code_and_data(server: StellarRpcServer) -> None: + """CONTRACT_CODE, the CONTRACT_DATA instance entry, and persistent CONTRACT_DATA storage.""" + from stellar_sdk.utils import sha256 + + keypair = Keypair.random() + account = Account(keypair.public_key, sequence=0) + + def builder() -> TransactionBuilder: + return TransactionBuilder(account, Network.TESTNET_NETWORK_PASSPHRASE) + + # Set up: create account, upload storage.wat, deploy, invoke store() which writes the + # persistent storage entry U32(7) -> U32(42). + _send_tx(server, keypair, builder().append_create_account_op(keypair.public_key, '1000')) + wasm_bytecode = wat_to_wasm(STORAGE_CONTRACT_WAT) + _send_tx(server, keypair, builder().append_upload_contract_wasm_op(wasm_bytecode)) + 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)) + contract_address = server.encoder.contract_address_from_deployer_address(keypair.public_key, salt) + _send_tx(server, keypair, builder().append_invoke_contract_function_op(contract_address, 'store', [])) + + code_key = _contract_code_ledger_key(wasm_hash) + instance_key = _contract_data_ledger_key( + contract_address, + xdr.SCVal(type=SCValType.SCV_LEDGER_KEY_CONTRACT_INSTANCE), + xdr.ContractDataDurability.PERSISTENT, + ) + storage_key_scval = xdr.SCVal(type=SCValType.SCV_U32, u32=xdr.Uint32(7)) + storage_key = _contract_data_ledger_key(contract_address, storage_key_scval, xdr.ContractDataDurability.PERSISTENT) + + result = _rpc(server.port(), 'getLedgerEntries', {'keys': [code_key, instance_key, storage_key]})['result'] + assert set(result) == {'entries', 'latestLedger'} + assert result['latestLedger'] == 4 + assert type(result['latestLedger']) is int + + entries = {entry['key']: entry for entry in result['entries']} + assert set(entries) == {code_key, instance_key, storage_key} + for key, entry in entries.items(): + _assert_ledger_entry_shape(entry, key) + + # CONTRACT_CODE: the uploaded wasm bytecode round-trips through the ledger entry. + code_data = xdr.LedgerEntryData.from_xdr(entries[code_key]['xdr']) + assert code_data.type == xdr.LedgerEntryType.CONTRACT_CODE + assert code_data.contract_code is not None + assert code_data.contract_code.hash.hash == wasm_hash + assert code_data.contract_code.code == wasm_bytecode + + # CONTRACT_DATA (instance): the deployed contract's instance entry points at the wasm. + instance_data = xdr.LedgerEntryData.from_xdr(entries[instance_key]['xdr']) + assert instance_data.type == xdr.LedgerEntryType.CONTRACT_DATA + assert instance_data.contract_data is not None + assert instance_data.contract_data.contract == Address(contract_address).to_xdr_sc_address() + assert instance_data.contract_data.durability == xdr.ContractDataDurability.PERSISTENT + assert instance_data.contract_data.key.type == SCValType.SCV_LEDGER_KEY_CONTRACT_INSTANCE + assert instance_data.contract_data.val.type == SCValType.SCV_CONTRACT_INSTANCE + instance = instance_data.contract_data.val.instance + assert instance is not None + assert instance.executable.type == xdr.ContractExecutableType.CONTRACT_EXECUTABLE_WASM + assert instance.executable.wasm_hash is not None + assert instance.executable.wasm_hash.hash == wasm_hash + + # CONTRACT_DATA (persistent): the value written by store() is readable. + storage_data = xdr.LedgerEntryData.from_xdr(entries[storage_key]['xdr']) + assert storage_data.type == xdr.LedgerEntryType.CONTRACT_DATA + assert storage_data.contract_data is not None + assert storage_data.contract_data.contract == Address(contract_address).to_xdr_sc_address() + assert storage_data.contract_data.durability == xdr.ContractDataDurability.PERSISTENT + assert storage_data.contract_data.key == storage_key_scval + assert storage_data.contract_data.val == xdr.SCVal(type=SCValType.SCV_U32, u32=xdr.Uint32(42)) + + +def test_get_ledger_entries_no_matches_returns_empty_entries(server: StellarRpcServer) -> None: + """Unknown keys are not an error: the result is an empty entries array.""" + result = _rpc(server.port(), 'getLedgerEntries', {'keys': [_account_ledger_key(Keypair.random().public_key)]})[ + 'result' + ] + assert result['entries'] == [] + assert result['latestLedger'] == 0 + assert type(result['latestLedger']) is int + + +def test_get_ledger_entries_unsupported_entry_type_is_not_found(server: StellarRpcServer) -> None: + """A well-formed key of a type komet-node does not track (DATA) is simply not found.""" + data_key = xdr.LedgerKey( + type=xdr.LedgerEntryType.DATA, + data=xdr.LedgerKeyData( + account_id=Keypair.random().xdr_account_id(), + data_name=xdr.String64(b'config'), + ), + ).to_xdr() + result = _rpc(server.port(), 'getLedgerEntries', {'keys': [data_key]})['result'] + assert result['entries'] == [] + + +def test_get_ledger_entries_xdr_format_base64_accepted(server: StellarRpcServer) -> None: + """xdrFormat 'base64' is the explicit spelling of the default and must be accepted.""" + keys = [_account_ledger_key(Keypair.random().public_key)] + result = _rpc(server.port(), 'getLedgerEntries', {'keys': keys, 'xdrFormat': 'base64'}) + assert 'error' not in result + assert result['result']['entries'] == [] + + +def test_get_ledger_entries_xdr_format_json_rejected(server: StellarRpcServer) -> None: + """komet-node does not support the JSON XDR format; asking for it is an invalid-params error.""" + keys = [_account_ledger_key(Keypair.random().public_key)] + result = _rpc(server.port(), 'getLedgerEntries', {'keys': keys, 'xdrFormat': 'json'}) + assert result['error']['code'] == -32602 + assert type(result['error']['message']) is str and result['error']['message'] != '' + + +def test_get_ledger_entries_invalid_xdr_format_rejected(server: StellarRpcServer) -> None: + keys = [_account_ledger_key(Keypair.random().public_key)] + result = _rpc(server.port(), 'getLedgerEntries', {'keys': keys, 'xdrFormat': 'xml'}) + assert result['error']['code'] == -32602 + + +def test_get_ledger_entries_missing_keys_returns_invalid_params(server: StellarRpcServer) -> None: + result = _rpc(server.port(), 'getLedgerEntries', {}) + assert result['error']['code'] == -32602 + + +def test_get_ledger_entries_non_array_keys_returns_invalid_params(server: StellarRpcServer) -> None: + result = _rpc(server.port(), 'getLedgerEntries', {'keys': 'AAAAAA=='}) + assert result['error']['code'] == -32602 + + +def test_get_ledger_entries_non_string_key_returns_invalid_params(server: StellarRpcServer) -> None: + result = _rpc(server.port(), 'getLedgerEntries', {'keys': [42]}) + assert result['error']['code'] == -32602 + + +def test_get_ledger_entries_invalid_key_xdr_returns_invalid_params(server: StellarRpcServer) -> None: + result = _rpc(server.port(), 'getLedgerEntries', {'keys': ['not-a-ledger-key']}) + assert result['error']['code'] == -32602 + + +def test_get_ledger_entries_too_many_keys_returns_invalid_params(server: StellarRpcServer) -> None: + """The spec caps a request at 200 ledger keys.""" + key = _account_ledger_key(Keypair.random().public_key) + result = _rpc(server.port(), 'getLedgerEntries', {'keys': [key] * 201}) + assert result['error']['code'] == -32602 From e67a8c121c47d2a7c5dadf4708eeb24940d8b26a Mon Sep 17 00:00:00 2001 From: Raoul Date: Fri, 3 Jul 2026 10:44:29 +0000 Subject: [PATCH 2/3] feat: implement getLedgerEntries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dispatch and state lookup live in the K semantics: new #ledgerEntries rules resolve key descriptors against the world-state cells (accounts, contracts, contractData, contractCodes) and answer with intermediate JSON entries plus the numeric latestLedger. #scVal2JSON serialises stored ScVals back to the JSON shape #decodeArg consumes, extended with the value-only types (void, string, u256, vec, map). Python handles only the XDR ends (ledger_entries.py): decoding the base64 LedgerKey params into key descriptors — with validation of keys, the 200-key cap, and xdrFormat (base64 only) — and re-encoding found entries as base64 LedgerEntryData. Unknown or untracked keys are skipped, not errors, per the spec. Because the K configuration stores uploaded wasm parsed, the server now persists the raw module bytes under wasms/.wasm at upload time and reattaches them to CONTRACT_CODE entries. lastModifiedLedgerSeq is approximated by the current ledger (the semantics do not track per-entry modification ledgers) and ACCOUNT entries synthesise the AccountEntry fields the semantics do not model beyond the balance. --- docs/architecture.md | 7 +- docs/node-semantics.md | 7 +- docs/notes.md | 8 +- docs/server.md | 21 ++- src/komet_node/kdist/node.md | 191 ++++++++++++++++++++++ src/komet_node/ledger_entries.py | 264 +++++++++++++++++++++++++++++++ src/komet_node/scval.py | 82 ++++++++++ src/komet_node/server.py | 37 ++++- src/komet_node/transaction.py | 23 ++- 9 files changed, 622 insertions(+), 18 deletions(-) create mode 100644 src/komet_node/ledger_entries.py diff --git a/docs/architecture.md b/docs/architecture.md index c36ae26..c50dba9 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -51,7 +51,7 @@ flowchart TB → **[Detailed documentation](server.md)** -The server implements six RPC methods — `getHealth`, `getNetwork`, `getLatestLedger`, `sendTransaction`, `getTransaction`, and `traceTransaction` — and the K semantics answer all of them. +The server implements seven RPC methods — `getHealth`, `getNetwork`, `getLatestLedger`, `sendTransaction`, `getTransaction`, `getLedgerEntries`, and `traceTransaction` — and the K semantics answer all of them. For `getLedgerEntries` the semantics' answer is an intermediate shape: K performs the state lookups, and `ledger_entries.py` translates between the base64 XDR wire format (`LedgerKey` in, `LedgerEntryData` out) and the JSON the semantics exchange, since K cannot parse or produce XDR. `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. @@ -92,6 +92,7 @@ All of the server's input and output artifacts live in one directory, the *io di | `receipts/receipt_.json` | persistent | the semantics (on success) or the server (on failure) | one stored receipt per transaction, keyed by tx hash, answering `getTransaction`. Each is `{status, ledger, createdAt, envelopeXdr, resultXdr, resultMetaXdr}`. | | `traces/trace_.jsonl` | persistent | the semantics | one execution trace per transaction, keyed by tx hash — the instruction-level records, one JSON object per line. `traceTransaction` returns this file's contents. | | `requests/request_.json` | persistent | the server | an archive of each incoming JSON-RPC request, numbered by a monotonic counter, kept for debugging. | +| `wasms/.wasm` | persistent | the server | the raw bytes of each successfully uploaded wasm module, keyed by hex sha256. The K state stores modules parsed (`ModuleDecl`), so `getLedgerEntries` CONTRACT_CODE entries read the original bytes from here. | | `request.json` | transient | the server | the request envelope for the call in flight (`method`, `id`, `now`, and method-specific fields). The semantics remove it once they respond. | | `response.json` | transient | the semantics | the JSON-RPC response (`{jsonrpc, id, result}`) for the most recent call. The server reads it back; it is absent when a transaction gets stuck. | @@ -102,7 +103,7 @@ The world state stays in KORE (rather than a JSON snapshot) because an uploaded ```mermaid flowchart TB boot(["server start"]) --> exists{"state.kore exists?"} - exists -->|"no"| init["empty_config() builds the idle K config in KORE
write state.kore · seed metadata.json {latest_ledger: 0}
create receipts/ traces/ requests/"] + exists -->|"no"| init["empty_config() builds the idle K config in KORE
write state.kore · seed metadata.json {latest_ledger: 0}
create receipts/ traces/ requests/ wasms/"] exists -->|"yes"| reuse["use existing state.kore
seed metadata.json if missing · ensure artifact dirs exist"] init --> ready(["ready for requests"]) reuse --> ready @@ -177,6 +178,6 @@ sequenceDiagram - `resultXdr` / `resultMetaXdr` in `getTransaction` responses (contract return values) - `simulateTransaction` (dry-run without state mutation) -- `getEvents`, `getLedgerEntries`, `getFeeStats` and other read-only RPC methods +- `getEvents`, `getFeeStats` and other read-only RPC methods (`getLedgerEntries` is implemented for the entry types the K state tracks: `ACCOUNT`, `CONTRACT_DATA`, `CONTRACT_CODE`) - `ExtendFootprintTTL` and `RestoreFootprint` operations - `SCVec` / `SCMap` contract-argument types in the request encoder (`scval_to_json`) diff --git a/docs/node-semantics.md b/docs/node-semantics.md index 6f72b79..e283971 100644 --- a/docs/node-semantics.md +++ b/docs/node-semantics.md @@ -39,7 +39,7 @@ insert-handleRequestFile → handleRequestFile ▼ #dispatchMethod(method, request) ← routes on the "method" field │ - ├─ getHealth / getNetwork / getLatestLedger / getTransaction / traceTransaction → #respond(...) + ├─ getHealth / getNetwork / getLatestLedger / getTransaction / getLedgerEntries / traceTransaction → #respond(...) │ └─ sendTransaction → #runTx → run steps → #finalizeTx → record receipt + bump ledger → #respond(...) @@ -62,6 +62,7 @@ If `request.json` is absent, `insert-handleRequestFile` does not fire and K halt - `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 +- `getLedgerEntries` → looks each *key descriptor* of the request up in the world-state cells (``, ``, ``, ``) and responds with `{ "entries": [...], "latestLedger": }`. The server decodes the base64 `LedgerKey` XDR into the descriptors beforehand and re-encodes the found entries as `LedgerEntryData` XDR afterwards (`ledger_entries.py`) — the entries in K's response are an intermediate JSON shape (per-kind payloads such as `balance`, `wasmHash`, or an ScVal value serialised by `#scVal2JSON`, the inverse of `#decodeArg`). Keys that match nothing are skipped via an `[owise]` rule — per the spec they are not an error `#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. @@ -141,6 +142,10 @@ rule HexBytes(S) => Int2Bytes(lengthString(S) /Int 2, String2Base(S, 16), BE) requires lengthString(S) >Int 0 ``` +### `Bytes2Hex(Bytes) → String` + +The inverse direction is K's built-in `Bytes2Hex` (hook `BYTES.bytes2hex`): it encodes `Bytes` as a lowercase, zero-padded hex string. The `getLedgerEntries` rules use it to report hashes, addresses, and `ScBytes` values to the server. + ### `string2WasmToken(String) → WasmStringToken` `string2WasmToken` wraps a K `String` into a `WasmStringToken` (`hook(STRING.string2token)`). It is required because `callTx` expects a `WasmString` for the function name. diff --git a/docs/notes.md b/docs/notes.md index f1a0b3d..426f5a3 100644 --- a/docs/notes.md +++ b/docs/notes.md @@ -13,7 +13,8 @@ | [`server.py`](server.md) — `StellarRpcServer` | Long-running HTTP/JSON-RPC server wrapping the one-shot K interpreter; `handle_rpc` dispatch; owns the io-dir files. Holds no ledger or receipt state. | | [`transaction.py`](transaction.md) — `TransactionEncoder` | XDR → request envelope + (for wasm uploads) kasmer steps; address/contract-id helpers. | | [`interpreter.py`](interpreter.md) — `NodeInterpreter` | Runs request envelopes through `llvm_interpret`; persists `state.kore`. No `kast`↔`kore` whole-config conversions. | -| `scval.py` | XDR `SCVal` ↔ Komet `SCValue` (`scvalue_from_xdr`) and XDR `SCVal` → request JSON (`scval_to_json`). | +| `scval.py` | XDR `SCVal` ↔ Komet `SCValue` (`scvalue_from_xdr`), XDR `SCVal` ↔ request/response JSON (`scval_to_json`, `scval_from_json`). | +| `ledger_entries.py` | `getLedgerEntries` XDR translation: base64 `LedgerKey` → key descriptors for the semantics, intermediate entries → base64 `LedgerEntryData`. | | [`kdist/node.md`](node-semantics.md) | The K RPC layer: reads `request.json`, dispatches, updates `metadata.json` and the per-transaction `receipts/` files, writes `response.json`. | State lives in the io dir as `state.kore` (KORE world state) and `metadata.json` (ledger counter), with per-transaction receipts and traces under `receipts/` and `traces/`. See [architecture.md](architecture.md). @@ -22,7 +23,7 @@ State lives in the io dir as `state.kore` (KORE world state) and `metadata.json` ## Tests (`src/tests/integration/`) -- `test_server.py` drives the running HTTP server end-to-end. It exercises the read-only methods, `sendTransaction` + `getTransaction`, ledger increments, the full lifecycle (create → upload wasm → deploy → invoke), and the `traceTransaction` flows. `test_call_tx_with_args` deploys `args.wat` and calls functions with `bool`, `u32`, `i32`, `u64`, `i64`, `u128`, `i128`, and `symbol` arguments, exercising the `scval_to_json` / `#decodeArg` pipeline. +- `test_server.py` drives the running HTTP server end-to-end. It exercises the read-only methods, `sendTransaction` + `getTransaction`, ledger increments, the full lifecycle (create → upload wasm → deploy → invoke), the `traceTransaction` flows, and `getLedgerEntries` (account, contract code, contract instance, and persistent storage entries via `storage.wat`, plus its parameter validation). `test_call_tx_with_args` deploys `args.wat` and calls functions with `bool`, `u32`, `i32`, `u64`, `i64`, `u128`, `i128`, and `symbol` arguments, exercising the `scval_to_json` / `#decodeArg` pipeline. - `test_integration.py` and `test_unit.py` hold small sanity checks. Run with `make test` (requires `make kdist-build` first). @@ -35,4 +36,5 @@ The tests do not yet cover `bytes` / `address` SCVal arguments or `SCVec` / `SCM - `resultXdr` / `resultMetaXdr` are empty stubs (contract return values not surfaced). - `SCVec` / `SCMap` contract arguments are not yet encoded. -- `simulateTransaction`, `getEvents`, `getLedgerEntries`, `getFeeStats`, and TTL/footprint operations are not implemented. +- `simulateTransaction`, `getEvents`, `getFeeStats`, and TTL/footprint operations are not implemented. +- `getLedgerEntries` covers the entry types the K state tracks (`ACCOUNT`, `CONTRACT_DATA`, `CONTRACT_CODE`); other key types are reported as not found. `lastModifiedLedgerSeq` is approximated by the current ledger (per-entry modification ledgers are not tracked), and only the balance is real in `ACCOUNT` entries (sequence number, thresholds, etc. are synthesised constants). diff --git a/docs/server.md b/docs/server.md index 0af41c5..6e89a6d 100644 --- a/docs/server.md +++ b/docs/server.md @@ -64,7 +64,7 @@ Once the socket is bound, `serve` logs three lines to stderr: whether it is star ``` startup (state.kore absent): → empty_config() → state.kore ; metadata.json {latest_ledger:0} - → create receipts/ traces/ requests/ + → create receipts/ traces/ requests/ wasms/ per successful transaction: → the semantics run the steps (trace → traces/trace_.jsonl), @@ -143,6 +143,23 @@ All methods are answered by the K semantics and follow the [Stellar RPC specific `resultXdr` and `resultMetaXdr` are currently empty stubs. The receipt carries no trace — use `traceTransaction` with the same hash to fetch it. +### `getLedgerEntries` + +`getLedgerEntries` takes `keys` (an array of up to 200 base64-encoded `LedgerKey` XDR strings; required) and an optional `xdrFormat` (only `"base64"` is supported — `"json"` is rejected with `-32602`). It returns the entries found for the supported key types — `ACCOUNT`, `CONTRACT_DATA` (both the contract-instance entry and persistent/temporary storage), and `CONTRACT_CODE` — the ones the K world state tracks. Keys that do not resolve (unknown, or of an untracked type) are not an error; they are simply absent from `entries`. + +**Response** (`latestLedger` and `lastModifiedLedgerSeq` are JSON numbers; `liveUntilLedgerSeq` appears only on Soroban entries): +```json +{ + "entries": [ + { "key": "", "xdr": "", + "lastModifiedLedgerSeq": 4, "liveUntilLedgerSeq": 4095 } + ], + "latestLedger": 4 +} +``` + +This is the one method whose response the server post-processes: the semantics look the keys up in the K state and answer with intermediate JSON entries, and `ledger_entries.py` re-encodes them as `LedgerEntryData` XDR (K cannot produce XDR). Because the K state keeps uploaded wasm parsed, the server stores the raw bytes under `wasms/.wasm` at upload time and reattaches them to `CONTRACT_CODE` entries. The semantics do not track per-entry modification ledgers, so `lastModifiedLedgerSeq` reports the current ledger; `ACCOUNT` entries carry the real balance but synthesised constants for the remaining required fields (sequence number 0, master weight 1, no signers). + --- ## Failure fallback @@ -161,4 +178,4 @@ komet-node [--host HOST] [--port PORT] [--io-dir DIR] |---|---|---| | `--host` | `localhost` | Bind address | | `--port` | `8000` | Port | -| `--io-dir` | a fresh temp dir | Directory holding every artifact (`state.kore`, `metadata.json`, `receipts/`, `traces/`, `requests/`) | +| `--io-dir` | a fresh temp dir | Directory holding every artifact (`state.kore`, `metadata.json`, `receipts/`, `traces/`, `requests/`, `wasms/`) | diff --git a/src/komet_node/kdist/node.md b/src/komet_node/kdist/node.md index b8f1db2..bea6f23 100644 --- a/src/komet_node/kdist/node.md +++ b/src/komet_node/kdist/node.md @@ -74,6 +74,9 @@ that leading zero bytes are preserved. requires lengthString(S) >Int 0 ``` +The inverse direction — Bytes to a lowercase, zero-padded hex string — is K's built-in +`Bytes2Hex` (hook `BYTES.bytes2hex`), used by the getLedgerEntries rules below. + `string2WasmToken` wraps a plain K String (for example, "foo") in double-quote delimiters and produces a WasmStringToken using K's generic string-to-token hook. @@ -350,6 +353,194 @@ exists for that hash. requires notBool #fileExists( #traceFile( HASH ) ) ``` +## getLedgerEntries + +The Python server decodes each base64 `LedgerKey` of the request (K cannot parse XDR) +into a JSON *key descriptor* and sends the list as the `keys` field of the request +envelope. The rules below look each descriptor up in the world-state cells and reply with +an intermediate JSON entry per *found* key — unknown keys are silently skipped, per the +spec. The server then re-encodes each intermediate entry as base64 `LedgerEntryData` XDR +and rebuilds the `entries` array (`ledger_entries.py`), the one step of this method that +cannot be done in K. + +Descriptor shapes (key order is significant — it must match `ledger_entries.py`): + + { "kind": "account", "key": "", "accountId": "" } + { "kind": "contractCode", "key": "", "hash": "" } + { "kind": "contractInstance", "key": "", "contract": "" } + { "kind": "contractData", "key": "", "contract": "", + "durability": "persistent"|"temporary", "scKey": } + { "kind": "unsupported", "key": "" } + +```k + syntax KItem ::= #ledgerEntries( JSON, JSONs, JSONs ) + | #contractDataEntry( JSON, String, StorageKey, JSONs, JSONs ) + + rule #dispatchMethod( "getLedgerEntries", REQ ) + => #ledgerEntries( + #getJSON( "id", REQ ), + #stepsJSONs( #getJSON( "keys", REQ, [ .JSONs ] ) ), + .JSONs + ) + ... + +``` + +All keys processed: respond with the found entries and the current ledger. +`latestLedger` is emitted as an Int, i.e. a JSON number, as the spec requires. + +```k + rule #ledgerEntries( ID, .JSONs, ENTRIES ) + => #respond( ID, { + "entries" : [ ENTRIES ], + "latestLedger" : #getInt( "latest_ledger", String2JSON( {#readFile("metadata.json")}:>String ) ) + }) + ... + +``` + +ACCOUNT keys resolve against the `` cell. The semantics track only the balance; +the remaining `AccountEntry` fields are synthesised by the server. + +```k + rule #ledgerEntries( ID, ({ "kind" : "account", "key" : KEY:String, "accountId" : AID:String }, REST:JSONs), ENTRIES ) + => #ledgerEntries( ID, REST, #concatJSONs( ENTRIES, + ({ "kind" : "account", "key" : KEY, "balance" : BAL }, .JSONs) ) ) + ... + + + Account(ACCTID) + BAL + + requires ACCTID ==K HexBytes(AID) +``` + +CONTRACT_CODE keys resolve against ``. The stored wasm is a parsed +`ModuleDecl` whose original bytes cannot be recovered here, so the entry reports only the +hash and TTL; the server keeps the raw bytes (written at upload time, `wasms/.wasm`) +and reattaches them when building the XDR. + +```k + rule #ledgerEntries( ID, ({ "kind" : "contractCode", "key" : KEY:String, "hash" : HASH:String }, REST:JSONs), ENTRIES ) + => #ledgerEntries( ID, REST, #concatJSONs( ENTRIES, + ({ "kind" : "contractCode", "key" : KEY, "hash" : HASH, "liveUntil" : LIVE }, .JSONs) ) ) + ... + + + CH + LIVE + ... + + requires CH ==K HexBytes(HASH) +``` + +A CONTRACT_DATA key whose ScVal key is `SCV_LEDGER_KEY_CONTRACT_INSTANCE` resolves +against the `` cell: the entry carries the wasm hash the instance points at +and the contract's instance storage. + +```k + rule #ledgerEntries( ID, ({ "kind" : "contractInstance", "key" : KEY:String, "contract" : CADDR:String }, REST:JSONs), ENTRIES ) + => #ledgerEntries( ID, REST, #concatJSONs( ENTRIES, + ({ "kind" : "contractInstance", + "key" : KEY, + "wasmHash" : Bytes2Hex(WH), + "storage" : [ #scMapEntries2JSONs( keys_list(ISTORE), ISTORE ) ], + "liveUntil" : LIVE }, .JSONs) ) ) + ... + + + Contract(CID) + WH + ISTORE + LIVE + + requires CID ==K HexBytes(CADDR) +``` + +Other CONTRACT_DATA keys (persistent/temporary storage) resolve against the +`` map. The storage-key ScVal is rebuilt with `#decodeArg` — the same +decoder the transaction path uses — so it matches the stored `#skey` exactly. + +```k + rule #ledgerEntries( ID, ({ "kind" : "contractData", "key" : KEY:String, "contract" : CADDR:String, "durability" : DUR:String, "scKey" : SK:JSON }, REST:JSONs), ENTRIES ) + => #contractDataEntry( ID, KEY, #skey( Contract(HexBytes(CADDR)), #decodeDurability(DUR), #decodeArg(SK) ), REST, ENTRIES ) + ... + + + rule #contractDataEntry( ID, KEY, SKEY, REST, ENTRIES ) + => #ledgerEntries( ID, REST, #concatJSONs( ENTRIES, + ({ "kind" : "contractData", + "key" : KEY, + "val" : #scVal2JSON( #svalData( {CDATA[SKEY]}:>StorageVal ) ), + "liveUntil" : #svalLive( {CDATA[SKEY]}:>StorageVal ) }, .JSONs) ) ) + ... + + CDATA + requires SKEY in_keys(CDATA) + + rule #contractDataEntry( ID, _KEY, SKEY, REST, ENTRIES ) => #ledgerEntries( ID, REST, ENTRIES ) ... + CDATA + requires notBool SKEY in_keys(CDATA) +``` + +Any other key — an unsupported entry type, or a supported type not present in the world +state — is not an error: it is skipped ([owise] fires when no lookup rule above matched). + +```k + rule #ledgerEntries( ID, (_KEY:JSON, REST:JSONs), ENTRIES ) => #ledgerEntries( ID, REST, ENTRIES ) ... [owise] + + syntax Durability ::= #decodeDurability( String ) [function] + // ------------------------------------------------------------ + rule #decodeDurability( "persistent" ) => #persistent + rule #decodeDurability( "temporary" ) => #temporary + + syntax ScVal ::= #svalData( StorageVal ) [function] + syntax Int ::= #svalLive( StorageVal ) [function] + // --------------------------------------------------- + rule #svalData( #sval( VAL, _ ) ) => VAL + rule #svalLive( #sval( _, LIVE ) ) => LIVE +``` + +`#scVal2JSON` serialises a stored ScVal back to the JSON encoding that `#decodeArg` +consumes (same shapes, same key order), extended with the value-only types that never +appear as call arguments (`void`, `string`, `u256`, `vec`, `map`). Values with no JSON +form are emitted as `{"type": "unsupported"}`; the server drops the enclosing entry. + +```k + syntax JSON ::= #scVal2JSON( ScVal ) [function, total, symbol(scVal2JSON)] + // -------------------------------------------------------------------------- + rule #scVal2JSON( SCBool(B) ) => { "type" : "bool", "value" : B } + rule #scVal2JSON( I32(V) ) => { "type" : "i32", "value" : V } + rule #scVal2JSON( U32(V) ) => { "type" : "u32", "value" : V } + rule #scVal2JSON( I64(V) ) => { "type" : "i64", "value" : V } + rule #scVal2JSON( U64(V) ) => { "type" : "u64", "value" : V } + rule #scVal2JSON( I128(V) ) => { "type" : "i128", "value" : V } + rule #scVal2JSON( U128(V) ) => { "type" : "u128", "value" : V } + rule #scVal2JSON( U256(V) ) => { "type" : "u256", "value" : V } + rule #scVal2JSON( Symbol(S) ) => { "type" : "symbol", "value" : S } + rule #scVal2JSON( ScBytes(B) ) => { "type" : "bytes", "value" : Bytes2Hex(B) } + rule #scVal2JSON( ScString(S) ) => { "type" : "string", "value" : S } + rule #scVal2JSON( Void ) => { "type" : "void" } + rule #scVal2JSON( ScAddress(Account(B)) ) => { "type" : "address", "addrType" : "account", "value" : Bytes2Hex(B) } + rule #scVal2JSON( ScAddress(Contract(B)) ) => { "type" : "address", "addrType" : "contract", "value" : Bytes2Hex(B) } + rule #scVal2JSON( ScVec(ITEMS) ) => { "type" : "vec", "value" : [ #scValList2JSONs(ITEMS) ] } + rule #scVal2JSON( ScMap(M) ) => { "type" : "map", "value" : [ #scMapEntries2JSONs(keys_list(M), M) ] } + rule #scVal2JSON( _ ) => { "type" : "unsupported" } [owise] + + syntax JSONs ::= #scValList2JSONs( List ) [function, total] + // ----------------------------------------------------------- + rule #scValList2JSONs( .List ) => .JSONs + rule #scValList2JSONs( ListItem(V:ScVal) REST ) => ( #scVal2JSON(V), #scValList2JSONs(REST) ) + rule #scValList2JSONs( ListItem(_) REST ) => ( { "type" : "unsupported" }, #scValList2JSONs(REST) ) [owise] + + syntax JSONs ::= #scMapEntries2JSONs( List, Map ) [function, total] + // ------------------------------------------------------------------- + rule #scMapEntries2JSONs( .List, _ ) => .JSONs + rule #scMapEntries2JSONs( ListItem(KEY:ScVal) REST, M ) + => ( { "key" : #scVal2JSON(KEY), "val" : #scVal2JSON( M {{ KEY }} orDefault Void ) }, #scMapEntries2JSONs(REST, M) ) + rule #scMapEntries2JSONs( ListItem(_) REST, M ) => ( { "type" : "unsupported" }, #scMapEntries2JSONs(REST, M) ) [owise] +``` + ############################################################################### # Step decoding diff --git a/src/komet_node/ledger_entries.py b/src/komet_node/ledger_entries.py new file mode 100644 index 0000000..de96787 --- /dev/null +++ b/src/komet_node/ledger_entries.py @@ -0,0 +1,264 @@ +"""XDR translation for the ``getLedgerEntries`` RPC method. + +The K semantics (``node.md``) own the actual state lookup and response dispatch. This +module performs only the two steps K cannot: decoding the base64 ``LedgerKey`` XDR of the +request into the JSON *key descriptors* the semantics consume, and re-encoding the +intermediate entries the semantics found as base64 ``LedgerEntryData`` XDR. + +Supported ledger-entry types — the ones the K world state tracks — are ``ACCOUNT``, +``CONTRACT_DATA`` (both the ``SCV_LEDGER_KEY_CONTRACT_INSTANCE`` entry and +persistent/temporary storage), and ``CONTRACT_CODE``. Any other well-formed key is mapped +to an ``unsupported`` descriptor, which the semantics never resolve: per the spec, an +unknown key is not an error, it is simply absent from ``entries``. +""" + +from __future__ import annotations + +import json +import logging +from typing import TYPE_CHECKING, Any + +from stellar_sdk import xdr +from stellar_sdk.xdr.sc_val_type import SCValType + +from komet_node.scval import scval_from_json, scval_to_json + +if TYPE_CHECKING: + from pathlib import Path + +_log = logging.getLogger('komet_node') + +# The spec caps a single getLedgerEntries request at 200 ledger keys. +KEY_LIMIT = 200 + + +class InvalidParamsError(Exception): + """Raised when getLedgerEntries params fail validation (JSON-RPC error -32602).""" + + +def ledger_key_descriptors(params: dict[str, Any]) -> list[dict[str, Any]]: + """Validate getLedgerEntries params and build the key descriptors for the K envelope. + + Key order within each descriptor is significant: the ``#ledgerEntries`` rules in + ``node.md`` pattern-match the JSON objects positionally. + """ + xdr_format = params.get('xdrFormat', 'base64') + if xdr_format == 'json': + raise InvalidParamsError("xdrFormat 'json' is not supported by komet-node; use 'base64'") + if xdr_format != 'base64': + raise InvalidParamsError(f"unknown xdrFormat {xdr_format!r}; only 'base64' is supported") + keys = params.get('keys') + if not isinstance(keys, list) or not all(isinstance(key, str) for key in keys): + raise InvalidParamsError("'keys' (array of base64-encoded LedgerKey strings) is required") + if len(keys) > KEY_LIMIT: + raise InvalidParamsError(f'key count ({len(keys)}) exceeds maximum supported ({KEY_LIMIT})') + + descriptors = [] + for key in keys: + try: + ledger_key = xdr.LedgerKey.from_xdr(key) + except Exception as err: + raise InvalidParamsError(f'cannot unmarshal key value {key!r}') from err + if ledger_key.to_xdr() != key: + # stellar_sdk's from_xdr ignores trailing bytes; real stellar-rpc (Go + # xdr.SafeUnmarshal) rejects them. The round-trip check restores that. + raise InvalidParamsError(f'cannot unmarshal key value {key!r}') + descriptors.append(_descriptor(key, ledger_key)) + return descriptors + + +def format_ledger_entries_response(response: str, wasms_dir: Path) -> str: + """Rewrite the semantics' intermediate getLedgerEntries response into the final one. + + The intermediate ``entries`` array (per-kind JSON payloads built in K) is replaced by + spec-shaped entries with base64 ``LedgerEntryData`` in ``xdr``; ``latestLedger`` + passes through as the JSON number K emitted. + """ + envelope = json.loads(response) + result = envelope.get('result') + if not isinstance(result, dict): + return response # not a result envelope; pass through untouched + latest_ledger = result['latestLedger'] + entries = [] + for entry in result.get('entries', []): + encoded = _entry_result(entry, latest_ledger, wasms_dir) + if encoded is not None: + entries.append(encoded) + envelope['result'] = {'entries': entries, 'latestLedger': latest_ledger} + return json.dumps(envelope) + + +# ---------------------------------------------------------------------- +# Request: LedgerKey -> key descriptor +# ---------------------------------------------------------------------- + + +def _descriptor(key: str, ledger_key: xdr.LedgerKey) -> dict[str, Any]: + unsupported = {'kind': 'unsupported', 'key': key} + match ledger_key.type: + case xdr.LedgerEntryType.ACCOUNT: + assert ledger_key.account is not None + ed25519 = ledger_key.account.account_id.account_id.ed25519 + if ed25519 is None: + return unsupported + return {'kind': 'account', 'key': key, 'accountId': ed25519.uint256.hex()} + + case xdr.LedgerEntryType.CONTRACT_CODE: + assert ledger_key.contract_code is not None + return {'kind': 'contractCode', 'key': key, 'hash': ledger_key.contract_code.hash.hash.hex()} + + case xdr.LedgerEntryType.CONTRACT_DATA: + assert ledger_key.contract_data is not None + contract_data = ledger_key.contract_data + if contract_data.contract.contract_id is None: + return unsupported # contract data lives under contract addresses only + contract_hex = contract_data.contract.contract_id.contract_id.hash.hex() + + if contract_data.key.type == SCValType.SCV_LEDGER_KEY_CONTRACT_INSTANCE: + if contract_data.durability != xdr.ContractDataDurability.PERSISTENT: + return unsupported # instance entries are always persistent + return {'kind': 'contractInstance', 'key': key, 'contract': contract_hex} + + match contract_data.durability: + case xdr.ContractDataDurability.PERSISTENT: + durability = 'persistent' + case xdr.ContractDataDurability.TEMPORARY: + durability = 'temporary' + case _: + return unsupported + try: + sc_key = scval_to_json(contract_data.key) + except NotImplementedError: + return unsupported # key ScVal outside the JSON-encodable subset + return { + 'kind': 'contractData', + 'key': key, + 'contract': contract_hex, + 'durability': durability, + 'scKey': sc_key, + } + + case _: + return unsupported + + +# ---------------------------------------------------------------------- +# Response: intermediate entry -> spec entry with LedgerEntryData XDR +# ---------------------------------------------------------------------- + + +class _UnrepresentableEntryError(Exception): + """An entry the semantics found cannot be re-encoded as XDR (dropped with a warning).""" + + +def _entry_result(entry: dict[str, Any], latest_ledger: int, wasms_dir: Path) -> dict[str, Any] | None: + key = entry['key'] + try: + data, live_until = _entry_data(entry, xdr.LedgerKey.from_xdr(key), wasms_dir) + except _UnrepresentableEntryError as err: + _log.warning('getLedgerEntries: dropping entry for key %s: %s', key, err) + return None + # The semantics do not track per-entry modification ledgers, so the current ledger is + # reported for lastModifiedLedgerSeq. Both ledger fields are JSON numbers per the spec. + result: dict[str, Any] = {'key': key, 'xdr': data.to_xdr(), 'lastModifiedLedgerSeq': latest_ledger} + if live_until is not None: + result['liveUntilLedgerSeq'] = live_until + return result + + +def _entry_data( + entry: dict[str, Any], ledger_key: xdr.LedgerKey, wasms_dir: Path +) -> tuple[xdr.LedgerEntryData, int | None]: + """Build the LedgerEntryData for one intermediate entry, plus its TTL (None = no TTL).""" + match entry['kind']: + case 'account': + assert ledger_key.account is not None + return _account_entry_data(ledger_key.account.account_id, entry['balance']), None + + case 'contractCode': + # The K configuration stores uploaded wasm as a parsed ModuleDecl, so the raw + # bytes come from the server's side store written at upload time. + wasm_file = wasms_dir / f'{entry["hash"]}.wasm' + if not wasm_file.exists(): + raise _UnrepresentableEntryError( + 'uploaded wasm bytes are not on disk (io-dir predates the wasm store?)' + ) + data = xdr.LedgerEntryData( + type=xdr.LedgerEntryType.CONTRACT_CODE, + contract_code=xdr.ContractCodeEntry( + ext=xdr.ContractCodeEntryExt(0), + hash=xdr.Hash(bytes.fromhex(entry['hash'])), + code=wasm_file.read_bytes(), + ), + ) + return data, entry['liveUntil'] + + case 'contractInstance': + assert ledger_key.contract_data is not None + instance = xdr.SCContractInstance( + executable=xdr.ContractExecutable( + xdr.ContractExecutableType.CONTRACT_EXECUTABLE_WASM, + wasm_hash=xdr.Hash(bytes.fromhex(entry['wasmHash'])), + ), + storage=_instance_storage(entry['storage']), + ) + val = xdr.SCVal(type=SCValType.SCV_CONTRACT_INSTANCE, instance=instance) + return _contract_data_entry_data(ledger_key.contract_data, val), entry['liveUntil'] + + case 'contractData': + assert ledger_key.contract_data is not None + try: + val = scval_from_json(entry['val']) + except NotImplementedError as err: + raise _UnrepresentableEntryError(str(err)) from err + return _contract_data_entry_data(ledger_key.contract_data, val), entry['liveUntil'] + + raise _UnrepresentableEntryError(f'unknown entry kind {entry["kind"]!r}') + + +def _account_entry_data(account_id: xdr.AccountID, balance: int) -> xdr.LedgerEntryData: + # The semantics track only the account's balance; the remaining (required) + # AccountEntry fields are fixed, plausible values for a fresh account. + return xdr.LedgerEntryData( + type=xdr.LedgerEntryType.ACCOUNT, + account=xdr.AccountEntry( + account_id=account_id, + balance=xdr.Int64(balance), + seq_num=xdr.SequenceNumber(xdr.Int64(0)), + num_sub_entries=xdr.Uint32(0), + inflation_dest=None, + flags=xdr.Uint32(0), + home_domain=xdr.String32(b''), + thresholds=xdr.Thresholds(bytes([1, 0, 0, 0])), + signers=[], + ext=xdr.AccountEntryExt(0), + ), + ) + + +def _contract_data_entry_data(key: xdr.LedgerKeyContractData, val: xdr.SCVal) -> xdr.LedgerEntryData: + return xdr.LedgerEntryData( + type=xdr.LedgerEntryType.CONTRACT_DATA, + contract_data=xdr.ContractDataEntry( + ext=xdr.ExtensionPoint(0), + contract=key.contract, + key=key.key, + durability=key.durability, + val=val, + ), + ) + + +def _instance_storage(pairs: list[dict[str, Any]]) -> xdr.SCMap | None: + """Rebuild a contract's instance-storage map; None when the instance stores nothing.""" + if not pairs: + return None + entries = [] + for pair in pairs: + if not {'key', 'val'} <= set(pair): + raise _UnrepresentableEntryError(f'instance storage pair has no JSON form: {pair!r}') + try: + entries.append(xdr.SCMapEntry(key=scval_from_json(pair['key']), val=scval_from_json(pair['val']))) + except NotImplementedError as err: + raise _UnrepresentableEntryError(str(err)) from err + return xdr.SCMap(entries) diff --git a/src/komet_node/scval.py b/src/komet_node/scval.py index c379b60..5d3b4be 100644 --- a/src/komet_node/scval.py +++ b/src/komet_node/scval.py @@ -20,6 +20,7 @@ SCSymbol, SCVec, ) +from stellar_sdk import xdr as stellar_xdr from stellar_sdk.xdr.sc_address_type import SCAddressType from stellar_sdk.xdr.sc_val_type import SCValType @@ -28,6 +29,8 @@ from stellar_sdk.xdr.sc_address import SCAddress as XDRSCAddress from stellar_sdk.xdr.sc_val import SCVal +_UINT64_MASK = (1 << 64) - 1 + def scval_to_json(scval: SCVal) -> dict: """Encode a Stellar XDR SCVal as a JSON-serialisable dict for the node request envelope. @@ -79,6 +82,85 @@ def scval_to_json(scval: SCVal) -> dict: raise NotImplementedError(f'Unsupported SCVal type for JSON encoding: {scval.type}') +def scval_from_json(value: dict) -> SCVal: + """Decode the JSON ScVal encoding emitted by the semantics back into an XDR SCVal. + + Inverse of :func:`scval_to_json`, extended with the value-only types the semantics can + hold in contract storage but that never appear as call arguments (``void``, ``string``, + ``u256``, ``vec``, ``map``) — see ``#scVal2JSON`` in ``node.md``. Raises + ``NotImplementedError`` for values with no JSON form (``{"type": "unsupported"}``). + """ + match value.get('type'): + case 'bool': + return stellar_xdr.SCVal(type=SCValType.SCV_BOOL, b=bool(value['value'])) + case 'i32': + return stellar_xdr.SCVal(type=SCValType.SCV_I32, i32=stellar_xdr.Int32(value['value'])) + case 'u32': + return stellar_xdr.SCVal(type=SCValType.SCV_U32, u32=stellar_xdr.Uint32(value['value'])) + case 'i64': + return stellar_xdr.SCVal(type=SCValType.SCV_I64, i64=stellar_xdr.Int64(value['value'])) + case 'u64': + return stellar_xdr.SCVal(type=SCValType.SCV_U64, u64=stellar_xdr.Uint64(value['value'])) + case 'i128': + val = value['value'] + parts = stellar_xdr.Int128Parts(hi=stellar_xdr.Int64(val >> 64), lo=stellar_xdr.Uint64(val & _UINT64_MASK)) + return stellar_xdr.SCVal(type=SCValType.SCV_I128, i128=parts) + case 'u128': + val = value['value'] + parts128 = stellar_xdr.UInt128Parts( + hi=stellar_xdr.Uint64(val >> 64), lo=stellar_xdr.Uint64(val & _UINT64_MASK) + ) + return stellar_xdr.SCVal(type=SCValType.SCV_U128, u128=parts128) + case 'u256': + val = value['value'] + parts256 = stellar_xdr.UInt256Parts( + hi_hi=stellar_xdr.Uint64(val >> 192), + hi_lo=stellar_xdr.Uint64((val >> 128) & _UINT64_MASK), + lo_hi=stellar_xdr.Uint64((val >> 64) & _UINT64_MASK), + lo_lo=stellar_xdr.Uint64(val & _UINT64_MASK), + ) + return stellar_xdr.SCVal(type=SCValType.SCV_U256, u256=parts256) + case 'symbol': + return stellar_xdr.SCVal(type=SCValType.SCV_SYMBOL, sym=stellar_xdr.SCSymbol(value['value'].encode())) + case 'string': + return stellar_xdr.SCVal(type=SCValType.SCV_STRING, str=stellar_xdr.SCString(value['value'].encode())) + case 'bytes': + return stellar_xdr.SCVal(type=SCValType.SCV_BYTES, bytes=stellar_xdr.SCBytes(bytes.fromhex(value['value']))) + case 'void': + return stellar_xdr.SCVal(type=SCValType.SCV_VOID) + case 'address': + raw = bytes.fromhex(value['value']) + if value['addrType'] == 'account': + address = stellar_xdr.SCAddress( + type=SCAddressType.SC_ADDRESS_TYPE_ACCOUNT, + account_id=stellar_xdr.AccountID( + stellar_xdr.PublicKey( + stellar_xdr.PublicKeyType.PUBLIC_KEY_TYPE_ED25519, + ed25519=stellar_xdr.Uint256(raw), + ) + ), + ) + else: + address = stellar_xdr.SCAddress( + type=SCAddressType.SC_ADDRESS_TYPE_CONTRACT, + contract_id=stellar_xdr.ContractID(stellar_xdr.Hash(raw)), + ) + return stellar_xdr.SCVal(type=SCValType.SCV_ADDRESS, address=address) + case 'vec': + items = [scval_from_json(item) for item in value['value']] + return stellar_xdr.SCVal(type=SCValType.SCV_VEC, vec=stellar_xdr.SCVec(items)) + case 'map': + if not all({'key', 'val'} <= set(pair) for pair in value['value']): + raise NotImplementedError(f'Unsupported SCMap entry in JSON encoding: {value!r}') + entries = [ + stellar_xdr.SCMapEntry(key=scval_from_json(pair['key']), val=scval_from_json(pair['val'])) + for pair in value['value'] + ] + return stellar_xdr.SCVal(type=SCValType.SCV_MAP, map=stellar_xdr.SCMap(entries)) + case _: + raise NotImplementedError(f'Unsupported SCVal JSON encoding: {value!r}') + + def sc_address_from_xdr(xdr: XDRSCAddress) -> SCAddress: """Convert an XDR SCAddress to a Komet SCAddress.""" match xdr.type: diff --git a/src/komet_node/server.py b/src/komet_node/server.py index 6399575..98fa15c 100644 --- a/src/komet_node/server.py +++ b/src/komet_node/server.py @@ -13,6 +13,7 @@ from stellar_sdk import Network from komet_node.interpreter import NodeInterpreter +from komet_node.ledger_entries import InvalidParamsError, format_ledger_entries_response, ledger_key_descriptors from komet_node.transaction import TransactionEncoder if TYPE_CHECKING: @@ -44,6 +45,9 @@ class StellarRpcServer: - ``receipts/receipt_.json`` — one stored receipt per transaction - ``traces/trace_.jsonl`` — one execution trace per transaction - ``requests/request_.json`` — an archive of each incoming JSON-RPC request + - ``wasms/.wasm`` — raw bytes of each uploaded wasm module (the K + configuration stores modules parsed, so getLedgerEntries CONTRACT_CODE lookups + read the original bytes from here) Splitting receipts, traces, and requests into per-item files keeps any single file from growing without bound as the chain advances. @@ -86,7 +90,8 @@ def __init__( self.receipts_dir = self.io_dir / 'receipts' self.traces_dir = self.io_dir / 'traces' self.requests_dir = self.io_dir / 'requests' - for directory in (self.receipts_dir, self.traces_dir, self.requests_dir): + self.wasms_dir = self.io_dir / 'wasms' + for directory in (self.receipts_dir, self.traces_dir, self.requests_dir, self.wasms_dir): directory.mkdir(exist_ok=True) # Continue the request archive numbering past anything a previous run left behind, so # resuming an io-dir never overwrites its earlier request files. @@ -188,7 +193,9 @@ def handle_rpc(self, method: str | None, params: dict[str, Any], request_id: Any if not isinstance(transaction, str): return _error_str(request_id, -32602, "Invalid params: 'transaction' (XDR string) is required") try: - envelope, program_steps = self.encoder.build_tx_request(method, request_id, transaction, now) + envelope, program_steps, uploaded_wasms = 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 @@ -198,8 +205,16 @@ def handle_rpc(self, method: str | None, params: dict[str, Any], request_id: Any 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)) + # Keep the raw bytes of successfully uploaded wasm modules: the K configuration + # stores modules parsed, and getLedgerEntries CONTRACT_CODE entries must return + # the original bytes. + for wasm_hash, wasm in uploaded_wasms.items(): + (self.wasms_dir / f'{wasm_hash}.wasm').write_bytes(wasm) return response + if method == 'getLedgerEntries': + return self._get_ledger_entries(params, request_id, now) + read_only_envelope = self._read_only_envelope(method, params, request_id, now) if isinstance(read_only_envelope, str): # a pre-formatted JSON-RPC error return read_only_envelope @@ -210,6 +225,24 @@ def handle_rpc(self, method: str | None, params: dict[str, Any], request_id: Any return _error_str(request_id, -32603, 'Internal error') return response + def _get_ledger_entries(self, params: dict[str, Any], request_id: Any, now: str) -> str: + """Handle getLedgerEntries: decode the LedgerKey XDR, run the K lookup, re-encode. + + Unlike the other read-only methods, the semantics' response is an intermediate + shape here: K performs the state lookups and emits per-kind JSON payloads, and + this side re-encodes them as base64 ``LedgerEntryData`` XDR (which K cannot + produce) before returning the final response. See ``ledger_entries.py``. + """ + try: + descriptors = ledger_key_descriptors(params) + except InvalidParamsError as err: + return _error_str(request_id, -32602, f'Invalid params: {err}') + envelope = {'method': 'getLedgerEntries', 'id': request_id, 'now': now, 'keys': descriptors} + response = self.interpreter.run(self.state_file, self.io_dir, envelope, None) + if response is None: + return _error_str(request_id, -32603, 'Internal error') + return format_ledger_entries_response(response, self.wasms_dir) + def _read_only_envelope( self, method: str | None, params: dict[str, Any], request_id: Any, now: str ) -> dict[str, Any] | str | None: diff --git a/src/komet_node/transaction.py b/src/komet_node/transaction.py index a8a82c9..cd34b3b 100644 --- a/src/komet_node/transaction.py +++ b/src/komet_node/transaction.py @@ -57,12 +57,15 @@ def build_tx_request( rpc_id: Any, transaction_xdr: str, now: str, - ) -> tuple[dict[str, Any], list[KInner] | None]: + ) -> tuple[dict[str, Any], list[KInner] | None, dict[str, bytes]]: """ Decode a transaction XDR envelope into a request envelope for the K semantics. Returns the envelope dict plus, for the wasm-upload path, the kasmer steps to embed - in the ```` cell (``None`` for the common JSON-steps path). + in the ```` cell (``None`` for the common JSON-steps path) and the raw + uploaded wasm bytes keyed by hex hash (empty for the JSON-steps path). The raw + bytes cannot be recovered from the K configuration (the module is stored parsed), + so the server persists them for ``getLedgerEntries`` CONTRACT_CODE lookups. """ envelope = TransactionEnvelope.from_xdr(transaction_xdr, self.network_passphrase) transaction = envelope.transaction @@ -82,8 +85,9 @@ def build_tx_request( # operation per transaction, so such a transaction is exactly one upload op, whose # step we build in K-AST form for direct injection into the cell. if json_steps is not None: - return request, None - return request, self._upload_steps(transaction) + return request, None, {} + upload_steps, uploaded_wasms = self._upload_steps(transaction) + return request, upload_steps, uploaded_wasms def _encode_steps(self, transaction: Transaction) -> list[dict] | None: """Encode each operation as a JSON step dict, or ``None`` if any op needs the wasm path. @@ -147,9 +151,13 @@ def _encode_operation(self, op: Operation, source: MuxedAccount) -> dict | None: case _: return None - def _upload_steps(self, transaction: Transaction) -> list[KInner]: - """Build the kasmer ``uploadWasm`` step(s) for a wasm-upload transaction.""" + def _upload_steps(self, transaction: Transaction) -> tuple[list[KInner], dict[str, bytes]]: + """Build the kasmer ``uploadWasm`` step(s) for a wasm-upload transaction. + + Also returns the raw wasm bytes keyed by hex hash, for the server's side store. + """ steps: list[KInner] = [] + uploaded_wasms: dict[str, bytes] = {} for op in transaction.operations: match op: case InvokeHostFunction(host_function=hf) if ( @@ -157,9 +165,10 @@ def _upload_steps(self, transaction: Transaction) -> list[KInner]: ): assert hf.wasm is not None steps.append(upload_wasm(sha256(hf.wasm), wasm2kast(BytesIO(hf.wasm)))) + uploaded_wasms[sha256(hf.wasm).hex()] = hf.wasm case _: raise NotImplementedError(f'Unexpected operation in wasm-upload transaction: {type(op)}') - return steps + return steps, uploaded_wasms # ------------------------------------------------------------------ # Address / contract-id helpers From b4bfa782f839e17baf5e590962727f0aeb622e9c Mon Sep 17 00:00:00 2001 From: Raoul Date: Fri, 3 Jul 2026 10:57:22 +0000 Subject: [PATCH 3/3] chore: fix mypy findings in scval decoding and the server fixture --- src/komet_node/scval.py | 3 ++- src/tests/integration/test_server.py | 7 +++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/komet_node/scval.py b/src/komet_node/scval.py index 5d3b4be..65eee29 100644 --- a/src/komet_node/scval.py +++ b/src/komet_node/scval.py @@ -253,7 +253,8 @@ def scvalue_from_xdr(xdr: SCVal) -> SCValue: case SCValType.SCV_VEC: assert xdr.vec is not None - return SCVec(tuple(scvalue_from_xdr(v) for v in xdr.vec.sc_vec)) + # komet annotates SCVec.val as tuple[SCValue] instead of tuple[SCValue, ...] + return SCVec(tuple(scvalue_from_xdr(v) for v in xdr.vec.sc_vec)) # type: ignore[arg-type] case SCValType.SCV_MAP: assert xdr.map is not None diff --git a/src/tests/integration/test_server.py b/src/tests/integration/test_server.py index f84c3e1..4ba356f 100644 --- a/src/tests/integration/test_server.py +++ b/src/tests/integration/test_server.py @@ -8,7 +8,7 @@ import time import urllib.request from pathlib import Path -from typing import Any +from typing import TYPE_CHECKING, Any import pytest from stellar_sdk import Account, Address, Keypair, Network, StrKey, TransactionBuilder, xdr @@ -16,6 +16,9 @@ from komet_node.server import StellarRpcServer +if TYPE_CHECKING: + from collections.abc import Iterator + 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) @@ -60,7 +63,7 @@ def _post(port: int, body: bytes) -> dict[str, Any]: @pytest.fixture -def server(tmp_path: Path): +def server(tmp_path: Path) -> Iterator[StellarRpcServer]: port = _find_free_port() srv = StellarRpcServer( host='localhost',