Skip to content
Open
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 @@ -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`)
5 changes: 3 additions & 2 deletions docs/notes.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
27 changes: 25 additions & 2 deletions docs/server.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

---

Expand Down Expand Up @@ -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": "<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 (`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": "<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`

`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.
Expand Down Expand Up @@ -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_<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 )

syntax Step ::= setLedgerSequence(Int) [symbol(setLedgerSequence)]
// ----------------------------------------------------------------------
Expand Down Expand Up @@ -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 `<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
(`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

Expand Down
67 changes: 66 additions & 1 deletion src/komet_node/scval.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -171,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
Expand Down
Loading
Loading