Skip to content

Latest commit

 

History

History
394 lines (280 loc) · 13.6 KB

File metadata and controls

394 lines (280 loc) · 13.6 KB

Oracle Integration Guide

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 EscrowContract that stores a trusted oracle address and authorises result submissions for on-chain payout,
  • an OracleContract that 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:

  1. verifies the platform result for game_id using an external chess API,
  2. calls EscrowContract::submit_result(match_id, winner) from the escrow-side oracle address,
  3. records the same result in OracleContract for auditing and optional verification.

The two contracts are separate:

  • EscrowContract enforces match state, funding, and oracle address authentication.
  • OracleContract enforces admin-only result storage and exposes public or admin-gated read interfaces.

game_id Format

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

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

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)


Game ID Formats

Platform Format Example Validation Rule
Lichess 8-character alphanumeric abcd1234 Exactly 8 chars; lowercase letters and digits only
Chess.com Numeric string (7–12 digits) 123456789 Digits only; no letters or special characters

All game IDs are subject to a maximum length of 64 bytes (MAX_GAME_ID_LEN). Submissions exceeding this limit are rejected on-chain with Error::InvalidGameId before any off-chain lookup is attempted.


Validation Rules

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.

Submitting a Result

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.


Chess.com API Rate Limits, Timeouts, and Offline Fallback

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.

Rate limiting behavior

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.

Error handling rules

If Chess.com returns:

  • 404: treat as GameNotFound (invalid game id or unavailable game).
  • non-2xx: treat as HttpStatus and retry using the oracle service’s retry strategy (if any).
  • timeouts / network errors: treat as transient; retry with exponential backoff.

Offline fallback strategy

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.

Oracle Submission Rate Limiting

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.

Sliding window algorithm

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 since window_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.

Admin configuration

The admin can override the default limits per oracle address:

oracle_client.set_oracle_rate_limits(&oracle_address, &hourly_limit, &daily_limit);
  • Passing 0 for either field resets that field to the contract default (100/1000).
  • hourly_limit must not exceed daily_limit (when both are non-zero), or the call returns Error::InvalidRateLimit.
  • Emits an oracle / ratelim event with (oracle, hourly_limit, daily_limit).

Querying rate limit status

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_limit

Suspicious pattern alerts

Once 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.

Errors

  • Error::RateLimitExceeded (9) — the submission(s) would exceed the oracle's hourly or daily limit.
  • Error::InvalidRateLimit (10) — set_oracle_rate_limits was called with hourly_limit > daily_limit.

Result Deletion Policy (delete_result)

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>

Why it exists

On-chain persistent storage has a finite TTL (~30 days). In normal operation results expire naturally. delete_result exists for two narrow operational cases:

  1. 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.
  2. Storage reclamation — proactively freeing storage rent for results that are no longer needed (e.g., after a dispute is fully resolved off-chain).

has_result vs has_result_admin

(Existing contract documentation continues unchanged.)


Data Structures

(Existing contract documentation continues unchanged.)


Example: Full Match Lifecycle

(Existing contract documentation continues unchanged.)


Troubleshooting

Rate limit exceeded (RateLimitExceeded)

Symptom: submit_result or submit_batch_results returns Error(Contract, #9).

Cause: The oracle has exhausted its hourly (100) or daily (1,000) submission quota on the OracleContract.

Fix:

  • Wait until the rolling window resets (up to 1 hour for hourly, 24 hours for daily).
  • Query current usage before retrying:
    stellar contract invoke --id $CONTRACT_ORACLE \
      -- get_oracle_rate_limit_status --oracle <ORACLE_ADDRESS>
  • If the default limits are too low for your workload, the admin can raise them:
    stellar contract invoke --id $CONTRACT_ORACLE \
      --source <ORACLE_ADMIN_KEYPAIR> \
      -- set_oracle_rate_limits \
      --oracle <ORACLE_ADDRESS> \
      --hourly_limit 500 \
      --daily_limit 5000

API key invalid / authentication failure

Symptom: The off-chain oracle service logs 401 Unauthorized or 403 Forbidden when calling the chess platform API.

Cause: LICHESS_API_TOKEN or CHESSDOTCOM_API_KEY in .env is missing, expired, or incorrect.

Fix:

  1. Re-generate or copy the correct key from your Lichess/Chess.com developer account.
  2. Update .env:
    LICHESS_API_TOKEN=lip_xxxxxxxxxxxx
    CHESSDOTCOM_API_KEY=your-key-here
  3. Restart the oracle service. No on-chain changes are required.

Game not finished yet (GameNotFinished)

Symptom: The oracle service logs GameNotFinished and does not submit a result; the match stays Active on-chain.

Cause: The chess platform API returned a game payload without a terminal end.result field — the game is still in progress.

Fix: This is expected behaviour. The oracle will retry automatically. No manual intervention is needed unless the game has genuinely ended but the platform API is lagging. In that case:

  • Wait a few minutes and allow the retry backoff to resolve it.
  • If the platform API continues to show the game as in progress after it has clearly ended, contact the platform's support or wait for the result to propagate (usually < 5 minutes).

Network timeout / chess platform unreachable

Symptom: Oracle service logs timeout, connection refused, or HttpStatus errors; no result is submitted on-chain.

Cause: The chess platform API is temporarily unreachable, or the 30-second HTTP timeout was exceeded.

Fix:

  • The oracle will not submit a result until a verified end-state is confirmed. Retry is automatic with exponential backoff.
  • Check the platform's status page (lichess.org/status or chess.com) for ongoing incidents.
  • Verify outbound connectivity from the oracle host:
    curl -I https://lichess.org/game/export/abcd1234
    curl -I https://api.chess.com/pub/game/123456789
  • If the oracle host is behind a firewall, ensure outbound HTTPS (port 443) is open to the chess platform domains.

Oracle not submitting results (wrong oracle address configured)

Symptom: submit_result returns UnauthorizedOracle; the transaction is signed by the oracle keypair but still rejected.

Cause: The escrow contract's stored oracle address does not match the keypair the oracle service is using.

Fix: Check which address the escrow contract has on record:

stellar contract invoke --id $CONTRACT_ESCROW -- get_oracle

Compare this to the oracle service's configured keypair address. If they differ, either:

  • Update the oracle service's keypair to match the on-chain address, or
  • Rotate the on-chain oracle address (requires escrow admin):
    stellar contract invoke --id $CONTRACT_ESCROW \
      --source <ESCROW_ADMIN_KEYPAIR> \
      -- update_oracle \
      --new_oracle <CORRECT_ORACLE_ADDRESS>