Skip to content

Commit dbcc7fe

Browse files
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.
1 parent 1179f4a commit dbcc7fe

2 files changed

Lines changed: 76 additions & 1 deletion

File tree

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
(module
2+
(type (;0;) (func))
3+
(type (;1;) (func (result i64)))
4+
5+
;; bytes_new: Soroban host function "b"."4" — allocates an empty Bytes object
6+
(import "b" "4" (func (;0;) (type 1)))
7+
8+
;; empty_bytes: take no args, return a freshly allocated empty Bytes object
9+
(func (;1;) (type 1) (result i64)
10+
call 0)
11+
12+
;; _ (Soroban ABI stub)
13+
(func (;2;) (type 0))
14+
15+
(memory (;0;) 16)
16+
(global (;0;) (mut i32) (i32.const 1048576))
17+
(global (;1;) i32 (i32.const 1048576))
18+
(global (;2;) i32 (i32.const 1048576))
19+
20+
(export "memory" (memory 0))
21+
(export "empty_bytes" (func 1))
22+
(export "_" (func 2))
23+
(export "__data_end" (global 1))
24+
(export "__heap_base" (global 2))
25+
)

src/tests/integration/test_server.py

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
EMPTY_CONTRACT_WAT = (Path(__file__).parent / 'data' / 'wasm' / 'empty.wat').resolve(strict=True)
2020
ARGS_CONTRACT_WAT = (Path(__file__).parent / 'data' / 'wasm' / 'args.wat').resolve(strict=True)
2121
ADDER_CONTRACT_WAT = (Path(__file__).parent / 'data' / 'wasm' / 'adder.wat').resolve(strict=True)
22+
BYTES_CONTRACT_WAT = (Path(__file__).parent / 'data' / 'wasm' / 'bytes.wat').resolve(strict=True)
2223

2324

2425
def wat_to_wasm(wat_path: Path) -> bytes:
@@ -552,6 +553,9 @@ def test_get_transaction_success_returns_decodable_result_xdr(server: StellarRpc
552553

553554
get_result = _rpc(server.port(), 'getTransaction', {'hash': tx_hash})['result']
554555
assert get_result['status'] == 'SUCCESS'
556+
# The K-side receipt's internal returnValue field must never reach RPC clients: the
557+
# server rewrites it into resultXdr/resultMetaXdr before the receipt is ever served.
558+
assert 'returnValue' not in get_result
555559

556560
tx_result = _tx_result_of(get_result)
557561
assert tx_result.result.code == xdr.TransactionResultCode.txSUCCESS
@@ -580,6 +584,8 @@ def send(tb: TransactionBuilder) -> dict[str, Any]:
580584
assert res['result']['status'] == 'PENDING'
581585
get_res = _rpc(server.port(), 'getTransaction', {'hash': res['result']['hash']})['result']
582586
assert get_res['status'] == 'SUCCESS', f'Transaction failed: {get_res}'
587+
# The internal K-side returnValue field must never leak into getTransaction responses.
588+
assert 'returnValue' not in get_res
583589
# Every successful receipt, whatever the operation, has a decodable txSUCCESS result.
584590
assert _tx_result_of(get_res).result.code == xdr.TransactionResultCode.txSUCCESS
585591
return get_res
@@ -623,6 +629,49 @@ def send(tb: TransactionBuilder) -> dict[str, Any]:
623629
assert return_value.u32 == xdr.Uint32(5)
624630

625631

632+
def test_get_transaction_reports_empty_bytes_return_value(server: StellarRpcServer) -> None:
633+
"""A contract returning empty Bytes yields SCV_BYTES(b'') in sorobanMeta.returnValue.
634+
635+
Regression test for the zero-length edge of the receipt's hex encoding: an empty
636+
byte string round-trips through the K-side JSON encoding as an empty hex string, not
637+
as ``"0"`` (an odd-length non-hex value that breaks the XDR rewrite and would leak
638+
the internal ``returnValue`` field to clients).
639+
"""
640+
keypair = Keypair.random()
641+
account = Account(keypair.public_key, sequence=0)
642+
643+
def builder() -> TransactionBuilder:
644+
return TransactionBuilder(account, Network.TESTNET_NETWORK_PASSPHRASE)
645+
646+
def send(tb: TransactionBuilder) -> dict[str, Any]:
647+
env = tb.set_timeout(30).build()
648+
env.sign(keypair)
649+
res = _rpc(server.port(), 'sendTransaction', {'transaction': env.to_xdr()})
650+
assert 'result' in res, f'sendTransaction failed: {res}'
651+
get_res = _rpc(server.port(), 'getTransaction', {'hash': res['result']['hash']})['result']
652+
assert get_res['status'] == 'SUCCESS', f'Transaction failed: {get_res}'
653+
return get_res
654+
655+
send(builder().append_create_account_op(keypair.public_key, '1000'))
656+
657+
wasm_bytecode = wat_to_wasm(BYTES_CONTRACT_WAT)
658+
send(builder().append_upload_contract_wasm_op(wasm_bytecode))
659+
660+
from stellar_sdk.utils import sha256
661+
662+
salt = b'\x00' * 32
663+
send(builder().append_create_contract_op(sha256(wasm_bytecode), keypair.public_key, None, salt))
664+
665+
contract_address = server.encoder.contract_address_from_deployer_address(keypair.public_key, salt)
666+
invoke_result = send(builder().append_invoke_contract_function_op(contract_address, 'empty_bytes', []))
667+
assert 'returnValue' not in invoke_result # internal receipt field, must never be served
668+
669+
return_value = _soroban_return_value(_tx_meta_of(invoke_result))
670+
assert return_value.type == SCValType.SCV_BYTES
671+
assert return_value.bytes is not None
672+
assert return_value.bytes.sc_bytes == b''
673+
674+
626675
def test_get_transaction_failed_reports_error_result_xdr(server: StellarRpcServer) -> None:
627676
"""A FAILED receipt carries a real error TransactionResult, not an empty stub.
628677
@@ -654,6 +703,7 @@ def builder() -> TransactionBuilder:
654703

655704
get_result = _rpc(server.port(), 'getTransaction', {'hash': bad_hash})['result']
656705
assert get_result['status'] == 'FAILED'
706+
assert 'returnValue' not in get_result # internal receipt field, must never be served
657707

658708
tx_result = _tx_result_of(get_result)
659709
assert tx_result.result.code == xdr.TransactionResultCode.txFAILED
@@ -675,5 +725,5 @@ def test_get_transaction_not_found_omits_transaction_fields(server: StellarRpcSe
675725
"""A NOT_FOUND response carries no transaction details (omitted, not empty/null)."""
676726
get_result = _rpc(server.port(), 'getTransaction', {'hash': 'b' * 64})['result']
677727
assert get_result['status'] == 'NOT_FOUND'
678-
for field in ('ledger', 'createdAt', 'envelopeXdr', 'resultXdr', 'resultMetaXdr'):
728+
for field in ('ledger', 'createdAt', 'envelopeXdr', 'resultXdr', 'resultMetaXdr', 'returnValue'):
679729
assert field not in get_result, f'NOT_FOUND response must omit {field}'

0 commit comments

Comments
 (0)