Skip to content

Commit 1179f4a

Browse files
feat: return real result XDR from getTransaction
Receipts now carry a real base64 TransactionResult in resultXdr and TransactionMeta v3 in resultMetaXdr instead of empty-string stubs. The semantics record the contract call's return value: uncheckedCallTx no longer resets the host after the call, so #recordAndRespond can read the returned ScVal off the hostStack, serialise it into the receipt's internal returnValue field (JSON-encoded via the new #scValToJSON), and reset the host afterwards. The server rewrites that field into the spec-mandated XDR structs (new result_xdr.py builders, scval_from_json decoder), since K cannot construct XDR. An invocation's return value is reported as sorobanMeta.returnValue in the meta, matching stellar-rpc. Failed transactions now store a txFAILED TransactionResult in the FAILED receipt (resultMetaXdr is omitted: a rolled-back run produces no meta), keeping ledger/createdAt encoded as on SUCCESS receipts. Fees and ledger-entry change sets in the synthesised structs are zero/empty because komet-node does not track them; documented in docs/notes.md.
1 parent 7d57885 commit 1179f4a

8 files changed

Lines changed: 313 additions & 19 deletions

File tree

docs/architecture.md

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@ All of the server's input and output artifacts live in one directory, the *io di
8989
|---|---|---|---|
9090
| `state.kore` | persistent | `NodeInterpreter` | the full K world-state configuration — accounts, contract code (including uploaded wasm `ModuleDecl`s), contract storage, ledger metadata — serialized in KORE. Read before each run and rewritten after a successful one. |
9191
| `metadata.json` | persistent | the K semantics | `{"latest_ledger": N}` — the server ledger counter, bumped by 1 per committed transaction. |
92-
| `receipts/receipt_<hash>.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}`. |
92+
| `receipts/receipt_<hash>.json` | persistent | the semantics and the server (on success — the server rewrites the semantics' internal `returnValue` field into `resultXdr`/`resultMetaXdr`), or the server alone (on failure) | one stored receipt per transaction, keyed by tx hash, answering `getTransaction`. Each is `{status, ledger, createdAt, envelopeXdr, resultXdr, resultMetaXdr?}`. |
9393
| `traces/trace_<hash>.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. |
9494
| `requests/request_<n>.json` | persistent | the server | an archive of each incoming JSON-RPC request, numbered by a monotonic counter, kept for debugging. |
9595
| `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. |
@@ -175,7 +175,6 @@ sequenceDiagram
175175

176176
## What's not yet implemented
177177

178-
- `resultXdr` / `resultMetaXdr` in `getTransaction` responses (contract return values)
179178
- `simulateTransaction` (dry-run without state mutation)
180179
- `getEvents`, `getLedgerEntries`, `getFeeStats` and other read-only RPC methods
181180
- `ExtendFootprintTTL` and `RestoreFootprint` operations

docs/node-semantics.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,9 +83,13 @@ If `request.json` is absent, `insert-handleRequestFile` does not fire and K halt
8383

8484
1. writes `metadata.json` with `latest_ledger + 1`,
8585
2. writes the receipt to `receipts/receipt_<hash>.json`:
86-
`{ status: "SUCCESS", ledger, createdAt, envelopeXdr, resultXdr: "", resultMetaXdr: "" }`,
86+
`{ status: "SUCCESS", ledger, createdAt, envelopeXdr, returnValue }``returnValue` is
87+
the contract call's return `ScVal`, JSON-encoded by `#scValToJSON` (or `null` when the
88+
transaction made no contract call), read off the `<hostStack>` before the host is reset,
8789
3. responds with `{hash, status: "PENDING", latestLedger, latestLedgerCloseTime}`.
8890

91+
The internal `returnValue` field never reaches an RPC client: the Python server immediately rewrites it into the spec-mandated `resultXdr`/`resultMetaXdr` base64 XDR fields (`_attach_result_xdr` in `server.py`), because K cannot construct XDR. To keep the return value observable here, `uncheckedCallTx` (unlike komet's `callTx`) does not `#resetHost` after the call; `#recordAndRespond` serialises the stack top and resets the host instead.
92+
8993
The trace is not part of the receipt — the executing steps already appended it to `traces/trace_<hash>.jsonl`. Reaching `#finalizeTx` means the steps completed without getting stuck, so the status is `SUCCESS`. A failed transaction gets stuck before this point, `response.json` is never written, and the Python server records the `FAILED` receipt instead.
9094

9195
### traceTransaction

docs/notes.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@ The tests do not yet cover `bytes` / `address` SCVal arguments or `SCVec` / `SCM
3333

3434
## Known gaps
3535

36-
- `resultXdr` / `resultMetaXdr` are empty stubs (contract return values not surfaced).
36+
- `resultXdr` / `resultMetaXdr` are synthesised: `feeCharged` is 0, ledger-entry change
37+
sets are empty, and the InvokeHostFunction success hash covers only the return value
38+
(komet-node does not track fees, entry changes, or events).
3739
- `SCVec` / `SCMap` contract arguments are not yet encoded.
3840
- `simulateTransaction`, `getEvents`, `getLedgerEntries`, `getFeeStats`, and TTL/footprint operations are not implemented.

docs/server.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -136,18 +136,20 @@ All methods are answered by the K semantics and follow the [Stellar RPC specific
136136
```json
137137
{
138138
"status": "SUCCESS", "ledger": "5", "createdAt": "1716000000",
139-
"envelopeXdr": "<base64 XDR>", "resultXdr": "", "resultMetaXdr": "",
139+
"envelopeXdr": "<base64 XDR>", "resultXdr": "<base64 XDR>", "resultMetaXdr": "<base64 XDR>",
140140
"latestLedger": "5", "latestLedgerCloseTime": "1716000000"
141141
}
142142
```
143143

144-
`resultXdr` and `resultMetaXdr` are currently empty stubs. The receipt carries no trace — use `traceTransaction` with the same hash to fetch it.
144+
`resultXdr` is a base64 `TransactionResult` (code `txSUCCESS` or `txFAILED`) and `resultMetaXdr` a base64 `TransactionMeta` v3; when the transaction invoked a contract, its return value is reported as `sorobanMeta.returnValue` inside the meta. Both are synthesised by the server (`result_xdr.py`) from the envelope and the return value the semantics recorded in the receipt — see [node-semantics.md](node-semantics.md). Fees and ledger-entry change sets in these structs are zero/empty (komet-node does not track them). `resultMetaXdr` is omitted on `FAILED` receipts — a failed run rolls back and produces no meta. The receipt carries no trace — use `traceTransaction` with the same hash to fetch it.
145145

146146
---
147147

148148
## Failure fallback
149149

150-
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.
150+
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) with a `txFAILED` `resultXdr` and no `resultMetaXdr`, without bumping the ledger, and returns `PENDING`. `ledger` on a `FAILED` receipt pins the latest ledger at failure time (the chain does not advance) and `createdAt` the submission time, with the same encoding as on `SUCCESS` receipts.
151+
152+
On the success path the server also post-processes the receipt the semantics wrote: `_attach_result_xdr` replaces the receipt's internal `returnValue` field (a JSON-encoded SCVal, or `null`) with the spec-mandated `resultXdr`/`resultMetaXdr` base64 XDR, which K cannot construct. These are the only response contents the server builds itself.
151153

152154
---
153155

src/komet_node/kdist/node.md

Lines changed: 82 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -291,6 +291,13 @@ After the steps run, record the receipt, write the new ledger counter, and respo
291291
was already written to its own file during execution, so we only reset `<ioDir>`. Reaching
292292
this point means the steps completed without getting stuck, so the status is `SUCCESS`.
293293

294+
The receipt carries the contract call's return value (if the transaction made one): after
295+
`uncheckedCallTx` runs, the call's result `ScVal` is still sitting on the `<hostStack>` (see
296+
below), so we serialise it into the receipt's internal `returnValue` field and only then
297+
reset the host. The Python server immediately rewrites that field into the spec-mandated
298+
`resultXdr`/`resultMetaXdr` base64 XDR fields (K cannot construct XDR), so `returnValue`
299+
never reaches an RPC client.
300+
294301
```k
295302
rule <k> #finalizeTx( REQ )
296303
=> #recordAndRespond(
@@ -304,22 +311,30 @@ this point means the steps completed without getting stuck, so the status is `SU
304311
rule <k> #recordAndRespond( REQ, L )
305312
=> #writeFile( "metadata.json", JSON2String({ "latest_ledger" : L +Int 1 }) )
306313
~> #writeFile( #receiptFile( #getString( "txHash", REQ ) ),
307-
JSON2String( #txReceipt( REQ, L +Int 1 ) ) )
314+
JSON2String( #txReceipt( REQ, L +Int 1, #returnValueJSON( STACK ) ) ) )
315+
~> #resetHost
308316
~> #respondTx( REQ, L +Int 1 )
309317
...
310318
</k>
319+
<hostStack> STACK </hostStack>
311320
312-
syntax JSON ::= #txReceipt( JSON, Int ) [function, symbol(txReceipt)]
313-
// ---------------------------------------------------------------------
314-
rule #txReceipt( REQ, NEWL ) => {
321+
syntax JSON ::= #txReceipt( JSON, Int, JSON ) [function, symbol(txReceipt)]
322+
// ---------------------------------------------------------------------------
323+
rule #txReceipt( REQ, NEWL, RETVAL ) => {
315324
"status" : "SUCCESS",
316325
"ledger" : Int2String( NEWL ),
317326
"createdAt" : #getString( "now", REQ ),
318327
"envelopeXdr" : #getString( "envelopeXdr", REQ ),
319-
"resultXdr" : "",
320-
"resultMetaXdr" : ""
328+
"returnValue" : RETVAL
321329
}
322330
331+
// The return value of the transaction's contract call: the ScVal left on top of the
332+
// host stack, or null when the transaction made no contract call.
333+
syntax JSON ::= #returnValueJSON( HostStack ) [function, total, symbol(returnValueJSON)]
334+
// ----------------------------------------------------------------------------------------
335+
rule #returnValueJSON( VAL:ScVal : _ ) => #scValToJSON( VAL )
336+
rule #returnValueJSON( _ ) => null [owise]
337+
323338
rule <k> #respondTx( REQ, NEWL )
324339
=> #respond( #getJSON( "id", REQ ), {
325340
"hash" : #getString( "txHash", REQ ),
@@ -419,6 +434,10 @@ SCVal arg encoding (key order also significant):
419434

420435
`uncheckedCallTx` is like komet's `callTx` but it does not entail a return value check.
421436

437+
Unlike `callTx`, it does not `#resetHost` after the call either: the call's return value is
438+
left on the `<hostStack>` so `#recordAndRespond` can serialise it into the receipt. The host
439+
is reset there instead (and each `uncheckedCallTx` clears the host cell before it runs, so a
440+
leftover value never bleeds into a later call).
422441

423442
```k
424443
syntax Step ::= uncheckedCallTx( from: Address, to: Address, func: WasmString, args: List) [symbol(uncheckedCallTx)]
@@ -427,11 +446,67 @@ SCVal arg encoding (key order also significant):
427446
<k> uncheckedCallTx(FROM, TO, FUNC, ARGS)
428447
=> allocObjects(ARGS)
429448
~> callContractFromStack(FROM, TO, FUNC)
430-
~> #resetHost
431449
...
432450
</k>
433451
// clear the host cell before contract calls
434452
(_:HostCell => <host> <hostStack> .HostStack </hostStack> ... </host>)
453+
```
454+
455+
###############################################################################
456+
# ScVal serialisation
457+
458+
`#scValToJSON` renders an `ScVal` as JSON for the receipt's internal `returnValue` field.
459+
The encoding mirrors the SCVal *argument* encoding accepted by `#decodeArg` above (and
460+
produced by `scval_to_json` in `scval.py`), extended with the value-only cases that can come
461+
back from a contract but never go in as arguments: `void`, `string`, `u256`, `vec`, `map`.
462+
The Python server decodes it with `scval_from_json` (`scval.py`) — keep the three in sync.
463+
Values with no JSON encoding (e.g. `Error`) render as `null`, i.e. as "no return value".
464+
465+
```k
466+
syntax JSON ::= #scValToJSON( ScVal ) [function, total, symbol(scValToJSON)]
467+
// ----------------------------------------------------------------------------
468+
rule #scValToJSON( SCBool(B) ) => { "type" : "bool" , "value" : B }
469+
rule #scValToJSON( Void ) => { "type" : "void" }
470+
rule #scValToJSON( I32(V) ) => { "type" : "i32" , "value" : V }
471+
rule #scValToJSON( U32(V) ) => { "type" : "u32" , "value" : V }
472+
rule #scValToJSON( I64(V) ) => { "type" : "i64" , "value" : V }
473+
rule #scValToJSON( U64(V) ) => { "type" : "u64" , "value" : V }
474+
rule #scValToJSON( I128(V) ) => { "type" : "i128" , "value" : V }
475+
rule #scValToJSON( U128(V) ) => { "type" : "u128" , "value" : V }
476+
rule #scValToJSON( U256(V) ) => { "type" : "u256" , "value" : V }
477+
rule #scValToJSON( Symbol(S) ) => { "type" : "symbol" , "value" : S }
478+
rule #scValToJSON( ScString(S)) => { "type" : "string" , "value" : S }
479+
rule #scValToJSON( ScBytes(B) ) => { "type" : "bytes" , "value" : #bytesToHex(B) }
480+
rule #scValToJSON( ScAddress(Account(B)) ) => { "type" : "address" , "addrType" : "account" , "value" : #bytesToHex(B) }
481+
rule #scValToJSON( ScAddress(Contract(B)) ) => { "type" : "address" , "addrType" : "contract" , "value" : #bytesToHex(B) }
482+
rule #scValToJSON( ScVec(L) ) => { "type" : "vec" , "value" : [ #scValsToJSONs(L) ] }
483+
rule #scValToJSON( ScMap(M) ) => { "type" : "map" , "value" : [ #mapToJSONs(keys_list(M), M) ] }
484+
rule #scValToJSON( _ ) => null [owise]
485+
486+
syntax JSONs ::= #scValsToJSONs( List ) [function, symbol(scValsToJSONs)]
487+
// -------------------------------------------------------------------------
488+
rule #scValsToJSONs( .List ) => .JSONs
489+
rule #scValsToJSONs( ListItem(V) REST ) => #scValToJSON({V}:>ScVal) , #scValsToJSONs(REST)
490+
491+
// Each map entry becomes a two-element [key, value] array, in key order.
492+
syntax JSONs ::= #mapToJSONs( List, Map ) [function, symbol(mapToJSONs)]
493+
// ------------------------------------------------------------------------
494+
rule #mapToJSONs( .List, _ ) => .JSONs
495+
rule #mapToJSONs( ListItem(KEY) REST, M )
496+
=> [ #scValToJSON({KEY}:>ScVal) , #scValToJSON({M[KEY]}:>ScVal) , .JSONs ] , #mapToJSONs(REST, M)
497+
```
498+
499+
`#bytesToHex` is the inverse of `HexBytes`: lowercase hex, two digits per byte (zero-padded,
500+
since Base2String drops leading zeroes).
501+
502+
```k
503+
syntax String ::= #bytesToHex( Bytes ) [function, total, symbol(bytesToHex)]
504+
| #padZeros( String, Int ) [function, total, symbol(padZeros)]
505+
// --------------------------------------------------------------------------------
506+
rule #bytesToHex( B ) => #padZeros( Base2String( Bytes2Int(B, BE, Unsigned), 16 ), 2 *Int lengthBytes(B) )
507+
508+
rule #padZeros( S, N ) => #padZeros( "0" +String S, N ) requires lengthString(S) <Int N
509+
rule #padZeros( S, _ ) => S [owise]
435510
436511
endmodule
437512
```

src/komet_node/result_xdr.py

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
"""Builders for the ``resultXdr`` / ``resultMetaXdr`` receipt fields.
2+
3+
Per the RPC spec, a SUCCESS or FAILED getTransaction response carries the transaction's
4+
outcome as base64-encoded ``TransactionResult`` and ``TransactionMeta`` XDR structs. The K
5+
semantics record the outcome (status and, for contract calls, the return value) but cannot
6+
construct XDR, so the server synthesises these structs from the transaction envelope and the
7+
recorded return value.
8+
9+
Being a mock chain, komet-node does not track fees or ledger-entry changes, so those parts
10+
of the structs are empty/zero: ``feeCharged`` is 0 and the meta carries no entry changes.
11+
The meta is emitted as ``TransactionMeta`` v3, the protocol-22 format (v4 is protocol 23+).
12+
"""
13+
14+
from __future__ import annotations
15+
16+
import hashlib
17+
from typing import TYPE_CHECKING
18+
19+
from stellar_sdk import xdr
20+
from stellar_sdk.operation import CreateAccount, InvokeHostFunction
21+
22+
if TYPE_CHECKING:
23+
from stellar_sdk import TransactionEnvelope
24+
from stellar_sdk.operation import Operation
25+
26+
27+
def transaction_result_xdr(envelope: TransactionEnvelope, return_value: xdr.SCVal | None, *, success: bool) -> str:
28+
"""Build the base64 ``TransactionResult`` XDR for a transaction's receipt.
29+
30+
On success every operation reports its success code (with the InvokeHostFunction result
31+
carrying a hash derived from the return value). On failure the code is ``txFAILED``; a
32+
single trapped InvokeHostFunction operation is reported when the transaction is a plain
33+
contract invocation, otherwise the per-operation detail is left empty — the semantics
34+
only report that the run got stuck, not which operation trapped.
35+
"""
36+
operations = envelope.transaction.operations
37+
if success:
38+
code = xdr.TransactionResultCode.txSUCCESS
39+
results = [_op_success_result(op, return_value) for op in operations]
40+
else:
41+
code = xdr.TransactionResultCode.txFAILED
42+
results = _op_failure_results(operations)
43+
result = xdr.TransactionResult(
44+
fee_charged=xdr.Int64(0),
45+
result=xdr.TransactionResultResult(code=code, results=results),
46+
ext=xdr.TransactionResultExt(0),
47+
)
48+
return result.to_xdr()
49+
50+
51+
def transaction_meta_xdr(envelope: TransactionEnvelope, return_value: xdr.SCVal | None) -> str:
52+
"""Build the base64 ``TransactionMeta`` (v3) XDR for a successful transaction's receipt.
53+
54+
When the transaction made a contract call, its return value is reported as
55+
``sorobanMeta.returnValue`` — this is where clients read an invocation's result. Other
56+
soroban transactions (upload, deploy) carry no soroban meta; ledger-entry change sets
57+
are empty because komet-node does not track them.
58+
"""
59+
soroban_meta = None
60+
if return_value is not None:
61+
soroban_meta = xdr.SorobanTransactionMeta(
62+
ext=xdr.SorobanTransactionMetaExt(0),
63+
events=[],
64+
return_value=return_value,
65+
diagnostic_events=[],
66+
)
67+
meta = xdr.TransactionMeta(
68+
v=3,
69+
v3=xdr.TransactionMetaV3(
70+
ext=xdr.ExtensionPoint(0),
71+
tx_changes_before=xdr.LedgerEntryChanges([]),
72+
operations=[xdr.OperationMeta(xdr.LedgerEntryChanges([])) for _ in envelope.transaction.operations],
73+
tx_changes_after=xdr.LedgerEntryChanges([]),
74+
soroban_meta=soroban_meta,
75+
),
76+
)
77+
return meta.to_xdr()
78+
79+
80+
def _op_success_result(op: Operation, return_value: xdr.SCVal | None) -> xdr.OperationResult:
81+
"""The success ``OperationResult`` for one operation of a committed transaction."""
82+
if isinstance(op, CreateAccount):
83+
tr = xdr.OperationResultTr(
84+
xdr.OperationType.CREATE_ACCOUNT,
85+
create_account_result=xdr.CreateAccountResult(xdr.CreateAccountResultCode.CREATE_ACCOUNT_SUCCESS),
86+
)
87+
elif isinstance(op, InvokeHostFunction):
88+
# The real network puts SHA-256(InvokeHostFunctionSuccessPreImage) here — a hash over
89+
# the emitted events and the return value. komet-node does not capture events, so the
90+
# hash is derived from the return value alone (empty for upload/deploy operations).
91+
payload = return_value.to_xdr_bytes() if return_value is not None else b''
92+
tr = xdr.OperationResultTr(
93+
xdr.OperationType.INVOKE_HOST_FUNCTION,
94+
invoke_host_function_result=xdr.InvokeHostFunctionResult(
95+
xdr.InvokeHostFunctionResultCode.INVOKE_HOST_FUNCTION_SUCCESS,
96+
success=xdr.Hash(hashlib.sha256(payload).digest()),
97+
),
98+
)
99+
else:
100+
# TransactionEncoder only admits CreateAccount and InvokeHostFunction operations, so
101+
# a committed transaction cannot contain anything else.
102+
raise NotImplementedError(f'No result encoding for operation type: {type(op).__name__}')
103+
return xdr.OperationResult(xdr.OperationResultCode.opINNER, tr=tr)
104+
105+
106+
def _op_failure_results(operations: list[Operation]) -> list[xdr.OperationResult]:
107+
"""The per-operation results for a failed (stuck) transaction."""
108+
if len(operations) == 1 and isinstance(operations[0], InvokeHostFunction):
109+
tr = xdr.OperationResultTr(
110+
xdr.OperationType.INVOKE_HOST_FUNCTION,
111+
invoke_host_function_result=xdr.InvokeHostFunctionResult(
112+
xdr.InvokeHostFunctionResultCode.INVOKE_HOST_FUNCTION_TRAPPED
113+
),
114+
)
115+
return [xdr.OperationResult(xdr.OperationResultCode.opINNER, tr=tr)]
116+
return []

0 commit comments

Comments
 (0)