diff --git a/.github/workflows/build-macos.yml b/.github/workflows/build-macos.yml index c28fb952..2971df84 100644 --- a/.github/workflows/build-macos.yml +++ b/.github/workflows/build-macos.yml @@ -42,12 +42,27 @@ jobs: run: | curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs > rust.sh sh rust.sh -y + source ~/.cargo/env + rustup update stable - name: Build hex files run: | export PATH=~/.cargo/bin:$PATH tools/build-chialisp.sh + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Install pytest + run: pip install pytest + + - name: Test build_chialisp.py + run: | + export PATH=~/.cargo/bin:$PATH + tools/test-build-chialisp.sh + - name: Run tests on macOS ARM run: | export PATH=~/.cargo/bin:$PATH diff --git a/.github/workflows/build-test.yml b/.github/workflows/build-test.yml index 6337407c..42fb4b2c 100644 --- a/.github/workflows/build-test.yml +++ b/.github/workflows/build-test.yml @@ -82,6 +82,17 @@ jobs: run: | tools/build-chialisp.sh + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Install pytest + run: pip install pytest + + - name: Test build_chialisp.py + run: tools/test-build-chialisp.sh + - name: Run rust tests and simulator tests run: | # Build without sim-tests to ensure that configuration compiles @@ -100,9 +111,11 @@ jobs: with: toolchain: ${{ env.RUST_VERSION }} components: "clippy, rustfmt" + - name: Build hex files + run: tools/build-chialisp.sh + - name: Run for coverage run: | - cp build.rs.disabled build.rs sudo apt-get update sudo apt-get install lcov -y rustup component add llvm-tools-preview diff --git a/CONNECTIVITY.md b/CONNECTIVITY.md new file mode 100644 index 00000000..0428cd66 --- /dev/null +++ b/CONNECTIVITY.md @@ -0,0 +1,439 @@ +# Connectivity Model + +This document describes the semantics of the four types of connections in the +player app and how they interact. It captures the intended design for session +rollover support — the ability to disconnect from and reconnect to wallets, +trackers, and peers without losing game sessions unnecessarily. + +For background on the system architecture, see `FRONTEND_ARCHITECTURE.md`. +For game lifecycle details, see `GAME_LIFECYCLE.md`. + +## Table of Contents + +- [The Four Axes](#the-four-axes) +- [State Space](#state-space) +- [Cascade Rules](#cascade-rules) +- [User Actions](#user-actions) +- [Session Lifecycle](#session-lifecycle) +- [Tracker Availability Protocol](#tracker-availability-protocol) +- [Implementation Status](#implementation-status) + +--- + +## The Four Axes + +### The Blockchain + +The blockchain is not a connection. It is the ground truth — always present, +immutable, the same chain regardless of how you reach it. All game state +ultimately lives on-chain (coins, channel state, resolution transactions). +No connectivity decision affects the blockchain itself. + +### Wallet + +The wallet is a **replaceable interface** to the blockchain. WalletConnect and +the simulator are different lenses into the same chain. Connecting a different +wallet (or reconnecting the same one) gives you the same view of the same +coins. Switching between simulator and real chain is a user error the app +doesn't guard against — coins simply won't exist. + +The wallet is **orthogonal** to the other three axes. It can be connected or +disconnected in any combination with tracker, peer, and session state. No +other connection depends on the wallet being up. The wallet affects only +whether blockchain operations (signing transactions, reading balances) can +make progress. + +### Tracker + +A tracker is a specific server with its own lobby and relay infrastructure. +Tracker A and Tracker B are distinct entities — different lobbies, different +pairings, different relay channels. The player connects to zero or one tracker +at a time. + +The tracker connection auto-reconnects with backoff on transient failures. A +tracker is considered permanently dead only after the retry budget is +exhausted or the user explicitly disconnects. + +### Peer + +A peer connection is mediated by a tracker. There is no direct peer-to-peer +transport (WebRTC is a future option). The peer relay rides on the tracker's +WebSocket — structurally, **peer requires tracker**. If the tracker is down, +the peer is down by definition. + +There are two ways to end a peer connection: + +1. **Hard disconnect** — `close()` via the tracker. The tracker notifies the + peer (`closed` event), removes the pairing, and sets both players to + `'waiting'`. The relay is gone, including chat. +2. **Soft disconnect** — going on-chain while the tracker/peer relay is still + alive. Game messages stop flowing, but chat continues over the same relay. + The pairing still exists on the tracker. + +Once the peer connection is considered lost (either via hard disconnect, or +liveness timeout without reconnect), it is gone. There is no "reconnect to +the same peer" — both sides would have to re-match on a tracker. + +### Session + +A session is an **obligation**, not a connection. Once started, it runs to +completion. Funds are locked in the channel coin and must be distributed +through the protocol — either cooperatively (clean shutdown) or unilaterally +(on-chain resolution). The session does not care whether you have a peer or a +tracker — it will grind to completion on the blockchain if it has to. + +--- + +## State Space + +The wallet is orthogonal and does not participate in the state machine. The +core state space is: + +``` +tracker: up | down +peer: up | down (peer up requires tracker up) +session: none | off-chain | on-chain +``` + +- **off-chain**: the game is being played through the peer relay. The relay is + the authority for game moves. +- **on-chain**: the blockchain is the authority. This transition is one-way — + once you initiate `goOnChain()`, you are on-chain from the perspective of + all connectivity rules. `cleanShutdown()` does **not** immediately + transition to on-chain — it stays in the cooperative off-chain flow until + the peer countersigns and the shutdown transaction is formed. + +2 × 2 × 3 = 12 combinations, minus 3 impossible states (tracker down, peer +up) = **9 reachable states**. + +Four of those are **ephemeral** — they exist for one tick and auto-transition: + +| Ephemeral state | Rule | Transitions to | +|-----------------|------|----------------| +| tracker up, peer down, session off-chain | off-chain + no peer → on-chain | tracker up, peer down, session on-chain | +| tracker down, peer down, session off-chain | off-chain + no peer → on-chain | tracker down, peer down, session on-chain | + +(Each of those can occur with or without wallet, but since wallet is +orthogonal, it doesn't affect the transition.) + +The rule is: **off-chain session without a peer must immediately transition to +on-chain.** No dialog, no prompt, no user decision. This is automatic. + +### Resting States + +After collapsing ephemeral states, the system has **7 resting states**: + +| # | Tracker | Peer | Session | Description | +|---|---------|------|---------|-------------| +| 1 | up | up | none | Idle on a tracker. Can accept challenges. | +| 2 | up | up | off-chain | Playing a game through the relay. | +| 3 | up | up | on-chain | Resolving on-chain, peer still connected. Chat works. | +| 4 | up | down | none | On a tracker, no match. Waiting in lobby. | +| 5 | up | down | on-chain | Resolving on-chain, peer gone. Tracker available for future matchmaking. | +| 6 | down | down | none | Disconnected from everything. Can reconnect. | +| 7 | down | down | on-chain | Resolving on-chain, no tracker. Grinding through blockchain. | + +The wallet can be up or down in any of these states. When the wallet is down, +blockchain operations stall but the logical state is unchanged. + +--- + +## Cascade Rules + +Forced cascades flow downward through the dependency chain: + +``` +tracker dies → peer dies → off-chain session goes on-chain +``` + +Specific rules: + +- **Tracker goes down permanently** → peer is gone (rides the same socket) → + if session is off-chain, auto-transition to on-chain. +- **Peer lost** (hard disconnect, liveness timeout, or tracker death) → if + session is off-chain, auto-transition to on-chain. +- **Session transitions off-chain → on-chain** — one-way. No going back. +- **Wallet loss** — no cascade. Session is logically unchanged; blockchain + operations just can't make progress until a wallet is reconnected. + +--- + +## User Actions + +### Wallet + +| Action | Allowed? | Warning | Consequence | +|--------|----------|---------|-------------| +| Disconnect | Always | "You are in a session. Blockchain operations will stall until you reconnect." (only if session exists) | Wallet interface torn down. Session save preserved. | +| Reconnect (same or different) | Always | None | Stalled operations resume. Session continues. | + +### Tracker + +| Action | Allowed? | Warning | Consequence | +|--------|----------|---------|-------------| +| Disconnect | Always | If peer up: "This will end your peer connection." If session off-chain: "This will force your game on-chain." | Peer dies (cascade). | +| Reconnect (same tracker) | Always | None | Tracker re-identifies. If pairing still exists on server, peer may reconnect. | +| Connect to new tracker | Always | None | New lobby. If session is active, join as unavailable. | + +### Peer + +| Action | Allowed? | Warning | Consequence | +|--------|----------|---------|-------------| +| Hard disconnect (`close()`) | Always | If session off-chain: "This will force your game on-chain." | Pairing removed. Peer notified. Both go to `'waiting'`. Off-chain session transitions to on-chain. | +| Reconnect | Not a user action | — | Handled by tracker auto-reconnect and `peer_reconnected` events. | + +### Session + +| Action | Allowed? | Warning | Consequence | +|--------|----------|---------|-------------| +| Go on-chain | When session = off-chain | "Are you sure? Your game will be resolved on the blockchain." | Session transitions to on-chain. Game messages stop. Chat continues. | +| Clean shutdown | Between hands only, requires peer cooperation | None (it's the graceful path) | Cooperative close. Channel resolves cleanly. | +| Abandon | When session exists | **"You will lose any funds locked in this channel. This cannot be undone."** | WASM cradle torn down. Session save wiped. Funds at risk. | + +--- + +## Session Lifecycle + +### Session States (as seen by Shell) + +| State | Derived from | Meaning | +|-------|-------------|---------| +| `none` | `gameParams === null` | No session. Available for matchmaking. | +| `off-chain` | Session exists, not yet resolving on-chain | Playing through the peer relay. `cleanShutdown()` stays off-chain until the shutdown transaction is formed. | +| `on-chain` | `goOnChain()` initiated, or clean shutdown transaction submitted | Resolving on the blockchain. May or may not have peer. | + +The on-chain state persists until the WASM cradle reports a terminal channel +status: `ResolvedClean`, `ResolvedUnrolled`, `ResolvedStale`, or `Failed`. +At that point the session is **done** — the save can be wiped, the player is +available for new matches. + +### What "on-chain" means for peer communication + +When a session transitions to on-chain: + +- **Game messages** (`OutboundMessage` from WASM, `deliverMessage` inbound): + **stop**. Don't send new game messages to the peer. Ignore incoming game + messages from the peer (ack them to prevent retransmit, but don't deliver + to the WASM cradle). +- **Acks for already-delivered messages**: still processed (they concern the + past). +- **Keepalives**: harmless but no longer meaningful for game liveness. +- **Chat**: **still works**. Chat is independent of the game protocol and + rides the same tracker relay. + +### Terminal detection + +When the WASM cradle emits a `ChannelStatus` notification with a terminal +state (`ResolvedClean`, `ResolvedUnrolled`, `ResolvedStale`, or `Failed`): + +1. The session is done. +2. Shell is notified (via callback from `useGameSession`). +3. The session save is cleared. +4. Shell tells the tracker/lobby that the player is available. +5. The player can accept new challenges. +6. The old peer connection is not forcibly closed — chat can continue until a + new match replaces the pairing or either side sends `close()`. + +--- + +## Tracker Availability Protocol + +### The problem + +A tracker only knows about pairings it created. If a player connects to a new +tracker while mid-session (on-chain resolution in progress), that tracker has +no idea the player is busy. Other players will see them as available and can +send challenges. + +### The solution + +The player app tells the tracker whether it's available for matching over the +**game channel WebSocket** (`TrackerConnection` → `/ws/game`). The tracker +is not trusted either (it's third-party code anyone can run), but the +WebSocket is a TCP connection with known coherent semantics — clear ordering, +connection state, and a single stream. The lobby iframe's `postMessage` +boundary is a broadcast mechanism with no delivery guarantees, no ordering, +and a much harder surface to guard against. Availability signaling goes over +the WebSocket because it's the more defensible transport, not because the +tracker is trusted. + +**Player app → Tracker (game channel):** + +```json +{ "type": "set_status", "session_id": "...", "available": false } +``` + +Sent whenever the session phase changes. Also included in the `identify` +message on reconnect so the tracker has correct status after a game channel +drop/restore. + +The tracker updates the player's lobby status to `'busy'` (unavailable) or +`'waiting'` (available) and broadcasts a lobby update. Challenges to/from +busy players are rejected. + +**When the session ends** (terminal channel status detected), the player app +sends `set_status` with `available: true`. The tracker sets the player back +to `'waiting'` and broadcasts the update. + +The lobby iframe receives the updated `Player.status` via the normal +`lobby_update` broadcast and renders busy players as unavailable. No +iframe-side protocol changes are needed — it is read-only for this signal. + +--- + +## Implementation Status + +### Currently implemented + +- **Tracker connection**: `TrackerConnection` class with auto-reconnect, + backoff, and keepalive (`front-end/src/services/TrackerConnection.ts`). +- **Peer relay**: Message relay through tracker WebSocket with numbered + ack protocol, reorder queue, and keepalive + (`front-end/src/hooks/WasmBlobWrapper.ts`). +- **Peer liveness**: 60-second activity timeout with 5-second polling + interval in `Shell.tsx`. Tracker liveness with 45-second timeout. +- **Tracker `close()`**: Protocol-level session end that notifies peer and + removes pairing (`TrackerConnection.close()`). +- **Session persistence**: `SessionState` in localStorage with serialized + WASM cradle, message numbers, unacked messages, etc. + (`front-end/src/hooks/save.ts`). +- **Resume on reload**: Boot state machine with resume dialog, lease system + for tab conflict detection (`Shell.tsx`). +- **Channel state tracking**: `ChannelStatus` notifications from WASM with + full state machine (`useGameSession.ts`). `isWindingDown()` helper for + UI gating. +- **Go on-chain and clean shutdown**: Both implemented in WASM wrapper and + exposed through `useGameSession`. + +### Recently implemented + +- **Wallet disconnect preserving session**: `handleDisconnectWallet` no + longer calls `clearSession()`. The session save is preserved across + wallet disconnects; blockchain operations stall until a wallet is + reconnected. (`Shell.tsx`) + +- **Session state surfaced to Shell**: `GameSession` reports coarse session + phase (`off-chain | on-chain | resolved`) and an error flag to Shell via + the `onSessionPhaseChange` callback. Shell tracks this as `sessionPhase` + and `sessionError` state. (`GameSession.tsx`, `Shell.tsx`) + +- **Terminal session detection and cleanup**: When `sessionPhase` becomes + `'resolved'` (derived from terminal channel states `ResolvedClean`, + `ResolvedUnrolled`, `ResolvedStale`, or `Failed`), Shell automatically + clears the session save and marks the player as available. + +- **Game message filtering on-chain**: `WasmBlobWrapper` has an `onChain` + flag. When set, `deliverMessage()` acks but does not deliver inbound game + messages to the WASM cradle, and `dispatchEvent()` suppresses outbound + `OutboundMessage` events. + +- **Tracker availability signaling**: `TrackerConnection.setAvailable()` + sends `{ type: "set_status", available }` over the game WebSocket. + The `identify` message on reconnect includes the current availability. + Shell calls `setAvailable(sessionPhase === 'none')` whenever the session + phase changes. + +- **Tracker-side `set_status` handler**: The tracker server accepts + `set_status` messages on the game channel. It updates the player's lobby + status to `'busy'` or `'waiting'` and broadcasts a lobby update. + Challenges to/from busy players are rejected. (`lobby-service/src/index.ts`) + +- **Session abandon (emergency exit)**: An "Abandon Session" button in the + Game tab tears down the WASM cradle, wipes the session save, and accepts + fund loss. Gated by a confirmation dialog. + +- **Tracker retry budget**: `TrackerConnection` now has a + `MAX_RECONNECT_ATTEMPTS` budget. After the budget is exhausted, the + tracker is declared permanently dead and `onClosed` fires. + +- **User-initiated tracker disconnect**: A "Disconnect" button in the + tracker tab header allows explicit tracker disconnect. Gated by a cascade + warning if peer/session would be affected. + +- **User-initiated peer disconnect**: An "End Peer" button in the Game tab + action bar sends `close()` via the tracker. Gated by a cascade warning + if an off-chain session would be forced on-chain. + +- **Cascade warning dialogs**: Confirmation dialogs warn before actions + that would cascade (e.g., disconnecting tracker while peer is up and + session is off-chain). Implemented via `confirmDialog` state in Shell. + +--- + +## UX: Connectivity Indicators + +### Tab dots + +Each tab in the tab bar has a small colored dot to the left of its label +text, indicating the connectivity health of the axis associated with that +tab. The dot is always present (gray when idle/irrelevant) so the tab bar +layout never shifts. + +Separately, the existing upper-right notification dots indicate unread +activity (unread chat messages, new game events, etc.). These are unchanged +and serve a different purpose. + +### Per-tab color semantics + +| Tab | Green | Yellow | Red | Gray | +|-----|-------|--------|-----|------| +| Wallet | Connected | — | Disconnected | — | +| Tracker | Connected | Reconnecting | Inactive (no heartbeat) | Not connected (null / disconnected) | +| Game | Off-chain, peer up | On-chain (resolving) | Error state or peer lost while off-chain | No session | +| Chat | Peer connected | — | — | No peer | +| History | — | — | — | Always gray | +| Log | — | — | — | Always gray | + +### Game tab dot priority + +The Game tab dot checks conditions in this order: + +1. `sessionPhase === 'none'` → **gray** (no session) +2. `sessionError` → **red** (genuine error — always wins) +3. `sessionPhase === 'on-chain'` → **yellow** (actively resolving — overrides peer-lost red because peer loss auto-transitions to on-chain) +4. `sessionPhase === 'off-chain'` and `!peerConnected` → **red** (peer lost while off-chain, briefly visible before auto-transition) +5. `sessionPhase === 'off-chain'` and `peerConnected` → **green** (playing normally) + +### Game tab error conditions (red dot) + +The Game tab shows a red dot when `sessionError` is true or when the peer +is lost while the session is off-chain. `sessionError` is derived from: + +- `Failed` channel state — the channel encountered an unrecoverable error +- `ResolvedStale` channel state — the channel resolved but the outcome is + suspect (e.g., opponent exploited a timeout) +- `opponent-successfully-cheated` game terminal — the opponent submitted an + invalid state and profited from it +- `game-error` game terminal — a generic game-level error +- `we-timed-out` with `cleanEnd = false` — a premature timeout where we + failed to post a move or the user didn't move in time + +A timeout with `game_finished = true` (the game had naturally ended, i.e., +the validation program was `nil`) is **not** an error — it produces a +"Game ended cleanly" label regardless of who timed out. + +### Timeout labels + +The frontend uses `game_finished` from `other_params` and the current +`turnState` to produce context-aware timeout labels: + +| Status | `game_finished` | `turnState` | Label | +|--------|----------------|-------------|-------| +| ended-we-timed-out | true | (any) | "Game ended cleanly" | +| ended-we-timed-out | false | replaying / their-turn | "We timed out while trying to post a move" | +| ended-we-timed-out | false | my-turn / other | "We timed out while waiting for user to move" | +| ended-opponent-timed-out | true | (any) | "Game ended cleanly" | +| ended-opponent-timed-out | false | (any) | "Opponent timed out" | + +### Button placement + +- **Disconnect Tracker**: In the tracker tab header strip, right-aligned + next to the "Connected to {trackerOrigin}" text. +- **End Peer**: In the Game tab action bar (bottom of the game pane), + visible when a peer is connected. +- **Abandon Session**: In the Game tab action bar (bottom of the game + pane), visible when any session exists. Styled as a destructive action. + +### Not yet implemented + +_(No remaining items from the original design.)_ diff --git a/DEBUGGING_GUIDE.md b/DEBUGGING_GUIDE.md index 2c9955c5..13e4b2d2 100644 --- a/DEBUGGING_GUIDE.md +++ b/DEBUGGING_GUIDE.md @@ -73,7 +73,7 @@ When a test fails, use this sequence: |----------|--------| | `SIM_TEST_FROM=name` | Start the test rotation at the first test matching `name`, wrap around (`./ct.sh name`) | | `SIM_TEST_ONLY=name` | Run only test(s) matching `name` (`./ct.sh -o name`) | -| `SIM_TIMING=1` | Print detailed timing for each simulation step (farm_block, new_block, push_tx, deliver_message) | +| `SIM_TIMING=1` | Print detailed timing for each simulation step (farm_block, new_block, push_transactions, deliver_message) | | `RUST_LOG=debug` | Enable `log::debug!` output (normally suppressed) | ### Test registration diff --git a/FRONTEND_ARCHITECTURE.md b/FRONTEND_ARCHITECTURE.md index b46fdff1..6abd5787 100644 --- a/FRONTEND_ARCHITECTURE.md +++ b/FRONTEND_ARCHITECTURE.md @@ -4,7 +4,9 @@ This document describes the architecture of the frontend JavaScript/TypeScript code. It reflects the current implementation unless explicitly marked as a future direction. -For the backend/WASM architecture, see `OVERVIEW.md`. +For the backend/WASM architecture, see `OVERVIEW.md`. For the connectivity +model (wallet, tracker, peer, session interactions and rollover), see +`CONNECTIVITY.md`. ## System-Level View @@ -457,7 +459,9 @@ on every inbound message delivery, ack reception, and keepalive reception. Peer liveness is **passive** — there is no automatic `goOnChain()` on timeout. Instead, `Shell.tsx` derives two liveness indicators using a 5-second polling -interval and passes them to `GameSession` for display. +interval. These feed into the **tab-dot connectivity indicators** — colored +dots to the left of each tab label showing connection health (green / yellow / +red / gray). They are also passed to `GameSession` for in-game display. **Tracker indicator** (`TrackerLiveness`) combines WebSocket connectivity with keepalive freshness into four states: @@ -478,6 +482,12 @@ Inactive. The activity ref is updated by wrapped handlers in `registerMessageHandler`, ensuring it stays current even after `TrackerConnection.registerMessageHandler` replaces the initial callbacks. +**Action buttons**: The tracker disconnect button lives in the tracker tab +header strip (right-aligned next to "Connected to {origin}"). Session action +buttons (End Peer, Abandon Session) live in the Game tab's bottom action bar. +Both are gated by cascade confirmation dialogs when the action would disrupt +an active session. See `CONNECTIVITY.md` for the full connectivity model. + #### Reconnect When a player reconnects (`connection_status` received), and the peer is @@ -749,6 +759,12 @@ In-game and between-hand events pushed to the game-scoped FIFO queue | `proposal-rejected` | `ProposalCancelled` with `CancelledByPeer` | | `insufficient-bal` | `InsufficientBalance` notification | +Timeout terminal labels use `game_finished` from `other_params` and the +current `turnState` (tracked via `turnStateRef`) to produce context-aware +messages — see `CONNECTIVITY.md` "Timeout labels" for the full matrix. +Premature timeouts where we failed to move are flagged as errors via +`cleanEnd: false` on `GameTerminalInfo`. + ### Game lifecycle (handled internally by session) These drive game proposal and acceptance flow. They are consumed by diff --git a/INTERNALS.md b/INTERNALS.md index 9d27dbe2..992577a3 100644 --- a/INTERNALS.md +++ b/INTERNALS.md @@ -271,7 +271,7 @@ there is a bug. | **RESERVE_FEE not satisfied** | Declared fee exceeds available implicit fee. Means the fee arithmetic is wrong. | -**Key code:** `src/simulator/mod.rs` — `push_tx` +**Key code:** `src/simulator/mod.rs` — `push_transactions` --- diff --git a/ON_CHAIN.md b/ON_CHAIN.md index c559ef54..73a6fdfe 100644 --- a/ON_CHAIN.md +++ b/ON_CHAIN.md @@ -161,12 +161,44 @@ immediately goes on-chain instead of cooperating. to reward conditions (each player's balance goes directly to their reward puzzle hash, with no game coins). The `clean_shutdown` field is separate from the `actions` list, so it is structurally processed after all actions - on the receive side. + on the receive side. The initiator remains in `PotatoHandler` after sending + this batch — it does **not** transition to `SpendChannelCoinHandler` yet. + While waiting for the response, `PotatoHandler` rejects any peer message + other than `CleanShutdownComplete` as a protocol violation (triggering + go-on-chain). 2. The responder receives the batch, processes any actions, then combines the initiator's half-signature with their own to produce a complete `CoinSpend`. They reply with `PeerMessage::CleanShutdownComplete(coin_spend)` — a - standalone message outside the normal potato flow. -3. Either side can then submit the completed spend on-chain. + standalone message outside the normal potato flow. The responder transitions + to `SpendChannelCoinHandler` immediately (it already has the complete spend). +3. The initiator receives `CleanShutdownComplete`, submits the transaction, + and transitions to `SpendChannelCoinHandler`. Either side can submit the + completed spend on-chain; duplicate submissions are harmless. + +### Assumes Single-Handing + +The current implementation assumes **single-handing** (at most one outstanding +proposal at a time). Under this assumption, when the user requests a clean +shutdown, there is never a pending proposal that could interfere — the shutdown +batch is the only thing queued. This allows the front-end to immediately report +`ShuttingDown` status, and allows `PotatoHandler` to reject any unexpected +peer messages while waiting for `CleanShutdownComplete`. + +In a future **multi-handing** model, the initiator might have outstanding +proposals when the user requests a shutdown. Those proposals would need to +resolve (accepted, rejected, or cancelled) before the shutdown batch can be +sent. This means: + +- The `ShuttingDown` status could not be emitted immediately — the system + would still be processing proposals. +- The message-rejection guard in `PotatoHandler` (which currently rejects + everything except `CleanShutdownComplete`) would need to also accept + proposal-resolution messages during the wind-down phase. +- The precondition check (`has_active_games()`) would need to account for + proposals that are still in flight. + +This is noted here as a future design consideration — the current code is +correct for single-handing. ### Why "Advisory" — Race Handling @@ -196,8 +228,12 @@ inspects the actual spend conditions: ### Key Code +- `src/potato_handler/mod.rs` — `pending_clean_shutdown` field, + `drain_queue_into_batch` (stores shutdown metadata), + `process_incoming_message` (receives `CleanShutdownComplete` and creates + `SpendChannelCoinHandler`) - `src/potato_handler/spend_channel_coin_handler.rs` — -`handle_channel_coin_spent`, `handle_unroll_from_channel_conditions` + `handle_channel_coin_spent`, `handle_unroll_from_channel_conditions` --- diff --git a/OVERVIEW.md b/OVERVIEW.md index e71b2bdd..8105b601 100644 --- a/OVERVIEW.md +++ b/OVERVIEW.md @@ -376,8 +376,8 @@ they map to WalletConnect RPCs: conditions and amount. The `extraConditions` parameter carries the channel-specific assertions; `coinIds` optionally pins the spend to a specific coin. -- `chia_pushTx` — broadcast the final combined `SpendBundle` to the network - (both players submit it). +- `chia_pushTransactions` — broadcast the final combined `SpendBundle` to the + network, wrapped in a `TransactionRecord` (both players submit it). #### Channel coin funding diff --git a/UX_NOTIFICATIONS.md b/UX_NOTIFICATIONS.md index eed0f3e0..3913eb26 100644 --- a/UX_NOTIFICATIONS.md +++ b/UX_NOTIFICATIONS.md @@ -122,6 +122,17 @@ an optional `advisory` string for context (e.g. error reason). The | `ResolvedStale` | Stale unroll completed | The opponent tried to unroll with an older state; per-game outcomes follow separately | | `Failed` | Unrecoverable error | The channel or unroll coin is in an unrecoverable state; `advisory` has the reason | +**Assumes single-handing for `ShuttingDown` timing.** The current clean shutdown +flow emits `ShuttingDown` as soon as the user requests it, even before the +potato arrives and the shutdown batch is actually sent. This is correct for +single-handing (one proposal at a time) because there is no outstanding +proposal that could fail. In a future multi-handing model, the shutdown batch +could arrive while proposals are still in flight, and the peer could reject +the shutdown or the proposals could fail. At that point, immediately reporting +`ShuttingDown` to the user would be premature — the status would need to wait +until the shutdown batch is actually sent. See `ON_CHAIN.md` for the protocol +details. + Each `ChannelStatus` notification is emitted when the `PeerHandler` is replaced (handler transition) or when the current handler's snapshot changes (e.g. balance update during `Active`). The frontend uses this single diff --git a/build.rs.disabled b/build.rs.disabled index 544eb3f4..e3cf17fa 100644 --- a/build.rs.disabled +++ b/build.rs.disabled @@ -1,16 +1,41 @@ use std::collections::HashMap; use std::fs; use std::path::Path; +use std::rc::Rc; use clvmr::allocator::Allocator; +use clvmr::serde::node_to_bytes; use toml::{Table, Value}; use chialisp::classic::clvm_tools::clvmc::CompileError; use chialisp::classic::clvm_tools::comp_input::RunAndCompileInputData; +use chialisp::classic::clvm_tools::stages::stage_0::DefaultProgramRunner; use chialisp::classic::platform::argparse::ArgumentValue; -use chialisp::compiler::comptypes::CompileErr; +use chialisp::compiler::clvm::convert_to_clvm_rs; +use chialisp::compiler::compiler::compile_file; +use chialisp::compiler::comptypes::{CompileErr, CompilerOutput}; use chialisp::compiler::srcloc::Srcloc; +fn to_hex(bytes: &[u8]) -> String { + let mut s = String::with_capacity(bytes.len() * 2); + for b in bytes { + s.push_str(&format!("{b:02x}")); + } + s +} + +/// Compile a single chialisp source file and write .hex output. +/// +/// For module compilations (files with `(export ...)`), the compiler writes hex +/// files as a side-effect of `compile_file` via `opts.write_new_file`. Each +/// named export `name` in `source_stem.clsp` produces `source_stem_name.hex`. +/// +/// We also handle the output explicitly as a safety net: if any expected hex +/// file is missing after compilation (e.g. the compiler elided it due to its +/// `.chialisp/` disk cache), we serialize and write it ourselves. +/// +/// For simple program compilations (no `(export ...)`), the compiler does NOT +/// write hex output, so we always write `source_stem.hex` explicitly. fn do_compile(title: &str, filename: &str) -> Result<(), CompileError> { let mut allocator = Allocator::new(); let mut arguments: HashMap = HashMap::new(); @@ -42,7 +67,60 @@ fn do_compile(title: &str, filename: &str) -> Result<(), CompileError> { })?; let mut symbol_table = HashMap::new(); - parsed.compile_modern(&mut allocator, &mut symbol_table)?; + let runner = Rc::new(DefaultProgramRunner::new()); + let mut includes = Vec::new(); + let output = compile_file( + &mut allocator, + runner, + parsed.opts.clone(), + &parsed.program.content, + &mut symbol_table, + &mut includes, + )?; + + match &output { + CompilerOutput::Module(module) => { + for component in &module.components { + if !Path::new(&component.filename).exists() { + let node = convert_to_clvm_rs(&mut allocator, component.content.clone())?; + let bytes = node_to_bytes(&allocator, node).map_err(|e| { + CompileErr( + Srcloc::start(filename), + format!("serialization error for {}: {e:?}", component.filename), + ) + })?; + if let Some(parent) = Path::new(&component.filename).parent() { + fs::create_dir_all(parent).ok(); + } + fs::write(&component.filename, to_hex(&bytes)).map_err(|e| { + CompileErr( + Srcloc::start(filename), + format!("failed to write {}: {e:?}", component.filename), + ) + })?; + } + } + } + CompilerOutput::Program(_, sexp) => { + let output_path = filename.replace(".clsp", ".hex"); + let node = convert_to_clvm_rs(&mut allocator, Rc::new(sexp.clone()))?; + let bytes = node_to_bytes(&allocator, node).map_err(|e| { + CompileErr( + Srcloc::start(filename), + format!("serialization error for {output_path}: {e:?}"), + ) + })?; + if let Some(parent) = Path::new(&output_path).parent() { + fs::create_dir_all(parent).ok(); + } + fs::write(&output_path, to_hex(&bytes)).map_err(|e| { + CompileErr( + Srcloc::start(filename), + format!("failed to write {output_path}: {e:?}"), + ) + })?; + } + } Ok(()) } diff --git a/cb.sh b/cb.sh index 426ea37b..0addb067 100755 --- a/cb.sh +++ b/cb.sh @@ -1,5 +1,6 @@ #!/bin/bash set -e SECONDS=0 +./tools/build-chialisp.sh cargo test --lib --no-run --features sim-server "$@" echo "Build completed in ${SECONDS}s" diff --git a/front-end/src/components/GameSession.tsx b/front-end/src/components/GameSession.tsx index e4c0dc46..13fba909 100644 --- a/front-end/src/components/GameSession.tsx +++ b/front-end/src/components/GameSession.tsx @@ -1,11 +1,11 @@ import { Component, useCallback, useEffect, useRef, useState, type RefObject, type ReactNode, type ErrorInfo } from 'react'; import { Observable } from 'rxjs'; -import { useGameSession, ChannelStatusInfo, GameTerminalAttentionInfo, GameTurnState, GameplayEvent, isWindingDown, QueuedNotification } from '../hooks/useGameSession'; +import { useGameSession, ChannelStatusInfo, GameTerminalAttentionInfo, GameTurnState, GameplayEvent, isWindingDown, deriveSessionPhase, QueuedNotification } from '../hooks/useGameSession'; import { useCalpokerHand } from '../hooks/useCalpokerHand'; import { CalpokerHandState, CalpokerDisplaySnapshot } from '../hooks/save'; import { formatMojos, formatAmount } from '../util'; import { getPlayerId } from '../hooks/save'; -import { CalpokerOutcome, ChannelState } from '../types/ChiaGaming'; +import { CalpokerOutcome, ChannelState, SessionPhase } from '../types/ChiaGaming'; import { WasmBlobWrapper } from '../hooks/WasmBlobWrapper'; import Calpoker from '../features/calPoker'; @@ -226,7 +226,7 @@ function NotificationOverlay({ const { cardRef, x, y, clampToViewport } = useViewportClampedDragWithInsets(boundsRef, { top: 8 }); const dragControls = useDragControls(); const isError = notification.kind === 'infra-error' || notification.kind === 'action-failed'; - const titleColor = isError ? 'text-alert-text' : 'text-canvas-text-contrast'; + const titleColor = 'text-canvas-text-contrast'; return ( void; sessionSave?: import('../hooks/save').SessionState; onGameActivity?: () => void; + onSessionPhaseChange?: (phase: Exclude, hasError: boolean) => void; } const TRACKER_LIVENESS_LABELS: Record = { @@ -369,11 +370,23 @@ const TRACKER_LIVENESS_LABELS: Record = { disconnected: 'Disconnected', }; -const GameSession: React.FC = ({ params, peerConn, trackerLiveness, peerConnected, registerMessageHandler, appendGameLog, sessionSave, onGameActivity }) => { +const GameSession: React.FC = ({ params, peerConn, trackerLiveness, peerConnected, registerMessageHandler, appendGameLog, sessionSave, onGameActivity, onSessionPhaseChange }) => { const uniqueId = getPlayerId(); const session = useGameSession(params, uniqueId, peerConn, registerMessageHandler, appendGameLog, sessionSave); + useEffect(() => { + if (!onSessionPhaseChange) return; + const phase = deriveSessionPhase(session.channelStatus.state, session.goOnChainPressed); + const hasError = + session.channelStatus.state === 'Failed' || + session.channelStatus.state === 'ResolvedStale' || + session.gameTerminal.type === 'opponent-successfully-cheated' || + session.gameTerminal.type === 'game-error' || + (session.gameTerminal.type === 'we-timed-out' && !session.gameTerminal.cleanEnd); + onSessionPhaseChange(phase, hasError); + }, [session.channelStatus.state, session.goOnChainPressed, session.gameTerminal.type, session.gameTerminal.cleanEnd, onSessionPhaseChange]); + useEffect(() => { if (!onGameActivity) return; const sub = session.gameplayEvent$.subscribe((evt) => { @@ -498,7 +511,7 @@ const GameSession: React.FC = ({ params, peerConn, trackerLive variant='solid' onClick={session.goOnChain} size='sm' - disabled={session.goOnChainPressed || isWindingDown(session.channelStatus.state)} + disabled={session.goOnChainPressed || isWindingDown(session.channelStatus.state) || session.channelStatus.state === 'ShuttingDown'} > Go On-Chain @@ -545,7 +558,7 @@ const GameSession: React.FC = ({ params, peerConn, trackerLive {/* Between-hand session controls */} - {session.betweenHands && !isWindingDown(session.channelStatus.state) && ( + {session.betweenHands && !isWindingDown(session.channelStatus.state) && session.channelStatus.state !== 'ShuttingDown' && ( <> {session.betweenHandMode === 'decision' && (
@@ -565,8 +578,9 @@ const GameSession: React.FC = ({ params, peerConn, trackerLive className='absolute right-2' onClick={session.chooseDoNotUseCurrentProposal} leadingIcon={×} - iconOnly - /> + > + Close +
)} diff --git a/front-end/src/components/Shell.tsx b/front-end/src/components/Shell.tsx index 261ec2c4..9febe3dc 100644 --- a/front-end/src/components/Shell.tsx +++ b/front-end/src/components/Shell.tsx @@ -4,7 +4,7 @@ import GameSession from './GameSession'; import { GameSessionErrorBoundary } from './GameSession'; import { SimulatorSetupModal } from './SimulatorSetupModal'; import QRCode from 'qrcode'; -import { GameSessionParams, PeerConnectionResult, ChatMessage, InternalBlockchainInterface, ConnectionSetup, TrackerLiveness } from '../types/ChiaGaming'; +import { GameSessionParams, PeerConnectionResult, ChatMessage, InternalBlockchainInterface, ConnectionSetup, TrackerLiveness, SessionPhase } from '../types/ChiaGaming'; import { TrackerConnection, MatchedParams, ConnectionStatus } from '../services/TrackerConnection'; import { subscribeLog } from '../services/log'; import { @@ -18,6 +18,7 @@ import { clearSession, hardReset, getBuildNonce, + clearWalletConnectStorage, SessionState, getDefaultFee, setDefaultFee as saveDefaultFee, @@ -41,7 +42,7 @@ import { onFenced, offFenced, } from '../hooks/save'; -import { blobSingleton } from '../hooks/blobSingleton'; +import { blobSingleton, destroyBlobSingleton } from '../hooks/blobSingleton'; import { fakeBlockchainInfo } from '../hooks/FakeBlockchainInterface'; import { realBlockchainInfo } from '../hooks/RealBlockchainInterface'; import { activate, deactivate, getActiveBlockchain } from '../hooks/activeBlockchain'; @@ -192,6 +193,7 @@ const Shell = () => { ); clearSession(); clearLease(); + clearWalletConnectStorage(); } } @@ -229,6 +231,9 @@ const Shell = () => { const [walletConnected, setWalletConnected] = useState(false); const [trackerLiveness, setTrackerLiveness] = useState(null); const [peerConnected, setPeerConnected] = useState(null); + const [sessionPhase, setSessionPhase] = useState('none'); + const [sessionError, setSessionError] = useState(false); + const [confirmDialog, setConfirmDialog] = useState<{ title: string; body: string; onConfirm: () => void } | null>(null); const trackerWsUpRef = useRef(false); const lastTrackerActivityRef = useRef(0); const lastPeerActivityRef = useRef(0); @@ -478,7 +483,7 @@ const Shell = () => { const now = Date.now(); const activityFresh = lastTrackerActivityRef.current > 0 && now - lastTrackerActivityRef.current <= 45_000; setTrackerLiveness((prev) => { - if (prev === 'disconnected') return prev; + if (prev === null || prev === 'disconnected') return prev; if (!trackerWsUpRef.current) return 'reconnecting'; return activityFresh ? 'connected' : 'inactive'; }); @@ -602,6 +607,8 @@ const Shell = () => { } }; + setTrackerLiveness('reconnecting'); + let conn: TrackerConnection; try { conn = new TrackerConnection(origin, sessionId, { @@ -779,6 +786,24 @@ const Shell = () => { } }, [uniqueId, sessionId, markPeerActive, markPeerInactive]); + const requestTrackerConnect = useCallback((origin: string) => { + if (peerConnected && sessionPhase === 'off-chain') { + setConfirmDialog({ + title: 'Disconnect from tracker?', + body: 'Disconnecting from this tracker will end your peer connection and force your game on-chain.', + onConfirm: () => { setConfirmDialog(null); connectToTracker(origin); }, + }); + } else if (peerConnected) { + setConfirmDialog({ + title: 'Disconnect from tracker?', + body: 'This will end your peer connection.', + onConfirm: () => { setConfirmDialog(null); connectToTracker(origin); }, + }); + } else { + connectToTracker(origin); + } + }, [peerConnected, sessionPhase, connectToTracker]); + // Auto-connect to saved tracker on reload; otherwise wait for user selection useEffect(() => { if (!userReady) { console.log('[Shell] tracker-reconnect effect: userReady=false, skipping'); return; } @@ -835,7 +860,7 @@ const Shell = () => { setConnecting(true); const setup = await iface.beginConnect(uniqueId); if (wcAbortRef.current) return; - setConnectionSetup(setup); + if (!setup.skipQr) setConnectionSetup(setup); if (setup.fields && !silent) { setShowSimModal(true); setConnecting(false); @@ -910,6 +935,31 @@ const Shell = () => { } }, [deferStateUpdate]); + const handleSessionPhaseChange = useCallback((phase: SessionPhase, hasError?: boolean) => { + setSessionPhase(phase); + setSessionError(!!hasError); + }, []); + + useEffect(() => { + if (sessionPhase !== 'resolved') return; + clearSession(); + sessionSaveRef.current = null; + }, [sessionPhase]); + + const closeResolvedSession = useCallback(() => { + destroyBlobSingleton(); + setGameParams(null); + setPeerConn(null); + activePairingTokenRef.current = null; + setSessionPhase('none'); + setSessionError(false); + trackerConnRef.current?.setAvailable(true); + }, []); + + useEffect(() => { + trackerConnRef.current?.setAvailable(sessionPhase === 'none' || sessionPhase === 'resolved'); + }, [sessionPhase]); + const handleTabChange = useCallback((tabId: TabId) => { setActiveTab(tabId); if (tabId === 'chat') setUnreadChat(false); @@ -1027,7 +1077,7 @@ const Shell = () => { window.location.reload(); }, []); - const handleDisconnectWallet = useCallback(async () => { + const doDisconnectWallet = useCallback(async () => { if (activeBlockchainRef.current) { try { await activeBlockchainRef.current.disconnect(); } catch (_) {} } @@ -1035,10 +1085,85 @@ const Shell = () => { activeBlockchainRef.current = null; setWalletConnected(false); setBlockchainType(undefined); - clearSession(); setBalance(undefined); }, []); + const handleDisconnectWallet = useCallback(() => { + if (sessionPhase !== 'none') { + setConfirmDialog({ + title: 'Disconnect wallet?', + body: 'You are in a session. Blockchain operations will stall until you reconnect a wallet.', + onConfirm: () => { setConfirmDialog(null); doDisconnectWallet(); }, + }); + } else { + doDisconnectWallet(); + } + }, [sessionPhase, doDisconnectWallet]); + + const doAbandonSession = useCallback(() => { + destroyBlobSingleton(); + clearSession(); + setGameParams(null); + setPeerConn(null); + sessionSaveRef.current = null; + activePairingTokenRef.current = null; + setSessionPhase('none'); + setSessionError(false); + trackerConnRef.current?.setAvailable(true); + }, []); + + const handleAbandonSession = useCallback(() => { + setConfirmDialog({ + title: 'Abandon session?', + body: 'You will lose any funds locked in this channel. This cannot be undone. Are you sure you want to abandon this session?', + onConfirm: () => { setConfirmDialog(null); doAbandonSession(); }, + }); + }, [doAbandonSession]); + + const doDisconnectTracker = useCallback(() => { + trackerConnRef.current?.disconnect(); + trackerConnRef.current = null; + saveTrackerUrl(undefined); + setTrackerOrigin(null); + setIframeUrl('about:blank'); + setTrackerLiveness(null); + markPeerInactive(); + }, [markPeerInactive]); + + const handleDisconnectTracker = useCallback(() => { + if (peerConnected && sessionPhase === 'off-chain') { + setConfirmDialog({ + title: 'Disconnect from tracker?', + body: 'Disconnecting from this tracker will end your peer connection and force your game on-chain.', + onConfirm: () => { setConfirmDialog(null); doDisconnectTracker(); }, + }); + } else if (peerConnected) { + setConfirmDialog({ + title: 'Disconnect from tracker?', + body: 'This will end your peer connection.', + onConfirm: () => { setConfirmDialog(null); doDisconnectTracker(); }, + }); + } else { + doDisconnectTracker(); + } + }, [peerConnected, sessionPhase, doDisconnectTracker]); + + const doEndPeerConnection = useCallback(() => { + trackerConnRef.current?.close(); + }, []); + + const handleEndPeerConnection = useCallback(() => { + if (sessionPhase === 'off-chain') { + setConfirmDialog({ + title: 'End peer connection?', + body: 'This will force your game on-chain.', + onConfirm: () => { setConfirmDialog(null); doEndPeerConnection(); }, + }); + } else { + doEndPeerConnection(); + } + }, [sessionPhase, doEndPeerConnection]); + const handleReconnect = useCallback(() => { if (!blockchainType) return; handleConnect(blockchainType); @@ -1186,11 +1311,48 @@ const Shell = () => { (tab.id === 'wallet' && walletAlert) || (tab.id === 'tracker' && trackerAlert) ); + + let dotColor: string | null = null; + switch (tab.id) { + case 'wallet': + dotColor = walletConnected ? 'var(--color-success-solid)' : 'var(--color-alert-solid)'; + break; + case 'tracker': + if (trackerLiveness === 'connected') { + dotColor = 'var(--color-success-solid)'; + } else if (trackerLiveness === 'reconnecting') { + dotColor = 'var(--color-warning-solid)'; + } else if (trackerLiveness === 'inactive') { + dotColor = 'var(--color-alert-solid)'; + } else { + dotColor = 'var(--color-canvas-text-subtle)'; + } + break; + case 'game': + if (sessionPhase === 'none') { + dotColor = 'var(--color-canvas-text-subtle)'; + } else if (sessionError) { + dotColor = 'var(--color-alert-solid)'; + } else if (sessionPhase === 'on-chain') { + dotColor = 'var(--color-warning-solid)'; + } else if (sessionPhase === 'off-chain' && !peerConnected) { + dotColor = 'var(--color-alert-solid)'; + } else if (sessionPhase === 'off-chain' && peerConnected) { + dotColor = 'var(--color-success-solid)'; + } else { + dotColor = 'var(--color-canvas-text-subtle)'; + } + break; + case 'chat': + dotColor = peerConnected ? 'var(--color-success-solid)' : 'var(--color-canvas-text-subtle)'; + break; + } + return (