Skip to content

Commit b5f66ed

Browse files
Merge pull request #37 from runtimeverification/feat/tx-result-xdr
feat: real resultXdr/resultMetaXdr and FAILED transaction reporting
2 parents fa5499c + 1c0ab76 commit b5f66ed

10 files changed

Lines changed: 534 additions & 31 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
| `ledgers/ledger_<seq>.json` | persistent | the server (on success) | one record per closed ledger — `{sequence, txHash, closedAt, hash, headerXdr, metadataXdr}` — the ledger→transaction index behind `getTransactions` and `getLedgers`. Written in Python because the header artifacts are XDR, which K cannot construct. |
9595
| `requests/request_<n>.json` | persistent | the server | an archive of each incoming JSON-RPC request, numbered by a monotonic counter, kept for debugging. |
@@ -177,7 +177,6 @@ sequenceDiagram
177177

178178
## What's not yet implemented
179179

180-
- `resultXdr` / `resultMetaXdr` in `getTransaction` responses (contract return values)
181180
- `simulateTransaction` of upload/deploy host functions, auth recording, resource metering, `events`/`restorePreamble`/`stateChanges` (only invoke-contract dry runs with the return value are supported)
182181
- `getEvents` and other read-only RPC methods (`getLedgerEntries` is implemented for the entry types the K state tracks: `ACCOUNT`, `CONTRACT_DATA`, `CONTRACT_CODE`)
183182
- `ExtendFootprintTTL` and `RestoreFootprint` operations

docs/node-semantics.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -89,10 +89,14 @@ If `request.json` is absent, `insert-handleRequestFile` does not fire and K halt
8989

9090
1. writes `metadata.json` with `latest_ledger + 1`,
9191
2. writes the receipt to `receipts/receipt_<hash>.json`:
92-
`{ status: "SUCCESS", applicationOrder: 1, feeBump: false, envelopeXdr, resultXdr: "", resultMetaXdr: "", ledger, createdAt }`,
92+
`{ status: "SUCCESS", applicationOrder: 1, feeBump: false, envelopeXdr, ledger, createdAt, returnValue }``returnValue` is
93+
the contract call's return `ScVal`, JSON-encoded by `#scValToJSON` (or `null` when the
94+
transaction made no contract call), read off the `<hostStack>` before the host is reset,
9395
3. responds with `{hash, status: "PENDING", latestLedger, latestLedgerCloseTime}`.
9496

95-
A `sendTransaction` whose hash already has a receipt never reaches `#runTx`: dispatch answers `{hash, status: "DUPLICATE", latestLedger, latestLedgerCloseTime}` directly, without executing anything or advancing the ledger. (For wasm uploads the Python server also suppresses the `<program>` injection on duplicates, since those steps would run before dispatch.)
97+
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.
98+
99+
A `sendTransaction` whose hash already has a receipt never reaches `#runTx`: dispatch answers `{hash, status: "DUPLICATE", latestLedger, latestLedgerCloseTime}` directly, without executing anything or advancing the ledger. (For wasm uploads the Python server also suppresses the `<program>` injection on duplicates, since those steps would run before dispatch.) The Python `_attach_result_xdr` rewrite is skipped for duplicates, so a resubmission never disturbs the stored receipt.
96100

97101
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.
98102

docs/notes.md

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

3535
## Known gaps
3636

37-
- `resultXdr` / `resultMetaXdr` are empty stubs (contract return values not surfaced).
37+
- `resultXdr` / `resultMetaXdr` are synthesised: `feeCharged` is 0, ledger-entry change
38+
sets are empty, and the InvokeHostFunction success hash covers only the return value
39+
(komet-node does not track fees, entry changes, or events).
3840
- `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.
3941
- `SCVec` / `SCMap` contract arguments are not yet encoded.
4042
- The `xdrFormat` parameter is accepted on `getTransaction`, `sendTransaction`, `getTransactions`, `getLedgers`, and `getLedgerEntries`, but only the default `'base64'` is supported; `'json'` is rejected with `-32602`.

docs/server.md

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -204,14 +204,14 @@ Failures are reported in the result body, matching real stellar-rpc; only an und
204204
```json
205205
{
206206
"status": "SUCCESS", "applicationOrder": 1, "feeBump": false,
207-
"envelopeXdr": "<base64 XDR>", "resultXdr": "", "resultMetaXdr": "",
207+
"envelopeXdr": "<base64 XDR>", "resultXdr": "<base64 XDR>", "resultMetaXdr": "<base64 XDR>",
208208
"ledger": 5, "createdAt": "1716000000",
209209
"latestLedger": 5, "latestLedgerCloseTime": "1716000000",
210210
"oldestLedger": 0, "oldestLedgerCloseTime": "0"
211211
}
212212
```
213213

214-
The ledger-range fields (`latestLedger` through `oldestLedgerCloseTime`) are present on every response, `NOT_FOUND` included. Every ledger contains exactly one transaction, so `applicationOrder` is always 1; fee-bump envelopes are not supported, so `feeBump` is always false. `resultXdr` and `resultMetaXdr` are currently empty stubs. The receipt carries no trace — use `traceTransaction` with the same hash to fetch it.
214+
The ledger-range fields (`latestLedger` through `oldestLedgerCloseTime`) are present on every response, `NOT_FOUND` included. Every ledger contains exactly one transaction, so `applicationOrder` is always 1; fee-bump envelopes are not supported, so `feeBump` is always false. `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.
215215

216216
Receipts are an internal format, not a stable one: receipts written by older komet-node versions stored `ledger` as a string and lack `applicationOrder`/`feeBump`, and `getTransaction` returns stored receipts verbatim. Start from a fresh io-dir after upgrading rather than resuming one with old receipts.
217217

@@ -231,7 +231,7 @@ Receipts are an internal format, not a stable one: receipts written by older kom
231231
}
232232
```
233233

234-
Serialization follows real stellar-rpc: ledger sequences and the top-level close times are JSON numbers, and per-transaction `createdAt` is a number too (unlike the singular `getTransaction`, where it is a string — an upstream quirk). `resultXdr`/`resultMetaXdr` are omitted while the receipts carry empty stubs. Each ledger holds exactly one transaction on this node, so `applicationOrder` is always `1`. The `cursor` is a TOID-style stringified integer (`ledger << 32 | applicationOrder << 12`) naming the page's last transaction when the page is full, and empty otherwise; resume by passing it as `pagination.cursor` (without `startLedger`).
234+
Serialization follows real stellar-rpc: ledger sequences and the top-level close times are JSON numbers, and per-transaction `createdAt` is a number too (unlike the singular `getTransaction`, where it is a string — an upstream quirk). `resultXdr`/`resultMetaXdr` are populated from the stored receipt (real base64 XDR for a contract invocation) and omitted only when a receipt carries none. Each ledger holds exactly one transaction on this node, so `applicationOrder` is always `1`. The `cursor` is a TOID-style stringified integer (`ledger << 32 | applicationOrder << 12`) naming the page's last transaction when the page is full, and empty otherwise; resume by passing it as `pagination.cursor` (without `startLedger`).
235235

236236
### `getLedgers`
237237

@@ -272,7 +272,9 @@ This is the one method whose response the server post-processes: the semantics l
272272

273273
## Failure fallback
274274

275-
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.
275+
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) 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. A stuck `simulateTransaction` run is answered with a result-level `{error, latestLedger}` and leaves no artifacts behind.
276+
277+
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 (skipped for a duplicate resubmission, whose receipt is already final). These are the only response contents the server builds itself.
276278

277279
---
278280

src/komet_node/kdist/node.md

Lines changed: 86 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -464,6 +464,13 @@ int64-as-string. Every ledger on this node contains exactly one transaction, so
464464
always false. (The Python failure fallback in server.py writes a `FAILED` receipt with the
465465
same field set — keep the two in sync.)
466466

467+
The receipt also carries the contract call's return value (if the transaction made one):
468+
after `uncheckedCallTx` runs, the call's result `ScVal` is still sitting on the
469+
`<hostStack>` (see below), so we serialise it into the receipt's internal `returnValue`
470+
field and only then reset the host. The Python server immediately rewrites that field into
471+
the spec-mandated `resultXdr`/`resultMetaXdr` base64 XDR fields (K cannot construct XDR), so
472+
`returnValue` never reaches an RPC client.
473+
467474
```k
468475
rule <k> #finalizeTx( REQ )
469476
=> #recordAndRespond(
@@ -477,24 +484,32 @@ same field set — keep the two in sync.)
477484
rule <k> #recordAndRespond( REQ, L )
478485
=> #writeFile( "metadata.json", JSON2String({ "latest_ledger" : L +Int 1 }) )
479486
~> #writeFile( #receiptFile( #getString( "txHash", REQ ) ),
480-
JSON2String( #txReceipt( REQ, L +Int 1 ) ) )
487+
JSON2String( #txReceipt( REQ, L +Int 1, #returnValueJSON( STACK ) ) ) )
488+
~> #resetHost
481489
~> #respondTx( REQ, "PENDING", L +Int 1 )
482490
...
483491
</k>
492+
<hostStack> STACK </hostStack>
484493
485-
syntax JSON ::= #txReceipt( JSON, Int ) [function, symbol(txReceipt)]
486-
// ---------------------------------------------------------------------
487-
rule #txReceipt( REQ, NEWL ) => {
494+
syntax JSON ::= #txReceipt( JSON, Int, JSON ) [function, symbol(txReceipt)]
495+
// ---------------------------------------------------------------------------
496+
rule #txReceipt( REQ, NEWL, RETVAL ) => {
488497
"status" : "SUCCESS",
489498
"applicationOrder" : 1,
490499
"feeBump" : false,
491500
"envelopeXdr" : #getString( "envelopeXdr", REQ ),
492-
"resultXdr" : "",
493-
"resultMetaXdr" : "",
494501
"ledger" : NEWL,
495-
"createdAt" : #getString( "now", REQ )
502+
"createdAt" : #getString( "now", REQ ),
503+
"returnValue" : RETVAL
496504
}
497505
506+
// The return value of the transaction's contract call: the ScVal left on top of the
507+
// host stack, or null when the transaction made no contract call.
508+
syntax JSON ::= #returnValueJSON( HostStack ) [function, total, symbol(returnValueJSON)]
509+
// ----------------------------------------------------------------------------------------
510+
rule #returnValueJSON( VAL:ScVal : _ ) => #scValToJSON( VAL )
511+
rule #returnValueJSON( _ ) => null [owise]
512+
498513
rule <k> #respondTx( REQ, STATUS, LL )
499514
=> #respond( #getJSON( "id", REQ ), {
500515
"hash" : #getString( "txHash", REQ ),
@@ -1096,6 +1111,10 @@ SCVal arg encoding (key order also significant):
10961111

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

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

11001119
```k
11011120
syntax Step ::= uncheckedCallTx( from: Address, to: Address, func: WasmString, args: List) [symbol(uncheckedCallTx)]
@@ -1104,11 +1123,70 @@ SCVal arg encoding (key order also significant):
11041123
<k> uncheckedCallTx(FROM, TO, FUNC, ARGS)
11051124
=> allocObjects(ARGS)
11061125
~> callContractFromStack(FROM, TO, FUNC)
1107-
~> #resetHost
11081126
...
11091127
</k>
11101128
// clear the host cell before contract calls
11111129
(_:HostCell => <host> <hostStack> .HostStack </hostStack> ... </host>)
1130+
```
1131+
1132+
###############################################################################
1133+
# ScVal serialisation
1134+
1135+
`#scValToJSON` renders an `ScVal` as JSON for the receipt's internal `returnValue` field.
1136+
The encoding mirrors the SCVal *argument* encoding accepted by `#decodeArg` above (and
1137+
produced by `scval_to_json` in `scval.py`), extended with the value-only cases that can come
1138+
back from a contract but never go in as arguments: `void`, `string`, `u256`, `vec`, `map`.
1139+
The Python server decodes it with `scval_from_json` (`scval.py`) — keep the three in sync.
1140+
Values with no JSON encoding (e.g. `Error`) render as `null`, i.e. as "no return value".
1141+
1142+
```k
1143+
syntax JSON ::= #scValToJSON( ScVal ) [function, total, symbol(scValToJSON)]
1144+
// ----------------------------------------------------------------------------
1145+
rule #scValToJSON( SCBool(B) ) => { "type" : "bool" , "value" : B }
1146+
rule #scValToJSON( Void ) => { "type" : "void" }
1147+
rule #scValToJSON( I32(V) ) => { "type" : "i32" , "value" : V }
1148+
rule #scValToJSON( U32(V) ) => { "type" : "u32" , "value" : V }
1149+
rule #scValToJSON( I64(V) ) => { "type" : "i64" , "value" : V }
1150+
rule #scValToJSON( U64(V) ) => { "type" : "u64" , "value" : V }
1151+
rule #scValToJSON( I128(V) ) => { "type" : "i128" , "value" : V }
1152+
rule #scValToJSON( U128(V) ) => { "type" : "u128" , "value" : V }
1153+
rule #scValToJSON( U256(V) ) => { "type" : "u256" , "value" : V }
1154+
rule #scValToJSON( Symbol(S) ) => { "type" : "symbol" , "value" : S }
1155+
rule #scValToJSON( ScString(S)) => { "type" : "string" , "value" : S }
1156+
rule #scValToJSON( ScBytes(B) ) => { "type" : "bytes" , "value" : #bytesToHex(B) }
1157+
rule #scValToJSON( ScAddress(Account(B)) ) => { "type" : "address" , "addrType" : "account" , "value" : #bytesToHex(B) }
1158+
rule #scValToJSON( ScAddress(Contract(B)) ) => { "type" : "address" , "addrType" : "contract" , "value" : #bytesToHex(B) }
1159+
rule #scValToJSON( ScVec(L) ) => { "type" : "vec" , "value" : [ #scValsToJSONs(L) ] }
1160+
rule #scValToJSON( ScMap(M) ) => { "type" : "map" , "value" : [ #mapToJSONs(keys_list(M), M) ] }
1161+
rule #scValToJSON( _ ) => null [owise]
1162+
1163+
syntax JSONs ::= #scValsToJSONs( List ) [function, symbol(scValsToJSONs)]
1164+
// -------------------------------------------------------------------------
1165+
rule #scValsToJSONs( .List ) => .JSONs
1166+
rule #scValsToJSONs( ListItem(V) REST ) => #scValToJSON({V}:>ScVal) , #scValsToJSONs(REST)
1167+
1168+
// Each map entry becomes a two-element [key, value] array, in key order.
1169+
syntax JSONs ::= #mapToJSONs( List, Map ) [function, symbol(mapToJSONs)]
1170+
// ------------------------------------------------------------------------
1171+
rule #mapToJSONs( .List, _ ) => .JSONs
1172+
rule #mapToJSONs( ListItem(KEY) REST, M )
1173+
=> [ #scValToJSON({KEY}:>ScVal) , #scValToJSON({M[KEY]}:>ScVal) , .JSONs ] , #mapToJSONs(REST, M)
1174+
```
1175+
1176+
`#bytesToHex` is the inverse of `HexBytes`: lowercase hex, two digits per byte (zero-padded,
1177+
since Base2String drops leading zeroes). Empty bytes encode as the empty string — the
1178+
general rule would yield `"0"` (Base2String of 0), which `#padZeros` cannot trim.
1179+
1180+
```k
1181+
syntax String ::= #bytesToHex( Bytes ) [function, total, symbol(bytesToHex)]
1182+
| #padZeros( String, Int ) [function, total, symbol(padZeros)]
1183+
// --------------------------------------------------------------------------------
1184+
rule #bytesToHex( B ) => "" requires lengthBytes(B) ==Int 0
1185+
rule #bytesToHex( B ) => #padZeros( Base2String( Bytes2Int(B, BE, Unsigned), 16 ), 2 *Int lengthBytes(B) )
1186+
requires lengthBytes(B) >Int 0
1187+
1188+
rule #padZeros( S, N ) => #padZeros( "0" +String S, N ) requires lengthString(S) <Int N
1189+
rule #padZeros( S, _ ) => S [owise]
11121190
11131191
endmodule
11141192
```

0 commit comments

Comments
 (0)