The Event Indexer Service is a lightweight backend service that indexes Soroban contract events from the Checkmate Escrow contract. It provides fast, filtered queries for match history and event data with sub-100ms latency.
- Event Polling: Periodically polls Soroban RPC for new events from the escrow contract
- Persistent Storage: SQLite database for reliable event persistence
- In-Memory Cache: High-performance cache for recently indexed events
- REST API: Simple JSON API for event and match queries
- Input Validation: Rate limiting and parameter validation
- Metrics: Query latency tracking and cache statistics
Soroban RPC
↓
Event Poller (polls every N seconds)
↓
Event Parser & Validator
↓
Cache Layer (in-memory)
↓
Database Layer (SQLite)
↓
REST API (Axum)
| Variable | Default | Description |
|---|---|---|
STELLAR_RPC_URL |
https://soroban-testnet.stellar.org |
Soroban RPC endpoint |
CONTRACT_ESCROW |
Required | Address of the escrow contract |
EVENT_INDEXER_DB_PATH |
./events.db |
Path to SQLite database |
EVENT_INDEXER_BIND_ADDR |
127.0.0.1 |
API server bind address |
EVENT_INDEXER_PORT |
8080 |
API server port |
EVENT_INDEXER_CACHE_SIZE |
10000 |
Maximum cache entries |
EVENT_INDEXER_POLL_INTERVAL |
5 |
Event polling interval in seconds |
REDIS_URL |
unset | Redis DSN for the shared response cache (redis://… or rediss://…). When unset, a process-local cache with the same TTLs is used |
Endpoint: GET /health
Description: Check if the service is running and healthy, including a live database connectivity check.
Response (healthy):
{
"db": "ok"
}Response (database error):
{
"db": "error",
"detail": "unable to open database file"
}Latency: < 10ms
Endpoint: GET /events
Description: Query events with optional filters. Returns events matching the criteria sorted by ledger sequence (newest first).
Query Parameters:
player_address(optional): Filter by player address (matches player1 or player2)status(optional): Filter by match status (pending,active,completed,cancelled,expired)limit(optional): Maximum number of results (default: 100, max: 1000)offset(optional): Pagination offset (default: 0)
Example Request:
curl "http://localhost:8080/events?player_address=GA7QSTFKSQX4K3DWORVLKFWQIHD7DKD3UD5RN7NXLKPX22FQVQY5HQW&status=completed&limit=50"Response:
{
"success": true,
"data": [
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"ledger_sequence": 12345,
"match_id": 1,
"event_type": "match:completed",
"player1": "GA7QSTFKSQX4K3DWORVLKFWQIHD7DKD3UD5RN7NXLKPX22FQVQY5HQW",
"player2": "GBBD47UZQ5SYWBZMW5XJBZPMZ5XLQNB4BBFZGHKZVKNXZX2FMGD2KVYF",
"status": "completed",
"winner": "player1",
"stake_amount": "1000000",
"token": "CABD7H7QWXSTDZ6YPMPZRJ2FLGDWP5AYWLF5PYQRB5PQV6PDBGFPMTD",
"game_id": "abc12345",
"platform": "lichess",
"timestamp": "2026-06-22T10:30:00Z",
"txn_hash": "0x1234..."
}
],
"error": null
}Latency: < 50ms (cached queries < 10ms)
Endpoint: GET /events/:match_id
Description: Get events for a specific match in chronological order. Supports pagination.
Path Parameters:
match_id(required): The match ID to query
Query Parameters:
limit(optional): Maximum number of results (default: 100)offset(optional): Pagination offset (default: 0)
Example Request:
curl "http://localhost:8080/events/1?limit=50&offset=100"Response:
{
"success": true,
"data": [
{
"id": "550e8400-e29b-41d4-a716-446655440001",
"ledger_sequence": 10000,
"match_id": 1,
"event_type": "match:created",
"player1": "GA7QSTFKSQX4K3DWORVLKFWQIHD7DKD3UD5RN7NXLKPX22FQVQY5HQW",
"player2": "GBBD47UZQ5SYWBZMW5XJBZPMZ5XLQNB4BBFZGHKZVKNXZX2FMGD2KVYF",
"status": "pending",
"winner": null,
"stake_amount": "1000000",
"token": "CABD7H7QWXSTDZ6YPMPZRJ2FLGDWP5AYWLF5PYQRB5PQV6PDBGFPMTD",
"game_id": "abc12345",
"platform": "lichess",
"timestamp": "2026-06-22T10:00:00Z",
"txn_hash": "0x1000..."
},
{
"id": "550e8400-e29b-41d4-a716-446655440002",
"ledger_sequence": 10500,
"match_id": 1,
"event_type": "match:activated",
"status": "active",
"timestamp": "2026-06-22T10:15:00Z"
}
],
"error": null
}Latency: < 10ms (with cache)
Endpoint: GET /matches
Description: Get all matches, optionally filtered by status.
Query Parameters:
status(optional): Filter by match status (pending,active,completed,cancelled,expired)
Example Request:
curl "http://localhost:8080/matches?status=active"Response:
{
"success": true,
"data": [
{
"match_id": 1,
"player1": "GA7QSTFKSQX4K3DWORVLKFWQIHD7DKD3UD5RN7NXLKPX22FQVQY5HQW",
"player2": "GBBD47UZQ5SYWBZMW5XJBZPMZ5XLQNB4BBFZGHKZVKNXZX2FMGD2KVYF",
"status": "active",
"winner": null,
"stake_amount": "1000000",
"token": "CABD7H7QWXSTDZ6YPMPZRJ2FLGDWP5AYWLF5PYQRB5PQV6PDBGFPMTD",
"game_id": "abc12345",
"platform": "lichess",
"created_ledger": 10000,
"completed_ledger": 10000,
"events": []
}
],
"error": null
}Latency: < 50ms
Endpoint: GET /match/:match_id
Description: Get complete match information including players, current status, and all events.
Path Parameters:
match_id(required): The match ID to query
Example Request:
curl "http://localhost:8080/match/1"Response:
{
"success": true,
"data": {
"match_id": 1,
"player1": "GA7QSTFKSQX4K3DWORVLKFWQIHD7DKD3UD5RN7NXLKPX22FQVQY5HQW",
"player2": "GBBD47UZQ5SYWBZMW5XJBZPMZ5XLQNB4BBFZGHKZVKNXZX2FMGD2KVYF",
"status": "completed",
"winner": "player1",
"stake_amount": "1000000",
"token": "CABD7H7QWXSTDZ6YPMPZRJ2FLGDWP5AYWLF5PYQRB5PQV6PDBGFPMTD",
"game_id": "abc12345",
"platform": "lichess",
"created_ledger": 10000,
"completed_ledger": 12345,
"events": [
{
"id": "550e8400-e29b-41d4-a716-446655440001",
"ledger_sequence": 10000,
"match_id": 1,
"event_type": "match:created",
"timestamp": "2026-06-22T10:00:00Z"
}
]
},
"error": null
}Latency: < 20ms
Endpoint: GET /stats
Description: Get indexer statistics including cache size and total event count.
Example Request:
curl "http://localhost:8080/stats"Response:
{
"success": true,
"data": {
"total_events": 1024,
"cache_size": 256
},
"error": null
}Latency: < 5ms
Caching: 60 seconds (see Response Caching).
Endpoint: GET /transactions/player/{player_address}
Description: A player's financial transaction history, projected from the
indexed events. Only events that move funds appear here: deposits, payouts and
fees. Lifecycle-only events (match:created, match:paused, …) are excluded.
Path parameter:
| Parameter | Description |
|---|---|
player_address |
Stellar account address (G…). The player is matched on either side of the match |
Query parameters:
| Parameter | Default | Description |
|---|---|---|
type |
all | deposit, payout or fee (also accepted as tx_type) |
token |
all | Token symbol or contract address |
from_date |
unbounded | RFC 3339 timestamp or YYYY-MM-DD (inclusive) |
to_date |
unbounded | RFC 3339 timestamp or YYYY-MM-DD (inclusive) |
limit |
100 |
Page size, 1–1000 |
offset |
0 |
Rows to skip |
sort_by |
timestamp |
timestamp, amount, match_id or type |
sort_order |
desc |
asc or desc (also accepted as order) |
Example Request:
curl "http://localhost:8080/transactions/player/GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN?type=payout&from_date=2026-01-01&limit=50"Response:
{
"success": true,
"data": {
"transactions": [
{
"match_id": 42,
"timestamp": "2026-07-27T12:34:56Z",
"type": "payout",
"amount": "20000000",
"token": "XLM",
"event_id": "a1b2c3d4e5f6",
"event_type": "match:completed",
"ledger_sequence": 1048576
}
],
"total": 137,
"limit": 50,
"offset": 0,
"has_more": true
},
"error": null
}Notes:
amountis a decimal string of stroops — amounts exceed the range JSON numbers can represent exactly.totalis the count matching the filters, ignoringlimitandoffset, so a client can render pagination without a second request.- Sorting by
amountis numeric, not lexicographic. - An empty history returns
200with an empty list, not404. - Reorg-invalidated events are excluded: a rolled-back ledger moved no funds.
Event → transaction type mapping:
| Type | Matching event names |
|---|---|
deposit |
deposit, funded |
payout |
completed, finalized, claim, payout, resolved |
fee |
fee, cancelled, expired |
Latency: < 50ms
Caching: none — responses are per-player and per-filter, so the hit rate would be poor while staleness would be user-visible.
High-traffic read endpoints are memoised in a TTL cache. With REDIS_URL set the
cache is shared across replicas, so one instance's invalidation is seen by
all of them; without it, each process keeps its own cache with identical TTLs.
| Endpoint | TTL | Invalidated by |
|---|---|---|
GET /matches (including ?status=pending) |
10 s | any contract event |
GET /match/{match_id} |
5 s | any contract event for that match |
GET /stats |
60 s | any contract event |
GET /events, GET /events/{match_id} |
not cached | — |
GET /transactions/player/{player_address} |
not cached | — |
Invalidation on state change. The poller drops the cached responses a newly ingested event makes stale: the match's own entry, every match list (a state change moves the match from one list to another) and the analytics entry (its counters are derived from the event table). A state change is therefore visible well before the TTL would have expired; the TTL only bounds staleness for matches nothing is happening to. A reorg drops all lists and analytics.
Failure behaviour. The cache is an optimisation, never a source of truth.
Every Redis error is logged and treated as a cache miss, and a 404 is never
cached. A cache outage costs latency, not correctness — and start-up does not
depend on Redis being reachable: if the connection fails, the service logs a
warning and falls back to the process-local cache.
The indexer tracks the following event types:
| Event Type | Description | Status | Winner | Triggered When |
|---|---|---|---|---|
match:created |
Match created | pending | - | Match created with player addresses |
match:deposit |
Player deposited | active | - | Player deposits stakes |
match:activated |
Match activated | active | - | Both players deposited |
match:completed |
Match result submitted | completed | player1/player2/draw | Oracle submits verified result |
match:cancelled |
Match cancelled | cancelled | - | Match cancelled before activation |
match:expired |
Match expired | expired | - | Match timeout reached |
All endpoints return standardized error responses:
{
"success": false,
"data": null,
"error": "Detailed error message"
}| Status Code | Scenario |
|---|---|
| 200 | Successful query with results |
| 404 | No events found matching criteria |
| 400 | Invalid query parameters |
| 500 | Database or RPC error |
Requests pass through a validation layer before any handler runs, so
malformed input never reaches the database. A rejection is a 400 whose message
names the offending field and says what was expected:
{
"success": false,
"data": null,
"error": "invalid limit: must be between 1 and 1000, got 5000"
}| Field | Rule |
|---|---|
player_address, player, address |
56-character Stellar strkey, upper-case, G (account) or C (contract) prefix, valid CRC16 checksum |
match_id (path or query) |
Unsigned integer; 0 is valid — match ids start at zero |
status |
pending, active, completed, cancelled, expired |
limit |
1–1000 |
offset |
Not negative |
amount, stake_amount |
Positive integer stroops; no sign, no decimal point |
game_id |
1–64 characters of A–Z, a–z, 0–9, -, _ |
token |
Symbol (letters, digits, :, -, _) or a checksum-valid contract address |
from_date, to_date, start_date, end_date |
RFC 3339 timestamp or YYYY-MM-DD; the range must not be inverted |
type / tx_type |
deposit, payout, fee |
sort_by, sort_order |
Whitelisted values only |
Checksum verification matters in practice: it is what turns a single mistyped
character in an address into a clear 400 instead of a query that silently
matches nothing. Unrecognised query parameters are ignored so a client can add
one without a coordinated deploy, and an empty value (?status=) is treated as
absent.
- Query Latency: < 100ms for typical requests (< 10ms for cached)
- Event Polling: Every 5 seconds (configurable)
- Database: SQLite with indexed queries on match_id, player addresses, and timestamp
- Cache Efficiency: LRU cache for frequently accessed match events
- Throughput: Handles 1000+ queries/second at 50th percentile latency
No rate limiting is enforced by the service itself in the current version. This is intentional for the initial release — the service is designed to run behind a reverse proxy or API gateway that handles rate limiting at the infrastructure level.
Current behavior: All endpoints are unbounded. Clients may send any number of requests per second.
Planned for a future release:
| Tier | Limit | Scope |
|---|---|---|
| Default (unauthenticated) | 60 req/min | Per IP |
| Authenticated (API key) | 600 req/min | Per key |
Bulk query (/events with large limit) |
10 req/min | Per IP |
Implementation options under consideration:
- Reverse proxy: nginx
limit_req_zoneor Caddy rate-limit middleware (no code changes required) - In-process:
tower-governoras an Axum layer (planned for v1.1)
Until rate limiting is enabled, operators deploying this service publicly should add a reverse proxy with appropriate limits.
- Input Validation: All query parameters are validated before database queries
- SQL Injection Prevention: Using parameterized queries with rusqlite
- CORS: Disabled by default (enable via
tower-httpif needed) - Authentication: Add API key validation layer if needed
FROM rust:1.75-slim
WORKDIR /app
COPY . .
RUN cargo build --release
CMD ["./target/release/event-indexer"]export STELLAR_RPC_URL=https://soroban-testnet.stellar.org
export CONTRACT_ESCROW=CABD7H7QWXSTDZ6YPMPZRJ2FLGDWP5AYWLF5PYQRB5PQV6PDBGFPMTD
export EVENT_INDEXER_BIND_ADDR=0.0.0.0
export EVENT_INDEXER_PORT=8080
cargo runMonitor these metrics:
- Query latency (p50, p95, p99)
- Cache hit rate
- Database query time
- Event polling success/failure rate
- Memory usage
- SQLite database size
- GraphQL endpoint for flexible querying
- WebSocket support for real-time event subscriptions
- Event archival to S3 after 30 days
- Multi-contract support
- Advanced metrics and observability
- Event replay functionality