Skip to content

Commit db3ef32

Browse files
test: add integration tests for getEvents
Cover the getEvents surface from the official OpenRPC spec: request validation (startLedger/cursor rules, filter and topic limits, xdrFormat), the result shape (events array, latestLedger as a JSON number, cursor string), event objects for a contract event published via the contract_event host function (TOID-style id, base64 SCVal topics/value, ISO-8601 close time), filtering by contract id, type and topics, and cursor-based pagination. The emitting contract (data/wasm/events.wat) builds its topics vector with vec_new/vec_push_back and runs successfully through the semantics today; the tests fail only because getEvents is not implemented yet.
1 parent 8f392c5 commit db3ef32

2 files changed

Lines changed: 373 additions & 0 deletions

File tree

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
(module
2+
(type (;0;) (func))
3+
(type (;1;) (func (result i64)))
4+
(type (;2;) (func (param i64 i64) (result i64)))
5+
6+
;; Soroban host imports (the semantics resolve these to hostCall):
7+
;; v._ vec_new() -> VecObject
8+
;; v.6 vec_push_back(vec, val) -> VecObject
9+
;; x.1 contract_event(topics, data) -> Void
10+
(import "v" "_" (func (;0;) (type 1)))
11+
(import "v" "6" (func (;1;) (type 2)))
12+
(import "x" "1" (func (;2;) (type 2)))
13+
14+
;; emit: publish one contract event with topics [Symbol("transfer")] and
15+
;; data U32(42), then return Void.
16+
;;
17+
;; Small-value encodings per CAP-46-01 (tag in the low 8 bits):
18+
;; Symbol("transfer") = (encode6bit("transfer") << 8) | 14 = 65154533130155790
19+
;; U32(42) = (42 << 32) | 4 = 180388626436
20+
;; Void = 2
21+
(func (;3;) (type 1) (result i64)
22+
call 0 ;; vec_new() -> empty topics vec
23+
i64.const 65154533130155790 ;; Symbol("transfer")
24+
call 1 ;; vec_push_back(vec, symbol)
25+
i64.const 180388626436 ;; U32(42)
26+
call 2 ;; contract_event(topics, data)
27+
drop
28+
i64.const 2) ;; Void
29+
30+
;; _ (Soroban ABI stub)
31+
(func (;4;) (type 0))
32+
33+
(memory (;0;) 16)
34+
(global (;0;) (mut i32) (i32.const 1048576))
35+
(global (;1;) i32 (i32.const 1048576))
36+
(global (;2;) i32 (i32.const 1048576))
37+
38+
(export "memory" (memory 0))
39+
(export "emit" (func 3))
40+
(export "_" (func 4))
41+
)
Lines changed: 332 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,332 @@
1+
"""Integration tests for the ``getEvents`` JSON-RPC method.
2+
3+
The expected behavior follows the official stellar-rpc OpenRPC spec (getEvents.json and the
4+
Event / EventFilters / Cursor schemas): a request selects a ledger range with ``startLedger``
5+
(inclusive) / ``endLedger`` (exclusive) or resumes from a pagination ``cursor``, optionally
6+
narrowed by up to five filters (event type, contract ids, topic matchers). The result carries
7+
``events`` (array), ``latestLedger`` (JSON number) and ``cursor`` (string).
8+
9+
The event-emitting contract lives in ``data/wasm/events.wat``: its ``emit`` function publishes
10+
one contract event with topics ``[Symbol("transfer")]`` and data ``U32(42)`` via the
11+
``contract_event`` host function (module "x", name "1").
12+
"""
13+
14+
from __future__ import annotations
15+
16+
import json
17+
import re
18+
import socket
19+
import subprocess
20+
import threading
21+
import time
22+
import urllib.request
23+
from datetime import datetime
24+
from pathlib import Path
25+
from typing import TYPE_CHECKING, Any
26+
27+
import pytest
28+
from stellar_sdk import Account, Keypair, Network, StrKey, TransactionBuilder, xdr
29+
from stellar_sdk.utils import sha256
30+
from stellar_sdk.xdr.sc_val_type import SCValType
31+
32+
from komet_node.server import StellarRpcServer
33+
34+
if TYPE_CHECKING:
35+
from collections.abc import Iterator
36+
37+
EVENTS_CONTRACT_WAT = (Path(__file__).parent / 'data' / 'wasm' / 'events.wat').resolve(strict=True)
38+
39+
# Base64 SCVal XDR of the topic and data emitted by events.wat's `emit` function.
40+
TRANSFER_TOPIC_XDR = xdr.SCVal(type=SCValType.SCV_SYMBOL, sym=xdr.SCSymbol(sc_symbol=b'transfer')).to_xdr()
41+
MINT_TOPIC_XDR = xdr.SCVal(type=SCValType.SCV_SYMBOL, sym=xdr.SCSymbol(sc_symbol=b'mint')).to_xdr()
42+
U32_42_XDR = xdr.SCVal(type=SCValType.SCV_U32, u32=xdr.Uint32(42)).to_xdr()
43+
44+
# TOID-style event id: 19-digit zero-padded TOID, hyphen, 10-digit zero-padded event index
45+
# (see the EventId schema / SEP-35).
46+
EVENT_ID_RE = re.compile(r'\d{19}-\d{10}')
47+
48+
49+
# ---------------------------------------------------------------------------
50+
# Helpers (mirrored from test_server.py so this module stays self-contained)
51+
# ---------------------------------------------------------------------------
52+
53+
54+
def wat_to_wasm(wat_path: Path) -> bytes:
55+
proc_res = subprocess.run(['wat2wasm', str(wat_path), '--output=/dev/stdout'], check=True, capture_output=True)
56+
return proc_res.stdout
57+
58+
59+
def _find_free_port() -> int:
60+
with socket.socket() as s:
61+
s.bind(('', 0))
62+
return s.getsockname()[1]
63+
64+
65+
def _wait_for_server(host: str, port: int, timeout: float = 10.0) -> None:
66+
deadline = time.time() + timeout
67+
while time.time() < deadline:
68+
try:
69+
with socket.create_connection((host, port), timeout=0.1):
70+
return
71+
except OSError:
72+
time.sleep(0.05)
73+
raise TimeoutError(f'Server did not start on {host}:{port}')
74+
75+
76+
def _rpc(port: int, method: str, params: dict[str, Any]) -> dict[str, Any]:
77+
body = json.dumps({'jsonrpc': '2.0', 'id': 1, 'method': method, 'params': params}).encode()
78+
req = urllib.request.Request(
79+
f'http://localhost:{port}',
80+
data=body,
81+
headers={'Content-Type': 'application/json'},
82+
)
83+
with urllib.request.urlopen(req) as resp:
84+
return json.loads(resp.read())
85+
86+
87+
@pytest.fixture
88+
def server(tmp_path: Path) -> Iterator[StellarRpcServer]:
89+
port = _find_free_port()
90+
srv = StellarRpcServer(
91+
host='localhost',
92+
port=port,
93+
io_dir=tmp_path,
94+
network_passphrase=Network.TESTNET_NETWORK_PASSPHRASE,
95+
)
96+
thread = threading.Thread(target=srv.serve, daemon=True)
97+
thread.start()
98+
_wait_for_server('localhost', port)
99+
yield srv
100+
srv.shutdown()
101+
102+
103+
def _send(server: StellarRpcServer, keypair: Keypair, tb: TransactionBuilder) -> str:
104+
"""Sign, submit, and confirm a transaction; return its hash."""
105+
env = tb.set_timeout(30).build()
106+
env.sign(keypair)
107+
res = _rpc(server.port(), 'sendTransaction', {'transaction': env.to_xdr()})
108+
assert res['result']['status'] == 'PENDING'
109+
tx_hash = res['result']['hash']
110+
get_res = _rpc(server.port(), 'getTransaction', {'hash': tx_hash})['result']
111+
assert get_res['status'] == 'SUCCESS', f'Transaction failed: {get_res}'
112+
return tx_hash
113+
114+
115+
def _deploy_events_contract(server: StellarRpcServer) -> tuple[Keypair, Account, str]:
116+
"""Create an account, upload events.wat, and deploy it (ledgers 1-3).
117+
118+
Returns the funding keypair, its (mutated) sequence-tracking account, and the C-strkey
119+
address of the deployed contract.
120+
"""
121+
keypair = Keypair.random()
122+
account = Account(keypair.public_key, sequence=0)
123+
124+
def builder() -> TransactionBuilder:
125+
return TransactionBuilder(account, Network.TESTNET_NETWORK_PASSPHRASE)
126+
127+
_send(server, keypair, builder().append_create_account_op(keypair.public_key, '1000'))
128+
129+
wasm_bytecode = wat_to_wasm(EVENTS_CONTRACT_WAT)
130+
_send(server, keypair, builder().append_upload_contract_wasm_op(wasm_bytecode))
131+
132+
wasm_hash = sha256(wasm_bytecode)
133+
salt = b'\x00' * 32
134+
_send(server, keypair, builder().append_create_contract_op(wasm_hash, keypair.public_key, None, salt))
135+
136+
contract_address = server.encoder.contract_address_from_deployer_address(keypair.public_key, salt)
137+
return keypair, account, contract_address
138+
139+
140+
def _emit_event(server: StellarRpcServer, keypair: Keypair, account: Account, contract_address: str) -> str:
141+
"""Invoke the contract's `emit` function; return the transaction hash."""
142+
tb = TransactionBuilder(account, Network.TESTNET_NETWORK_PASSPHRASE)
143+
return _send(server, keypair, tb.append_invoke_contract_function_op(contract_address, 'emit', []))
144+
145+
146+
def _is_json_number(value: Any) -> bool:
147+
"""True for a JSON number decoded to int (bool is an int subclass, so exclude it)."""
148+
return isinstance(value, int) and not isinstance(value, bool)
149+
150+
151+
def _assert_request_error(response: dict[str, Any]) -> None:
152+
"""The call must be rejected with a JSON-RPC request/params error.
153+
154+
The OpenRPC spec documents which requests are invalid but not the numeric code; real
155+
stellar-rpc uses -32600 (Invalid Request) for getEvents request validation, and -32602
156+
(Invalid params) is the JSON-RPC code for bad params. Accept either, but nothing else.
157+
"""
158+
assert 'error' in response, f'expected an error response, got: {response}'
159+
assert response['error']['code'] in (-32600, -32602), response['error']
160+
161+
162+
# ---------------------------------------------------------------------------
163+
# Request validation
164+
# ---------------------------------------------------------------------------
165+
166+
167+
def test_get_events_requires_start_ledger_or_cursor(server: StellarRpcServer) -> None:
168+
"""Neither startLedger nor a pagination cursor: the request is invalid (per StartLedger)."""
169+
_assert_request_error(_rpc(server.port(), 'getEvents', {}))
170+
171+
172+
def test_get_events_rejects_start_ledger_beyond_latest(server: StellarRpcServer) -> None:
173+
"""startLedger greater than the latest ledger seen by the node is an error (per StartLedger)."""
174+
_assert_request_error(_rpc(server.port(), 'getEvents', {'startLedger': 999999}))
175+
176+
177+
def test_get_events_rejects_cursor_combined_with_start_ledger(server: StellarRpcServer) -> None:
178+
"""If a cursor is included, startLedger must be omitted (per StartLedger/EndLedger)."""
179+
response = _rpc(
180+
server.port(),
181+
'getEvents',
182+
{'startLedger': 1, 'pagination': {'cursor': '0000000004294967296-0000000000'}},
183+
)
184+
_assert_request_error(response)
185+
186+
187+
def test_get_events_rejects_more_than_five_filters(server: StellarRpcServer) -> None:
188+
"""EventFilters allows at most 5 filters per request."""
189+
filters = [{'type': 'contract'}] * 6
190+
_assert_request_error(_rpc(server.port(), 'getEvents', {'startLedger': 1, 'filters': filters}))
191+
192+
193+
def test_get_events_rejects_topic_filter_with_five_segments(server: StellarRpcServer) -> None:
194+
"""A TopicFilter holds 1 to 4 SegmentMatchers."""
195+
filters = [{'topics': [[TRANSFER_TOPIC_XDR] * 5]}]
196+
_assert_request_error(_rpc(server.port(), 'getEvents', {'startLedger': 1, 'filters': filters}))
197+
198+
199+
def test_get_events_rejects_invalid_xdr_format(server: StellarRpcServer) -> None:
200+
"""xdrFormat only admits 'base64' and 'json'; anything else is invalid params.
201+
202+
komet-node does not implement the JSON XDR representation, so 'json' is also rejected
203+
with -32602 (documented limitation, consistent with the other methods).
204+
"""
205+
response = _rpc(server.port(), 'getEvents', {'startLedger': 1, 'xdrFormat': 'bogus'})
206+
assert 'error' in response, f'expected an error response, got: {response}'
207+
assert response['error']['code'] == -32602
208+
209+
response = _rpc(server.port(), 'getEvents', {'startLedger': 1, 'xdrFormat': 'json'})
210+
assert 'error' in response, f'expected an error response, got: {response}'
211+
assert response['error']['code'] == -32602
212+
213+
214+
# ---------------------------------------------------------------------------
215+
# Result shape
216+
# ---------------------------------------------------------------------------
217+
218+
219+
def test_get_events_no_events_returns_empty_array(server: StellarRpcServer) -> None:
220+
"""A range that contains no contract events yields an empty events array, not null."""
221+
keypair = Keypair.random()
222+
account = Account(keypair.public_key, sequence=0)
223+
tb = TransactionBuilder(account, Network.TESTNET_NETWORK_PASSPHRASE)
224+
_send(server, keypair, tb.append_create_account_op(keypair.public_key, '1000'))
225+
226+
result = _rpc(server.port(), 'getEvents', {'startLedger': 1})['result']
227+
assert result['events'] == []
228+
assert _is_json_number(result['latestLedger'])
229+
assert result['latestLedger'] == 1
230+
# Real stellar-rpc always populates the cursor (the position to resume scanning from).
231+
assert isinstance(result['cursor'], str)
232+
233+
234+
def test_get_events_returns_emitted_contract_event(server: StellarRpcServer) -> None:
235+
"""An event published via contract_event is returned with the exact spec shape."""
236+
keypair, account, contract_address = _deploy_events_contract(server)
237+
tx_hash = _emit_event(server, keypair, account, contract_address) # ledger 4
238+
239+
result = _rpc(server.port(), 'getEvents', {'startLedger': 1})['result']
240+
assert _is_json_number(result['latestLedger'])
241+
assert result['latestLedger'] == 4
242+
assert isinstance(result['cursor'], str)
243+
244+
assert isinstance(result['events'], list)
245+
assert len(result['events']) == 1, f'expected exactly one event: {result["events"]}'
246+
event = result['events'][0]
247+
248+
assert event['type'] == 'contract'
249+
assert _is_json_number(event['ledger'])
250+
assert event['ledger'] == 4
251+
# ledgerClosedAt is an ISO-8601 timestamp string.
252+
assert isinstance(event['ledgerClosedAt'], str)
253+
datetime.fromisoformat(event['ledgerClosedAt'].replace('Z', '+00:00'))
254+
assert event['contractId'] == contract_address
255+
assert StrKey.is_valid_contract(event['contractId'])
256+
# The id is TOID-style: 19-digit TOID, hyphen, 10-digit event index; the TOID's high
257+
# 32 bits are the ledger sequence.
258+
assert EVENT_ID_RE.fullmatch(event['id']), event['id']
259+
assert int(event['id'].split('-')[0]) >> 32 == 4
260+
assert event['txHash'] == tx_hash
261+
# Topics and value are base64-encoded SCVal XDR.
262+
assert event['topic'] == [TRANSFER_TOPIC_XDR]
263+
assert event['value'] == U32_42_XDR
264+
# Deprecated but still emitted by stellar-rpc v22; if present it must be a true bool
265+
# (the transaction succeeded).
266+
if 'inSuccessfulContractCall' in event:
267+
assert event['inSuccessfulContractCall'] is True
268+
269+
# endLedger is exclusive: a window that ends at the event's ledger does not include it.
270+
result = _rpc(server.port(), 'getEvents', {'startLedger': 1, 'endLedger': 4})['result']
271+
assert result['events'] == []
272+
273+
# xdrFormat 'base64' is the default and must be accepted explicitly as well.
274+
result = _rpc(server.port(), 'getEvents', {'startLedger': 1, 'xdrFormat': 'base64'})['result']
275+
assert len(result['events']) == 1
276+
277+
278+
# ---------------------------------------------------------------------------
279+
# Filters and pagination
280+
# ---------------------------------------------------------------------------
281+
282+
283+
def test_get_events_filtering_and_pagination(server: StellarRpcServer) -> None:
284+
"""Filters narrow by contract id, type, and topics; pagination resumes from the cursor."""
285+
keypair, account, contract_address = _deploy_events_contract(server)
286+
_emit_event(server, keypair, account, contract_address) # ledger 4
287+
_emit_event(server, keypair, account, contract_address) # ledger 5
288+
289+
def get_events(params: dict[str, Any]) -> dict[str, Any]:
290+
response = _rpc(server.port(), 'getEvents', params)
291+
assert 'result' in response, f'expected a result, got: {response}'
292+
return response['result']
293+
294+
# No filters: both events, in order.
295+
result = get_events({'startLedger': 1})
296+
assert [e['ledger'] for e in result['events']] == [4, 5]
297+
298+
# A matching contractIds filter keeps the events.
299+
result = get_events({'startLedger': 1, 'filters': [{'contractIds': [contract_address]}]})
300+
assert len(result['events']) == 2
301+
302+
# A non-matching contract id filters everything out.
303+
other_contract = StrKey.encode_contract(b'\x11' * 32)
304+
result = get_events({'startLedger': 1, 'filters': [{'contractIds': [other_contract]}]})
305+
assert result['events'] == []
306+
307+
# Type filter: both events are contract events; no system events were emitted.
308+
result = get_events({'startLedger': 1, 'filters': [{'type': 'contract'}]})
309+
assert len(result['events']) == 2
310+
result = get_events({'startLedger': 1, 'filters': [{'type': 'system'}]})
311+
assert result['events'] == []
312+
313+
# Topic filters: exact segment match, single-segment wildcard, and a non-matching topic.
314+
result = get_events({'startLedger': 1, 'filters': [{'topics': [[TRANSFER_TOPIC_XDR]]}]})
315+
assert len(result['events']) == 2
316+
result = get_events({'startLedger': 1, 'filters': [{'topics': [['*']]}]})
317+
assert len(result['events']) == 2
318+
result = get_events({'startLedger': 1, 'filters': [{'topics': [[MINT_TOPIC_XDR]]}]})
319+
assert result['events'] == []
320+
321+
# Pagination: limit the first page to one event, then resume from the cursor (without
322+
# startLedger, per the StartLedger descriptor) to fetch the second.
323+
first_page = get_events({'startLedger': 1, 'pagination': {'limit': 1}})
324+
assert len(first_page['events']) == 1
325+
assert first_page['events'][0]['ledger'] == 4
326+
cursor = first_page['cursor']
327+
assert isinstance(cursor, str) and cursor != ''
328+
329+
second_page = get_events({'pagination': {'cursor': cursor, 'limit': 1}})
330+
assert len(second_page['events']) == 1
331+
assert second_page['events'][0]['ledger'] == 5
332+
assert second_page['events'][0]['id'] != first_page['events'][0]['id']

0 commit comments

Comments
 (0)