Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,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` 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`)
3 changes: 2 additions & 1 deletion docs/notes.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,8 @@ The tests do not yet cover `bytes` / `address` SCVal arguments or `SCVec` / `SCM
- `sendTransaction`'s `errorResultXdr` is always a generic `txMALFORMED` result (no per-cause codes such as `txBAD_SEQ` — sequence numbers, fees, and signatures are not modelled), and `diagnosticEventsXdr` is never populated (both fields are optional in the spec). `TRY_AGAIN_LATER` is never returned by design: it signals mempool backpressure, which cannot arise in a synchronous node without a mempool.
- `SCVec` / `SCMap` contract arguments are not yet encoded.
- The `xdrFormat` parameter is accepted on `getTransaction`, `sendTransaction`, `getTransactions`, `getLedgers`, and `getLedgerEntries`, but only the default `'base64'` is supported; `'json'` is rejected with `-32602`.
- `simulateTransaction`, `getEvents`, 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` and TTL/footprint operations are not implemented.
- `getFeeStats` reports constant distributions (there is no fee market); only its `latestLedger` is live.
- `getTransactions` / `getLedgers` serve only ledgers with an index file under `ledgers/`; io-dirs created before the ledger index existed resume fine, but their earlier ledgers do not appear in the history.
- `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).
Expand Down
27 changes: 25 additions & 2 deletions docs/server.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,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` (skipping the `<program>` wasm injection when the hash already has a receipt, so a duplicate is not re-executed — the semantics answer `DUPLICATE`); for the read-only methods (`getHealth`, `getNetwork`, `getLatestLedger`, `getTransaction`, `getTransactions`, `getLedgers`, `traceTransaction`) it builds a small envelope and runs it. For the history methods (`getTransactions`, `getLedgers`) the server also validates the pagination parameters — limit range, `startLedger` bounds, `cursor`/`startLedger` exclusivity — and resolves the cursor to the first ledger sequence to serve, because the JSON-RPC parameter-error path lives here. 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 per-ledger XDR artifacts (`_record_closed_ledger`), which K cannot construct. Each call is logged to stderr.
For `sendTransaction` it builds the request envelope with `encoder.build_tx_request` and runs it with `interpreter.run` (skipping the `<program>` wasm injection when the hash already has a receipt, so a duplicate is not re-executed — the semantics answer `DUPLICATE`); 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`, `getTransactions`, `getLedgers`, `getLedgerEntries`, `traceTransaction`) it builds a small envelope and runs it. For the history methods (`getTransactions`, `getLedgers`) the server also validates the pagination parameters — limit range, `startLedger` bounds, `cursor`/`startLedger` exclusivity — and resolves the cursor to the first ledger sequence to serve, because the JSON-RPC parameter-error path lives here. 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), the per-ledger XDR artifacts (`_record_closed_ledger`), and the XDR fields of the `simulateTransaction` response, which K cannot construct or serialize. Each call is logged to stderr.

---

Expand Down Expand Up @@ -157,6 +157,29 @@ Resubmitting a transaction whose hash already has a receipt returns status `DUPL

A transaction that decodes as XDR but cannot be processed (e.g. it contains an operation the semantics do not support) is rejected at admission time with status `ERROR` and an `errorResultXdr` carrying a `txMALFORMED` `TransactionResult` — mirroring how real stellar-rpc reports core's admission rejections. Such a transaction never reaches the ledger: no receipt is stored (`getTransaction` stays `NOT_FOUND`) and the ledger does not advance. Undecodable XDR remains a plain `-32602 Invalid params` error, as in real stellar-rpc. `TRY_AGAIN_LATER` is never returned: it signals mempool backpressure, and komet-node executes synchronously without a mempool, so the condition it reports cannot arise.

### `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": "<base64 SCVal XDR>", "auth": [] } ],
"transactionData": "<base64 SorobanTransactionData XDR>"
}
```

`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 (`scval_from_json` 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": "<reason>", "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` (komet-specific extension)

`traceTransaction` is **not part of the Stellar RPC specification** — it exists only on komet-node, and clients must not expect it from real Stellar RPC endpoints. It keeps its plain name rather than a vendor-prefixed one (`komet_traceTransaction`): the official spec has no method of that name and none is announced, so there is no collision to avoid, and renaming would break every existing client for no gain. If stellar-rpc ever claims the name, the method will be renamed with a prefix.
Expand Down Expand Up @@ -249,7 +272,7 @@ This is the one method whose response the server post-processes: the semantics l

## 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_<hash>.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_<hash>.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.

---

Expand Down
9 changes: 8 additions & 1 deletion src/komet_node/interpreter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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()
Expand All @@ -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

Expand Down
115 changes: 115 additions & 0 deletions src/komet_node/kdist/node.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@ module NODE
| #getTxResult( String, String, JSON, Int )
| #respondTrace( JSON, String )
| #respond( JSON, JSON )
| #simulateStep( JSON )
| #simulateRespond( JSON )
| #respondError( JSON, Int, String )
| #respondHealth( JSON, String, Int )
| #respondLatestLedger( JSON, Int, Int )
Expand Down Expand Up @@ -523,6 +525,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 `<ioDir>` 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
`<hostStack>` 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": <scval
json> }` on success or `{ "latestLedger": N, "error": <string> }` 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 <k> #dispatchMethod( "simulateTransaction", REQ )
=> setLedgerSequence( #getInt( "latest_ledger", String2JSON( {#readFile("metadata.json")}:>String ) ) )
~> #simulateStep( #firstJSON( #getJSON( "steps", REQ, [ .JSONs ] ) ) )
~> #simulateRespond( #getJSON( "id", REQ ) )
...
</k>

syntax JSON ::= #firstJSON( JSON ) [function, symbol(firstJSON)]
// ----------------------------------------------------------------
rule #firstJSON( [ J , _ ] ) => J
```

Like komet's `callTx`, the invocation clears the `<host>` 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]:
<k> #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 "\""))
...
</k>
(_:HostCell => <host> <hostStack> .HostStack </hostStack> ... </host>)

rule [simulateStep-contract]:
<k> #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 "\""))
...
</k>
(_:HostCell => <host> <hostStack> .HostStack </hostStack> ... </host>)

rule [simulateRespond]:
<k> #simulateRespond( ID )
=> #respond( ID, #simulateResult( SCVAL,
#getInt( "latest_ledger", String2JSON( {#readFile("metadata.json")}:>String ) ) ) )
...
</k>
<hostStack> SCVAL:ScVal : _ </hostStack>

// 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
(`scval_from_json` 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.

## getLedgerEntries

The Python server decodes each base64 `LedgerKey` of the request (K cannot parse XDR)
Expand Down
2 changes: 2 additions & 0 deletions src/komet_node/scval.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@
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
Expand Down
Loading
Loading