Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
40 changes: 40 additions & 0 deletions contracts/oracle/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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() {
Expand Down
51 changes: 47 additions & 4 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 │ ─────────────────────────────▶─────────┘
└─────────────┘
Expand Down Expand Up @@ -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<u64>` | Returns all match IDs (past and present) for a player. |
| `get_active_matches` | `() -> Vec<u64>` | 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<u64>` 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<u64>` 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<u64> = 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.
34 changes: 34 additions & 0 deletions docs/oracle.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion docs/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down