This document describes the current oracle architecture used by Checkmate Escrow. It explains the two distinct on-chain oracle components and how they work together with the off-chain oracle service.
The current design has:
- an
EscrowContractthat stores a trustedoracleaddress and authorises result submissions for on-chain payout, - an
OracleContractthat stores an independent, auditable copy of verified match results.
The escrow contract uses its configured oracle address as the authoritative permission for submitting results to trigger payouts. The oracle contract is supplementary: it does not authorise escrow payouts or act as a gatekeeper for escrow result submission. It provides an audit log and an independent on-chain record of results that can be queried later.
The off-chain oracle service today is the trusted operator that:
- verifies the platform result for
game_idusing an external chess API, - calls
EscrowContract::submit_result(match_id, winner)from the escrow-side oracle address, - records the same result in
OracleContractfor auditing and optional verification.
The two contracts are separate:
EscrowContractenforces match state, funding, and oracle address authentication.OracleContractenforces admin-only result storage and exposes public or admin-gated read interfaces.
The game_id field is a platform-specific string that uniquely identifies a
chess game. It is supplied when creating a match and must be passed to the
oracle when submitting a result. The oracle uses it to look up the game outcome
via the platform's public API.
Lichess game IDs are 8-character alphanumeric strings (case-sensitive, lowercase letters and digits).
They appear in the game URL:
https://lichess.org/abcd1234
^^^^^^^^
game_id = "abcd1234"
Example API call the oracle makes:
GET https://lichess.org/game/export/abcd1234
Valid example: "abcd1234"
Invalid examples: "ABCD1234" (uppercase), "abcd123" (7 chars), "" (empty)
Chess.com game IDs are numeric strings, typically 7–12 digits, found in the live game URL:
https://www.chess.com/game/live/123456789
^^^^^^^^^
game_id = "123456789"
Example API call the oracle makes:
GET https://api.chess.com/pub/game/123456789
Valid example: "123456789"
Invalid examples: "abc" (non-numeric), "" (empty)
| Rule | Details |
|---|---|
| Max length | 64 bytes (MAX_GAME_ID_LEN). Enforced on-chain — create_match returns Error::InvalidGameId if exceeded. |
| Uniqueness | Each game_id can only be used once. A duplicate returns Error::DuplicateGameId. |
| Format | Not validated on-chain. Passing a malformed ID will cause the oracle to fail result lookup off-chain. |
| Platform match | The platform field must match the source of the game_id. Mismatches are not caught on-chain but will cause oracle verification to fail. |
Once a game is finished, the off-chain oracle service verifies the result via an external chess platform API and then submits the verified outcome to the escrow contract from the configured oracle address.
// Winner::Player1 | Winner::Player2 | Winner::Draw
escrow_client.submit_result(&match_id, &winner);That escrow submission is the authoritative payout trigger. The escrow contract
trusts only its configured oracle address when authorising submit_result.
Separately, the oracle service records the same result in the on-chain
OracleContract for auditability and later verification.
oracle_client.submit_result(&match_id, &game_id, &MatchResult::Player1Wins);For tournament support, the oracle contract also exposes a batch API:
submit_batch_results. This lets the oracle submit 10–100 verified match
results in a single atomic transaction.
The off-chain Chess.com client (see oracle-service/src/oracle/chess_com_client.rs) must obey Chess.com’s public API limits:
- Rate limit: 30 requests / minute (≈ 1 request / 2 seconds, globally).
- Timeout: 30 seconds max per HTTP request.
The oracle client uses a client-side rate limiter. If a request would exceed the quota, it waits until tokens are available before issuing the HTTP call.
If Chess.com returns:
- 404: treat as
GameNotFound(invalid game id or unavailable game). - non-2xx: treat as
HttpStatusand retry using the oracle service’s retry strategy (if any). - timeouts / network errors: treat as transient; retry with exponential backoff.
When Chess.com is unreachable or rate-limited:
- Do not submit an on-chain result until a verified end-state is fetched.
- Mark the match as pending verification and retry later.
- If a verification attempt observes a game payload without a known terminal
end.result, treat it as GameNotFinished and retry.
To prevent spam or denial-of-service against the on-chain oracle log, the
OracleContract enforces per-oracle submission limits on submit_result and
submit_batch_results:
| Limit | Default | Notes |
|---|---|---|
| Hourly | 100 submissions | Rolling 1-hour window |
| Daily | 1,000 submissions | Rolling 24-hour window |
A submit_batch_results call counts its full entry count against both limits
in a single check — e.g. a 40-entry batch consumes 40 units of quota. The
check runs before any storage writes, so a rejected call (whole batch or
single result) never partially succeeds and never consumes quota.
Limits are tracked with a sliding-window counter rather than a naive fixed window, so a burst spanning a window boundary can't double the effective limit. Each window (hourly, daily) stores:
window_start— the timestamp (env.ledger().timestamp()) the current window began,current_count— submissions recorded sincewindow_start,previous_count— submissions recorded in the window immediately before.
The estimated count for rate-limit purposes is:
estimate = current_count + previous_count * (window_size - elapsed_in_current) / window_size
This weights the previous window's count by how much of it still falls inside the trailing lookback period, giving an accurate approximation of a true sliding window without storing a timestamp per submission.
The admin can override the default limits per oracle address:
oracle_client.set_oracle_rate_limits(&oracle_address, &hourly_limit, &daily_limit);- Passing
0for either field resets that field to the contract default (100/1000). hourly_limitmust not exceeddaily_limit(when both are non-zero), or the call returnsError::InvalidRateLimit.- Emits an
oracle / ratelimevent with(oracle, hourly_limit, daily_limit).
There is no HTTP layer on-chain, so instead of rate-limit response headers, callers query current usage directly:
let status = oracle_client.get_oracle_rate_limit_status(&oracle_address);
// status.hourly_used / .hourly_limit / .hourly_remaining
// status.daily_used / .daily_limit / .daily_remaining
let limits = oracle_client.get_oracle_rate_limits(&oracle_address);
// limits.hourly_limit / .daily_limitOnce an oracle's usage reaches 80% of either its hourly or daily limit,
the contract emits an oracle / alert event with
(oracle, window_label, used, limit), where window_label is "hourly" or
"daily". Off-chain monitoring can subscribe to this event to page an admin
before the oracle is actually throttled.
Error::RateLimitExceeded(9) — the submission(s) would exceed the oracle's hourly or daily limit.Error::InvalidRateLimit(10) —set_oracle_rate_limitswas called withhourly_limit > daily_limit.
The oracle contract exposes a delete_result function that allows the admin to remove a previously submitted result from persistent storage:
oracle_client.delete_result(&match_id); // → Result<(), Error>On-chain persistent storage has a finite TTL (~30 days). In normal operation results expire naturally. delete_result exists for two narrow operational cases:
- Erroneous submission — the oracle submitted a result for the wrong
match_id(e.g., due to a bug or misconfiguration) before the escrow payout was triggered. Deletion allows the correct result to be re-submitted. - Storage reclamation — proactively freeing storage rent for results that are no longer needed (e.g., after a dispute is fully resolved off-chain).
(Existing contract documentation continues unchanged.)
(Existing contract documentation continues unchanged.)
(Existing contract documentation continues unchanged.)