Last updated: 2026-06-22 · Verified against commit: 81a3793
This document is the exhaustive, user-facing reference for every error a caller can receive from the two on-chain Soroban contracts in this repo:
EscrowContract—contracts/escrow/src/errors.rs(20 variants)OracleContract—contracts/oracle/src/errors.rs(10 variants)
Every variant defined in those two files is documented below. If you add, remove, or renumber a variant, update this file in the same PR.
Both contracts use Soroban's #[contracterror] macro. An error is not a
string — it's a small integer (u32) discriminant attached to the function's
Result<T, Error>. When a call fails, the CLI/SDK surfaces it as something
like:
Error(Contract, #4)
#4 is the numeric code from the tables below. Map it back to a name using
this document, then look up the cause and recovery steps.
stellar contract invoke --id $ESCROW_CONTRACT_ID -- deposit \
--match_id 42 --player <ADDRESS>
# ... Error(Contract, #4) ...
# → 4 = Unauthorized (see Escrow table below)- The on-chain error is only the numeric discriminant — no message text,
stack trace, storage contents, or argument values are ever included in the
contract's return value. This is enforced by
#[contracterror]itself, not by application logic, so there is no on-chain string to accidentally leak. - Several variants are intentionally coarse-grained for this reason. For
example,
Unauthorizedis returned both when the contract has never been initialized and when the caller is simply the wrong account — this avoids confirming or denying internal state (e.g. "does this contract have an admin set?") to an unauthenticated caller. - Off-chain consumers (frontend, oracle-service, support tooling) are the place sensitive detail can leak. When mapping these codes to user-facing UI text, do not embed request payloads, private keys, raw RPC responses, or internal match data in the displayed message — surface only the code, name, and the generic recovery guidance from this document.
- Recoverable — the caller (player, admin, or oracle) can take a concrete action — fix input, wait, switch signer, or call a different function — and the same operation will succeed afterward. No funds or state are lost.
- Fatal — the error indicates an invariant violation or a hard arithmetic/storage limit. There is no client-side retry that fixes it; it requires investigation, an admin/dev intervention, or in the worst case means that specific match is stuck (other matches are unaffected).
| Code | Name | Thrown By | Cause | Recovery | Example |
|---|---|---|---|---|---|
| 1 | MatchNotFound |
deposit, submit_result, cancel_match, expire_match, get_match, is_funded, get_depositor_count, get_escrow_balance |
match_id has no stored Match — wrong ID, typo, or wrong contract/network. |
Call get_match_count to confirm the valid ID range, or get_player_matches_paginated to re-fetch a player's real match IDs. Double-check $ESCROW_CONTRACT_ID and --network. |
get_match --match_id 999 on a contract with only 50 matches → #1. |
| 2 | AlreadyFunded |
deposit |
The same player called deposit twice for one match. |
No funds are at risk — the second call is simply rejected. Call get_depositor_count first if unsure whether you've already deposited. |
Player1 deposits, then accidentally retries the same tx after a slow confirmation → #2 on the retry; original deposit is untouched. |
| 3 | NotFunded |
submit_result (incl. via submit_result_with_oracle_record) |
Result submission was attempted before both players deposited. | Wait for both deposits; poll is_funded or get_depositor_count before asking the oracle to submit. |
Oracle submits a result the moment a game finishes, but Player2 never funded the escrow → #3. |
| 4 | Unauthorized |
pause, unpause, add_allowed_token, remove_allowed_token, deposit, submit_result, cancel_match, get_admin, get_oracle, set_match_timeout, propose_admin, accept_admin, update_oracle, transfer_admin |
Caller isn't the required signer (admin/oracle/depositing player) or the contract hasn't been initialized yet (admin/oracle key absent in storage). |
Re-sign with the correct keypair, or call initialize first on a fresh deployment. Use is_initialized to tell the two cases apart safely. |
Calling pause with a non-admin key → #4. Calling get_admin on a contract that was never initialized → also #4. |
| 5 | InvalidState |
deposit, submit_result, cancel_match, expire_match |
The match isn't in the lifecycle state the function requires (e.g. depositing into a Completed match, submitting a result for a non-Active match). |
Call get_match and check the state field before retrying the action. |
Calling submit_result on a match already Completed → #5. |
| 7 | AlreadyInitialized |
initialize |
initialize was called a second time. |
No action needed — the contract is already configured. Use get_admin/get_oracle to confirm current config instead of re-initializing. |
Re-running a deploy script that calls initialize unconditionally → #7 on the second run. |
| 9 | ContractPaused |
create_match, deposit, submit_result, submit_result_with_oracle_record |
Admin called pause; these functions are blocked while paused. |
Wait for the admin to call unpause; poll is_paused to know when it's safe to retry. |
create_match during an incident-response pause → #9 until unpause is called. |
| 10 | InvalidAmount |
create_match |
stake_amount <= 0. |
Resubmit with a positive stake_amount. |
create_match with stake_amount = 0 → #10. |
| 13 | DuplicateGameId |
create_match |
game_id was already used by a previous match (each game maps to exactly one escrow match, to prevent oracle replay across matches). |
Use a fresh, unique game_id, or look up the existing match instead of creating a new one. |
Two players try to escrow the same Lichess game URL twice → second create_match gets #13. |
| 14 | MatchNotExpired |
expire_match |
expire_match was called before current_ledger - created_ledger >= timeout. |
Wait until the configured timeout elapses. Check get_match_timeout and the match's created_ledger (via get_match) to compute the earliest valid ledger. |
Calling expire_match one day into a 30-day default timeout → #14. |
| 15 | InvalidGameId |
create_match |
game_id is empty or longer than 64 bytes. |
Pass a valid Lichess (8-char alphanumeric) or Chess.com (numeric) game ID under the 64-byte limit. | create_match with game_id = "" → #15. |
| 16 | InvalidPlayers |
create_match |
player1 == player2, or player2 is the escrow contract's own address. |
Supply two distinct, real player addresses. | create_match where both players are the same wallet → #16. |
| 17 | TokenNotAllowed |
create_match |
The token allowlist is active (at least one token was ever added) and the supplied token isn't on it. | Admin must call add_allowed_token for that token, or the caller should pick an already-allowed one via get_allowed_tokens. |
create_match with an unlisted custom token after the admin enabled allowlisting → #17. |
| 18 | InvalidAddress |
initialize, update_oracle |
The oracle/new_oracle address equals the escrow contract's own address. |
Supply a distinct external account or contract address. | initialize called with oracle = <ESCROW_CONTRACT_ID itself> → #18. |
| 19 | MatchAlreadyActive |
cancel_match |
cancel_match was called on a match that's already Active (both players deposited) — voluntary cancellation is pre-activation only. |
Let the match proceed to submit_result, or wait for expire_match eligibility if it stalls. Active matches cannot be cancelled by players. |
A player tries to back out after both stakes are in → #19. |
| 20 | InvalidTimeout |
set_match_timeout |
timeout is outside [17,280, 1,555,200] ledgers (1–90 days @ ~6 sec/ledger, or ~1 min – ~104 days wall-clock). |
Pass a timeout within the 1–90 day ledger range. Use MIN_MATCH_TIMEOUT_LEDGERS = 17,280 (1 day) and MAX_MATCH_TIMEOUT_LEDGERS = 1,555,200 (90 days) as bounds. |
set_match_timeout with timeout = 100 (≈10 minutes) → #20. |
| 21 | SnapshotNotFound |
submit_result (ledger snapshot verification) |
An internal ledger snapshot required to verify the oracle's result proof is not available — typically when the result is submitted too far in the past (TTL expired) or ledger data was purged. | Resubmit the result sooner after the game finishes. Ensure oracle service processes results within a few hours of completion, not days later. | Oracle attempts to verify a result 1+ months after the game ended → #21 (ledger snapshot purged). |
| Code | Name | Thrown By | Cause | Recovery | Example |
|---|---|---|---|---|---|
| 6 | AlreadyExists |
create_match |
A Match already exists at the storage slot for the next sequential match ID before create_match assigns it. Under normal operation MatchCount is the sole source of the next ID, so this should never trigger. |
Not client-recoverable. Indicates storage/state corruption or a bug in ID assignment — requires admin/dev investigation; in the worst case, a contract migration. | Would only be observed after manual storage tampering or a contract bug — not reachable via the public API in current code. |
| 8 | Overflow |
add_allowed_token (token counter), create_match (match counter), submit_result (stake_amount * 2) |
An arithmetic guard (checked_add/checked_mul) tripped: a counter hit u32/u64::MAX, or stake_amount is large enough that doubling it overflows i128. |
Counter overflow isn't realistically recoverable (would require billions of matches/tokens) short of a contract upgrade. Pot overflow is fatal for that one match only — it must be guarded against at create_match time by capping stake_amount well under i128::MAX / 2; once such a match exists, submit_result will always revert, so the only path forward is cancel_match/expire_match to return the deposits. |
A match created with stake_amount near i128::MAX / 2 will permanently fail submit_result with #8 — recover player funds via expire_match instead. |
| Code | Name | Status |
|---|---|---|
| 11 | MatchCancelled |
Defined in errors.rs but not returned by any function in the current lib.rs. Reserved for a future explicit "this match is cancelled" check (today, a cancelled match falls through to InvalidState instead). |
| 12 | MatchCompleted |
Same as above — reserved for a future explicit "this match is already completed" check; today this case also surfaces as InvalidState. |
All ten variants are recoverable — none represent an invariant violation or unrecoverable state.
| Code | Name | Thrown By | Cause | Recovery | Example |
|---|---|---|---|---|---|
| 1 | Unauthorized |
submit_result, submit_batch_results, has_result_admin, delete_result, update_admin, pause, unpause, set_oracle_rate_limits |
Caller isn't the configured admin, or the contract hasn't been initialized (admin key absent). |
Re-sign with the correct admin keypair, or call initialize first. Use is_initialized to distinguish the two cases. |
submit_result signed by a non-admin oracle service key → #1. |
| 2 | AlreadySubmitted |
submit_result, submit_batch_results |
A result for match_id is already stored — results are immutable once recorded (integrity guard). |
Check has_result/get_result before submitting. If a genuine correction is needed, admin must delete_result first, then resubmit. |
The oracle service retries a submission after a network timeout, not realizing the first attempt actually landed → #2 on the retry (safe — no duplicate result is written). |
| 3 | ResultNotFound |
get_result, delete_result |
No result exists for match_id — never submitted, wrong ID, or the persistent entry's TTL expired and was purged. |
Confirm match_id, check has_result to see if it was ever submitted, or submit the result if it's genuinely missing. |
get_result --match_id 7 before the oracle has reported anything for match 7 → #3. |
| 4 | AlreadyInitialized |
initialize |
initialize was called a second time. |
No action needed — the contract is already configured. | Re-running a deploy script unconditionally → #4 on the second run. |
| 5 | ContractPaused |
submit_result, submit_batch_results, delete_result |
Admin called pause. |
Wait for unpause; poll a paused-status check before retrying. |
Result submission attempted during an incident-response pause → #5. |
| 6 | InvalidGameId |
submit_result, submit_batch_results |
game_id is empty in the submission (or in any batch entry). |
Resubmit with the real platform game ID populated. | A batch entry built from a malformed scrape with game_id = "" → #6. |
| 7 | BatchTooLarge |
submit_batch_results |
entries.len() > 100 (MAX_BATCH_SIZE). |
Split the batch into chunks of ≤100 entries. | Submitting 250 tournament results in one call → #7. |
| 8 | BatchDuplicateEntry |
submit_batch_results |
Two entries in the same batch share a match_id. |
De-duplicate entries client-side — each match_id may appear once per batch. |
A batch builder accidentally includes the same match_id twice after a join bug → #8. |
| 9 | RateLimitExceeded |
submit_result, submit_batch_results (via check_oracle_rate_limit) |
The submission(s) would exceed the oracle's configured hourly or daily sliding-window limit (see set_oracle_rate_limits). |
Check get_oracle_rate_limit_status for remaining quota and window reset timing; wait for the window to roll over, or have the admin raise the limit. |
An oracle service burst-submits 150 results in one hour against the default 100/hour limit → #9 once the limit is hit, with an oracle / alert event already emitted at 80% usage. |
| 10 | InvalidRateLimit |
set_oracle_rate_limits |
hourly_limit > daily_limit when both are non-zero. |
Pass consistent limits (hourly_limit <= daily_limit), or pass 0 for either to fall back to the contract default. |
set_oracle_rate_limits(oracle, 500, 100) → #10. |
Use this when you only know the symptom, not the code.
| Symptom | Likely error(s) | First thing to check |
|---|---|---|
| "Transaction failed, can't tell why" | Any | Decode the numeric code from the tx result (Error(Contract, #N)), then look it up above. |
| Deposit/submit/cancel rejected right after deploy | Unauthorized (Escrow #4 / Oracle #1) |
Did you call initialize on this contract yet? is_initialized. |
| Player can't deposit | MatchNotFound (#1), InvalidState (#5), AlreadyFunded (#2), Unauthorized (#4) |
get_match — confirm the ID exists, state is Pending, and you haven't already deposited. |
| Oracle can't submit a result | ContractPaused (#9 / #5), MatchNotFound (#1), NotFunded (#3), Unauthorized (#4 / #1), RateLimitExceeded (#9 oracle) |
is_paused, is_funded, get_oracle_rate_limit_status. |
create_match rejected |
InvalidAmount (#10), InvalidGameId (#15), DuplicateGameId (#13), InvalidPlayers (#16), TokenNotAllowed (#17), ContractPaused (#9) |
Validate stake_amount > 0, game_id format/uniqueness, distinct players, and get_allowed_tokens if allowlisting is on. |
| Can't cancel a match | MatchAlreadyActive (#19), InvalidState (#5), Unauthorized (#4) |
get_match — cancellation only works on Pending matches you're a player in. |
expire_match rejected |
MatchNotExpired (#14), InvalidState (#5), MatchNotFound (#1) |
Compare get_match_timeout against the match's created_ledger. |
| Oracle batch submission rejected | BatchTooLarge (#7), BatchDuplicateEntry (#8), InvalidGameId (#6), AlreadySubmitted (#2) |
Validate the batch client-side before sending: size ≤100, unique match_ids, non-empty game_ids. |
| Admin config call rejected | Unauthorized (#4 / #1), InvalidTimeout (Escrow #20), InvalidRateLimit (Oracle #10), InvalidAddress (Escrow #18) |
Confirm you're signing with the current admin key and that the new value is within the documented bounds. |
A match seems permanently stuck on submit_result |
Overflow (Escrow #8, fatal) |
Check stake_amount isn't absurdly large; recover funds via cancel_match/expire_match instead of retrying submit_result. |
This document covers all variants present in source as of commit 81a3793:
- Escrow (
contracts/escrow/src/errors.rs): 22/22 variants documented (19 recoverable, 2 fatal, 1 reserved/unused). - Oracle (
contracts/oracle/src/errors.rs): 10/10 variants documented (10 recoverable).
If cargo build or a code review surfaces a new variant in either
errors.rs, add a row here in the same PR — this file is expected to stay in
lockstep with the source enums.