| Actor | Role | Trust Boundary |
|---|---|---|
| Player 1 / Player 2 | Create matches, deposit stakes, cancel matches | Authenticated by Stellar keypair. Trusted for own key management. Not trusted to act honestly toward each other. |
| Oracle | Submits verified game results from Lichess/Chess.com | Authenticated by oracle keypair. Trusted for accurate result reporting. Not trusted with custody of funds. |
| Admin | Pause/unpause contract, rotate oracle address | Authenticated by admin keypair. Trusted for emergency operations. Not trusted to access escrowed funds. |
| Lichess / Chess.com API | External source of truth for game outcomes | Outside the Soroban runtime boundary. Trusted to return correct results (within the oracle's verification logic). |
| Stellar / Soroban runtime | Executes contract code, enforces authorization | Fully trusted for correct execution, state isolation, and ledger security. |
[Player 1] ──auth──┐
├──► [Escrow Contract] ◄── [Oracle] ◄── [Lichess API]
[Player 2] ──auth──┘ │ └── [Chess.com API]
│
[Admin key]
- Boundary A (Player → Contract): Authentication via
require_auth(). Players cannot impersonate each other. - Boundary B (Oracle → Contract): Authentication via stored oracle address. Only the registered oracle may submit results.
- Boundary C (Admin → Contract): Authentication via stored admin address. Admin controls emergency functions only.
- Boundary D (External API → Oracle): Off-chain. Oracle is trusted to verify API responses before submitting on-chain.
| Threat | Target | Risk | Mitigation |
|---|---|---|---|
| Attacker impersonates a player to deposit or cancel | deposit, cancel_match |
Medium | player.require_auth() enforces Stellar signature verification |
| Attacker impersonates the oracle to submit fraudulent results | submit_result |
High | Oracle address stored in contract; caller.require_auth() + comparison against stored address |
| Attacker impersonates the admin to pause or rotate oracle | pause, unpause, update_oracle |
Medium | admin.require_auth() enforced on each admin function |
| Threat | Target | Risk | Mitigation |
|---|---|---|---|
| Oracle submits result for wrong match_id | submit_result |
Medium | game_id parameter cross-checked against stored game_id in match record |
| Oracle submits duplicate result to double-payout | submit_result |
High | State machine rejects submit_result when state != Active; Completed is terminal |
| Attacker creates multiple matches with same game_id | create_match |
Medium | DataKey::GameId tracked; duplicate game_id returns DuplicateGameId error |
| Admin drains funds without pause precondition | emergency_drain |
Medium | NotPaused error if contract is not paused; event emitted for transparency |
| Threat | Target | Risk | Mitigation |
|---|---|---|---|
| Player denies having deposited | deposit |
Low | On-chain events (match:deposit) record player address, match_id, and amount |
| Oracle denies having submitted a result | submit_result |
Low | On-chain events (match:completed) record winner, match_id, and payout amount |
| Admin denies having paused or drained | pause, emergency_drain |
Low | On-chain events (admin:paused, admin:drain) record the action |
| Threat | Target | Risk | Mitigation |
|---|---|---|---|
| Attacker reads all match data including player addresses | get_match, is_funded, get_escrow_balance |
Low | All match state is public by design on Stellar; no confidential data is stored |
| Threat | Target | Risk | Mitigation |
|---|---|---|---|
| Attacker creates many matches to fill storage | create_match |
Low | Each match requires player1 auth; cost of storage is a deterrent; no DoS vector identified |
| Attacker front-runs match creation with duplicate game_id | create_match |
Low | The legitimate creator can choose a unique or unpredictably long game_id string |
| Oracle never submits a result, locking funds in Active state | submit_result |
Medium | Players can cancel only while Pending, not Active. Known limitation: no auto-timeout exists; a future enhancement should add a timeout-based cancellation. |
| Admin never unpauses after emergency | unpause |
Low | Admin is a trusted party; governance processes should handle key loss scenarios |
| Threat | Target | Risk | Mitigation |
|---|---|---|---|
| Non-oracle address calls submit_result | submit_result |
High | Caller verified against stored oracle address before any state change |
| Non-admin calls pause/unpause/drain | pause, unpause, emergency_drain |
High | Admin address verified via require_auth() |
| Non-player deposits into or cancels a match | deposit, cancel_match |
Medium | player == m.player1 || player == m.player2 enforced |
| Actor | Trusted for | Not trusted for |
|---|---|---|
| Stellar network | Correct execution of Soroban contracts | — |
| Oracle service | Accurate game result reporting | Custody of player funds |
| Admin key | Pause/unpause, oracle rotation | Accessing escrowed funds |
| Players | Their own key management | Each other |
No single party can unilaterally steal funds. The escrow contract enforces all payout rules on-chain.
| Attribute | Assessment |
|---|---|
| Impact | Critical. An attacker with the oracle key can call submit_result on any Active match, directing the entire pot to any winner (including Player1 or Player2 arbitrarily). The contract has no on-chain mechanism to distinguish a legitimate oracle submission from a compromised one. |
| Likelihood | Low to Medium. The oracle key is held by a centralized service. The risk depends on the security posture of the oracle infrastructure (HSM, key rotation, access controls). |
| Mitigations | |
1. Key rotation: Admin can call update_oracle() to replace the compromised key with a new one. This does not revert past submissions but prevents further damage. |
|
| 2. Separation of duties: Oracle key and admin key are separate. A single compromise cannot both submit results and rotate the oracle key. | |
3. Event monitoring: Every submit_result emits a match:completed event. Monitoring infrastructure can detect anomalous submissions. |
|
| 4. Future enhancement: Multi-sig oracle (M-of-N) so a single key compromise does not permit result submission. | |
| Recovery | 1. Admin calls pause() to block all state-changing operations. 2. Admin calls update_oracle() with a new, uncompromised key. 3. For disputed matches, the admin can use emergency_drain() (requires pause) to move funds to a safe address pending dispute resolution off-chain. 4. Unpause once the new oracle is operational. |
| Attribute | Assessment |
|---|---|
| Impact | High. A lost admin key permanently prevents pause(), unpause(), update_oracle(), and emergency_drain(). If the oracle key is also compromised, there is no way to stop the contract. If the oracle is functioning, the contract continues to operate normally. |
| Likelihood | Low. Admin key should be stored securely (hardware wallet, multi-sig, cold storage). |
| Mitigations | |
| 1. Multi-sig: The admin address SHOULD be a multi-sig contract (e.g., Stellar clawback or Soroban multi-sig) requiring M-of-N signatures. | |
| 2. Backup keys: At minimum, a backup admin key should be held by a separate trusted party. | |
3. Timelock: A future enhancement should add a timelock between pause() and emergency_drain(), giving players time to react. |
|
| Recovery | |
| 1. If the contract has been initialized with a multi-sig admin, the admin role can be recovered through the multi-sig's governance process. | |
| 2. No on-chain recovery exists for a single-key admin. The contract would need to be re-deployed and all active matches migrated off-chain. | |
| 3. Future enhancement: A social recovery mechanism (e.g., N-of-M guardian accounts) could restore admin privileges. |
| Attribute | Assessment |
|---|---|
| Impact | High. If the chess platform returns fraudulent results (or the oracle does not verify API responses correctly), incorrect payouts are executed. The contract trusts the oracle to verify API responses. |
| Likelihood | Low. Both Lichess and Chess.com have established reputations; API responses are signed or served over TLS. The oracle should additionally verify the response against a secondary source or require cryptographic proof. |
| Mitigations | |
| 1. Oracle verification: The oracle service must independently verify the API response (e.g., check multiple endpoints, validate TLS certificates, cross-reference game state). | |
2. Game ID binding: The game_id stored in the match record must match the game_id submitted by the oracle. This prevents the oracle from reusing a legitimate result from one match in another. |
|
| 3. Event monitoring: All result submissions emit events; anomalous results can be detected off-chain. | |
| 4. Dispute window: A future enhancement should add a challenge period during which players can dispute a result with proof. | |
| Recovery | 1. Admin pauses the contract. 2. Admin investigates the fraudulent submissions off-chain. 3. If funds remain in the contract, emergency_drain() can recover them. 4. For funds already paid out, legal recourse or social consensus is required. |
| Threat | Description | Risk | Mitigation |
|---|---|---|---|
| Deposit front-running | An attacker observes a create_match transaction and submits a deposit before the intended player. |
Low | deposit checks that the caller is player1 or player2 from the match record. The attacker's address would not match either player. |
| Cancel front-running | An attacker observes a deposit and front-runs with cancel_match to lock the deposit. |
Low | cancel_match also requires the caller to be a player. Additionally, once both players have deposited (state = Active), cancel_match is rejected. |
| Oracle result front-running | An attacker observes a pending oracle submission and submits their own result first. | Low | Only the registered oracle address can submit results. The attacker cannot spoof the oracle identity. |
Threat: An attacker calls initialize a second time to overwrite the oracle or admin address.
Mitigation: Both contracts check env.storage().instance().has(&DataKey::Oracle/Admin) before writing. A second call panics immediately.
Threat: An attacker replaces the oracle address to redirect payouts.
Mitigation: The oracle address is set at initialization and can only be rotated by the admin via update_oracle. The admin key is separate from the oracle key.
Threat: A non-oracle address calls submit_result on the escrow contract.
Mitigation: submit_result compares caller against the stored DataKey::Oracle address and returns Error::Unauthorized on mismatch, before any state change.
Threat: The oracle submits a result twice, potentially changing the winner.
Mitigation: The escrow contract checks m.state == Active before processing. Once set to Completed, any further submit_result call returns Error::InvalidState. The oracle contract additionally rejects duplicate submissions with Error::AlreadySubmitted.
Threat: A compromised oracle submits a result for the correct match_id but with a game_id belonging to a different game, redirecting a payout to the wrong winner.
Mitigation: submit_result on the escrow contract requires a game_id parameter and compares it against the game_id stored in the match record at creation time. A mismatch returns Error::GameIdMismatch before any state change or token transfer occurs.
Threat: An attacker creates multiple matches referencing the same chess game_id. If the oracle submits a result for that game, all duplicate matches could be paid out, draining the contract.
Mitigation: create_match tracks every accepted game_id in persistent storage under DataKey::GameId(game_id). A second create_match call with the same game_id returns Error::DuplicateGameId immediately, before any match record is written.
Threat: A player deposits into a cancelled or completed match, locking funds.
Mitigation: deposit checks m.state == Pending and returns Error::InvalidState for any other state.
Threat: A match is created with stake_amount = 0, wasting ledger storage.
Mitigation: create_match returns Error::InvalidAmount if stake_amount <= 0.
Threat: A single address creates a match against itself to manipulate state or waste resources.
Mitigation: create_match checks player1 != player2 and returns Error::InvalidPlayers on violation.
Threat: A persistent Match entry expires mid-game, causing MatchNotFound errors.
Mitigation: Every persistent write calls extend_ttl with MATCH_TTL_LEDGERS (518,400 ledgers, ~30 days). TTL is refreshed on deposit, result submission, and cancellation.
Threat: A critical bug is discovered post-deployment with no way to stop ongoing damage.
Mitigation: The admin can call pause() to block create_match, deposit, and submit_result. Existing matches can still be cancelled to recover funds. unpause() restores normal operation.
Threat: MatchCount wraps silently in release mode, reusing match IDs.
Mitigation: create_match uses id.checked_add(1).ok_or(Error::Overflow)?.
Threat: deposit is called without the player first approving sufficient token allowance for the escrow contract, resulting in a generic transfer failure.
Mitigation: Before calling try_transfer, deposit checks token.allowance(player, env.current_contract_address()) >= stake_amount and returns Error::InsufficientAllowance with a clear message if not met. See contracts/escrow/src/lib.rs.
Threat: A malicious token contract could re-enter the escrow contract during the try_transfer or transfer call, modifying state in unexpected ways.
Mitigation: Soroban's execution model prevents re-entrancy — the runtime does not allow a contract to be re-entered while it is already executing. All state changes in deposit occur after the external call (checks-effects-interactions pattern is followed for token transfers). See // SAFETY: no re-entrancy risk because... comments in contracts/escrow/src/lib.rs and the re-entrancy tests in contracts/escrow/src/tests.rs.
Threat: Functions accepting a match_id may receive an out-of-range value (e.g., u64::MAX), causing a storage read miss that returns MatchNotFound rather than a more descriptive error.
Mitigation: All functions accepting match_id validate match_id < get_match_count() up front and return Error::MatchNotFound for invalid IDs, providing a consistent and predictable error path. See contracts/escrow/src/lib.rs.
| Function | Who can call |
|---|---|
initialize |
Anyone (once only) |
create_match |
player1 (requires auth) |
deposit |
player1 or player2 (requires auth) |
cancel_match |
player1 or player2 (requires auth) |
submit_result |
Registered oracle address only |
pause / unpause |
Admin only |
emergency_drain |
Admin only (contract must be paused) |
update_oracle |
Admin only |
get_match / is_funded / get_escrow_balance |
Anyone (read-only) |
- The oracle service is a centralised component. A compromised oracle key can submit incorrect results. Key rotation via
update_oraclemitigates this without redeployment. - There is no timeout mechanism to auto-cancel a match if the oracle never submits a result. Players must manually cancel a pending match to recover funds. For matches in
Activestate (both players deposited), no on-chain cancellation is possible — the oracle must submit a result. - The admin
pausefunction does not blockcancel_match, so players can always recover deposits even when the contract is paused. - There is no on-chain recovery for a lost single-key admin. Multi-sig admin is strongly recommended for production deployments.
- There is no challenge/dispute period for oracle-submitted results. Players must trust the oracle's correctness off-chain.
- Multi-sig oracle: Require M-of-N oracle signatures to submit a result, preventing single-key compromise from authorizing fraudulent payouts.
- Timelocked emergency drain: Add a delay between
pause()andemergency_drain()so players can monitor and react. - Result dispute window: Allow players to challenge a result within a defined window by providing cryptographic proof.
- Admin social recovery: N-of-M guardian accounts could restore admin access in case of key loss.
- Auto-cancel timeout: Automatically cancel matches that remain in
Activebeyond a reasonable ledger count.
| Date | Reviewer | Sign-off |
|---|---|---|
| 2026-06-24 | Internal review | ✅ Approved — STRIDE analysis complete, all identified threats have documented mitigations or accepted risk. |
The oracle service signs Stellar transactions using a private key loaded exclusively from the ORACLE_SECRET_KEY environment variable. This key must never be hardcoded in source code or committed to the repository.
| Variable | Description | Required |
|---|---|---|
ORACLE_SECRET_KEY |
Stellar secret key (S…) used by the oracle to sign result submissions | Yes |
LICHESS_API_TOKEN |
Lichess API token for fetching game results | Yes |
CHESSDOTCOM_API_KEY |
Chess.com API key for fetching game results | Yes |
The oracle service validates all required environment variables at startup and exits with a fatal log message if any are missing.
Production guidance: Store ORACLE_SECRET_KEY in a secrets manager (e.g., AWS Secrets Manager, HashiCorp Vault) and inject it at runtime. Rotate the key via update_oracle on the escrow contract after rotation.
Open a GitHub issue with the label security. For critical vulnerabilities, contact the maintainers directly before public disclosure.