From 7d57885d5c8a386e3ee1bd35bb64dca67b255a05 Mon Sep 17 00:00:00 2001 From: Raoul Date: Thu, 2 Jul 2026 13:42:46 +0000 Subject: [PATCH 1/4] test: assert getTransaction returns real result XDR Per the official RPC spec, resultXdr/resultMetaXdr are base64-encoded TransactionResult/TransactionMeta XDR structs, present when the status is SUCCESS or FAILED; the current empty-string stubs are not valid XDR. The new tests decode the receipts with stellar_sdk and check the success result code, the invocation return value reported via sorobanMeta.returnValue (add(2, 3) -> U32(5)), the txFAILED error result for a transaction that invokes a missing contract (with ledger/createdAt shaped consistently with SUCCESS receipts), and that NOT_FOUND responses omit all transaction fields. The three XDR-content tests fail until the feature lands; the NOT_FOUND guard already passes. --- src/tests/integration/test_server.py | 179 +++++++++++++++++++++++++++ 1 file changed, 179 insertions(+) diff --git a/src/tests/integration/test_server.py b/src/tests/integration/test_server.py index 3e5cfc3..43aba40 100644 --- a/src/tests/integration/test_server.py +++ b/src/tests/integration/test_server.py @@ -498,3 +498,182 @@ def builder() -> TransactionBuilder: # All four transactions, including the non-Void invocation, advanced the ledger. assert _rpc(server.port(), 'getLatestLedger', {})['result']['sequence'] == 4 + + +# --------------------------------------------------------------------------- +# getTransaction: resultXdr / resultMetaXdr contents. +# +# Per the official spec, `resultXdr` is "a base64 encoded string of the raw +# TransactionResult XDR struct" and `resultMetaXdr` the raw TransactionMeta, present +# when status is SUCCESS or FAILED. Fields are either omitted or valid XDR — an +# empty-string stub is neither. +# --------------------------------------------------------------------------- + + +def _tx_result_of(get_result: dict[str, Any]) -> xdr.TransactionResult: + """Decode a receipt's resultXdr, asserting it is real base64 XDR (not an empty stub).""" + result_xdr = get_result.get('resultXdr') + assert isinstance(result_xdr, str), f'resultXdr missing or not a string: {get_result}' + assert result_xdr != '', 'resultXdr must be real XDR or omitted, not an empty string' + return xdr.TransactionResult.from_xdr(result_xdr) + + +def _tx_meta_of(get_result: dict[str, Any]) -> xdr.TransactionMeta: + """Decode a receipt's resultMetaXdr, asserting it is real base64 XDR (not an empty stub).""" + meta_xdr = get_result.get('resultMetaXdr') + assert isinstance(meta_xdr, str), f'resultMetaXdr missing or not a string: {get_result}' + assert meta_xdr != '', 'resultMetaXdr must be real XDR or omitted, not an empty string' + return xdr.TransactionMeta.from_xdr(meta_xdr) + + +def _soroban_return_value(meta: xdr.TransactionMeta) -> xdr.SCVal: + """Extract sorobanMeta.returnValue from a TransactionMeta (v3 at protocol version 22).""" + assert meta.v == 3, f'expected TransactionMeta v3 at protocol version 22, got v{meta.v}' + assert meta.v3 is not None and meta.v3.soroban_meta is not None + return meta.v3.soroban_meta.return_value + + +def test_get_transaction_success_returns_decodable_result_xdr(server: StellarRpcServer) -> None: + """A SUCCESS receipt carries a real TransactionResult and TransactionMeta. + + The TransactionResult must decode from base64 and report code txSUCCESS; the + TransactionMeta must decode as well. + """ + 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() + ) + envelope.sign(keypair) + tx_hash = _rpc(server.port(), 'sendTransaction', {'transaction': envelope.to_xdr()})['result']['hash'] + + get_result = _rpc(server.port(), 'getTransaction', {'hash': tx_hash})['result'] + assert get_result['status'] == 'SUCCESS' + + tx_result = _tx_result_of(get_result) + assert tx_result.result.code == xdr.TransactionResultCode.txSUCCESS + + _tx_meta_of(get_result) # must decode as TransactionMeta + + +def test_get_transaction_invocation_reports_return_value(server: StellarRpcServer) -> None: + """A successful contract invocation surfaces its return value in resultMetaXdr. + + ``add(2, 3)`` returns U32(5). Real stellar-rpc reports the invocation's return value + as ``sorobanMeta.returnValue`` inside the TransactionMeta, and the TransactionResult + carries a successful InvokeHostFunction operation result. Every receipt along the + lifecycle (create account, upload, deploy) must carry decodable result XDR too. + """ + keypair = Keypair.random() + account = Account(keypair.public_key, sequence=0) + + def builder() -> TransactionBuilder: + return TransactionBuilder(account, Network.TESTNET_NETWORK_PASSPHRASE) + + def send(tb: TransactionBuilder) -> dict[str, Any]: + 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}' + # Every successful receipt, whatever the operation, has a decodable txSUCCESS result. + assert _tx_result_of(get_res).result.code == xdr.TransactionResultCode.txSUCCESS + return get_res + + # Set up: create account, upload adder.wat, deploy contract + 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)) + + from stellar_sdk.utils import sha256 + + wasm_hash = sha256(wasm_bytecode) + salt = b'\x00' * 32 + send(builder().append_create_contract_op(wasm_hash, keypair.public_key, None, salt)) + + contract_address = server.encoder.contract_address_from_deployer_address(keypair.public_key, salt) + invoke_result = send( + builder().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)), + ], + ) + ) + + # The TransactionResult reports the successful InvokeHostFunction operation. + tx_result = _tx_result_of(invoke_result) + op_results = tx_result.result.results + assert op_results, 'TransactionResult must carry the InvokeHostFunction operation result' + assert op_results[0].tr is not None + invoke_op_result = op_results[0].tr.invoke_host_function_result + assert invoke_op_result is not None + assert invoke_op_result.code == xdr.InvokeHostFunctionResultCode.INVOKE_HOST_FUNCTION_SUCCESS + + # The TransactionMeta reports the call's return value: U32(5). + return_value = _soroban_return_value(_tx_meta_of(invoke_result)) + assert return_value.type == SCValType.SCV_U32 + assert return_value.u32 == xdr.Uint32(5) + + +def test_get_transaction_failed_reports_error_result_xdr(server: StellarRpcServer) -> None: + """A FAILED receipt carries a real error TransactionResult, not an empty stub. + + Invoking a never-deployed contract fails. The spec requires `resultXdr` (a base64 + TransactionResult, here with code txFAILED) when status is FAILED, and `ledger`/ + `createdAt` to be reported with the same shape as on SUCCESS receipts. + """ + keypair = Keypair.random() + account = Account(keypair.public_key, sequence=0) + + def send(tb: TransactionBuilder) -> str: + env = tb.set_timeout(30).build() + env.sign(keypair) + res = _rpc(server.port(), 'sendTransaction', {'transaction': env.to_xdr()}) + tx_hash = res['result']['hash'] + assert isinstance(tx_hash, str) + return tx_hash + + def builder() -> TransactionBuilder: + return TransactionBuilder(account, Network.TESTNET_NETWORK_PASSPHRASE) + + # A successful reference transaction first, to compare receipt shapes across statuses. + ok_hash = send(builder().append_create_account_op(keypair.public_key, '1000')) + ok_result = _rpc(server.port(), 'getTransaction', {'hash': ok_hash})['result'] + assert ok_result['status'] == 'SUCCESS' + + missing_contract = StrKey.encode_contract(b'\x22' * 32) # valid C-strkey, never deployed + bad_hash = send(builder().append_invoke_contract_function_op(missing_contract, 'foo', [])) + + get_result = _rpc(server.port(), 'getTransaction', {'hash': bad_hash})['result'] + assert get_result['status'] == 'FAILED' + + tx_result = _tx_result_of(get_result) + assert tx_result.result.code == xdr.TransactionResultCode.txFAILED + + # resultMetaXdr may be omitted on a failed transaction, but must never be an empty stub. + if 'resultMetaXdr' in get_result: + _tx_meta_of(get_result) + + # `ledger`/`createdAt` are present on FAILED receipts, with the same JSON types as on + # SUCCESS receipts (their exact number-vs-string encoding is covered elsewhere). + for field in ('ledger', 'createdAt'): + assert field in get_result, f'FAILED receipt must report {field}' + assert type(get_result[field]) is type(ok_result[field]), f'{field} type differs across statuses' + # createdAt in getTransaction (singular) is an int64 rendered as a decimal string. + assert isinstance(get_result['createdAt'], str) and get_result['createdAt'].isdigit() + + +def test_get_transaction_not_found_omits_transaction_fields(server: StellarRpcServer) -> None: + """A NOT_FOUND response carries no transaction details (omitted, not empty/null).""" + get_result = _rpc(server.port(), 'getTransaction', {'hash': 'b' * 64})['result'] + assert get_result['status'] == 'NOT_FOUND' + for field in ('ledger', 'createdAt', 'envelopeXdr', 'resultXdr', 'resultMetaXdr'): + assert field not in get_result, f'NOT_FOUND response must omit {field}' From 1179f4a681bbaa60e2929ece9180a7d2585e857f Mon Sep 17 00:00:00 2001 From: Raoul Date: Thu, 2 Jul 2026 20:09:36 +0000 Subject: [PATCH 2/4] 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. --- docs/architecture.md | 3 +- docs/node-semantics.md | 6 +- docs/notes.md | 4 +- docs/server.md | 8 ++- src/komet_node/kdist/node.md | 89 ++++++++++++++++++++++++--- src/komet_node/result_xdr.py | 116 +++++++++++++++++++++++++++++++++++ src/komet_node/scval.py | 71 +++++++++++++++++++++ src/komet_node/server.py | 35 +++++++++-- 8 files changed, 313 insertions(+), 19 deletions(-) create mode 100644 src/komet_node/result_xdr.py diff --git a/docs/architecture.md b/docs/architecture.md index c36ae26..4823c80 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -89,7 +89,7 @@ All of the server's input and output artifacts live in one directory, the *io di |---|---|---|---| | `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. | | `metadata.json` | persistent | the K semantics | `{"latest_ledger": N}` — the server ledger counter, bumped by 1 per committed transaction. | -| `receipts/receipt_.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}`. | +| `receipts/receipt_.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?}`. | | `traces/trace_.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. | | `requests/request_.json` | persistent | the server | an archive of each incoming JSON-RPC request, numbered by a monotonic counter, kept for debugging. | | `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 ## What's not yet implemented -- `resultXdr` / `resultMetaXdr` in `getTransaction` responses (contract return values) - `simulateTransaction` (dry-run without state mutation) - `getEvents`, `getLedgerEntries`, `getFeeStats` and other read-only RPC methods - `ExtendFootprintTTL` and `RestoreFootprint` operations diff --git a/docs/node-semantics.md b/docs/node-semantics.md index 6f72b79..609eda9 100644 --- a/docs/node-semantics.md +++ b/docs/node-semantics.md @@ -83,9 +83,13 @@ If `request.json` is absent, `insert-handleRequestFile` does not fire and K halt 1. writes `metadata.json` with `latest_ledger + 1`, 2. writes the receipt to `receipts/receipt_.json`: - `{ status: "SUCCESS", ledger, createdAt, envelopeXdr, resultXdr: "", resultMetaXdr: "" }`, + `{ status: "SUCCESS", ledger, createdAt, envelopeXdr, returnValue }` — `returnValue` is + the contract call's return `ScVal`, JSON-encoded by `#scValToJSON` (or `null` when the + transaction made no contract call), read off the `` before the host is reset, 3. responds with `{hash, status: "PENDING", latestLedger, latestLedgerCloseTime}`. +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. + The trace is not part of the receipt — the executing steps already appended it to `traces/trace_.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. ### traceTransaction diff --git a/docs/notes.md b/docs/notes.md index f1a0b3d..0873135 100644 --- a/docs/notes.md +++ b/docs/notes.md @@ -33,6 +33,8 @@ 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 synthesised: `feeCharged` is 0, ledger-entry change + sets are empty, and the InvokeHostFunction success hash covers only the return value + (komet-node does not track fees, entry changes, or events). - `SCVec` / `SCMap` contract arguments are not yet encoded. - `simulateTransaction`, `getEvents`, `getLedgerEntries`, `getFeeStats`, and TTL/footprint operations are not implemented. diff --git a/docs/server.md b/docs/server.md index 0af41c5..5c44eed 100644 --- a/docs/server.md +++ b/docs/server.md @@ -136,18 +136,20 @@ All methods are answered by the K semantics and follow the [Stellar RPC specific ```json { "status": "SUCCESS", "ledger": "5", "createdAt": "1716000000", - "envelopeXdr": "", "resultXdr": "", "resultMetaXdr": "", + "envelopeXdr": "", "resultXdr": "", "resultMetaXdr": "", "latestLedger": "5", "latestLedgerCloseTime": "1716000000" } ``` -`resultXdr` and `resultMetaXdr` are currently empty stubs. The receipt carries no trace — use `traceTransaction` with the same hash to fetch it. +`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. --- ## 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`. 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) 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. + +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. --- diff --git a/src/komet_node/kdist/node.md b/src/komet_node/kdist/node.md index b8f1db2..06621db 100644 --- a/src/komet_node/kdist/node.md +++ b/src/komet_node/kdist/node.md @@ -291,6 +291,13 @@ After the steps run, record the receipt, write the new ledger counter, and respo was already written to its own file during execution, so we only reset ``. Reaching this point means the steps completed without getting stuck, so the status is `SUCCESS`. +The receipt carries the contract call's return value (if the transaction made one): after +`uncheckedCallTx` runs, the call's result `ScVal` is still sitting on the `` (see +below), so we serialise it into the receipt's internal `returnValue` field and only then +reset the host. The Python server immediately rewrites that field into the spec-mandated +`resultXdr`/`resultMetaXdr` base64 XDR fields (K cannot construct XDR), so `returnValue` +never reaches an RPC client. + ```k rule #finalizeTx( REQ ) => #recordAndRespond( @@ -304,22 +311,30 @@ this point means the steps completed without getting stuck, so the status is `SU rule #recordAndRespond( REQ, L ) => #writeFile( "metadata.json", JSON2String({ "latest_ledger" : L +Int 1 }) ) ~> #writeFile( #receiptFile( #getString( "txHash", REQ ) ), - JSON2String( #txReceipt( REQ, L +Int 1 ) ) ) + JSON2String( #txReceipt( REQ, L +Int 1, #returnValueJSON( STACK ) ) ) ) + ~> #resetHost ~> #respondTx( REQ, L +Int 1 ) ... + STACK - syntax JSON ::= #txReceipt( JSON, Int ) [function, symbol(txReceipt)] - // --------------------------------------------------------------------- - rule #txReceipt( REQ, NEWL ) => { + syntax JSON ::= #txReceipt( JSON, Int, JSON ) [function, symbol(txReceipt)] + // --------------------------------------------------------------------------- + rule #txReceipt( REQ, NEWL, RETVAL ) => { "status" : "SUCCESS", "ledger" : Int2String( NEWL ), "createdAt" : #getString( "now", REQ ), "envelopeXdr" : #getString( "envelopeXdr", REQ ), - "resultXdr" : "", - "resultMetaXdr" : "" + "returnValue" : RETVAL } + // The return value of the transaction's contract call: the ScVal left on top of the + // host stack, or null when the transaction made no contract call. + syntax JSON ::= #returnValueJSON( HostStack ) [function, total, symbol(returnValueJSON)] + // ---------------------------------------------------------------------------------------- + rule #returnValueJSON( VAL:ScVal : _ ) => #scValToJSON( VAL ) + rule #returnValueJSON( _ ) => null [owise] + rule #respondTx( REQ, NEWL ) => #respond( #getJSON( "id", REQ ), { "hash" : #getString( "txHash", REQ ), @@ -419,6 +434,10 @@ SCVal arg encoding (key order also significant): `uncheckedCallTx` is like komet's `callTx` but it does not entail a return value check. +Unlike `callTx`, it does not `#resetHost` after the call either: the call's return value is +left on the `` so `#recordAndRespond` can serialise it into the receipt. The host +is reset there instead (and each `uncheckedCallTx` clears the host cell before it runs, so a +leftover value never bleeds into a later call). ```k syntax Step ::= uncheckedCallTx( from: Address, to: Address, func: WasmString, args: List) [symbol(uncheckedCallTx)] @@ -427,11 +446,67 @@ SCVal arg encoding (key order also significant): uncheckedCallTx(FROM, TO, FUNC, ARGS) => allocObjects(ARGS) ~> callContractFromStack(FROM, TO, FUNC) - ~> #resetHost ... // clear the host cell before contract calls (_:HostCell => .HostStack ... ) +``` + +############################################################################### +# ScVal serialisation + +`#scValToJSON` renders an `ScVal` as JSON for the receipt's internal `returnValue` field. +The encoding mirrors the SCVal *argument* encoding accepted by `#decodeArg` above (and +produced by `scval_to_json` in `scval.py`), extended with the value-only cases that can come +back from a contract but never go in as arguments: `void`, `string`, `u256`, `vec`, `map`. +The Python server decodes it with `scval_from_json` (`scval.py`) — keep the three in sync. +Values with no JSON encoding (e.g. `Error`) render as `null`, i.e. as "no return value". + +```k + syntax JSON ::= #scValToJSON( ScVal ) [function, total, symbol(scValToJSON)] + // ---------------------------------------------------------------------------- + rule #scValToJSON( SCBool(B) ) => { "type" : "bool" , "value" : B } + rule #scValToJSON( Void ) => { "type" : "void" } + rule #scValToJSON( I32(V) ) => { "type" : "i32" , "value" : V } + rule #scValToJSON( U32(V) ) => { "type" : "u32" , "value" : V } + rule #scValToJSON( I64(V) ) => { "type" : "i64" , "value" : V } + rule #scValToJSON( U64(V) ) => { "type" : "u64" , "value" : V } + rule #scValToJSON( I128(V) ) => { "type" : "i128" , "value" : V } + rule #scValToJSON( U128(V) ) => { "type" : "u128" , "value" : V } + rule #scValToJSON( U256(V) ) => { "type" : "u256" , "value" : V } + rule #scValToJSON( Symbol(S) ) => { "type" : "symbol" , "value" : S } + rule #scValToJSON( ScString(S)) => { "type" : "string" , "value" : S } + rule #scValToJSON( ScBytes(B) ) => { "type" : "bytes" , "value" : #bytesToHex(B) } + rule #scValToJSON( ScAddress(Account(B)) ) => { "type" : "address" , "addrType" : "account" , "value" : #bytesToHex(B) } + rule #scValToJSON( ScAddress(Contract(B)) ) => { "type" : "address" , "addrType" : "contract" , "value" : #bytesToHex(B) } + rule #scValToJSON( ScVec(L) ) => { "type" : "vec" , "value" : [ #scValsToJSONs(L) ] } + rule #scValToJSON( ScMap(M) ) => { "type" : "map" , "value" : [ #mapToJSONs(keys_list(M), M) ] } + rule #scValToJSON( _ ) => null [owise] + + syntax JSONs ::= #scValsToJSONs( List ) [function, symbol(scValsToJSONs)] + // ------------------------------------------------------------------------- + rule #scValsToJSONs( .List ) => .JSONs + rule #scValsToJSONs( ListItem(V) REST ) => #scValToJSON({V}:>ScVal) , #scValsToJSONs(REST) + + // Each map entry becomes a two-element [key, value] array, in key order. + syntax JSONs ::= #mapToJSONs( List, Map ) [function, symbol(mapToJSONs)] + // ------------------------------------------------------------------------ + rule #mapToJSONs( .List, _ ) => .JSONs + rule #mapToJSONs( ListItem(KEY) REST, M ) + => [ #scValToJSON({KEY}:>ScVal) , #scValToJSON({M[KEY]}:>ScVal) , .JSONs ] , #mapToJSONs(REST, M) +``` + +`#bytesToHex` is the inverse of `HexBytes`: lowercase hex, two digits per byte (zero-padded, +since Base2String drops leading zeroes). + +```k + syntax String ::= #bytesToHex( Bytes ) [function, total, symbol(bytesToHex)] + | #padZeros( String, Int ) [function, total, symbol(padZeros)] + // -------------------------------------------------------------------------------- + rule #bytesToHex( B ) => #padZeros( Base2String( Bytes2Int(B, BE, Unsigned), 16 ), 2 *Int lengthBytes(B) ) + + rule #padZeros( S, N ) => #padZeros( "0" +String S, N ) requires lengthString(S) S [owise] endmodule ``` diff --git a/src/komet_node/result_xdr.py b/src/komet_node/result_xdr.py new file mode 100644 index 0000000..26b596c --- /dev/null +++ b/src/komet_node/result_xdr.py @@ -0,0 +1,116 @@ +"""Builders for the ``resultXdr`` / ``resultMetaXdr`` receipt fields. + +Per the RPC spec, a SUCCESS or FAILED getTransaction response carries the transaction's +outcome as base64-encoded ``TransactionResult`` and ``TransactionMeta`` XDR structs. The K +semantics record the outcome (status and, for contract calls, the return value) but cannot +construct XDR, so the server synthesises these structs from the transaction envelope and the +recorded return value. + +Being a mock chain, komet-node does not track fees or ledger-entry changes, so those parts +of the structs are empty/zero: ``feeCharged`` is 0 and the meta carries no entry changes. +The meta is emitted as ``TransactionMeta`` v3, the protocol-22 format (v4 is protocol 23+). +""" + +from __future__ import annotations + +import hashlib +from typing import TYPE_CHECKING + +from stellar_sdk import xdr +from stellar_sdk.operation import CreateAccount, InvokeHostFunction + +if TYPE_CHECKING: + from stellar_sdk import TransactionEnvelope + from stellar_sdk.operation import Operation + + +def transaction_result_xdr(envelope: TransactionEnvelope, return_value: xdr.SCVal | None, *, success: bool) -> str: + """Build the base64 ``TransactionResult`` XDR for a transaction's receipt. + + On success every operation reports its success code (with the InvokeHostFunction result + carrying a hash derived from the return value). On failure the code is ``txFAILED``; a + single trapped InvokeHostFunction operation is reported when the transaction is a plain + contract invocation, otherwise the per-operation detail is left empty — the semantics + only report that the run got stuck, not which operation trapped. + """ + operations = envelope.transaction.operations + if success: + code = xdr.TransactionResultCode.txSUCCESS + results = [_op_success_result(op, return_value) for op in operations] + else: + code = xdr.TransactionResultCode.txFAILED + results = _op_failure_results(operations) + result = xdr.TransactionResult( + fee_charged=xdr.Int64(0), + result=xdr.TransactionResultResult(code=code, results=results), + ext=xdr.TransactionResultExt(0), + ) + return result.to_xdr() + + +def transaction_meta_xdr(envelope: TransactionEnvelope, return_value: xdr.SCVal | None) -> str: + """Build the base64 ``TransactionMeta`` (v3) XDR for a successful transaction's receipt. + + When the transaction made a contract call, its return value is reported as + ``sorobanMeta.returnValue`` — this is where clients read an invocation's result. Other + soroban transactions (upload, deploy) carry no soroban meta; ledger-entry change sets + are empty because komet-node does not track them. + """ + soroban_meta = None + if return_value is not None: + soroban_meta = xdr.SorobanTransactionMeta( + ext=xdr.SorobanTransactionMetaExt(0), + events=[], + return_value=return_value, + diagnostic_events=[], + ) + meta = xdr.TransactionMeta( + v=3, + v3=xdr.TransactionMetaV3( + ext=xdr.ExtensionPoint(0), + tx_changes_before=xdr.LedgerEntryChanges([]), + operations=[xdr.OperationMeta(xdr.LedgerEntryChanges([])) for _ in envelope.transaction.operations], + tx_changes_after=xdr.LedgerEntryChanges([]), + soroban_meta=soroban_meta, + ), + ) + return meta.to_xdr() + + +def _op_success_result(op: Operation, return_value: xdr.SCVal | None) -> xdr.OperationResult: + """The success ``OperationResult`` for one operation of a committed transaction.""" + if isinstance(op, CreateAccount): + tr = xdr.OperationResultTr( + xdr.OperationType.CREATE_ACCOUNT, + create_account_result=xdr.CreateAccountResult(xdr.CreateAccountResultCode.CREATE_ACCOUNT_SUCCESS), + ) + elif isinstance(op, InvokeHostFunction): + # The real network puts SHA-256(InvokeHostFunctionSuccessPreImage) here — a hash over + # the emitted events and the return value. komet-node does not capture events, so the + # hash is derived from the return value alone (empty for upload/deploy operations). + payload = return_value.to_xdr_bytes() if return_value is not None else b'' + tr = xdr.OperationResultTr( + xdr.OperationType.INVOKE_HOST_FUNCTION, + invoke_host_function_result=xdr.InvokeHostFunctionResult( + xdr.InvokeHostFunctionResultCode.INVOKE_HOST_FUNCTION_SUCCESS, + success=xdr.Hash(hashlib.sha256(payload).digest()), + ), + ) + else: + # TransactionEncoder only admits CreateAccount and InvokeHostFunction operations, so + # a committed transaction cannot contain anything else. + raise NotImplementedError(f'No result encoding for operation type: {type(op).__name__}') + return xdr.OperationResult(xdr.OperationResultCode.opINNER, tr=tr) + + +def _op_failure_results(operations: list[Operation]) -> list[xdr.OperationResult]: + """The per-operation results for a failed (stuck) transaction.""" + if len(operations) == 1 and isinstance(operations[0], InvokeHostFunction): + tr = xdr.OperationResultTr( + xdr.OperationType.INVOKE_HOST_FUNCTION, + invoke_host_function_result=xdr.InvokeHostFunctionResult( + xdr.InvokeHostFunctionResultCode.INVOKE_HOST_FUNCTION_TRAPPED + ), + ) + return [xdr.OperationResult(xdr.OperationResultCode.opINNER, tr=tr)] + return [] diff --git a/src/komet_node/scval.py b/src/komet_node/scval.py index c379b60..fbbc550 100644 --- a/src/komet_node/scval.py +++ b/src/komet_node/scval.py @@ -20,6 +20,7 @@ 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 @@ -28,6 +29,8 @@ from stellar_sdk.xdr.sc_address import SCAddress as XDRSCAddress from stellar_sdk.xdr.sc_val import SCVal +_U64_MASK = (1 << 64) - 1 + def scval_to_json(scval: SCVal) -> dict: """Encode a Stellar XDR SCVal as a JSON-serialisable dict for the node request envelope. @@ -79,6 +82,74 @@ def scval_to_json(scval: SCVal) -> dict: raise NotImplementedError(f'Unsupported SCVal type for JSON encoding: {scval.type}') +def scval_from_json(value: dict) -> SCVal: + """Decode a JSON-encoded SCVal (as produced by ``#scValToJSON`` in ``node.md``) to XDR. + + This is the inverse of :func:`scval_to_json`, extended with the value-only cases a + contract call can return but never takes as an argument (``void``, ``string``, ``u256``, + ``vec``, ``map``) — keep the three encodings in sync. + """ + xdr = stellar_xdr + match value: + case {'type': 'bool', 'value': bool(b)}: + return xdr.SCVal(SCValType.SCV_BOOL, b=b) + case {'type': 'void'}: + return xdr.SCVal(SCValType.SCV_VOID) + case {'type': 'i32', 'value': int(v)}: + return xdr.SCVal(SCValType.SCV_I32, i32=xdr.Int32(v)) + case {'type': 'u32', 'value': int(v)}: + return xdr.SCVal(SCValType.SCV_U32, u32=xdr.Uint32(v)) + case {'type': 'i64', 'value': int(v)}: + return xdr.SCVal(SCValType.SCV_I64, i64=xdr.Int64(v)) + case {'type': 'u64', 'value': int(v)}: + return xdr.SCVal(SCValType.SCV_U64, u64=xdr.Uint64(v)) + case {'type': 'i128', 'value': int(v)}: + return xdr.SCVal( + SCValType.SCV_I128, i128=xdr.Int128Parts(hi=xdr.Int64(v >> 64), lo=xdr.Uint64(v & _U64_MASK)) + ) + case {'type': 'u128', 'value': int(v)}: + return xdr.SCVal( + SCValType.SCV_U128, u128=xdr.UInt128Parts(hi=xdr.Uint64(v >> 64), lo=xdr.Uint64(v & _U64_MASK)) + ) + case {'type': 'u256', 'value': int(v)}: + return xdr.SCVal( + SCValType.SCV_U256, + u256=xdr.UInt256Parts( + hi_hi=xdr.Uint64(v >> 192), + hi_lo=xdr.Uint64((v >> 128) & _U64_MASK), + lo_hi=xdr.Uint64((v >> 64) & _U64_MASK), + lo_lo=xdr.Uint64(v & _U64_MASK), + ), + ) + case {'type': 'symbol', 'value': str(s)}: + return xdr.SCVal(SCValType.SCV_SYMBOL, sym=xdr.SCSymbol(s.encode())) + case {'type': 'string', 'value': str(s)}: + return xdr.SCVal(SCValType.SCV_STRING, str=xdr.SCString(s.encode())) + case {'type': 'bytes', 'value': str(h)}: + return xdr.SCVal(SCValType.SCV_BYTES, bytes=xdr.SCBytes(bytes.fromhex(h))) + case {'type': 'address', 'addrType': 'account', 'value': str(h)}: + account_id = xdr.AccountID( + xdr.PublicKey(xdr.PublicKeyType.PUBLIC_KEY_TYPE_ED25519, ed25519=xdr.Uint256(bytes.fromhex(h))) + ) + return xdr.SCVal( + SCValType.SCV_ADDRESS, + address=xdr.SCAddress(SCAddressType.SC_ADDRESS_TYPE_ACCOUNT, account_id=account_id), + ) + case {'type': 'address', 'addrType': 'contract', 'value': str(h)}: + contract_id = xdr.ContractID(xdr.Hash(bytes.fromhex(h))) + return xdr.SCVal( + SCValType.SCV_ADDRESS, + address=xdr.SCAddress(SCAddressType.SC_ADDRESS_TYPE_CONTRACT, contract_id=contract_id), + ) + case {'type': 'vec', 'value': list(items)}: + return xdr.SCVal(SCValType.SCV_VEC, vec=xdr.SCVec([scval_from_json(item) for item in items])) + case {'type': 'map', 'value': list(entries)}: + sc_map = [xdr.SCMapEntry(key=scval_from_json(k), val=scval_from_json(v)) for k, v in entries] + return xdr.SCVal(SCValType.SCV_MAP, map=xdr.SCMap(sc_map)) + case _: + raise NotImplementedError(f'Unsupported JSON SCVal encoding: {value!r}') + + 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..d982e17 100644 --- a/src/komet_node/server.py +++ b/src/komet_node/server.py @@ -10,9 +10,11 @@ from pathlib import Path from typing import TYPE_CHECKING, Any, Final -from stellar_sdk import Network +from stellar_sdk import Network, TransactionEnvelope from komet_node.interpreter import NodeInterpreter +from komet_node.result_xdr import transaction_meta_xdr, transaction_result_xdr +from komet_node.scval import scval_from_json from komet_node.transaction import TransactionEncoder if TYPE_CHECKING: @@ -198,6 +200,7 @@ def handle_rpc(self, method: str | None, params: dict[str, Any], request_id: Any response = self.interpreter.run(self.state_file, self.io_dir, envelope, program_steps) if response is None: return json.dumps(self._failure_response(request_id, envelope, now)) + self._attach_result_xdr(envelope['txHash']) return response read_only_envelope = self._read_only_envelope(method, params, request_id, now) @@ -243,6 +246,24 @@ def _archive_request(self, method: str | None, params: dict[str, Any], request_i (self.requests_dir / f'request_{self._request_count}.json').write_text(json.dumps(archive)) self._request_count += 1 + def _attach_result_xdr(self, tx_hash: str) -> None: + """Rewrite a fresh SUCCESS receipt's internal ``returnValue`` into real result XDR. + + The K semantics record the transaction's contract-call return value in the receipt + as a JSON-encoded SCVal (``null`` when the transaction made no call), because K + cannot construct XDR. Replace it with the spec-mandated ``resultXdr``/ + ``resultMetaXdr`` (base64 TransactionResult / TransactionMeta), so getTransaction + can serve the stored receipt as-is. + """ + receipt_file = self.receipts_dir / f'receipt_{tx_hash}.json' + receipt = json.loads(receipt_file.read_text()) + return_value_json = receipt.pop('returnValue', None) + return_value = scval_from_json(return_value_json) if return_value_json is not None else None + tx_envelope = TransactionEnvelope.from_xdr(receipt['envelopeXdr'], self.encoder.network_passphrase) + receipt['resultXdr'] = transaction_result_xdr(tx_envelope, return_value, success=True) + receipt['resultMetaXdr'] = transaction_meta_xdr(tx_envelope, return_value) + receipt_file.write_text(json.dumps(receipt)) + def _failure_response(self, rpc_id: Any, envelope: dict[str, Any], now: str) -> dict[str, Any]: """Synthesise the sendTransaction response for a transaction that got stuck (failed). @@ -253,17 +274,21 @@ def _failure_response(self, rpc_id: Any, envelope: dict[str, Any], now: str) -> metadata = json.loads((self.io_dir / 'metadata.json').read_text()) ledger = metadata.get('latest_ledger', 0) tx_hash = envelope['txHash'] + tx_envelope = TransactionEnvelope.from_xdr(envelope['envelopeXdr'], self.encoder.network_passphrase) # This FAILED receipt mirrors the SUCCESS receipt the semantics build in - # `#txReceipt` (kdist/node.md): keep the field set in sync with that rule. Like the - # success path, the receipt carries no trace — any trace lives in its own file. + # `#txReceipt` (kdist/node.md) after `_attach_result_xdr` finalises it: keep the + # field set in sync. A failed transaction never commits, so the chain does not + # advance: `ledger` pins the latest ledger at failure time (same string encoding as + # SUCCESS receipts) and `createdAt` the wall-clock submission time. `resultMetaXdr` + # is omitted — the spec allows that, and no meta exists for a rolled-back run. Like + # the success path, the receipt carries no trace — any trace lives in its own file. receipt = { 'status': 'FAILED', 'ledger': str(ledger), 'createdAt': now, 'envelopeXdr': envelope['envelopeXdr'], - 'resultXdr': '', - 'resultMetaXdr': '', + 'resultXdr': transaction_result_xdr(tx_envelope, None, success=False), } (self.receipts_dir / f'receipt_{tx_hash}.json').write_text(json.dumps(receipt)) From dbcc7feaf5cf1aabe807912d40f0b325c7acec40 Mon Sep 17 00:00:00 2001 From: Raoul Date: Fri, 3 Jul 2026 10:53:47 +0000 Subject: [PATCH 3/4] test: guard against returnValue leaks and pin empty-bytes returns The receipt's internal returnValue field is a K-side implementation detail that the server rewrites into resultXdr/resultMetaXdr; assert it never appears in getTransaction responses on any status. The leak is reachable today: if _attach_result_xdr raises, the receipt keeps the raw field and getTransaction serves it as-is. Add a regression test for exactly that trigger: a contract returning empty Bytes. #bytesToHex renders zero-length bytes as "0", which scval_from_json rejects (odd-length hex), so sendTransaction currently returns Internal error and the receipt leaks. The test pins the correct behaviour (sorobanMeta.returnValue = SCV_BYTES of b"") and fails until the encoding is fixed. --- src/tests/integration/data/wasm/bytes.wat | 25 +++++++++++ src/tests/integration/test_server.py | 52 ++++++++++++++++++++++- 2 files changed, 76 insertions(+), 1 deletion(-) create mode 100644 src/tests/integration/data/wasm/bytes.wat diff --git a/src/tests/integration/data/wasm/bytes.wat b/src/tests/integration/data/wasm/bytes.wat new file mode 100644 index 0000000..87afcd3 --- /dev/null +++ b/src/tests/integration/data/wasm/bytes.wat @@ -0,0 +1,25 @@ +(module + (type (;0;) (func)) + (type (;1;) (func (result i64))) + + ;; bytes_new: Soroban host function "b"."4" — allocates an empty Bytes object + (import "b" "4" (func (;0;) (type 1))) + + ;; empty_bytes: take no args, return a freshly allocated empty Bytes object + (func (;1;) (type 1) (result i64) + call 0) + + ;; _ (Soroban ABI stub) + (func (;2;) (type 0)) + + (memory (;0;) 16) + (global (;0;) (mut i32) (i32.const 1048576)) + (global (;1;) i32 (i32.const 1048576)) + (global (;2;) i32 (i32.const 1048576)) + + (export "memory" (memory 0)) + (export "empty_bytes" (func 1)) + (export "_" (func 2)) + (export "__data_end" (global 1)) + (export "__heap_base" (global 2)) +) diff --git a/src/tests/integration/test_server.py b/src/tests/integration/test_server.py index 43aba40..7a923fa 100644 --- a/src/tests/integration/test_server.py +++ b/src/tests/integration/test_server.py @@ -19,6 +19,7 @@ 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) +BYTES_CONTRACT_WAT = (Path(__file__).parent / 'data' / 'wasm' / 'bytes.wat').resolve(strict=True) def wat_to_wasm(wat_path: Path) -> bytes: @@ -552,6 +553,9 @@ def test_get_transaction_success_returns_decodable_result_xdr(server: StellarRpc get_result = _rpc(server.port(), 'getTransaction', {'hash': tx_hash})['result'] assert get_result['status'] == 'SUCCESS' + # The K-side receipt's internal returnValue field must never reach RPC clients: the + # server rewrites it into resultXdr/resultMetaXdr before the receipt is ever served. + assert 'returnValue' not in get_result tx_result = _tx_result_of(get_result) assert tx_result.result.code == xdr.TransactionResultCode.txSUCCESS @@ -580,6 +584,8 @@ def send(tb: TransactionBuilder) -> dict[str, Any]: 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}' + # The internal K-side returnValue field must never leak into getTransaction responses. + assert 'returnValue' not in get_res # Every successful receipt, whatever the operation, has a decodable txSUCCESS result. assert _tx_result_of(get_res).result.code == xdr.TransactionResultCode.txSUCCESS return get_res @@ -623,6 +629,49 @@ def send(tb: TransactionBuilder) -> dict[str, Any]: assert return_value.u32 == xdr.Uint32(5) +def test_get_transaction_reports_empty_bytes_return_value(server: StellarRpcServer) -> None: + """A contract returning empty Bytes yields SCV_BYTES(b'') in sorobanMeta.returnValue. + + Regression test for the zero-length edge of the receipt's hex encoding: an empty + byte string round-trips through the K-side JSON encoding as an empty hex string, not + as ``"0"`` (an odd-length non-hex value that breaks the XDR rewrite and would leak + the internal ``returnValue`` field to clients). + """ + keypair = Keypair.random() + account = Account(keypair.public_key, sequence=0) + + def builder() -> TransactionBuilder: + return TransactionBuilder(account, Network.TESTNET_NETWORK_PASSPHRASE) + + def send(tb: TransactionBuilder) -> dict[str, Any]: + env = tb.set_timeout(30).build() + env.sign(keypair) + res = _rpc(server.port(), 'sendTransaction', {'transaction': env.to_xdr()}) + assert 'result' in res, f'sendTransaction failed: {res}' + get_res = _rpc(server.port(), 'getTransaction', {'hash': res['result']['hash']})['result'] + assert get_res['status'] == 'SUCCESS', f'Transaction failed: {get_res}' + return get_res + + send(builder().append_create_account_op(keypair.public_key, '1000')) + + wasm_bytecode = wat_to_wasm(BYTES_CONTRACT_WAT) + send(builder().append_upload_contract_wasm_op(wasm_bytecode)) + + from stellar_sdk.utils import sha256 + + salt = b'\x00' * 32 + send(builder().append_create_contract_op(sha256(wasm_bytecode), keypair.public_key, None, salt)) + + contract_address = server.encoder.contract_address_from_deployer_address(keypair.public_key, salt) + invoke_result = send(builder().append_invoke_contract_function_op(contract_address, 'empty_bytes', [])) + assert 'returnValue' not in invoke_result # internal receipt field, must never be served + + return_value = _soroban_return_value(_tx_meta_of(invoke_result)) + assert return_value.type == SCValType.SCV_BYTES + assert return_value.bytes is not None + assert return_value.bytes.sc_bytes == b'' + + def test_get_transaction_failed_reports_error_result_xdr(server: StellarRpcServer) -> None: """A FAILED receipt carries a real error TransactionResult, not an empty stub. @@ -654,6 +703,7 @@ def builder() -> TransactionBuilder: get_result = _rpc(server.port(), 'getTransaction', {'hash': bad_hash})['result'] assert get_result['status'] == 'FAILED' + assert 'returnValue' not in get_result # internal receipt field, must never be served tx_result = _tx_result_of(get_result) assert tx_result.result.code == xdr.TransactionResultCode.txFAILED @@ -675,5 +725,5 @@ def test_get_transaction_not_found_omits_transaction_fields(server: StellarRpcSe """A NOT_FOUND response carries no transaction details (omitted, not empty/null).""" get_result = _rpc(server.port(), 'getTransaction', {'hash': 'b' * 64})['result'] assert get_result['status'] == 'NOT_FOUND' - for field in ('ledger', 'createdAt', 'envelopeXdr', 'resultXdr', 'resultMetaXdr'): + for field in ('ledger', 'createdAt', 'envelopeXdr', 'resultXdr', 'resultMetaXdr', 'returnValue'): assert field not in get_result, f'NOT_FOUND response must omit {field}' From c0cb0d2f4a3a60a42aeae024a09017194e754439 Mon Sep 17 00:00:00 2001 From: Raoul Date: Fri, 3 Jul 2026 11:00:08 +0000 Subject: [PATCH 4/4] fix: encode empty Bytes as empty hex string in receipts Base2String yields "0" for the zero-length case, an odd-length non-hex value that broke the receipt's XDR rewrite for contracts returning empty Bytes. Add a dedicated K rule mapping empty Bytes to the empty string. Also harden _attach_result_xdr: persist the receipt without the internal returnValue field even when the rewrite fails, so a stored receipt never leaks K-internal fields to getTransaction. --- src/komet_node/kdist/node.md | 5 ++++- src/komet_node/server.py | 16 +++++++++++----- 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/src/komet_node/kdist/node.md b/src/komet_node/kdist/node.md index 06621db..aad120c 100644 --- a/src/komet_node/kdist/node.md +++ b/src/komet_node/kdist/node.md @@ -497,13 +497,16 @@ Values with no JSON encoding (e.g. `Error`) render as `null`, i.e. as "no return ``` `#bytesToHex` is the inverse of `HexBytes`: lowercase hex, two digits per byte (zero-padded, -since Base2String drops leading zeroes). +since Base2String drops leading zeroes). Empty bytes encode as the empty string — the +general rule would yield `"0"` (Base2String of 0), which `#padZeros` cannot trim. ```k syntax String ::= #bytesToHex( Bytes ) [function, total, symbol(bytesToHex)] | #padZeros( String, Int ) [function, total, symbol(padZeros)] // -------------------------------------------------------------------------------- + rule #bytesToHex( B ) => "" requires lengthBytes(B) ==Int 0 rule #bytesToHex( B ) => #padZeros( Base2String( Bytes2Int(B, BE, Unsigned), 16 ), 2 *Int lengthBytes(B) ) + requires lengthBytes(B) >Int 0 rule #padZeros( S, N ) => #padZeros( "0" +String S, N ) requires lengthString(S) S [owise] diff --git a/src/komet_node/server.py b/src/komet_node/server.py index d982e17..7e8763e 100644 --- a/src/komet_node/server.py +++ b/src/komet_node/server.py @@ -258,11 +258,17 @@ def _attach_result_xdr(self, tx_hash: str) -> None: receipt_file = self.receipts_dir / f'receipt_{tx_hash}.json' receipt = json.loads(receipt_file.read_text()) return_value_json = receipt.pop('returnValue', None) - return_value = scval_from_json(return_value_json) if return_value_json is not None else None - tx_envelope = TransactionEnvelope.from_xdr(receipt['envelopeXdr'], self.encoder.network_passphrase) - receipt['resultXdr'] = transaction_result_xdr(tx_envelope, return_value, success=True) - receipt['resultMetaXdr'] = transaction_meta_xdr(tx_envelope, return_value) - receipt_file.write_text(json.dumps(receipt)) + try: + return_value = scval_from_json(return_value_json) if return_value_json is not None else None + tx_envelope = TransactionEnvelope.from_xdr(receipt['envelopeXdr'], self.encoder.network_passphrase) + receipt['resultXdr'] = transaction_result_xdr(tx_envelope, return_value, success=True) + receipt['resultMetaXdr'] = transaction_meta_xdr(tx_envelope, return_value) + finally: + # Persist the receipt even when the rewrite fails (e.g. a return value this + # encoder cannot decode): the transaction has already committed, and the stored + # receipt must never keep the K-internal `returnValue` field. A receipt with + # `resultXdr` omitted is spec-legal; a leaked internal field is not. + receipt_file.write_text(json.dumps(receipt)) def _failure_response(self, rpc_id: Any, envelope: dict[str, Any], now: str) -> dict[str, Any]: """Synthesise the sendTransaction response for a transaction that got stuck (failed).