diff --git a/.gitignore b/.gitignore index 299cef5..795b5de 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,15 @@ Thumbs.db # Lock file (optional β€” remove if you want to commit it) # Cargo.lock + +# Task files +task1.md + +# Task files +task1.md + +# Task files +task1.md + +# Task files +task1.md \ No newline at end of file diff --git a/README.md b/README.md index 503f933..a0a6f74 100644 --- a/README.md +++ b/README.md @@ -129,10 +129,10 @@ is_funded(match_id) -> bool ``` submit_result(match_id, winner) -verify_result(match_id) -> bool -execute_payout(match_id) ``` +`submit_result` is called by the trusted oracle address. It verifies the caller, records the winner, and immediately executes the payout (or refund on draw) in a single transaction. There are no separate `verify_result` or `execute_payout` functions. + ## πŸ§ͺ Testing Comprehensive test suite covering: diff --git a/contracts/oracle/src/lib.rs b/contracts/oracle/src/lib.rs index 2c5d532..99f8a5b 100644 --- a/contracts/oracle/src/lib.rs +++ b/contracts/oracle/src/lib.rs @@ -149,7 +149,22 @@ impl OracleContract { } /// Admin removes a previously submitted result from persistent storage. + /// + /// # Errors + /// - [`Error::ContractPaused`] β€” contract is paused. + /// - [`Error::Unauthorized`] β€” contract has not been initialized or caller is not the admin. + /// - [`Error::ResultNotFound`] β€” no result exists for `match_id`. pub fn delete_result(env: Env, match_id: u64) -> Result<(), Error> { + extend_instance_ttl(&env); + if env + .storage() + .instance() + .get(&DataKey::Paused) + .unwrap_or(false) + { + return Err(Error::ContractPaused); + } + let admin: Address = env .storage() .instance() @@ -781,6 +796,31 @@ mod tests { assert_eq!(result, Err(Ok(Error::ResultNotFound))); } + /// Test that delete_result is blocked when the contract is paused. + #[test] + fn test_delete_result_blocked_when_paused() { + let (env, contract_id, ..) = setup(); + let client = OracleContractClient::new(&env, &contract_id); + + // Submit a result so there is something to delete + client.submit_result( + &0u64, + &String::from_str(&env, "chess_game_99"), + &Winner::Player2, + ); + assert!(client.has_result(&0u64)); + + // Pause the contract + client.pause(); + + // Attempt delete_result β€” must be blocked + let result = client.try_delete_result(&0u64); + assert_eq!(result, Err(Ok(Error::ContractPaused))); + + // Result must still exist + assert!(client.has_result(&0u64)); + } + #[test] #[should_panic] fn test_delete_result_requires_admin_auth() { diff --git a/docs/architecture.md b/docs/architecture.md index 6a2830b..2f4267f 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -8,7 +8,7 @@ Checkmate-Escrow is a trustless chess wagering platform built on Stellar Soroban β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” create/deposit/cancel β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ Players β”‚ ─────────────────────────────▢│ Escrow Contract β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ - β”‚ submit_result / execute_payout + β”‚ submit_result (payout inline) β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” verify game result β”‚ β”‚ Oracle β”‚ β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Άβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ @@ -115,6 +115,49 @@ Returned by `get_match(match_id)`. All fields below are stable and safe to read. | Function | Signature | Description | |----------|-----------|-------------| -| `submit_result` | `(match_id: u64, winner: Winner)` | Oracle submits the verified match result. | -| `verify_result` | `(match_id: u64) -> bool` | Returns `true` if a result has been submitted. | -| `execute_payout` | `(match_id: u64)` | Transfers escrowed funds to the winner (or refunds on draw). | +| `submit_result` | `(match_id: u64, winner: Winner)` | Oracle submits the verified match result. Payout (or draw refund) is executed atomically in the same transaction β€” there are no separate `verify_result` or `execute_payout` functions. | + +#### Read Indexes + +| Function | Signature | Description | +|----------|-----------|-------------| +| `get_player_matches` | `(player: Address) -> Vec` | Returns all match IDs (past and present) for a player. | +| `get_active_matches` | `() -> Vec` | Returns match IDs currently in `Pending` or `Active` state. | + +## Index Behavior, TTL Caveats, and Pagination + +### Player-Match Index (`get_player_matches`) + +`get_player_matches` reads a `Vec` stored under `DataKey::PlayerMatches(player)` in persistent storage. The index is append-only: a match ID is added when `create_match` is called and is **never removed**, regardless of the match outcome. This means: + +- The list grows monotonically over a player's lifetime. +- It includes `Completed` and `Cancelled` matches as well as live ones. +- To determine a match's current state, call `get_match(match_id)` for each ID. + +### Active-Match Index (`get_active_matches`) + +`get_active_matches` reads `DataKey::ActiveMatches` from persistent storage. A match ID is added on `create_match` and removed when the match transitions to `Completed` or `Cancelled`. This index therefore reflects only live matches (`Pending` or `Active` state) at the time of the call. + +> **Caveat:** Because the index is stored in persistent storage and updated by separate transactions, there is a brief window where a match may appear in the active index after its terminal transition has been committed but before the index write has been confirmed. Treat `get_active_matches` as a best-effort snapshot and always verify state with `get_match` before acting on a result. + +### TTL Caveats + +Both indexes are stored in **persistent storage** with a TTL of `MATCH_TTL_LEDGERS` (~30 days at 5 s/ledger). The TTL is extended each time the index entry is written (on `create_match`, `submit_result`, `cancel_match`, `expire_match`). However: + +- If no matches are created or resolved for a player for ~30 days, `PlayerMatches` for that player may expire and `get_player_matches` will return an empty list. +- `ActiveMatches` is refreshed on every match state change, so it is unlikely to expire on an active deployment. +- Individual `Match` records in persistent storage follow the same ~30-day TTL and are extended on every write to that match. + +Off-chain indexers should not rely solely on these on-chain indexes for long-term history. Subscribe to contract events (`match.created`, `match.result`, `match.cancelled`) for a durable record. + +### Pagination + +Neither `get_player_matches` nor `get_active_matches` supports server-side pagination β€” both return the full `Vec` in a single call. For deployments with a large number of matches, apply client-side slicing: + +```rust +// Example: fetch page of 20 starting at offset 40 +let all_ids = client.get_player_matches(&player); +let page: Vec = all_ids.iter().skip(40).take(20).collect(); +``` + +If on-chain pagination becomes necessary, the recommended approach is to introduce a `get_player_matches_page(player, offset, limit)` function that reads the stored `Vec` and returns a slice β€” avoiding the need to change the storage layout. diff --git a/docs/oracle.md b/docs/oracle.md index a12813b..def4fcc 100644 --- a/docs/oracle.md +++ b/docs/oracle.md @@ -85,6 +85,40 @@ oracle_client.submit_result(&match_id, &game_id, &MatchResult::Player1Wins); --- +## 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: + +```rust +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). + +### Trust assumptions and risks + +`delete_result` is an admin-only operation and is **blocked while the contract is paused**. Despite these guards, deletion carries meaningful trust implications: + +| Risk | Detail | +|------|--------| +| Audit trail removal | Deleting a result removes the on-chain record of that outcome. Anyone relying solely on `get_result` for historical verification will see `ResultNotFound` after deletion. | +| Re-submission after deletion | Once deleted, a new result can be submitted for the same `match_id`. A compromised or malicious admin could use this to alter the apparent outcome of a match. | +| Payout already executed | If the escrow payout has already been triggered by `submit_result`, deleting the oracle record does not reverse the payout. The escrow contract state is independent. | + +### Expected operational use + +- **Do not** use `delete_result` as routine cleanup. Results should be left to expire naturally via TTL. +- **Do** use it only to correct a demonstrably erroneous submission, and only before the corresponding escrow `submit_result` has been called. +- Any deletion should be logged off-chain (e.g., via the admin's operational runbook) since the on-chain event record will no longer contain the original submission after deletion. +- In production, admin keys should be held in a multi-sig wallet so that deletion requires multiple approvals, reducing the risk of unilateral misuse. + +--- + ## has_result vs has_result_admin The oracle contract exposes two ways to check whether a result has been diff --git a/docs/security.md b/docs/security.md index 70e3fdf..39b24a7 100644 --- a/docs/security.md +++ b/docs/security.md @@ -53,9 +53,10 @@ Both contracts have admin addresses with elevated privileges. Compromise of admi ### Oracle Contract Admin Powers -- **Pause/Unpause**: Can halt result submissions +- **Pause/Unpause**: Can halt result submissions (and block `delete_result` while paused) - **Admin Rotation**: Can change the oracle admin address - **Result Submission**: Can submit results directly (bypassing automated oracle) +- **Result Deletion**: Can remove a stored result via `delete_result`. This is an irreversible on-chain action β€” see [Result Deletion Policy](oracle.md#result-deletion-policy-delete_result) for risks and expected use. ### Risk Mitigations