From 3efd1b3768ab07e467d5f8b7bd4e00ee0a4f5b19 Mon Sep 17 00:00:00 2001 From: Raoul Date: Thu, 2 Jul 2026 13:42:09 +0000 Subject: [PATCH 1/3] test: add integration tests for simulateTransaction Cover the spec-required response shape: latestLedger as a JSON number, minResourceFee as a stringified number, results[0].xdr carrying the host function return value as base64 SCVal XDR, results[0].auth as an array of base64 strings, and transactionData as valid SorobanTransactionData XDR. Optional fields must be omitted rather than null, matching the omitempty serialization of real stellar-rpc. Also assert that simulation never commits (no ledger bump, no receipt, no trace), that failures are reported as a result-level {error, latestLedger} object without the success-only fields, that non-InvokeHostFunction transactions are rejected with a result-level error, and that missing or malformed transaction params return JSON-RPC error -32602. All six tests currently fail with -32601 because the method is not implemented yet. --- src/tests/integration/test_server.py | 199 ++++++++++++++++++++++++++- 1 file changed, 198 insertions(+), 1 deletion(-) diff --git a/src/tests/integration/test_server.py b/src/tests/integration/test_server.py index 3e5cfc3..6a4363f 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, Keypair, Network, StrKey, TransactionBuilder, xdr @@ -16,6 +16,9 @@ from komet_node.server import StellarRpcServer +if TYPE_CHECKING: + from stellar_sdk import TransactionEnvelope + 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) @@ -498,3 +501,197 @@ def builder() -> TransactionBuilder: # All four transactions, including the non-Void invocation, advanced the ledger. assert _rpc(server.port(), 'getLatestLedger', {})['result']['sequence'] == 4 + + +# --------------------------------------------------------------------------- +# simulateTransaction +# +# Response shape per the official OpenRPC spec (methods/simulateTransaction.json) and the +# stellar-rpc Go serialization structs: `latestLedger` is a JSON number and the only +# always-required field; `minResourceFee` is a stringified number; `results` holds exactly +# one `{xdr, auth}` entry for the host-function invocation; optional fields are omitted +# (Go `omitempty`), not null. On failure only `error` (+ `latestLedger`) is returned. +# --------------------------------------------------------------------------- + + +def _deploy_adder_contract(server: StellarRpcServer, keypair: Keypair, account: Account) -> str: + """Create the account, upload adder.wat, and deploy it; return the contract address. + + Submits three transactions, so the ledger sequence afterwards is 3. + """ + from stellar_sdk.utils import sha256 + + def send(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 builder() -> TransactionBuilder: + return TransactionBuilder(account, Network.TESTNET_NETWORK_PASSPHRASE) + + send(builder().append_create_account_op(keypair.public_key, '1000')) + wasm_bytecode = wat_to_wasm(ADDER_CONTRACT_WAT) + send(builder().append_upload_contract_wasm_op(wasm_bytecode)) + wasm_hash = sha256(wasm_bytecode) + salt = b'\x00' * 32 + send(builder().append_create_contract_op(wasm_hash, keypair.public_key, None, salt)) + return server.encoder.contract_address_from_deployer_address(keypair.public_key, salt) + + +def _build_add_invocation(account: Account, contract_address: str) -> TransactionEnvelope: + """Build an *unsigned* envelope invoking add(2, 3) — simulation takes unsigned txs.""" + return ( + TransactionBuilder(account, Network.TESTNET_NETWORK_PASSPHRASE) + .append_invoke_contract_function_op( + contract_address, + 'add', + [ + xdr.SCVal(type=SCValType.SCV_U32, u32=xdr.Uint32(2)), + xdr.SCVal(type=SCValType.SCV_U32, u32=xdr.Uint32(3)), + ], + ) + .set_timeout(30) + .build() + ) + + +def test_simulate_transaction_missing_params_returns_invalid_params(server: StellarRpcServer) -> None: + result = _rpc(server.port(), 'simulateTransaction', {}) + assert result['error']['code'] == -32602 + + +def test_simulate_transaction_bad_xdr_returns_invalid_params(server: StellarRpcServer) -> None: + result = _rpc(server.port(), 'simulateTransaction', {'transaction': 'not-valid-xdr'}) + assert result['error']['code'] == -32602 + + +def test_simulate_transaction_returns_invocation_return_value(server: StellarRpcServer) -> None: + """A successful simulation reports the host function's return value as base64 SCVal XDR.""" + keypair = Keypair.random() + account = Account(keypair.public_key, sequence=0) + contract_address = _deploy_adder_contract(server, keypair, account) + + envelope = _build_add_invocation(account, contract_address) + response = _rpc(server.port(), 'simulateTransaction', {'transaction': envelope.to_xdr()}) + assert 'error' not in response, f'simulateTransaction failed: {response}' + result = response['result'] + + # A successful simulation carries no error field. + assert 'error' not in result + + # latestLedger is a JSON number reflecting the chain tip (three transactions committed). + assert isinstance(result['latestLedger'], int) and not isinstance(result['latestLedger'], bool) + assert result['latestLedger'] == 3 + + # minResourceFee is a stringified number (Go int64 with `,string` encoding). + assert isinstance(result['minResourceFee'], str) + assert result['minResourceFee'].isdigit() + + # Exactly one host-function result: the return value of add(2, 3), plus auth entries. + results = result['results'] + assert isinstance(results, list) + assert len(results) == 1 + assert set(results[0]) == {'xdr', 'auth'} + return_value = xdr.SCVal.from_xdr(results[0]['xdr']) + assert return_value.type == SCValType.SCV_U32 + assert return_value.u32 is not None + assert return_value.u32.uint32 == 5 + assert isinstance(results[0]['auth'], list) + assert all(isinstance(entry, str) for entry in results[0]['auth']) + + # transactionData is valid base64-encoded SorobanTransactionData XDR. + assert isinstance(result['transactionData'], str) + xdr.SorobanTransactionData.from_xdr(result['transactionData']) + + # events is optional; when present it is an array of base64 strings. + if 'events' in result: + assert isinstance(result['events'], list) + assert all(isinstance(event, str) for event in result['events']) + + +def test_simulate_transaction_does_not_commit(server: StellarRpcServer) -> None: + """Simulation must not change the chain: no ledger bump, no receipt, no trace.""" + keypair = Keypair.random() + account = Account(keypair.public_key, sequence=0) + contract_address = _deploy_adder_contract(server, keypair, account) + assert _rpc(server.port(), 'getLatestLedger', {})['result']['sequence'] == 3 + + envelope = _build_add_invocation(account, contract_address) + tx_hash = envelope.hash_hex() + response = _rpc(server.port(), 'simulateTransaction', {'transaction': envelope.to_xdr()}) + assert 'error' not in response, f'simulateTransaction failed: {response}' + assert 'error' not in response['result'] + + # The ledger did not advance and no per-transaction artifacts were persisted. + assert _rpc(server.port(), 'getLatestLedger', {})['result']['sequence'] == 3 + assert not (server.io_dir / 'receipts' / f'receipt_{tx_hash}.json').exists() + assert not (server.io_dir / 'traces' / f'trace_{tx_hash}.jsonl').exists() + assert _rpc(server.port(), 'getTransaction', {'hash': tx_hash})['result']['status'] == 'NOT_FOUND' + + # Simulation is repeatable: simulating the same envelope again yields the same result. + repeat = _rpc(server.port(), 'simulateTransaction', {'transaction': envelope.to_xdr()}) + assert repeat['result']['results'] == response['result']['results'] + assert _rpc(server.port(), 'getLatestLedger', {})['result']['sequence'] == 3 + + +def test_simulate_transaction_failure_returns_error(server: StellarRpcServer) -> None: + """A failing simulation returns {error, latestLedger}; success-only fields are omitted.""" + keypair = Keypair.random() + account = Account(keypair.public_key, sequence=0) + + missing_contract = StrKey.encode_contract(b'\x22' * 32) # valid C-strkey, never deployed + envelope = ( + TransactionBuilder(account, Network.TESTNET_NETWORK_PASSPHRASE) + .append_invoke_contract_function_op(missing_contract, 'foo', []) + .set_timeout(30) + .build() + ) + response = _rpc(server.port(), 'simulateTransaction', {'transaction': envelope.to_xdr()}) + # Simulation failure is reported in the result body, not as a JSON-RPC error. + assert 'error' not in response, f'expected a result-level error, got: {response}' + result = response['result'] + + assert isinstance(result['error'], str) + assert result['error'] != '' + assert isinstance(result['latestLedger'], int) and not isinstance(result['latestLedger'], bool) + assert result['latestLedger'] == 0 + + # Not present in case of error (Go omitempty — omitted, not null). + assert 'results' not in result + assert 'transactionData' not in result + assert 'minResourceFee' not in result + + # A failed simulation likewise commits nothing. + assert _rpc(server.port(), 'getLatestLedger', {})['result']['sequence'] == 0 + assert not (server.io_dir / 'receipts' / f'receipt_{envelope.hash_hex()}.json').exists() + + +def test_simulate_transaction_non_invoke_host_function_returns_error(server: StellarRpcServer) -> None: + """Simulating a non-InvokeHostFunction transaction reports a result-level error. + + Per the spec, the provided transaction must contain only a single operation of type + invokeHostFunction; real stellar-rpc reports the violation in the result body. + """ + keypair = Keypair.random() + account = Account(keypair.public_key, sequence=0) + envelope = ( + TransactionBuilder(account, Network.TESTNET_NETWORK_PASSPHRASE) + .append_create_account_op(destination=keypair.public_key, starting_balance='1000') + .set_timeout(30) + .build() + ) + response = _rpc(server.port(), 'simulateTransaction', {'transaction': envelope.to_xdr()}) + assert 'error' not in response, f'expected a result-level error, got: {response}' + result = response['result'] + + assert isinstance(result['error'], str) + assert result['error'] != '' + assert isinstance(result['latestLedger'], int) and not isinstance(result['latestLedger'], bool) + assert 'results' not in result + + # Nothing was committed: the create-account operation did not execute. + assert _rpc(server.port(), 'getLatestLedger', {})['result']['sequence'] == 0 + assert not (server.io_dir / 'receipts' / f'receipt_{envelope.hash_hex()}.json').exists() From 64f4d9af75e7242e80808ce064bda63b6c63f049 Mon Sep 17 00:00:00 2001 From: Raoul Date: Fri, 3 Jul 2026 10:33:13 +0000 Subject: [PATCH 2/3] feat: implement simulateTransaction as a non-committing dry run Dispatch a simulateTransaction request through the K semantics like a regular invocation, but discard the resulting configuration (interpreter.run gains a commit flag) and write no receipt, trace, or ledger bump. The semantics report the invocation's return value as a JSON-encoded ScVal; the server serializes it to base64 SCVal XDR (json_to_scval) and fills in a synthetic minResourceFee and an empty-footprint SorobanTransactionData, since K cannot build XDR. Failures (trapped call, unknown contract, non-invoke transaction) are reported in the result body as {error, latestLedger}, matching real stellar-rpc; only undecodable transaction XDR is a -32602 error. --- docs/architecture.md | 2 +- docs/notes.md | 5 +- docs/server.md | 27 +++++++- src/komet_node/interpreter.py | 9 ++- src/komet_node/kdist/node.md | 115 ++++++++++++++++++++++++++++++++++ src/komet_node/scval.py | 64 +++++++++++++++++++ src/komet_node/server.py | 98 +++++++++++++++++++++++++++-- src/komet_node/transaction.py | 40 ++++++++++++ 8 files changed, 350 insertions(+), 10 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index c36ae26..244fb8f 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -176,7 +176,7 @@ sequenceDiagram ## What's not yet implemented - `resultXdr` / `resultMetaXdr` in `getTransaction` responses (contract return values) -- `simulateTransaction` (dry-run without state mutation) +- `simulateTransaction` of upload/deploy host functions, auth recording, resource metering, `events`/`restorePreamble`/`stateChanges` (only invoke-contract dry runs with the return value are supported) - `getEvents`, `getLedgerEntries`, `getFeeStats` and other read-only RPC methods - `ExtendFootprintTTL` and `RestoreFootprint` operations - `SCVec` / `SCMap` contract-argument types in the request encoder (`scval_to_json`) diff --git a/docs/notes.md b/docs/notes.md index f1a0b3d..adf916c 100644 --- a/docs/notes.md +++ b/docs/notes.md @@ -33,6 +33,7 @@ The tests do not yet cover `bytes` / `address` SCVal arguments or `SCVec` / `SCM ## Known gaps -- `resultXdr` / `resultMetaXdr` are empty stubs (contract return values not surfaced). +- `resultXdr` / `resultMetaXdr` are empty stubs (contract return values not surfaced in `getTransaction`). - `SCVec` / `SCMap` contract arguments are not yet encoded. -- `simulateTransaction`, `getEvents`, `getLedgerEntries`, `getFeeStats`, and TTL/footprint operations are not implemented. +- `simulateTransaction` only simulates invoke-contract host functions (not uploads/deploys); `auth` is always empty, `minResourceFee`/`transactionData` are synthetic constants, and `events`/`restorePreamble`/`stateChanges` and the `resourceConfig`/`authMode`/`xdrFormat` parameters are not supported. `ScMap` return values are not yet encoded. +- `getEvents`, `getLedgerEntries`, `getFeeStats`, and TTL/footprint operations are not implemented. diff --git a/docs/server.md b/docs/server.md index 0af41c5..58e5f5d 100644 --- a/docs/server.md +++ b/docs/server.md @@ -28,7 +28,7 @@ server = StellarRpcServer(io_dir=Path('out')) server.handle_rpc('sendTransaction', {'transaction': xdr}) ``` -For `sendTransaction` it builds the request envelope with `encoder.build_tx_request` and runs it with `interpreter.run`; for the read-only methods (`getHealth`, `getNetwork`, `getLatestLedger`, `getTransaction`, `traceTransaction`) it builds a small envelope and runs it. In every case the *content* of the response is produced by the semantics (`node.md`), not by Python — the one exception is the failure fallback (below). Each call is logged to stderr. +For `sendTransaction` it builds the request envelope with `encoder.build_tx_request` and runs it with `interpreter.run`; for `simulateTransaction` it builds the envelope with `encoder.build_simulate_request` and runs it without committing; for the read-only methods (`getHealth`, `getNetwork`, `getLatestLedger`, `getTransaction`, `traceTransaction`) it builds a small envelope and runs it. In every case the *content* of the response is produced by the semantics (`node.md`), not by Python — the exceptions are the failure fallback (below) and the XDR fields of the `simulateTransaction` response, which K cannot serialize. Each call is logged to stderr. --- @@ -114,6 +114,29 @@ All methods are answered by the K semantics and follow the [Stellar RPC specific { "hash": "<64-char hex>", "status": "PENDING", "latestLedger": "5", "latestLedgerCloseTime": "1716000000" } ``` +### `simulateTransaction` + +`simulateTransaction` takes a base64-encoded XDR transaction envelope (which may be unsigned) containing exactly one `InvokeHostFunction` operation and executes it as a dry run: the invocation runs against the current world state, but nothing is committed — no receipt, no trace, no ledger bump, and the resulting configuration is discarded (`interpreter.run(..., commit=False)`). + +**Response** (success): +```json +{ + "latestLedger": 4, + "minResourceFee": "100000", + "results": [ { "xdr": "", "auth": [] } ], + "transactionData": "" +} +``` + +`results[0].xdr` is the host function's return value. The K semantics report it as a JSON-encoded ScVal (see the simulateTransaction section of `node.md`), and the server serializes it to SCVal XDR (`json_to_scval` in `scval.py`) — K has no XDR encoder and no base64 hook, so this is the one read path where Python builds part of the response content. `minResourceFee` is a synthetic constant and `transactionData` an empty-footprint, all-zero-resources placeholder, because the semantics do not meter resources. `auth` is always empty (authorization recording is not implemented). + +**Response** (failure — the invocation trapped, the target contract does not exist, the transaction is not a single `InvokeHostFunction`, or the host function is an upload/deploy, which cannot be simulated yet): +```json +{ "error": "", "latestLedger": 4 } +``` + +Failures are reported in the result body, matching real stellar-rpc; only an undecodable `transaction` parameter is a JSON-RPC error (`-32602`). A failed simulation likewise commits nothing. The optional `events`, `restorePreamble`, and `stateChanges` response fields and the `resourceConfig`/`authMode`/`xdrFormat` parameters are not supported. + ### `traceTransaction` `traceTransaction` retrieves the instruction trace of a previously submitted transaction. It takes a `hash` parameter (the same one `getTransaction` takes) and returns the trace that `sendTransaction` stored on that transaction's receipt. The result is the trace itself: a JSONL string with one record per executed WebAssembly instruction, or `null` when no transaction with that hash exists. @@ -147,7 +170,7 @@ All methods are answered by the K semantics and follow the [Stellar RPC specific ## Failure fallback -A failed transaction leaves the semantics stuck without writing `response.json`, so `interpreter.run` returns `None`. Only `sendTransaction` executes a transaction, so it is the only method that reaches this path. The server then synthesises the response in Python: it writes a `FAILED` `receipts/receipt_.json` (so a later `getTransaction` finds it), without bumping the ledger, and returns `PENDING`. This is the only response content the server builds itself. +A failed transaction leaves the semantics stuck without writing `response.json`, so `interpreter.run` returns `None`. For `sendTransaction` the server then synthesises the response in Python: it writes a `FAILED` `receipts/receipt_.json` (so a later `getTransaction` finds it), without bumping the ledger, and returns `PENDING`. A stuck `simulateTransaction` run is answered with a result-level `{error, latestLedger}` and leaves no artifacts behind. --- diff --git a/src/komet_node/interpreter.py b/src/komet_node/interpreter.py index 07311d6..05ee4d1 100644 --- a/src/komet_node/interpreter.py +++ b/src/komet_node/interpreter.py @@ -124,6 +124,8 @@ def run( io_dir: Path, request: dict[str, Any], program_steps: list[KInner] | None = None, + *, + commit: bool = True, ) -> str | None: """ Run a single RPC request envelope against the saved KORE configuration. @@ -137,6 +139,10 @@ def run( persist the new configuration to ``state.kore``. If ``response.json`` was not produced the request got stuck (a failed transaction) — we keep the previous ``state.kore`` and return ``None`` so the caller can synthesise a failure response. + + With ``commit=False`` the resulting configuration is discarded even on success: + the run executes against the current state but never writes ``state.kore`` back. + This is what makes ``simulateTransaction`` a dry run. """ state_file = state_file.resolve() io_dir = io_dir.resolve() @@ -153,7 +159,8 @@ def run( result = _llvm_interpret(self.definition.path, pattern, cwd=io_dir) if response_file.exists(): - state_file.write_text(result.text) + if commit: + state_file.write_text(result.text) return response_file.read_text() return None diff --git a/src/komet_node/kdist/node.md b/src/komet_node/kdist/node.md index b8f1db2..dfb6e52 100644 --- a/src/komet_node/kdist/node.md +++ b/src/komet_node/kdist/node.md @@ -55,6 +55,8 @@ module NODE | #getTxResult( String, String, JSON, Int ) | #respondTrace( JSON, String ) | #respond( JSON, JSON ) + | #simulateStep( JSON ) + | #simulateRespond( JSON ) syntax Step ::= setLedgerSequence(Int) [symbol(setLedgerSequence)] // ---------------------------------------------------------------------- @@ -350,6 +352,119 @@ exists for that hash. requires notBool #fileExists( #traceFile( HASH ) ) ``` +## simulateTransaction + +Run a single contract invocation against the current world state *without committing +anything*: no receipt, no trace, no ledger bump, and the Python server does not persist the +resulting configuration (`interpreter.run(..., commit=False)`). Tracing stays disabled +because `` is left empty. + +The request envelope carries exactly one `callTx` step (the server rejects anything else +before dispatching here). After the call, the invocation's return value sits on the +`` as a fully resolved `ScVal` — the one thing `sendTransaction` discards and +simulation exists to report. + +The K side responds with an *internal* result, `{ "latestLedger": N, "returnValue": }` on success or `{ "latestLedger": N, "error": }` when the call trapped. +The server maps it to the spec response shape: the return value must be serialized as +base64 SCVal XDR and `transactionData` as base64 `SorobanTransactionData`, and K can build +neither (no XDR encoder, no base64 hook), so that final serialization step lives in Python +(`server.py`). A simulation that gets stuck (e.g. calling a contract that does not exist) +produces no `response.json`, and the server synthesises the error result. + +```k + rule #dispatchMethod( "simulateTransaction", REQ ) + => setLedgerSequence( #getInt( "latest_ledger", String2JSON( {#readFile("metadata.json")}:>String ) ) ) + ~> #simulateStep( #firstJSON( #getJSON( "steps", REQ, [ .JSONs ] ) ) ) + ~> #simulateRespond( #getJSON( "id", REQ ) ) + ... + + + syntax JSON ::= #firstJSON( JSON ) [function, symbol(firstJSON)] + // ---------------------------------------------------------------- + rule #firstJSON( [ J , _ ] ) => J +``` + +Like komet's `callTx`, the invocation clears the `` cell first, so afterwards the +stack holds exactly the call's result. Unlike `callTx` (and `uncheckedCallTx`), there is no +`#resetHost` after the call: `#simulateRespond` still needs the result, and the +configuration is thrown away when the run ends, so nothing leaks into later requests. The +step patterns must mirror the `callTx` case of `#decodeStep` (key order is significant). + +```k + rule [simulateStep-account]: + #simulateStep({ "op" : "callTx" , "from" : FROM:String , "fromIsContract" : false , "func" : FUNC:String , "to" : TO:String , "args" : [ ARGS:JSONs ] }) + => allocObjects(#decodeArgList(ARGS)) + ~> callContractFromStack(Account(HexBytes(FROM)), Contract(HexBytes(TO)), string2WasmToken("\"" +String FUNC +String "\"")) + ... + + (_:HostCell => .HostStack ... ) + + rule [simulateStep-contract]: + #simulateStep({ "op" : "callTx" , "from" : FROM:String , "fromIsContract" : true , "func" : FUNC:String , "to" : TO:String , "args" : [ ARGS:JSONs ] }) + => allocObjects(#decodeArgList(ARGS)) + ~> callContractFromStack(Contract(HexBytes(FROM)), Contract(HexBytes(TO)), string2WasmToken("\"" +String FUNC +String "\"")) + ... + + (_:HostCell => .HostStack ... ) + + rule [simulateRespond]: + #simulateRespond( ID ) + => #respond( ID, #simulateResult( SCVAL, + #getInt( "latest_ledger", String2JSON( {#readFile("metadata.json")}:>String ) ) ) ) + ... + + SCVAL:ScVal : _ + + // A trapped call self-heals (the world state is rolled back by `endWasm-error`) and + // leaves an `Error` ScVal on the stack; report it as a result-level error. + syntax JSON ::= #simulateResult( ScVal, Int ) [function, symbol(simulateResult)] + // -------------------------------------------------------------------------------- + rule #simulateResult( Error(T, C), LL ) => { + "latestLedger" : LL, + "error" : "host function invocation failed (error type " + +String Int2String(ErrorType2Int(T)) + +String ", code " +String Int2String(C) +String ")" + } + rule #simulateResult( SCVAL, LL ) => { + "latestLedger" : LL, + "returnValue" : #scValJSON( SCVAL ) + } [owise] +``` + +`#scValJSON` encodes an `ScVal` return value as JSON for the Python side to XDR-serialize +(`json_to_scval` in `scval.py` is its inverse — keep the two in sync). Python reads these +objects with order-independent lookups, but the fields are emitted in the same order as the +`#decodeArg` patterns for consistency. Unsupported return types (`ScMap`, unresolved host +values, ...) have no rule: the run gets stuck and the server reports a simulation error. + +```k + syntax JSON ::= #scValJSON( ScVal ) [function, symbol(scValJSON)] + // ----------------------------------------------------------------- + rule #scValJSON( SCBool(B) ) => { "type" : "bool" , "value" : B } + rule #scValJSON( Void ) => { "type" : "void" } + rule #scValJSON( U32(I) ) => { "type" : "u32" , "value" : I } + rule #scValJSON( I32(I) ) => { "type" : "i32" , "value" : I } + rule #scValJSON( U64(I) ) => { "type" : "u64" , "value" : I } + rule #scValJSON( I64(I) ) => { "type" : "i64" , "value" : I } + rule #scValJSON( U128(I) ) => { "type" : "u128" , "value" : I } + rule #scValJSON( I128(I) ) => { "type" : "i128" , "value" : I } + rule #scValJSON( Symbol(S) ) => { "type" : "symbol" , "value" : S } + rule #scValJSON( ScString(S) ) => { "type" : "string" , "value" : S } + rule #scValJSON( ScBytes(B) ) => { "type" : "bytes" , "value" : Bytes2Hex(B) } + rule #scValJSON( ScAddress(Account(B)) ) => { "type" : "address" , "addrType" : "account" , "value" : Bytes2Hex(B) } + rule #scValJSON( ScAddress(Contract(B)) ) => { "type" : "address" , "addrType" : "contract" , "value" : Bytes2Hex(B) } + rule #scValJSON( ScVec(L) ) => { "type" : "vec" , "value" : [ #scValJSONs(L) ] } + + syntax JSONs ::= #scValJSONs( List ) [function, symbol(scValJSONs)] + // ------------------------------------------------------------------- + rule #scValJSONs( .List ) => .JSONs + rule #scValJSONs( ListItem(V:ScVal) L ) => #scValJSON(V) , #scValJSONs(L) +``` + +`Bytes2Hex` (the inverse of `HexBytes`, two hex digits per byte) is K's hooked builtin from +the BYTES module; the Python decoder (`bytes.fromhex`) accepts either letter case. + ############################################################################### # Step decoding diff --git a/src/komet_node/scval.py b/src/komet_node/scval.py index c379b60..8ce3511 100644 --- a/src/komet_node/scval.py +++ b/src/komet_node/scval.py @@ -20,9 +20,12 @@ 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 +_UINT64_MASK = (1 << 64) - 1 + if TYPE_CHECKING: from komet.scval import SCValue from stellar_sdk.xdr.sc_address import SCAddress as XDRSCAddress @@ -79,6 +82,67 @@ def scval_to_json(scval: SCVal) -> dict: raise NotImplementedError(f'Unsupported SCVal type for JSON encoding: {scval.type}') +def json_to_scval(value: dict) -> stellar_xdr.SCVal: + """Decode the JSON encoding of an ScVal produced by K (``#scValJSON`` in ``node.md``) + into a Stellar XDR SCVal. + + This is the K-to-Python direction (contract *return values*, e.g. from + ``simulateTransaction``), the inverse of :func:`scval_to_json` — keep the two and the + ``#scValJSON`` rules in sync. + """ + match value['type']: + case 'bool': + return stellar_xdr.SCVal(SCValType.SCV_BOOL, b=value['value']) + case 'void': + return stellar_xdr.SCVal(SCValType.SCV_VOID) + case 'u32': + return stellar_xdr.SCVal(SCValType.SCV_U32, u32=stellar_xdr.Uint32(value['value'])) + case 'i32': + return stellar_xdr.SCVal(SCValType.SCV_I32, i32=stellar_xdr.Int32(value['value'])) + case 'u64': + return stellar_xdr.SCVal(SCValType.SCV_U64, u64=stellar_xdr.Uint64(value['value'])) + case 'i64': + return stellar_xdr.SCVal(SCValType.SCV_I64, i64=stellar_xdr.Int64(value['value'])) + case 'u128': + val = value['value'] + parts = stellar_xdr.UInt128Parts( + hi=stellar_xdr.Uint64(val >> 64), lo=stellar_xdr.Uint64(val & _UINT64_MASK) + ) + return stellar_xdr.SCVal(SCValType.SCV_U128, u128=parts) + case 'i128': + val = value['value'] + # Arithmetic right shift keeps the sign in hi; lo is the unsigned low word. + iparts = stellar_xdr.Int128Parts(hi=stellar_xdr.Int64(val >> 64), lo=stellar_xdr.Uint64(val & _UINT64_MASK)) + return stellar_xdr.SCVal(SCValType.SCV_I128, i128=iparts) + case 'symbol': + return stellar_xdr.SCVal(SCValType.SCV_SYMBOL, sym=stellar_xdr.SCSymbol(value['value'].encode('ascii'))) + case 'string': + return stellar_xdr.SCVal(SCValType.SCV_STRING, str=stellar_xdr.SCString(value['value'].encode('utf-8'))) + case 'bytes': + return stellar_xdr.SCVal(SCValType.SCV_BYTES, bytes=stellar_xdr.SCBytes(bytes.fromhex(value['value']))) + case 'address': + return stellar_xdr.SCVal(SCValType.SCV_ADDRESS, address=_json_to_sc_address(value)) + case 'vec': + vec = stellar_xdr.SCVec([json_to_scval(item) for item in value['value']]) + return stellar_xdr.SCVal(SCValType.SCV_VEC, vec=vec) + case other: + raise NotImplementedError(f'Unsupported ScVal JSON type: {other}') + + +def _json_to_sc_address(value: dict) -> XDRSCAddress: + raw = bytes.fromhex(value['value']) + if value['addrType'] == 'account': + account_id = stellar_xdr.AccountID( + stellar_xdr.PublicKey(stellar_xdr.PublicKeyType.PUBLIC_KEY_TYPE_ED25519, ed25519=stellar_xdr.Uint256(raw)) + ) + return stellar_xdr.SCAddress(SCAddressType.SC_ADDRESS_TYPE_ACCOUNT, account_id=account_id) + assert value['addrType'] == 'contract' + return stellar_xdr.SCAddress( + SCAddressType.SC_ADDRESS_TYPE_CONTRACT, + contract_id=stellar_xdr.ContractID(stellar_xdr.Hash(raw)), + ) + + 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..451c78e 100644 --- a/src/komet_node/server.py +++ b/src/komet_node/server.py @@ -10,18 +10,48 @@ from pathlib import Path from typing import TYPE_CHECKING, Any, Final -from stellar_sdk import Network +from stellar_sdk import Network, xdr from komet_node.interpreter import NodeInterpreter -from komet_node.transaction import TransactionEncoder +from komet_node.scval import json_to_scval +from komet_node.transaction import SimulationRejected, TransactionEncoder if TYPE_CHECKING: from http.server import HTTPServer as HTTPServerType _PROTOCOL_VERSION: Final = '22' -# Only sendTransaction executes a transaction. traceTransaction is a read-only lookup of the -# trace stored on a previously executed transaction's receipt (see _read_only_envelope). +# Synthetic minimum resource fee (in stroops) reported by simulateTransaction. The K +# semantics do not meter resources, so any plausible constant is as honest as another; +# clients only need a stringified number they can add on top of the inclusion fee. +_MIN_RESOURCE_FEE: Final = '100000' + + +def _empty_transaction_data() -> str: + """Minimal valid ``SorobanTransactionData``: empty footprint, all-zero resources. + + komet-node does not compute ledger footprints or resource usage, so all-zero values + are the honest choice; the field exists because clients expect to XDR-decode it. + """ + data = xdr.SorobanTransactionData( + ext=xdr.SorobanTransactionDataExt(0), + resources=xdr.SorobanResources( + footprint=xdr.LedgerFootprint(read_only=[], read_write=[]), + instructions=xdr.Uint32(0), + disk_read_bytes=xdr.Uint32(0), + write_bytes=xdr.Uint32(0), + ), + resource_fee=xdr.Int64(0), + ) + return data.to_xdr() + + +_TRANSACTION_DATA: Final = _empty_transaction_data() + +# Only sendTransaction executes and commits a transaction. simulateTransaction also executes +# one, but as a dry run with its own dispatch path (see _handle_simulate); traceTransaction +# is a read-only lookup of the trace stored on a previously executed transaction's receipt +# (see _read_only_envelope). _TX_METHODS: Final = ('sendTransaction',) _log = logging.getLogger('komet_node') @@ -200,6 +230,9 @@ def handle_rpc(self, method: str | None, params: dict[str, Any], request_id: Any return json.dumps(self._failure_response(request_id, envelope, now)) return response + if method == 'simulateTransaction': + return self._handle_simulate(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 @@ -232,6 +265,63 @@ def _read_only_envelope( return {**base, 'hash': tx_hash} return None + # ------------------------------------------------------------------ + # simulateTransaction + # ------------------------------------------------------------------ + + def _handle_simulate(self, params: dict[str, Any], request_id: Any, now: str) -> str: + """Dispatch a ``simulateTransaction`` call: a dry run that commits nothing. + + The K semantics execute the invocation against the current state and report the + return value (see the simulateTransaction section of ``node.md``); the run is not + persisted (``commit=False``), no receipt or trace is written, and the ledger does + not advance. Simulation failures are reported in the result body (``{error, + latestLedger}``), matching real stellar-rpc; only an undecodable ``transaction`` + param is a JSON-RPC error. + """ + transaction = params.get('transaction') + if not isinstance(transaction, str): + return _error_str(request_id, -32602, "Invalid params: 'transaction' (XDR string) is required") + try: + envelope = self.encoder.build_simulate_request(request_id, transaction, now) + except SimulationRejected as err: + return json.dumps(self._simulate_error_response(request_id, str(err))) + except Exception: + traceback.print_exc() + return _error_str(request_id, -32602, 'Invalid params: could not process transaction') + + response = self.interpreter.run(self.state_file, self.io_dir, envelope, None, commit=False) + if response is None: + # The run got stuck: the invocation could not be executed at all (e.g. the + # target contract does not exist). Report it as a simulation error. + return json.dumps(self._simulate_error_response(request_id, 'transaction simulation failed')) + return json.dumps(self._simulate_response(request_id, json.loads(response)['result'])) + + def _simulate_response(self, rpc_id: Any, k_result: dict[str, Any]) -> dict[str, Any]: + """Map the internal K simulation result to the spec response shape. + + This is the one read path where Python builds response content: the spec requires + the return value as base64 SCVal XDR and ``transactionData`` as base64 + ``SorobanTransactionData``, and K can construct neither (no XDR encoder, no base64 + hook). ``latestLedger`` and the error/success split come from K unchanged. + """ + if 'error' in k_result: + result: dict[str, Any] = {'error': k_result['error'], 'latestLedger': k_result['latestLedger']} + else: + result = { + 'latestLedger': k_result['latestLedger'], + 'minResourceFee': _MIN_RESOURCE_FEE, + 'results': [{'xdr': json_to_scval(k_result['returnValue']).to_xdr(), 'auth': []}], + 'transactionData': _TRANSACTION_DATA, + } + return {'jsonrpc': '2.0', 'id': rpc_id, 'result': result} + + def _simulate_error_response(self, rpc_id: Any, message: str) -> dict[str, Any]: + """Build the ``{error, latestLedger}`` result for a simulation that never ran in K.""" + metadata = json.loads((self.io_dir / 'metadata.json').read_text()) + result = {'error': message, 'latestLedger': metadata.get('latest_ledger', 0)} + return {'jsonrpc': '2.0', 'id': rpc_id, 'result': result} + def _archive_request(self, method: str | None, params: dict[str, Any], request_id: Any) -> None: """Write each incoming JSON-RPC call to its own ``requests/request_.json`` file. diff --git a/src/komet_node/transaction.py b/src/komet_node/transaction.py index a8a82c9..39feb7f 100644 --- a/src/komet_node/transaction.py +++ b/src/komet_node/transaction.py @@ -36,6 +36,17 @@ def _xlm_to_stroops(balance: object) -> int: return int(stroops) +class SimulationRejected(Exception): + """The transaction decoded fine but cannot be simulated. + + Raised by :meth:`TransactionEncoder.build_simulate_request` for transactions the + simulator rejects up front (wrong operation set, unsupported host function). The + message is user-facing: the server reports it verbatim as the result-level ``error`` + of the ``simulateTransaction`` response, mirroring how real stellar-rpc reports these + violations in the result body rather than as a JSON-RPC error. + """ + + class TransactionEncoder: """ Decodes Stellar XDR transactions into the request envelope consumed by ``node.md``. @@ -85,6 +96,35 @@ def build_tx_request( return request, None return request, self._upload_steps(transaction) + def build_simulate_request(self, rpc_id: Any, transaction_xdr: str, now: str) -> dict[str, Any]: + """ + Decode a transaction XDR envelope into a ``simulateTransaction`` request envelope. + + Per the spec, the transaction must contain exactly one ``InvokeHostFunction`` + operation. Of the three host-function kinds, only contract invocation is + simulated: uploads and deploys carry a wasm payload / deterministic address rather + than a computed return value, and simulating them is not supported yet. Both + violations raise :class:`SimulationRejected` (a result-level error); a malformed + XDR string raises from ``TransactionEnvelope.from_xdr`` (an invalid-params error). + """ + envelope = TransactionEnvelope.from_xdr(transaction_xdr, self.network_passphrase) + transaction = envelope.transaction + + if len(transaction.operations) != 1 or not isinstance(transaction.operations[0], InvokeHostFunction): + raise SimulationRejected('transaction must contain exactly one InvokeHostFunction operation') + op = transaction.operations[0] + if op.host_function.type != xdr.HostFunctionType.HOST_FUNCTION_TYPE_INVOKE_CONTRACT: + raise SimulationRejected(f'host function type {op.host_function.type.name} cannot be simulated yet') + + step = self._encode_operation(op, transaction.source) + assert step is not None # the invoke-contract case always encodes + return { + 'method': 'simulateTransaction', + 'id': rpc_id, + 'now': now, + 'steps': [step], + } + 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. From 2dd5259c3e732c50d2a0ba360c74dffdc262760c Mon Sep 17 00:00:00 2001 From: Raoul Date: Fri, 3 Jul 2026 10:52:33 +0000 Subject: [PATCH 3/3] chore: fix mypy errors Both errors predate this branch: komet's SCVec.val annotation is tuple[SCValue] (a 1-tuple) rather than tuple[SCValue, ...], so the call in scvalue_from_xdr needs a targeted ignore, and the server test fixture was missing its return type annotation. make check now passes. --- src/komet_node/scval.py | 3 ++- src/tests/integration/test_server.py | 4 +++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/komet_node/scval.py b/src/komet_node/scval.py index 8ce3511..90bd55f 100644 --- a/src/komet_node/scval.py +++ b/src/komet_node/scval.py @@ -235,7 +235,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 6a4363f..40e8264 100644 --- a/src/tests/integration/test_server.py +++ b/src/tests/integration/test_server.py @@ -17,6 +17,8 @@ from komet_node.server import StellarRpcServer if TYPE_CHECKING: + from collections.abc import Iterator + from stellar_sdk import TransactionEnvelope EMPTY_CONTRACT_WAT = (Path(__file__).parent / 'data' / 'wasm' / 'empty.wat').resolve(strict=True) @@ -62,7 +64,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',