From c9cf595598ee1a36480a8076412a6f3675df4a84 Mon Sep 17 00:00:00 2001 From: Syed Ghufran Hassan Date: Fri, 20 Feb 2026 16:31:49 +0500 Subject: [PATCH] feat: Add time-locked refunds and game cancellation to escrow contract - Add game status tracking ("waiting", "active", "completed", "refunded", "cancelled") - Implement cancel-game for white player before black joins - Add time-locked refund mechanism for games where black never joins - Implement claim-abandoned for games inactive past timelock - Add update-last-move to track game activity - Enhance release-to-winner with winner validation - Enhance refund-game with player authorization - Add read-only functions: get-game-status, get-refund-timelock, can-request-refund - Add admin function to set refund timelock --- contracts/chess-escrow.clar | 230 ++++++++++++++++++++++++++++++++++-- 1 file changed, 221 insertions(+), 9 deletions(-) diff --git a/contracts/chess-escrow.clar b/contracts/chess-escrow.clar index ab5a1986..e5b44f65 100644 --- a/contracts/chess-escrow.clar +++ b/contracts/chess-escrow.clar @@ -14,6 +14,17 @@ (define-constant ERR-GAME-NOT-FOUND (err u201)) (define-constant ERR-ALREADY-CLAIMED (err u202)) (define-constant ERR-INVALID-AMOUNT (err u203)) +(define-constant ERR-GAME-ACTIVE (err u204)) +(define-constant ERR-ALREADY-JOINED (err u205)) +(define-constant ERR-TIMELOCK-ACTIVE (err u206)) +(define-constant ERR-INVALID-WAITING-PLAYER (err u207)) +(define-constant ERR-GAME-NOT-ACTIVE (err u208)) + +;; ------------------------------------------------------- +;; Configuration +;; ------------------------------------------------------- + +(define-data-var refund-timelock uint u100) ;; ~100 blocks (~1 hour) before refund available ;; ------------------------------------------------------- ;; Storage @@ -27,7 +38,10 @@ white-amount: uint, black-amount: uint, total: uint, - claimed: bool + claimed: bool, + created-at: uint, + last-move-at: uint, + status: (string-ascii 10) ;; "waiting", "active", "completed", "refunded", "cancelled" } ) @@ -43,7 +57,10 @@ white-amount: amount, black-amount: u0, total: amount, - claimed: false + claimed: false, + created-at: block-height, + last-move-at: block-height, + status: "waiting" }) (ok true) ) @@ -58,17 +75,121 @@ ( (game-data (unwrap! (map-get? game-escrow game-id) ERR-GAME-NOT-FOUND)) ) + ;; Validate game is in waiting state + (asserts! (is-eq (get status game-data) "waiting") ERR-GAME-ACTIVE) + (asserts! (is-none (get black-player game-data)) ERR-ALREADY-JOINED) + (map-set game-escrow game-id (merge game-data { black-player: (some black), black-amount: amount, - total: (+ (get white-amount game-data) amount) + total: (+ (get white-amount game-data) amount), + status: "active", + last-move-at: block-height }) ) (ok true) ) ) +;; ------------------------------------------------------- +;; Cancel Game (White only, before black joins) +;; ------------------------------------------------------- + +(define-public (cancel-game (game-id uint)) + (let + ( + (game-data (unwrap! (map-get? game-escrow game-id) ERR-GAME-NOT-FOUND)) + ) + ;; Validate caller is white player + (asserts! (is-eq tx-sender (get white-player game-data)) ERR-NOT-AUTHORIZED) + ;; Validate game is in waiting state + (asserts! (is-eq (get status game-data) "waiting") ERR-GAME-ACTIVE) + (asserts! (is-none (get black-player game-data)) ERR-ALREADY-JOINED) + (asserts! (not (get claimed game-data)) ERR-ALREADY-CLAIMED) + + ;; Mark as cancelled and claimed (for refund) + (map-set game-escrow game-id + (merge game-data { + claimed: true, + status: "cancelled" + }) + ) + + (ok (get white-amount game-data)) + ) +) + +;; ------------------------------------------------------- +;; Time-locked Refund (if opponent never joins or game abandoned) +;; ------------------------------------------------------- + +(define-public (request-refund (game-id uint)) + (let + ( + (game-data (unwrap! (map-get? game-escrow game-id) ERR-GAME-NOT-FOUND)) + (current-block block-height) + ) + ;; Validate game is in waiting state (black never joined) + (asserts! (is-eq (get status game-data) "waiting") ERR-GAME-ACTIVE) + (asserts! (is-none (get black-player game-data)) ERR-ALREADY-JOINED) + (asserts! (is-eq tx-sender (get white-player game-data)) ERR-NOT-AUTHORIZED) + (asserts! (not (get claimed game-data)) ERR-ALREADY-CLAIMED) + + ;; Check timelock has passed + (let ((time-elapsed (- current-block (get created-at game-data)))) + (asserts! (>= time-elapsed (var-get refund-timelock)) ERR-TIMELOCK-ACTIVE) + ) + + ;; Mark as claimed + (map-set game-escrow game-id + (merge game-data { + claimed: true, + status: "refunded" + }) + ) + + (ok (get white-amount game-data)) + ) +) + +;; ------------------------------------------------------- +;; Abandon Game (if opponent stops playing) +;; ------------------------------------------------------- + +(define-public (claim-abandoned (game-id uint)) + (let + ( + (game-data (unwrap! (map-get? game-escrow game-id) ERR-GAME-NOT-FOUND)) + (current-block block-height) + ) + ;; Validate game is active + (asserts! (is-eq (get status game-data) "active") ERR-GAME-NOT-ACTIVE) + (asserts! (not (get claimed game-data)) ERR-ALREADY-CLAIMED) + + ;; Check last move time + (let ((time-since-move (- current-block (get last-move-at game-data)))) + (asserts! (>= time-since-move (var-get refund-timelock)) ERR-TIMELOCK-ACTIVE) + ) + + ;; Determine caller's role + (asserts! (or + (is-eq tx-sender (get white-player game-data)) + (is-eq tx-sender (unwrap! (get black-player game-data) ERR-NOT-AUTHORIZED)) + ) ERR-NOT-AUTHORIZED) + + ;; Mark as claimed + (map-set game-escrow game-id + (merge game-data { + claimed: true, + status: "refunded" + }) + ) + + (ok (get total game-data)) + ) +) + ;; ------------------------------------------------------- ;; Release to Winner ;; ------------------------------------------------------- @@ -78,15 +199,29 @@ ( (game-data (unwrap! (map-get? game-escrow game-id) ERR-GAME-NOT-FOUND)) ) + ;; Validate game is active + (asserts! (is-eq (get status game-data) "active") ERR-GAME-NOT-ACTIVE) (asserts! (not (get claimed game-data)) ERR-ALREADY-CLAIMED) - (map-set game-escrow game-id (merge game-data { claimed: true })) + ;; Validate winner is either white or black player + (asserts! (or + (is-eq winner (get white-player game-data)) + (is-eq winner (unwrap! (get black-player game-data) false)) + ) ERR-NOT-AUTHORIZED) + + (map-set game-escrow game-id + (merge game-data { + claimed: true, + status: "completed" + }) + ) + (ok (get total game-data)) ) ) ;; ------------------------------------------------------- -;; Refund Both Players +;; Refund Both Players (draw or mutual agreement) ;; ------------------------------------------------------- (define-public (refund-game (game-id uint)) @@ -94,9 +229,44 @@ ( (game-data (unwrap! (map-get? game-escrow game-id) ERR-GAME-NOT-FOUND)) ) + ;; Validate game is active + (asserts! (is-eq (get status game-data) "active") ERR-GAME-NOT-ACTIVE) (asserts! (not (get claimed game-data)) ERR-ALREADY-CLAIMED) - (map-set game-escrow game-id (merge game-data { claimed: true })) + ;; Validate caller is either white or black player + (asserts! (or + (is-eq tx-sender (get white-player game-data)) + (is-eq tx-sender (unwrap! (get black-player game-data) ERR-NOT-AUTHORIZED)) + ) ERR-NOT-AUTHORIZED) + + (map-set game-escrow game-id + (merge game-data { + claimed: true, + status: "refunded" + }) + ) + + (ok true) + ) +) + +;; ------------------------------------------------------- +;; Update Game Last Move (called by game logic contract) +;; ------------------------------------------------------- + +(define-public (update-last-move (game-id uint)) + (let + ( + (game-data (unwrap! (map-get? game-escrow game-id) ERR-GAME-NOT-FOUND)) + ) + ;; Validate game is active + (asserts! (is-eq (get status game-data) "active") ERR-GAME-NOT-ACTIVE) + + (map-set game-escrow game-id + (merge game-data { + last-move-at: block-height + }) + ) (ok true) ) ) @@ -111,14 +281,56 @@ (define-read-only (get-total-locked (game-id uint)) (ok (get total (default-to - { white-player: tx-sender, black-player: none, white-amount: u0, black-amount: u0, total: u0, claimed: false } + { white-player: tx-sender, black-player: none, white-amount: u0, black-amount: u0, total: u0, claimed: false, created-at: u0, last-move-at: u0, status: "waiting" } (map-get? game-escrow game-id) ))) ) (define-read-only (is-claimed (game-id uint)) (ok (get claimed (default-to - { white-player: tx-sender, black-player: none, white-amount: u0, black-amount: u0, total: u0, claimed: false } + { white-player: tx-sender, black-player: none, white-amount: u0, black-amount: u0, total: u0, claimed: false, created-at: u0, last-move-at: u0, status: "waiting" } + (map-get? game-escrow game-id) + ))) +) + +(define-read-only (get-game-status (game-id uint)) + (ok (get status (default-to + { white-player: tx-sender, black-player: none, white-amount: u0, black-amount: u0, total: u0, claimed: false, created-at: u0, last-move-at: u0, status: "waiting" } (map-get? game-escrow game-id) ))) -) \ No newline at end of file +) + +(define-read-only (get-refund-timelock) + (ok (var-get refund-timelock)) +) + +(define-read-only (can-request-refund (game-id uint) (player principal)) + (let + ( + (game-data (default-to + { white-player: player, black-player: none, white-amount: u0, black-amount: u0, total: u0, claimed: false, created-at: u0, last-move-at: u0, status: "waiting" } + (map-get? game-escrow game-id) + )) + (current-block block-height) + ) + (ok (and + (is-eq (get status game-data) "waiting") + (is-none (get black-player game-data)) + (is-eq player (get white-player game-data)) + (not (get claimed game-data)) + (>= (- current-block (get created-at game-data)) (var-get refund-timelock)) + )) + ) +) + +;; ------------------------------------------------------- +;; Admin Functions +;; ------------------------------------------------------- + +(define-public (set-refund-timelock (new-timelock uint)) + (begin + (asserts! (is-eq tx-sender CONTRACT-OWNER) ERR-NOT-AUTHORIZED) + (var-set refund-timelock new-timelock) + (ok true) + ) +)