From 9ee8e8862a9bed2ae01a402b11d6ee7dfbd16ac9 Mon Sep 17 00:00:00 2001 From: Bram Cohen Date: Thu, 13 Aug 2026 13:56:27 +0200 Subject: [PATCH 1/9] Classify unroll banner situations in WASM and show the actual unroll target. The UI was inferring unroll type from initiator and leftover numbers, and it showed the current potato state even when the spendable unroll was one behind. --- UX_NOTIFICATIONS.md | 18 +- front-end/src/hooks/SessionController.ts | 3 +- front-end/src/hooks/WalletConnectRpc.ts | 17 +- front-end/src/lib/session/normalization.ts | 9 + front-end/src/lib/session/persistence.ts | 17 +- .../src/lib/session/persistencePayloads.ts | 27 +- .../src/lib/session/persistencePrimitives.ts | 19 ++ front-end/src/lib/session/presentation.ts | 58 +++- front-end/src/lib/session/selectors.ts | 39 +-- front-end/src/lib/session/types.ts | 3 + front-end/src/lib/tests/jsonSafe.test.ts | 17 ++ .../tests/message_protocol.terminal.test.ts | 4 +- .../tests/session_model.presentation.test.ts | 225 +++++++++++---- .../lib/tests/terminal_finalization.test.ts | 3 + front-end/src/types/ChiaGaming.ts | 21 +- front-end/src/util/jsonSafe.ts | 20 ++ src/channel_state/mod.rs | 35 ++- src/game_session.rs | 59 +--- src/session_phases/effects.rs | 62 ++-- src/session_phases/handshake_initiator.rs | 23 +- src/session_phases/handshake_receiver.rs | 23 +- src/session_phases/mod.rs | 15 +- src/session_phases/on_chain.rs | 8 +- .../spend_channel_coin_phase.rs | 270 ++++++++++++++---- src/simulator/tests/session_phases_sim.rs | 124 ++++---- .../tests/session_phases_sim/harness.rs | 19 +- src/test_support/krunk_sim.rs | 20 +- src/test_support/peer/peer_harness.rs | 8 +- src/tests/channel_state.rs | 92 ++++++ 29 files changed, 851 insertions(+), 407 deletions(-) diff --git a/UX_NOTIFICATIONS.md b/UX_NOTIFICATIONS.md index 803ee2a3e..7d99749c2 100644 --- a/UX_NOTIFICATIONS.md +++ b/UX_NOTIFICATIONS.md @@ -201,10 +201,11 @@ advisory, coin identity and amount, both balances, game allocation, `havePotato`, `zeroPayout`, and optional on-chain progress context. During an unroll, `unrollInitiator` identifies whether we or the opponent caused the observed channel spend when that attribution is definitive; a locally queued -spend or cooperative-close setup alone leaves it unknown. `semanticPhase` refines the existing `GoingOnChain` / -`Unrolling` state as submitting or resolving the channel spend, preempting, -waiting for the relative timeout, submitting the timeout finish, or resolving -the unroll spend. These are display facts, not new lifecycle states. Banner text, the potato indicator, dashboard +spend or cooperative-close setup alone leaves it unknown. `semanticPhase` is +the situation within `GoingOnChain` / `Unrolling`: submitting or resolving a +channel spend, finding the landed unroll state, preempting, waiting for the +relative timeout, or spending the timeout finish. Actor is not encoded in the +phase. These are display facts, not new lifecycle states. Banner text, the potato indicator, dashboard actions, phase selection, persistence, and restore all project from that one snapshot instead of maintaining parallel channel-status shapes. During a cooperative terminal handoff, Rust sets @@ -226,12 +227,13 @@ Monotonicity applies across all three lenses: When a watched timeout spend becomes mature, `TransactionManager` is the sole component that queues its submission. Before the host drains the submission buffer, it updates the session's canonical status snapshot to -`submitting_timeout_finish`; the normal `ChannelStatus` notification then -persists and restores that fact. The UI never infers timeout maturity, submits +`finishing_spending` (with `unrollInitiator` naming who started the unroll); +the normal `ChannelStatus` notification then persists and restores that fact. The UI never infers timeout maturity, submits the transaction, or mutates a durable channel snapshot from a transient event. If a reorg changes or clears the watched coin's birthday, the manager re-arms -the relative timeout and restores the canonical phase to `waiting_timeout` -until the claim becomes mature again. +the relative timeout and restores the canonical waiting phase +(`finishing_waiting_timeout`) until the claim becomes mature +again. --- diff --git a/front-end/src/hooks/SessionController.ts b/front-end/src/hooks/SessionController.ts index 04685e40b..cb257cee9 100644 --- a/front-end/src/hooks/SessionController.ts +++ b/front-end/src/hooks/SessionController.ts @@ -17,7 +17,7 @@ import { import { BlockchainPoller, PollingGameSession } from './BlockchainPoller'; import { spend_bundle_to_clvm, coerceToBytes } from '../util'; import { log, diagStack } from '../services/log'; -import { jsonStringify } from '../util/jsonSafe'; +import { integersToBigInt, jsonStringify } from '../util/jsonSafe'; import { flushSessionSave } from './save'; import type { PersistedGameState } from './save'; import type { RegisteredGameType } from '../lib/session/types'; @@ -733,6 +733,7 @@ export class SessionController implements PollingGameSession { if (this.protocolStopped) { return; } + result = integersToBigInt(result); const disposition = result.disposition ?? { kind: 'active' as const }; const terminal = disposition.kind === 'terminal'; diff --git a/front-end/src/hooks/WalletConnectRpc.ts b/front-end/src/hooks/WalletConnectRpc.ts index 18945e242..eab477cea 100644 --- a/front-end/src/hooks/WalletConnectRpc.ts +++ b/front-end/src/hooks/WalletConnectRpc.ts @@ -25,7 +25,7 @@ import { import { PushTransactionsRequest, PushTransactionsResponse } from '../types/rpc/PushTransactions'; import { SelectCoinsRequest, SelectCoinsResponse } from '../types/rpc/SelectCoins'; import { log } from '../services/log'; -import { jsonStringify } from '../util/jsonSafe'; +import { integersToBigInt, jsonStringify } from '../util/jsonSafe'; import { walletConnectState } from './useWalletConnect'; @@ -92,19 +92,6 @@ function summarizeRpcValue(value: unknown, maxLen = 400): string { return text.length > maxLen ? `${text.slice(0, maxLen)}…` : text; } -function deepNumbersToBigInt(value: unknown): unknown { - if (typeof value === 'number' && Number.isInteger(value)) return BigInt(value); - if (Array.isArray(value)) return value.map(deepNumbersToBigInt); - if (value !== null && typeof value === 'object') { - const out: Record = {}; - for (const [k, v] of Object.entries(value)) { - out[k] = deepNumbersToBigInt(v); - } - return out; - } - return value; -} - /** WC wire hack: negative BigInt → decimal string (avoids WC "-100n" leftover). Positives stay bigint. */ function negativeBigintsToDecimalStrings(value: unknown): unknown { if (typeof value === 'bigint') return value < 0n ? value.toString() : value; @@ -235,7 +222,7 @@ class WalletConnectRpcClient { } private normalizeResult(prepared: PreparedRpc, raw: unknown): T { - const result = deepNumbersToBigInt(raw) as Record | undefined; + const result = integersToBigInt(raw) as Record | undefined; if (result?.error) { const errorText = toDebugJson(result.error); const trace = new Error().stack?.split('\n').slice(1, 6).join('\n') ?? ''; diff --git a/front-end/src/lib/session/normalization.ts b/front-end/src/lib/session/normalization.ts index 26b8183e3..8e9bf3cb7 100644 --- a/front-end/src/lib/session/normalization.ts +++ b/front-end/src/lib/session/normalization.ts @@ -25,6 +25,9 @@ export const INITIAL_CHANNEL_STATUS_MODEL: ChannelStatusModel = { zeroPayout: null, unrollInitiator: null, semanticPhase: null, + stateNumber: null, + unrollingStateNumber: null, + preemptingStateNumber: null, }; export const DEFAULT_GAME_TIMEOUT_BLOCKS = 15n; export const DEFAULT_CHANNEL_TIMEOUT_BLOCKS = 15n; @@ -66,6 +69,9 @@ export function channelStatusModelFromPayload(status: ChannelStatusPayload): Cha zeroPayout: status.zero_payout ?? null, unrollInitiator: status.unroll_initiator ?? null, semanticPhase: status.semantic_phase ?? null, + stateNumber: status.state_number ?? null, + unrollingStateNumber: status.unrolling_state_number ?? null, + preemptingStateNumber: status.preempting_state_number ?? null, }; } export function channelStatusPayloadFromModel(status: ChannelStatusModel): ChannelStatusPayload { @@ -81,6 +87,9 @@ export function channelStatusPayloadFromModel(status: ChannelStatusModel): Chann zero_payout: status.zeroPayout, unroll_initiator: status.unrollInitiator, semantic_phase: status.semanticPhase, + state_number: status.stateNumber, + unrolling_state_number: status.unrollingStateNumber, + preempting_state_number: status.preemptingStateNumber, }; } diff --git a/front-end/src/lib/session/persistence.ts b/front-end/src/lib/session/persistence.ts index b31fe06a5..03891859b 100644 --- a/front-end/src/lib/session/persistence.ts +++ b/front-end/src/lib/session/persistence.ts @@ -1,4 +1,5 @@ import type { ChannelStatus, ChannelStatusPayload } from '../../types/ChiaGaming'; +import { CHANNEL_SEMANTIC_PHASES } from '../../types/ChiaGaming'; import type { LiveSessionSave, PreHandshakeSessionSave, @@ -56,6 +57,7 @@ import { parseDiscriminant, requireBigintString, requireBigint, + requireOptionalBigint, requireBoolean, requireNullableString, requireRecord, @@ -243,16 +245,12 @@ export function decodeChannelStatusPayload(value: unknown): ChannelStatusPayload ? fields.semantic_phase : parseDiscriminant>( fields.semantic_phase, - new Set([ - 'submitting_channel_spend', - 'resolving_opponent_channel_spend', - 'preempting', - 'waiting_timeout', - 'submitting_timeout_finish', - 'resolving', - ]), + new Set(CHANNEL_SEMANTIC_PHASES), 'channelStatus.semantic_phase', ); + const optionalStateNumber = ( + field: 'state_number' | 'unrolling_state_number' | 'preempting_state_number', + ) => requireOptionalBigint(fields[field], `channelStatus.${field}`); return { state: parseDiscriminant(fields.state, CHANNEL_STATUSES, 'channelStatus.state'), session_disposition: sessionDisposition, @@ -265,6 +263,9 @@ export function decodeChannelStatusPayload(value: unknown): ChannelStatusPayload zero_payout: zeroPayout, unroll_initiator: unrollInitiator, semantic_phase: semanticPhase, + state_number: optionalStateNumber('state_number'), + unrolling_state_number: optionalStateNumber('unrolling_state_number'), + preempting_state_number: optionalStateNumber('preempting_state_number'), }; } diff --git a/front-end/src/lib/session/persistencePayloads.ts b/front-end/src/lib/session/persistencePayloads.ts index 787b15180..5c5812e8d 100644 --- a/front-end/src/lib/session/persistencePayloads.ts +++ b/front-end/src/lib/session/persistencePayloads.ts @@ -1,4 +1,5 @@ import type { ChannelStatus } from '../../types/ChiaGaming'; +import { CHANNEL_SEMANTIC_PHASES } from '../../types/ChiaGaming'; import { isSettlementOutcome, type SettlementOutcome } from '../settlement'; import type { LiveSessionSave, @@ -62,14 +63,7 @@ const NOTIFICATION_KINDS = new Set([ 'insufficient-bal', ]); const SESSION_DISPOSITIONS = new Set(['AwaitOutboundTerminal', 'Abandoned']); -const CHANNEL_SEMANTIC_PHASES = new Set([ - 'submitting_channel_spend', - 'resolving_opponent_channel_spend', - 'preempting', - 'waiting_timeout', - 'submitting_timeout_finish', - 'resolving', -]); +const CHANNEL_SEMANTIC_PHASE_SET = new Set(CHANNEL_SEMANTIC_PHASES); const OUTCOME_FLAGS = new Set(['win', 'lose', 'tie']); const GAME_TERMINAL_TYPES: ReadonlySet = new Set([ 'none', @@ -377,10 +371,25 @@ export function validateChannelStatus(value: unknown): void { status.semantic_phase !== undefined && status.semantic_phase !== null && (typeof status.semantic_phase !== 'string' || - !CHANNEL_SEMANTIC_PHASES.has(status.semantic_phase)) + !CHANNEL_SEMANTIC_PHASE_SET.has(status.semantic_phase)) ) { throw new Error('Garbled save: invalid channelStatus.semantic_phase'); } + for (const field of [ + 'state_number', + 'unrolling_state_number', + 'preempting_state_number', + ] as const) { + const value = status[field]; + if (value === undefined || value === null) continue; + if (typeof value === 'bigint') { + if (value < 0n) throw new Error(`Garbled save: invalid channelStatus.${field}`); + continue; + } + if (typeof value !== 'number' || !Number.isInteger(value) || value < 0) { + throw new Error(`Garbled save: invalid channelStatus.${field}`); + } + } } export function validateTerminalCoins(value: unknown): void { diff --git a/front-end/src/lib/session/persistencePrimitives.ts b/front-end/src/lib/session/persistencePrimitives.ts index dc92e56f1..31a8686c2 100644 --- a/front-end/src/lib/session/persistencePrimitives.ts +++ b/front-end/src/lib/session/persistencePrimitives.ts @@ -51,6 +51,25 @@ export function requireBigint(value: unknown, label: string, minimum = 0n): bigi return value; } +/** Inbound integer (`bigint` or JS `number`) → `bigint`. */ +export function requireOptionalBigint( + value: unknown, + label: string, + minimum = 0n, +): bigint | null | undefined { + if (value === undefined || value === null) return value; + if (typeof value === 'bigint') { + if (value < minimum) throw new Error(`Garbled save: invalid ${label}`); + return value; + } + if (typeof value === 'number' && Number.isInteger(value)) { + const parsed = BigInt(value); + if (parsed < minimum) throw new Error(`Garbled save: invalid ${label}`); + return parsed; + } + throw new Error(`Garbled save: invalid ${label}`); +} + export function parseDecimalString(value: unknown, label: string, minimum?: bigint): bigint { if (typeof value !== 'string' || !/^-?\d+$/.test(value)) { throw new Error(`Garbled save: invalid ${label}: ${String(value)}`); diff --git a/front-end/src/lib/session/presentation.ts b/front-end/src/lib/session/presentation.ts index a2ac44f84..04ef11aea 100644 --- a/front-end/src/lib/session/presentation.ts +++ b/front-end/src/lib/session/presentation.ts @@ -1,5 +1,11 @@ -import type { ChannelStatus, GameStatusPayload, GameStatusState } from '../../types/ChiaGaming'; import type { + ChannelSemanticPhase, + ChannelStatus, + GameStatusPayload, + GameStatusState, +} from '../../types/ChiaGaming'; +import type { + ChannelStatusModel, GameCoinModel, GameInstanceModel, GameInstanceViewModel, @@ -249,3 +255,53 @@ export function nextGameInstanceAfterLocalTurn( const next = nextGamePresentationAfterLocalTurn(instance, isMyTurn, channelState); return next === instance ? instance : { ...instance, ...next }; } + +type UnrollCopyChannel = Pick< + ChannelStatusModel, + 'semanticPhase' | 'unrollInitiator' | 'unrollingStateNumber' | 'preemptingStateNumber' +>; + +const UNROLL_PHASE_LABEL: Record string | null> = { + submitting_channel_spend: () => null, + unrolling: () => 'Unrolling', + finding_state: (opponent) => (opponent ? 'Opponent unrolled' : 'Unrolled'), + preempting: () => 'Preempting', + finishing_waiting_timeout: (opponent) => + opponent ? 'Finishing opponent unroll' : 'Finishing unroll', + finishing_spending: (opponent) => (opponent ? 'Finishing opponent unroll' : 'Finishing unroll'), + resolving: () => null, +}; + +const UNROLL_PHASE_DETAIL: Record< + ChannelSemanticPhase, + (channel: UnrollCopyChannel) => string | null +> = { + submitting_channel_spend: () => 'Submitting channel spend', + unrolling: (channel) => + channel.unrollingStateNumber != null ? `to state ${channel.unrollingStateNumber}` : null, + finding_state: () => 'finding state', + preempting: (channel) => { + const landed = channel.unrollingStateNumber; + const preempting = channel.preemptingStateNumber; + if (landed != null && preempting != null) return `from ${landed} to ${preempting}`; + if (landed != null) return `from ${landed}`; + return null; + }, + finishing_waiting_timeout: (channel) => + channel.unrollingStateNumber != null + ? `waiting for timeout state ${channel.unrollingStateNumber}` + : 'waiting for timeout', + finishing_spending: (channel) => + channel.unrollingStateNumber != null ? `spending state ${channel.unrollingStateNumber}` : null, + resolving: () => 'Resolving', +}; + +export function unrollActionLabel(channel: UnrollCopyChannel): string | null { + return channel.semanticPhase + ? UNROLL_PHASE_LABEL[channel.semanticPhase](channel.unrollInitiator === 'opponent') + : null; +} + +export function unrollActionDetail(channel: UnrollCopyChannel): string | null { + return channel.semanticPhase ? UNROLL_PHASE_DETAIL[channel.semanticPhase](channel) : null; +} diff --git a/front-end/src/lib/session/selectors.ts b/front-end/src/lib/session/selectors.ts index 62e0b6bdb..610567328 100644 --- a/front-end/src/lib/session/selectors.ts +++ b/front-end/src/lib/session/selectors.ts @@ -10,6 +10,8 @@ import { INITIAL_GAME_TERMINAL_MODEL, ON_CHAIN_CHANNEL_STATES, gameInstanceView, + unrollActionDetail, + unrollActionLabel, } from './presentation'; import { RESOLVED_CHANNEL_STATES, WINDING_DOWN_CHANNEL_STATES } from './normalization'; import type { @@ -298,26 +300,27 @@ function channelStatusDetail(model: SessionModel): string | null { if (channel.sessionDisposition === 'AwaitOutboundTerminal') { return channel.advisory ?? 'Waiting for peer to acknowledge close'; } - const phaseLabels: Record, string> = { - submitting_channel_spend: 'Submitting channel spend', - resolving_opponent_channel_spend: 'Resolving opponent channel spend', - preempting: 'Preempting unroll', - waiting_timeout: 'Waiting for timeout', - submitting_timeout_finish: 'Submitting timeout finish', - resolving: 'Resolving', - }; + const unrollLabel = unrollActionLabel(channel); + if (unrollLabel) { + const unrollDetail = unrollActionDetail(channel); + if (unrollDetail) { + return channel.advisory ? `${unrollDetail}: ${channel.advisory}` : unrollDetail; + } + return channel.advisory; + } if (channel.semanticPhase) { - const phase = phaseLabels[channel.semanticPhase]; - const initiator = - channel.unrollInitiator === 'us' - ? ' (initiated by you)' - : channel.unrollInitiator === 'opponent' - ? ' (initiated by opponent)' - : ''; - const detail = `${phase}${initiator}`; - return channel.advisory ? `${detail}: ${channel.advisory}` : detail; + const detail = unrollActionDetail(channel); + if (detail) { + return channel.advisory ? `${detail}: ${channel.advisory}` : detail; + } } switch (channel.state) { + case 'Active': + if (channel.stateNumber != null) { + const stateDetail = `state ${channel.stateNumber}`; + return channel.advisory ? `${stateDetail}: ${channel.advisory}` : stateDetail; + } + return channel.advisory; case 'Failed': return channel.advisory ?? model.restore.error ?? 'Channel failed'; default: @@ -535,7 +538,7 @@ export function selectGameDashboardView( ? 'Abandoned' : channel.sessionDisposition === 'AwaitOutboundTerminal' ? 'Waiting for Peer' - : CHANNEL_STATUS_LABELS[channel.state], + : (unrollActionLabel(channel) ?? CHANNEL_STATUS_LABELS[channel.state]), channelDetail: channelStatusDetail(model), havePotato: channel.havePotato === true, handStatusLabel: collapsedHandStatusLabel(model), diff --git a/front-end/src/lib/session/types.ts b/front-end/src/lib/session/types.ts index e82cc4311..fe8cab700 100644 --- a/front-end/src/lib/session/types.ts +++ b/front-end/src/lib/session/types.ts @@ -107,6 +107,9 @@ export interface ChannelStatusModel { zeroPayout: boolean | null; unrollInitiator: 'us' | 'opponent' | null; semanticPhase: ChannelStatusPayload['semantic_phase'] | null; + stateNumber: bigint | null; + unrollingStateNumber: bigint | null; + preemptingStateNumber: bigint | null; } export interface QueuedNotificationModel { diff --git a/front-end/src/lib/tests/jsonSafe.test.ts b/front-end/src/lib/tests/jsonSafe.test.ts index e9c5d642f..70996fcdf 100644 --- a/front-end/src/lib/tests/jsonSafe.test.ts +++ b/front-end/src/lib/tests/jsonSafe.test.ts @@ -1,4 +1,5 @@ import { + integersToBigInt, jsonParse, jsonParseLossless, jsonStringify, @@ -42,4 +43,20 @@ describe('jsonSafe codecs', () => { expect(jsonStringify({ value: 42n })).toBe('{"value":42}'); expect(jsonParse('{"value":42}')).toEqual({ value: 42n }); }); + + it('promotes integer numbers to bigint and leaves typed arrays alone', () => { + const coin = new Uint8Array([1, 2, 3]); + expect( + integersToBigInt({ + state_number: 0, + nested: { count: 7 }, + coin, + }), + ).toEqual({ + state_number: 0n, + nested: { count: 7n }, + coin, + }); + expect(integersToBigInt({ coin }).coin).toBe(coin); + }); }); diff --git a/front-end/src/lib/tests/message_protocol.terminal.test.ts b/front-end/src/lib/tests/message_protocol.terminal.test.ts index 29404e98b..bbad83759 100644 --- a/front-end/src/lib/tests/message_protocol.terminal.test.ts +++ b/front-end/src/lib/tests/message_protocol.terminal.test.ts @@ -315,7 +315,7 @@ describe('terminal protocol cleanup', () => { ChannelStatus: channelStatus({ state: 'Unrolling', unroll_initiator: 'opponent', - semantic_phase: 'submitting_timeout_finish', + semantic_phase: 'finishing_spending', }), }, }, @@ -326,7 +326,7 @@ describe('terminal protocol cleanup', () => { expect((blob as any).lastChannelStatus).toMatchObject({ state: 'Unrolling', unroll_initiator: 'opponent', - semantic_phase: 'submitting_timeout_finish', + semantic_phase: 'finishing_spending', }); }); }); diff --git a/front-end/src/lib/tests/session_model.presentation.test.ts b/front-end/src/lib/tests/session_model.presentation.test.ts index f7bbc7626..93d50cf8a 100644 --- a/front-end/src/lib/tests/session_model.presentation.test.ts +++ b/front-end/src/lib/tests/session_model.presentation.test.ts @@ -12,6 +12,7 @@ import { sessionModelFromSave, nextGameInstanceAfterLocalTurn, } from '../session/model'; +import { decodeChannelStatusPayload } from '../session/persistence'; import type { SessionSave } from '../../hooks/save'; import { baseSave, liveSave } from './session_save_envelope.fixtures'; @@ -161,6 +162,25 @@ describe('session model dashboard and on-chain presentation contracts', () => { actionEnabled: true, actionKind: 'clean-shutdown', }); + expect( + selectGameDashboardView( + createSessionModel({ + channel: { + status: { + ...INITIAL_CHANNEL_STATUS_MODEL, + state: 'Active', + stateNumber: 7n, + havePotato: true, + }, + }, + game: { activeIds: [] }, + }), + ), + ).toMatchObject({ + channelStatusLabel: 'Active', + channelDetail: 'state 7', + havePotato: true, + }); expect( selectGameDashboardView( createSessionModel({ @@ -251,7 +271,10 @@ describe('session model dashboard and on-chain presentation contracts', () => { have_potato: true, zero_payout: true, unroll_initiator: 'opponent', - semantic_phase: 'waiting_timeout', + semantic_phase: 'finishing_waiting_timeout', + state_number: 4n, + unrolling_state_number: 3n, + preempting_state_number: 5n, }), ).toMatchObject({ state: 'ShuttingDown', @@ -262,68 +285,155 @@ describe('session model dashboard and on-chain presentation contracts', () => { havePotato: true, zeroPayout: true, unrollInitiator: 'opponent', - semanticPhase: 'waiting_timeout', + semanticPhase: 'finishing_waiting_timeout', + stateNumber: 4n, + unrollingStateNumber: 3n, + preemptingStateNumber: 5n, }); }); - it('shows semantic progress alongside the authoritative channel advisory', () => { - const view = selectGameDashboardView( - createSessionModel({ - channel: { - status: { - ...INITIAL_CHANNEL_STATUS_MODEL, - state: 'Unrolling', - semanticPhase: 'waiting_timeout', - advisory: 'The observed spend needs manual review', - }, - }, + it('promotes inbound integer state numbers to bigint before persistence', () => { + expect( + decodeChannelStatusPayload({ + state: 'Active', + advisory: null, + coin: null, + our_balance: null, + their_balance: null, + game_allocated: null, + state_number: 0, + unrolling_state_number: 2, + preempting_state_number: 3, }), - ); - - expect(view.channelDetail).toBe('Waiting for timeout: The observed spend needs manual review'); + ).toMatchObject({ + state_number: 0n, + unrolling_state_number: 2n, + preempting_state_number: 3n, + }); }); - it('renders known unroll initiators without inventing an unknown label', () => { - const opponent = selectGameDashboardView( - createSessionModel({ - channel: { - status: { - ...INITIAL_CHANNEL_STATUS_MODEL, - state: 'Unrolling', - semanticPhase: 'waiting_timeout', - unrollInitiator: 'opponent', - }, - }, + it('names unroll, preempt, and finish-unroll actions with the relevant state numbers', () => { + const view = (status: Partial) => + selectGameDashboardView( + createSessionModel({ + channel: { status: { ...INITIAL_CHANNEL_STATUS_MODEL, ...status } }, + }), + ); + + expect( + view({ + state: 'GoingOnChain', + semanticPhase: 'unrolling', + stateNumber: 7n, + unrollingStateNumber: 7n, }), - ); - expect(opponent.channelDetail).toBe('Waiting for timeout (initiated by opponent)'); + ).toMatchObject({ + channelStatusLabel: 'Unrolling', + channelDetail: 'to state 7', + }); - const us = selectGameDashboardView( - createSessionModel({ - channel: { - status: { - ...INITIAL_CHANNEL_STATUS_MODEL, - state: 'Unrolling', - semanticPhase: 'preempting', - unrollInitiator: 'us', - }, - }, + expect( + view({ + state: 'GoingOnChain', + semanticPhase: 'unrolling', + stateNumber: 7n, + unrollingStateNumber: 6n, }), - ); - expect(us.channelDetail).toBe('Preempting unroll (initiated by you)'); + ).toMatchObject({ + channelStatusLabel: 'Unrolling', + channelDetail: 'to state 6', + }); - const unknown = selectGameDashboardView( - createSessionModel({ - channel: { - status: { - ...INITIAL_CHANNEL_STATUS_MODEL, - state: 'Unrolling', - semanticPhase: 'resolving', - }, - }, + expect( + view({ + state: 'GoingOnChain', + semanticPhase: 'finding_state', + unrollInitiator: 'opponent', }), - ); - expect(unknown.channelDetail).toBe('Resolving'); + ).toMatchObject({ + channelStatusLabel: 'Opponent unrolled', + channelDetail: 'finding state', + }); + + expect( + view({ + state: 'GoingOnChain', + semanticPhase: 'finding_state', + stateNumber: 7n, + }), + ).toMatchObject({ + channelStatusLabel: 'Unrolled', + channelDetail: 'finding state', + }); + + expect( + view({ + state: 'Unrolling', + semanticPhase: 'finishing_waiting_timeout', + unrollingStateNumber: 7n, + }), + ).toMatchObject({ + channelStatusLabel: 'Finishing unroll', + channelDetail: 'waiting for timeout state 7', + }); + + expect( + view({ + state: 'Unrolling', + semanticPhase: 'finishing_spending', + unrollingStateNumber: 7n, + }), + ).toMatchObject({ + channelStatusLabel: 'Finishing unroll', + channelDetail: 'spending state 7', + }); + + expect( + view({ + state: 'Unrolling', + semanticPhase: 'preempting', + unrollingStateNumber: 2n, + preemptingStateNumber: 5n, + }), + ).toMatchObject({ + channelStatusLabel: 'Preempting', + channelDetail: 'from 2 to 5', + }); + + expect( + view({ + state: 'Unrolling', + semanticPhase: 'finishing_waiting_timeout', + unrollInitiator: 'opponent', + unrollingStateNumber: 2n, + advisory: 'The observed spend needs manual review', + }), + ).toMatchObject({ + channelStatusLabel: 'Finishing opponent unroll', + channelDetail: 'waiting for timeout state 2: The observed spend needs manual review', + }); + + expect( + view({ + state: 'Unrolling', + semanticPhase: 'finishing_spending', + unrollInitiator: 'opponent', + unrollingStateNumber: 2n, + }), + ).toMatchObject({ + channelStatusLabel: 'Finishing opponent unroll', + channelDetail: 'spending state 2', + }); + + expect( + view({ + state: 'ShutdownTransactionPending', + semanticPhase: 'submitting_channel_spend', + }), + ).toMatchObject({ + channelStatusLabel: 'Shutting Down', + channelDetail: 'Submitting channel spend', + }); }); it('prioritizes terminal disposition details over stale semantic progress', () => { @@ -334,7 +444,7 @@ describe('session model dashboard and on-chain presentation contracts', () => { ...INITIAL_CHANNEL_STATUS_MODEL, state: 'Unrolling', sessionDisposition: 'Abandoned', - semanticPhase: 'waiting_timeout', + semanticPhase: 'finishing_waiting_timeout', advisory: 'Local session was abandoned', }, }, @@ -352,7 +462,7 @@ describe('session model dashboard and on-chain presentation contracts', () => { ...INITIAL_CHANNEL_STATUS_MODEL, state: 'Unrolling', sessionDisposition: 'AwaitOutboundTerminal', - semanticPhase: 'submitting_timeout_finish', + semanticPhase: 'finishing_spending', advisory: null, }, }, @@ -377,7 +487,7 @@ describe('session model dashboard and on-chain presentation contracts', () => { have_potato: false, zero_payout: false, unroll_initiator: 'us' as const, - semantic_phase: 'submitting_timeout_finish' as const, + semantic_phase: 'finishing_spending' as const, }; const restored = sessionModelFromSave( baseSave({ @@ -393,7 +503,7 @@ describe('session model dashboard and on-chain presentation contracts', () => { expect(restored.channel.status.ourBalance).toBe('42'); expect(restored.channel.status).toMatchObject({ unrollInitiator: 'us', - semanticPhase: 'submitting_timeout_finish', + semanticPhase: 'finishing_spending', }); }); @@ -415,6 +525,9 @@ describe('session model dashboard and on-chain presentation contracts', () => { expect(restored.channel.status).toMatchObject({ unrollInitiator: null, semanticPhase: null, + stateNumber: null, + unrollingStateNumber: null, + preemptingStateNumber: null, }); }); diff --git a/front-end/src/lib/tests/terminal_finalization.test.ts b/front-end/src/lib/tests/terminal_finalization.test.ts index a0fb99806..82d0b5957 100644 --- a/front-end/src/lib/tests/terminal_finalization.test.ts +++ b/front-end/src/lib/tests/terminal_finalization.test.ts @@ -97,6 +97,9 @@ const model = createSessionModel({ zeroPayout: null, unrollInitiator: null, semanticPhase: null, + stateNumber: null, + unrollingStateNumber: null, + preemptingStateNumber: null, }, }, game: { diff --git a/front-end/src/types/ChiaGaming.ts b/front-end/src/types/ChiaGaming.ts index 9fdb41cce..36e517aeb 100644 --- a/front-end/src/types/ChiaGaming.ts +++ b/front-end/src/types/ChiaGaming.ts @@ -193,15 +193,22 @@ export interface ChannelStatusPayload { zero_payout?: boolean | null; unroll_initiator?: 'us' | 'opponent' | null; semantic_phase?: ChannelSemanticPhase | null; + state_number?: bigint | null; + unrolling_state_number?: bigint | null; + preempting_state_number?: bigint | null; } -export type ChannelSemanticPhase = - | 'submitting_channel_spend' - | 'resolving_opponent_channel_spend' - | 'preempting' - | 'waiting_timeout' - | 'submitting_timeout_finish' - | 'resolving'; +export const CHANNEL_SEMANTIC_PHASES = [ + 'submitting_channel_spend', + 'unrolling', + 'finding_state', + 'preempting', + 'finishing_waiting_timeout', + 'finishing_spending', + 'resolving', +] as const; + +export type ChannelSemanticPhase = (typeof CHANNEL_SEMANTIC_PHASES)[number]; export interface ProposalAcceptedPayload { id: bigint | number | string; diff --git a/front-end/src/util/jsonSafe.ts b/front-end/src/util/jsonSafe.ts index 19e9219ba..d1cc90f79 100644 --- a/front-end/src/util/jsonSafe.ts +++ b/front-end/src/util/jsonSafe.ts @@ -7,6 +7,26 @@ export function jsonParse(text: string): any { }); } +/** + * Promote integer `number`s to `bigint` at an inbound JS boundary. + * Typed arrays stay bytes — they are the documented exception to the bigint rule. + */ +export function integersToBigInt(value: T): T { + return convertIntegersToBigInt(value) as T; +} + +function convertIntegersToBigInt(value: unknown): unknown { + if (typeof value === 'number' && Number.isInteger(value)) return BigInt(value); + if (value === null || typeof value !== 'object') return value; + if (ArrayBuffer.isView(value) || value instanceof ArrayBuffer) return value; + if (Array.isArray(value)) return value.map(convertIntegersToBigInt); + const out: Record = {}; + for (const [key, entry] of Object.entries(value as Record)) { + out[key] = convertIntegersToBigInt(entry); + } + return out; +} + const BYTES_TAG = '$bytes'; function uint8ToBase64(bytes: Uint8Array): string { diff --git a/src/channel_state/mod.rs b/src/channel_state/mod.rs index dd014458a..56dc41914 100644 --- a/src/channel_state/mod.rs +++ b/src/channel_state/mod.rs @@ -171,6 +171,22 @@ impl ChannelState { .map(|t| t.coin.state_number) } + /// Last fully co-signed unroll we can spend the channel coin to. + /// Equal to `state_number()` when we hold the potato; one behind after we + /// send, until the peer countersigns the new channel spend. + pub fn unroll_target_state_number(&self) -> Option { + if let Some(info) = &self.latest_received_unroll { + return Some(info.coin.state_number); + } + // Never received: the only co-signed unroll is the handshake state (0). + // Holding the potato means we have not sent yet, so that is current. + if self.have_potato { + Some(self.state_number) + } else { + Some(0) + } + } + pub fn have_potato(&self) -> bool { self.have_potato } @@ -1685,11 +1701,15 @@ impl ChannelState { }) } - /// Build a preemption (challenge-path) spend of the unroll coin. - /// The PUZZLE must match the on-chain coin (built from the state that - /// matches the on-chain unroll). The SOLUTION and SIGNATURE come from - /// our latest state that satisfies the CLSP parity constraint: - /// logand(1, logxor(our_state_number, OLD_SEQUENCE_NUMBER)) == 1 + /// State number we would preempt `old_state_number` with, if any eligible + /// stored unroll record has the required parity and peer half-signature. + pub fn preempting_state_number_for(&self, old_state_number: usize) -> Option { + self.preemption_source(old_state_number) + .map(|info| info.coin.state_number) + } + + /// Pick a stored unroll whose state number has opposite parity from + /// `old_state_number` and that carries the peer's preemption half-signature. fn preemption_source(&self, old_state_number: usize) -> Option<&ChannelUnrollSpendInfo> { let has_peer_sig = |info: &ChannelUnrollSpendInfo| { info.signatures.unroll_preempt_half_sig != Aggsig::default() @@ -1707,6 +1727,11 @@ impl ChannelState { } } + /// Build a preemption (challenge-path) spend of the unroll coin. + /// The PUZZLE must match the on-chain coin (built from the state that + /// matches the on-chain unroll). The SOLUTION and SIGNATURE come from + /// our latest state that satisfies the CLSP parity constraint: + /// logand(1, logxor(our_state_number, OLD_SEQUENCE_NUMBER)) == 1 fn make_preemption_unroll_spend( &self, env: &mut ChannelEnv<'_>, diff --git a/src/game_session.rs b/src/game_session.rs index be1dbb5da..6fefca4a5 100644 --- a/src/game_session.rs +++ b/src/game_session.rs @@ -914,26 +914,14 @@ impl GameSession { { return true; } - // In Active state, re-emit on balance changes (potato firings). + // In Active state, re-emit on balance / potato / state_number changes. // In other states, suppress same-state re-emissions (e.g. coin // changes within Unrolling). new_state == Some(&ChannelStatus::Active) } fn make_channel_status_notification(snap: &ChannelStatusSnapshot) -> GameNotification { - GameNotification::ChannelStatus { - state: snap.state.clone(), - session_disposition: snap.session_disposition.clone(), - advisory: snap.advisory.clone(), - coin: snap.coin.clone(), - our_balance: snap.our_balance.clone(), - their_balance: snap.their_balance.clone(), - game_allocated: snap.game_allocated.clone(), - have_potato: snap.have_potato, - zero_payout: snap.zero_payout, - unroll_initiator: snap.unroll_initiator, - semantic_phase: snap.semantic_phase, - } + GameNotification::ChannelStatus(snap.clone()) } fn emit_channel_status_if_changed(&mut self) { @@ -946,19 +934,7 @@ impl GameSession { .peer .channel_status_snapshot() .or_else(|| self.last_channel_status.clone()) - .unwrap_or(ChannelStatusSnapshot { - state: ChannelStatus::Handshaking, - session_disposition: None, - advisory: None, - coin: None, - our_balance: None, - their_balance: None, - game_allocated: None, - have_potato: None, - zero_payout: None, - unroll_initiator: None, - semantic_phase: None, - }); + .unwrap_or_else(|| ChannelStatusSnapshot::new(ChannelStatus::Handshaking)); snapshot.session_disposition = Some(session_disposition); Some(snapshot) } else { @@ -1079,17 +1055,8 @@ impl GameSession { self.state.channel_expired = true; self.state.is_failed = true; let snapshot = ChannelStatusSnapshot { - state: ChannelStatus::Failed, - session_disposition: None, advisory: Some("channel coin not confirmed in time".to_string()), - coin: None, - our_balance: None, - their_balance: None, - game_allocated: None, - have_potato: None, - zero_payout: None, - unroll_initiator: None, - semantic_phase: None, + ..ChannelStatusSnapshot::new(ChannelStatus::Failed) }; self.state.events.push_back(GameSessionEvent::Notification( Self::make_channel_status_notification(&snapshot), @@ -1769,17 +1736,9 @@ mod sequencing_tests { phase: Option, ) -> Option { Some(ChannelStatusSnapshot { - state: ChannelStatus::Unrolling, - session_disposition: None, - advisory: None, - coin: None, - our_balance: None, - their_balance: None, - game_allocated: None, - have_potato: None, - zero_payout: None, unroll_initiator: initiator, semantic_phase: phase, + ..ChannelStatusSnapshot::new(ChannelStatus::Unrolling) }) } @@ -1788,14 +1747,14 @@ mod sequencing_tests { use crate::session_phases::effects::{ChannelSemanticPhase, UnrollInitiator}; assert!(GameSession::should_emit_status( - &unrolling_snapshot(None, Some(ChannelSemanticPhase::WaitingTimeout)), - &unrolling_snapshot(None, Some(ChannelSemanticPhase::SubmittingTimeoutFinish)), + &unrolling_snapshot(None, Some(ChannelSemanticPhase::FinishingWaitingTimeout),), + &unrolling_snapshot(None, Some(ChannelSemanticPhase::FinishingSpending)), )); assert!(GameSession::should_emit_status( - &unrolling_snapshot(None, Some(ChannelSemanticPhase::WaitingTimeout)), + &unrolling_snapshot(None, Some(ChannelSemanticPhase::FinishingWaitingTimeout),), &unrolling_snapshot( Some(UnrollInitiator::Opponent), - Some(ChannelSemanticPhase::WaitingTimeout), + Some(ChannelSemanticPhase::FinishingWaitingTimeout), ), )); } diff --git a/src/session_phases/effects.rs b/src/session_phases/effects.rs index 2b541d402..cd5dd773e 100644 --- a/src/session_phases/effects.rs +++ b/src/session_phases/effects.rs @@ -66,6 +66,37 @@ pub struct ChannelStatusSnapshot { pub unroll_initiator: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub semantic_phase: Option, + /// Most recent channel state number this side is aware of. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub state_number: Option, + /// State number of the unroll we are publishing, or of the unroll coin + /// that landed on-chain. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub unrolling_state_number: Option, + /// State number we are (or were) preempting the landed unroll with. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub preempting_state_number: Option, +} + +impl ChannelStatusSnapshot { + pub fn new(state: ChannelStatus) -> Self { + Self { + state, + session_disposition: None, + advisory: None, + coin: None, + our_balance: None, + their_balance: None, + game_allocated: None, + have_potato: None, + zero_payout: None, + unroll_initiator: None, + semantic_phase: None, + state_number: None, + unrolling_state_number: None, + preempting_state_number: None, + } + } } /// Which party caused the observed channel-to-unroll transition. @@ -77,14 +108,17 @@ pub enum UnrollInitiator { } /// Fine-grained progress within the existing on-chain channel lifecycle. +/// Actor (us vs opponent) is `unroll_initiator`, not this enum. +/// UI copy is an exhaustive map from this enum plus initiator. #[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] #[serde(rename_all = "snake_case")] pub enum ChannelSemanticPhase { SubmittingChannelSpend, - ResolvingOpponentChannelSpend, + Unrolling, + FindingState, Preempting, - WaitingTimeout, - SubmittingTimeoutFinish, + FinishingWaitingTimeout, + FinishingSpending, Resolving, } @@ -239,24 +273,7 @@ pub enum GameNotification { tag: String, message: String, }, - ChannelStatus { - state: ChannelStatus, - #[serde(default, skip_serializing_if = "Option::is_none")] - session_disposition: Option, - advisory: Option, - coin: Option, - our_balance: Option, - their_balance: Option, - game_allocated: Option, - #[serde(skip_serializing_if = "Option::is_none")] - have_potato: Option, - #[serde(skip_serializing_if = "Option::is_none")] - zero_payout: Option, - #[serde(skip_serializing_if = "Option::is_none")] - unroll_initiator: Option, - #[serde(skip_serializing_if = "Option::is_none")] - semantic_phase: Option, - }, + ChannelStatus(ChannelStatusSnapshot), } /// A coin id worth surfacing in the dashboard so the user can look it up in a @@ -531,6 +548,9 @@ mod tests { assert_eq!(restored.zero_payout, None); assert_eq!(restored.unroll_initiator, None); assert_eq!(restored.semantic_phase, None); + assert_eq!(restored.state_number, None); + assert_eq!(restored.unrolling_state_number, None); + assert_eq!(restored.preempting_state_number, None); } #[test] diff --git a/src/session_phases/handshake_initiator.rs b/src/session_phases/handshake_initiator.rs index e43a0b462..ee986eac5 100644 --- a/src/session_phases/handshake_initiator.rs +++ b/src/session_phases/handshake_initiator.rs @@ -815,8 +815,6 @@ impl PeerLifecyclePhase for HandshakeInitiatorPhase { fn channel_status_snapshot(&self) -> Option { if self.failed { return Some(ChannelStatusSnapshot { - state: ChannelStatus::Failed, - session_disposition: None, advisory: self.failure_advisory.clone(), coin: self .channel_state @@ -834,10 +832,7 @@ impl PeerLifecyclePhase for HandshakeInitiatorPhase { .channel_state .as_ref() .map(|ch| ch.total_game_allocated()), - have_potato: None, - zero_payout: None, - unroll_initiator: None, - semantic_phase: None, + ..ChannelStatusSnapshot::new(ChannelStatus::Failed) }); } // The channel-creation expiry -> Failed signal now lives in the @@ -846,9 +841,6 @@ impl PeerLifecyclePhase for HandshakeInitiatorPhase { // value; it no longer drives a status branch. if self.pending_coin_spend { return Some(ChannelStatusSnapshot { - state: ChannelStatus::WaitingForHeightToOffer, - session_disposition: None, - advisory: None, coin: self .channel_state .as_ref() @@ -865,10 +857,7 @@ impl PeerLifecyclePhase for HandshakeInitiatorPhase { .channel_state .as_ref() .map(|ch| ch.total_game_allocated()), - have_potato: None, - zero_payout: None, - unroll_initiator: None, - semantic_phase: None, + ..ChannelStatusSnapshot::new(ChannelStatus::WaitingForHeightToOffer) }); } let state = match &self.state { @@ -903,17 +892,11 @@ impl PeerLifecyclePhase for HandshakeInitiatorPhase { (None, None, None) }; Some(ChannelStatusSnapshot { - state, - session_disposition: None, - advisory: None, coin, our_balance, their_balance, game_allocated, - have_potato: None, - zero_payout: None, - unroll_initiator: None, - semantic_phase: None, + ..ChannelStatusSnapshot::new(state) }) } fn coins_of_interest(&self) -> Vec<(CoinOfInterest, CoinString)> { diff --git a/src/session_phases/handshake_receiver.rs b/src/session_phases/handshake_receiver.rs index 9a0d1008e..c8d6bc624 100644 --- a/src/session_phases/handshake_receiver.rs +++ b/src/session_phases/handshake_receiver.rs @@ -770,8 +770,6 @@ impl PeerLifecyclePhase for HandshakeReceiverPhase { fn channel_status_snapshot(&self) -> Option { if self.failed { return Some(ChannelStatusSnapshot { - state: ChannelStatus::Failed, - session_disposition: None, advisory: self.failure_advisory.clone(), coin: self .channel_state @@ -789,10 +787,7 @@ impl PeerLifecyclePhase for HandshakeReceiverPhase { .channel_state .as_ref() .map(|ch| ch.total_game_allocated()), - have_potato: None, - zero_payout: None, - unroll_initiator: None, - semantic_phase: None, + ..ChannelStatusSnapshot::new(ChannelStatus::Failed) }); } // The channel-creation expiry -> Failed signal now lives in the @@ -801,9 +796,6 @@ impl PeerLifecyclePhase for HandshakeReceiverPhase { // value; it no longer drives a status branch. if self.pending_coin_spend { return Some(ChannelStatusSnapshot { - state: ChannelStatus::WaitingForHeightToAccept, - session_disposition: None, - advisory: None, coin: self .channel_state .as_ref() @@ -820,10 +812,7 @@ impl PeerLifecyclePhase for HandshakeReceiverPhase { .channel_state .as_ref() .map(|ch| ch.total_game_allocated()), - have_potato: None, - zero_payout: None, - unroll_initiator: None, - semantic_phase: None, + ..ChannelStatusSnapshot::new(ChannelStatus::WaitingForHeightToAccept) }); } let state = match &self.state { @@ -850,17 +839,11 @@ impl PeerLifecyclePhase for HandshakeReceiverPhase { (None, None, None) }; Some(ChannelStatusSnapshot { - state, - session_disposition: None, - advisory: None, coin, our_balance, their_balance, game_allocated, - have_potato: None, - zero_payout: None, - unroll_initiator: None, - semantic_phase: None, + ..ChannelStatusSnapshot::new(state) }) } fn coins_of_interest(&self) -> Vec<(CoinOfInterest, CoinString)> { diff --git a/src/session_phases/mod.rs b/src/session_phases/mod.rs index 4460d558e..d876237dd 100644 --- a/src/session_phases/mod.rs +++ b/src/session_phases/mod.rs @@ -1884,21 +1884,18 @@ impl PeerLifecyclePhase for OffChainPhase { .iter() .any(|a| matches!(a, GameAction::CleanShutdown)); Some(ChannelStatusSnapshot { - state: if shutting_down { - ChannelStatus::ShuttingDown - } else { - ChannelStatus::Active - }, - session_disposition: None, - advisory: None, coin: Some(ch.channel_coin().clone()), our_balance: Some(ch.my_out_of_game_balance()), their_balance: Some(ch.their_out_of_game_balance()), game_allocated: Some(ch.total_game_allocated()), have_potato: Some(matches!(self.have_potato, PotatoState::Present)), zero_payout: shutting_down.then(|| ch.has_zero_payout()), - unroll_initiator: None, - semantic_phase: None, + state_number: Some(ch.state_number()), + ..ChannelStatusSnapshot::new(if shutting_down { + ChannelStatus::ShuttingDown + } else { + ChannelStatus::Active + }) }) } fn coins_of_interest(&self) -> Vec<(CoinOfInterest, CoinString)> { diff --git a/src/session_phases/on_chain.rs b/src/session_phases/on_chain.rs index 8ed9355aa..9ca97978b 100644 --- a/src/session_phases/on_chain.rs +++ b/src/session_phases/on_chain.rs @@ -1936,17 +1936,11 @@ impl PeerLifecyclePhase for OnChainPhase { ChannelStatus::ResolvedUnrolled }; Some(ChannelStatusSnapshot { - state, - session_disposition: None, advisory: self.advisory.clone(), coin: self.terminal_reward_coin.clone(), our_balance: Some(self.my_out_of_game_balance.clone()), their_balance: Some(self.their_out_of_game_balance.clone()), - game_allocated: None, - have_potato: None, - zero_payout: None, - unroll_initiator: None, - semantic_phase: None, + ..ChannelStatusSnapshot::new(state) }) } diff --git a/src/session_phases/spend_channel_coin_phase.rs b/src/session_phases/spend_channel_coin_phase.rs index 49fd09f19..8f6f9837f 100644 --- a/src/session_phases/spend_channel_coin_phase.rs +++ b/src/session_phases/spend_channel_coin_phase.rs @@ -43,11 +43,15 @@ enum SpendChannelCoinState { UnrollSpend { unroll_coin: CoinString, state_number: usize, + preempting: bool, + preempting_state_number: Option, reward_coin: Option, }, UnrollConditions { unroll_coin: CoinString, state_number: usize, + preempting: bool, + preempting_state_number: Option, }, } @@ -109,7 +113,7 @@ impl SpendChannelCoinPhase { was_stale: false, terminal_reward_coin: None, channel_spend_started_locally: Some(true), - unroll_initiator: None, + unroll_initiator: Some(UnrollInitiator::Us), timeout_finish_submitted: false, expected_clean_shutdown_solution: None, last_channel_coin_spend_info, @@ -143,7 +147,7 @@ impl SpendChannelCoinPhase { was_stale: false, terminal_reward_coin: None, channel_spend_started_locally: Some(false), - unroll_initiator: None, + unroll_initiator: Some(UnrollInitiator::Opponent), timeout_finish_submitted: false, expected_clean_shutdown_solution, last_channel_coin_spend_info: None, @@ -237,6 +241,30 @@ impl SpendChannelCoinPhase { false } + fn finishing_unroll_semantic_phase(&self, spending: bool) -> ChannelSemanticPhase { + if spending { + ChannelSemanticPhase::FinishingSpending + } else { + ChannelSemanticPhase::FinishingWaitingTimeout + } + } + + fn channel_spend_semantic_phase(&self) -> ChannelSemanticPhase { + if self.expected_clean_shutdown_solution.is_some() { + ChannelSemanticPhase::SubmittingChannelSpend + } else { + ChannelSemanticPhase::Unrolling + } + } + + fn channel_conditions_semantic_phase(&self) -> ChannelSemanticPhase { + if self.expected_clean_shutdown_solution.is_some() { + ChannelSemanticPhase::Resolving + } else { + ChannelSemanticPhase::FindingState + } + } + // --- Peer messages (delegated) --- pub fn received_message( @@ -348,6 +376,11 @@ impl SpendChannelCoinPhase { self.state = SpendChannelCoinState::ChannelConditions { channel_coin: channel_coin.clone(), }; + // A local unroll spend is not proof it landed: the opponent can + // win the race. Attribution waits until the state number is known. + if self.channel_spend_started_locally == Some(true) { + self.unroll_initiator = None; + } effects.push(Effect::Log(format!( "[spend-channel:channel-coin-spent] {}", format_coin(coin_id) @@ -358,11 +391,15 @@ impl SpendChannelCoinPhase { SpendChannelCoinState::UnrollSpend { unroll_coin, state_number, + preempting, + preempting_state_number, .. } if coin_id == unroll_coin => { self.state = SpendChannelCoinState::UnrollConditions { unroll_coin: unroll_coin.clone(), state_number: *state_number, + preempting: *preempting, + preempting_state_number: *preempting_state_number, }; effects.push(Effect::Log(format!( "[spend-channel:unroll-coin-spent] {}", @@ -378,6 +415,8 @@ impl SpendChannelCoinPhase { self.state = SpendChannelCoinState::UnrollConditions { unroll_coin: unroll_coin.clone(), state_number: *state_number, + preempting: false, + preempting_state_number: None, }; effects.push(Effect::Log(format!( "[spend-channel:unroll-coin-spent] {}", @@ -453,6 +492,7 @@ impl SpendChannelCoinPhase { SpendChannelCoinState::UnrollConditions { unroll_coin, state_number, + .. } if *coin_id == *unroll_coin => { let sn = *state_number; match self.finish_on_chain_transition(env, coin_id, puzzle_and_solution, sn) { @@ -641,12 +681,6 @@ impl SpendChannelCoinPhase { } }; - // A locally queued spend is not proof that it landed: the opponent can - // win the race with a valid channel spend. Passive observation while we - // remained off-chain is the only attribution available in this model. - self.unroll_initiator = (self.channel_spend_started_locally == Some(false)) - .then_some(UnrollInitiator::Opponent); - { let ch = self.base.channel_state_mut()?; let cancelled_ids = ch.cancel_all_proposals(); @@ -693,6 +727,19 @@ impl SpendChannelCoinPhase { )? }; + // Preemption means the landed unroll is the opponent's. Wait/finish + // keeps the local vs passively-observed attribution. + self.unroll_initiator = match &outcome { + UnrollOutcome::Preempted(_) => Some(UnrollInitiator::Opponent), + UnrollOutcome::WaitForTimeout | UnrollOutcome::Unrecoverable(_) => { + if self.channel_spend_started_locally == Some(false) { + Some(UnrollInitiator::Opponent) + } else { + Some(UnrollInitiator::Us) + } + } + }; + effects.push(Effect::Log(format!( "[unroll-started] {} state={on_chain_state}", format_coin(unroll_coin), @@ -700,13 +747,20 @@ impl SpendChannelCoinPhase { match outcome { UnrollOutcome::Preempted(bundle) => { + let preempting_state_number = self + .base + .channel_state() + .ok() + .and_then(|ch| ch.preempting_state_number_for(on_chain_state)); effects.push(Effect::SpendTransaction(bundle, None)); effects.push(Effect::Log(format!( - "[unroll-preempt] state={on_chain_state}", + "[unroll-preempt] state={on_chain_state} preempting={preempting_state_number:?}" ))); self.state = SpendChannelCoinState::UnrollSpend { unroll_coin: unroll_coin.clone(), state_number: on_chain_state, + preempting: true, + preempting_state_number, reward_coin: None, }; effects.push(Effect::RegisterCoin { @@ -1178,51 +1232,90 @@ impl PeerLifecyclePhase for SpendChannelCoinPhase { SpendChannelCoinPhase::go_on_chain(self, env) } fn channel_status_snapshot(&self) -> Option { - let (state, coin, semantic_phase) = match &self.state { + struct SpendSnapshotView { + state: ChannelStatus, + coin: Option, + semantic_phase: Option, + unrolling_state_number: Option, + preempting_state_number: Option, + } + let view = match &self.state { SpendChannelCoinState::ChannelSpend { channel_coin } => { - let s = if self.expected_clean_shutdown_solution.is_some() { + let state = if self.expected_clean_shutdown_solution.is_some() { ChannelStatus::ShutdownTransactionPending } else { ChannelStatus::GoingOnChain }; - ( - s, - Some(channel_coin.clone()), - Some(ChannelSemanticPhase::SubmittingChannelSpend), - ) + let unroll_target = if state == ChannelStatus::GoingOnChain { + self.base + .channel_state + .as_ref() + .and_then(|ch| ch.unroll_target_state_number()) + } else { + None + }; + SpendSnapshotView { + state, + coin: Some(channel_coin.clone()), + semantic_phase: Some(self.channel_spend_semantic_phase()), + unrolling_state_number: unroll_target, + preempting_state_number: None, + } } SpendChannelCoinState::ChannelConditions { channel_coin } => { - let s = if self.expected_clean_shutdown_solution.is_some() { + let state = if self.expected_clean_shutdown_solution.is_some() { ChannelStatus::ShutdownTransactionPending } else { ChannelStatus::GoingOnChain }; - let phase = if self.channel_spend_started_locally == Some(false) { - ChannelSemanticPhase::ResolvingOpponentChannelSpend - } else { - ChannelSemanticPhase::Resolving - }; - (s, Some(channel_coin.clone()), Some(phase)) + SpendSnapshotView { + state, + coin: Some(channel_coin.clone()), + semantic_phase: Some(self.channel_conditions_semantic_phase()), + unrolling_state_number: None, + preempting_state_number: None, + } } - SpendChannelCoinState::UnrollTimeoutOrSpend { unroll_coin, .. } => ( - ChannelStatus::Unrolling, - Some(unroll_coin.clone()), - Some(if self.timeout_finish_submitted { - ChannelSemanticPhase::SubmittingTimeoutFinish + SpendChannelCoinState::UnrollTimeoutOrSpend { + unroll_coin, + state_number, + } => SpendSnapshotView { + state: ChannelStatus::Unrolling, + coin: Some(unroll_coin.clone()), + semantic_phase: Some( + self.finishing_unroll_semantic_phase(self.timeout_finish_submitted), + ), + unrolling_state_number: Some(*state_number), + preempting_state_number: None, + }, + SpendChannelCoinState::UnrollSpend { + unroll_coin, + state_number, + preempting_state_number, + .. + } => SpendSnapshotView { + state: ChannelStatus::Unrolling, + coin: Some(unroll_coin.clone()), + semantic_phase: Some(ChannelSemanticPhase::Preempting), + unrolling_state_number: Some(*state_number), + preempting_state_number: *preempting_state_number, + }, + SpendChannelCoinState::UnrollConditions { + unroll_coin, + state_number, + preempting, + preempting_state_number, + } => SpendSnapshotView { + state: ChannelStatus::Unrolling, + coin: Some(unroll_coin.clone()), + semantic_phase: Some(if *preempting { + ChannelSemanticPhase::Preempting } else { - ChannelSemanticPhase::WaitingTimeout + self.finishing_unroll_semantic_phase(true) }), - ), - SpendChannelCoinState::UnrollSpend { unroll_coin, .. } => ( - ChannelStatus::Unrolling, - Some(unroll_coin.clone()), - Some(ChannelSemanticPhase::Preempting), - ), - SpendChannelCoinState::UnrollConditions { unroll_coin, .. } => ( - ChannelStatus::Unrolling, - Some(unroll_coin.clone()), - Some(ChannelSemanticPhase::Resolving), - ), + unrolling_state_number: Some(*state_number), + preempting_state_number: *preempting_state_number, + }, }; let (our_balance, their_balance, game_allocated) = if let Some(ch) = self.base.channel_state.as_ref() { @@ -1234,24 +1327,25 @@ impl PeerLifecyclePhase for SpendChannelCoinPhase { } else { (None, None, None) }; - let zero_payout = (state == ChannelStatus::ShutdownTransactionPending).then(|| { + let zero_payout = (view.state == ChannelStatus::ShutdownTransactionPending).then(|| { self.base .channel_state .as_ref() .is_some_and(|ch| ch.has_zero_payout()) }); Some(ChannelStatusSnapshot { - state, - session_disposition: None, advisory: self.advisory.clone(), - coin, + coin: view.coin, our_balance, their_balance, game_allocated, - have_potato: None, zero_payout, unroll_initiator: self.unroll_initiator, - semantic_phase, + semantic_phase: view.semantic_phase, + state_number: self.base.channel_state.as_ref().map(|ch| ch.state_number()), + unrolling_state_number: view.unrolling_state_number, + preempting_state_number: view.preempting_state_number, + ..ChannelStatusSnapshot::new(view.state) }) } fn coins_of_interest(&self) -> Vec<(CoinOfInterest, CoinString)> { @@ -1337,12 +1431,14 @@ mod tests { assert_eq!(snapshot.unroll_initiator, None); assert_eq!( snapshot.semantic_phase, - Some(ChannelSemanticPhase::WaitingTimeout) + Some(ChannelSemanticPhase::FinishingWaitingTimeout) ); + assert_eq!(snapshot.unrolling_state_number, Some(1)); + assert_eq!(snapshot.preempting_state_number, None); } #[test] - fn passive_channel_spend_snapshot_reports_resolution_without_attribution() { + fn passive_channel_spend_snapshot_attributes_the_opponent() { let phase = SpendChannelCoinPhase::new_at_channel_conditions( None, test_coin(), @@ -1354,15 +1450,15 @@ mod tests { ); let snapshot = phase.channel_status_snapshot().expect("snapshot"); - assert_eq!(snapshot.unroll_initiator, None); + assert_eq!(snapshot.unroll_initiator, Some(UnrollInitiator::Opponent)); assert_eq!( snapshot.semantic_phase, - Some(ChannelSemanticPhase::ResolvingOpponentChannelSpend) + Some(ChannelSemanticPhase::FindingState) ); } #[test] - fn locally_started_or_clean_shutdown_channel_spends_remain_unattributed() { + fn local_unroll_is_attributed_to_us_and_clean_shutdown_stays_unattributed() { let locally_started = SpendChannelCoinPhase::new( None, test_coin(), @@ -1377,7 +1473,7 @@ mod tests { .channel_status_snapshot() .unwrap() .unroll_initiator, - None + Some(UnrollInitiator::Us) ); let clean_shutdown = SpendChannelCoinPhase::new_for_clean_shutdown( @@ -1390,12 +1486,11 @@ mod tests { Timeout::new(5), None, ); + let clean_shutdown_snapshot = clean_shutdown.channel_status_snapshot().unwrap(); + assert_eq!(clean_shutdown_snapshot.unroll_initiator, None); assert_eq!( - clean_shutdown - .channel_status_snapshot() - .unwrap() - .unroll_initiator, - None + clean_shutdown_snapshot.semantic_phase, + Some(ChannelSemanticPhase::SubmittingChannelSpend) ); } @@ -1412,7 +1507,11 @@ mod tests { ); assert_eq!( phase.channel_status_snapshot().unwrap().semantic_phase, - Some(ChannelSemanticPhase::SubmittingChannelSpend) + Some(ChannelSemanticPhase::Unrolling) + ); + assert_eq!( + phase.channel_status_snapshot().unwrap().unroll_initiator, + Some(UnrollInitiator::Us) ); let mut allocator = AllocEncoder::new(); @@ -1420,10 +1519,12 @@ mod tests { phase .coin_spent(&mut env, &test_coin()) .expect("observe channel spend"); + let after_spend = phase.channel_status_snapshot().unwrap(); assert_eq!( - phase.channel_status_snapshot().unwrap().semantic_phase, - Some(ChannelSemanticPhase::Resolving) + after_spend.semantic_phase, + Some(ChannelSemanticPhase::FindingState) ); + assert_eq!(after_spend.unroll_initiator, None); phase.state = SpendChannelCoinState::UnrollTimeoutOrSpend { unroll_coin: test_coin(), @@ -1431,7 +1532,7 @@ mod tests { }; assert_eq!( phase.channel_status_snapshot().unwrap().semantic_phase, - Some(ChannelSemanticPhase::WaitingTimeout) + Some(ChannelSemanticPhase::FinishingWaitingTimeout) ); } @@ -1457,12 +1558,55 @@ mod tests { assert!(phase.timeout_claim_submitted(TimeoutClaimSemantic::ChannelTimeoutFinish)); assert_eq!( phase.channel_status_snapshot().unwrap().semantic_phase, - Some(ChannelSemanticPhase::SubmittingTimeoutFinish) + Some(ChannelSemanticPhase::FinishingSpending) ); assert!(phase.timeout_claim_rearmed(TimeoutClaimSemantic::ChannelTimeoutFinish)); assert_eq!( phase.channel_status_snapshot().unwrap().semantic_phase, - Some(ChannelSemanticPhase::WaitingTimeout) + Some(ChannelSemanticPhase::FinishingWaitingTimeout) + ); + } + + #[test] + fn unroll_conditions_classify_preempt_versus_finish() { + let mut phase = SpendChannelCoinPhase { + state: SpendChannelCoinState::UnrollConditions { + unroll_coin: test_coin(), + state_number: 2, + preempting: true, + preempting_state_number: Some(5), + }, + base: legacy_base(), + advisory: None, + was_stale: false, + terminal_reward_coin: None, + channel_spend_started_locally: Some(true), + unroll_initiator: Some(UnrollInitiator::Us), + timeout_finish_submitted: false, + expected_clean_shutdown_solution: None, + last_channel_coin_spend_info: None, + replacement: None, + }; + assert_eq!( + phase.channel_status_snapshot().unwrap().semantic_phase, + Some(ChannelSemanticPhase::Preempting) + ); + + phase.state = SpendChannelCoinState::UnrollConditions { + unroll_coin: test_coin(), + state_number: 2, + preempting: false, + preempting_state_number: None, + }; + assert_eq!( + phase.channel_status_snapshot().unwrap().semantic_phase, + Some(ChannelSemanticPhase::FinishingSpending) + ); + + phase.unroll_initiator = Some(UnrollInitiator::Opponent); + assert_eq!( + phase.channel_status_snapshot().unwrap().semantic_phase, + Some(ChannelSemanticPhase::FinishingSpending) ); } } diff --git a/src/simulator/tests/session_phases_sim.rs b/src/simulator/tests/session_phases_sim.rs index b2926ad22..de6373115 100644 --- a/src/simulator/tests/session_phases_sim.rs +++ b/src/simulator/tests/session_phases_sim.rs @@ -17,8 +17,8 @@ use crate::common::types::{ }; use crate::game_session::{GameSession, GameSessionConfig, MessagePeerQueue, MessagePipe}; use crate::session_phases::effects::{ - CancelReason, ChannelStatus, GameNotification, GameSessionEvent, GameStatusKind, - SettlementOutcome, UnrollInitiator, + CancelReason, ChannelStatus, ChannelStatusSnapshot, GameNotification, GameSessionEvent, + GameStatusKind, SettlementOutcome, UnrollInitiator, }; use crate::session_phases::game_collection; use crate::session_phases::handshake::CoinSpendRequest; @@ -225,7 +225,7 @@ impl ToLocalUI for SimulatedPeer { } Ok(()) } - GameNotification::ChannelStatus { state, .. } => { + GameNotification::ChannelStatus(ChannelStatusSnapshot { state, .. }) => { use crate::session_phases::effects::ChannelStatus; match state { ChannelStatus::GoingOnChain @@ -477,10 +477,10 @@ fn event_matches(actual: &TestEvent, expected: &ExpectedEvent) -> bool { ExpectedNotification::InsufficientBalance, ) => true, ( - GameNotification::ChannelStatus { + GameNotification::ChannelStatus(ChannelStatusSnapshot { state: actual_state, .. - }, + }), ExpectedNotification::ChannelStatus(expected_state), ) => actual_state == expected_state, _ => false, @@ -515,7 +515,7 @@ fn event_shape(actual: &TestEvent) -> String { GameNotification::InsufficientBalance { id, our_balance_short, their_balance_short } => format!("Notif(InsufficientBalance(id={id:?},ours={our_balance_short},theirs={their_balance_short}))"), GameNotification::ActionFailed { reason, .. } => format!("Notif(ActionFailed(reason={reason}))"), GameNotification::MoveRejected { id, tag, message } => format!("Notif(MoveRejected(id={id:?},tag={tag},message={message}))"), - GameNotification::ChannelStatus { state, .. } => format!("Notif(ChannelStatus(state={state:?}))"), + GameNotification::ChannelStatus(ChannelStatusSnapshot { state, .. }) => format!("Notif(ChannelStatus(state={state:?}))"), }, } } @@ -700,10 +700,10 @@ impl LocalTestUIReceiver { impl ToLocalUI for LocalTestUIReceiver { fn notification(&mut self, notification: &GameNotification) -> Result<(), Error> { match notification { - GameNotification::ChannelStatus { + GameNotification::ChannelStatus(ChannelStatusSnapshot { state: ChannelStatus::Active, .. - } => { + }) => { self.channel_created = true; } GameNotification::GameStatus { @@ -802,7 +802,7 @@ impl ToLocalUI for LocalTestUIReceiver { self.events .push(TestEvent::Notification(notification.clone())); } - GameNotification::ChannelStatus { state, .. } => { + GameNotification::ChannelStatus(ChannelStatusSnapshot { state, .. }) => { if matches!(state, ChannelStatus::Active) { self.channel_created = true; } @@ -1008,10 +1008,10 @@ fn run_game_container_with_action_list_with_success_predicate( let channel_failed = lui.notifications.iter().any(|n| { matches!( n, - GameNotification::ChannelStatus { + GameNotification::ChannelStatus(ChannelStatusSnapshot { state: ChannelStatus::Failed, .. - } + }) ) }); assert!( @@ -1196,10 +1196,10 @@ fn run_game_container_with_action_list_with_success_predicate( let first_unrolling_idx = lui.notifications.iter().position(|n| { matches!( n, - GameNotification::ChannelStatus { + GameNotification::ChannelStatus(ChannelStatusSnapshot { state: ChannelStatus::Unrolling, .. - } + }) ) }); let Some(unroll_idx) = first_unrolling_idx else { @@ -1280,7 +1280,7 @@ fn run_game_container_with_action_list_with_success_predicate( for (i, lui) in local_uis.iter().enumerate() { let mut last_state: Option = None; for n in &lui.notifications { - if let GameNotification::ChannelStatus { state, .. } = n { + if let GameNotification::ChannelStatus(ChannelStatusSnapshot { state, .. }) = n { let ord = channel_state_ordinal(state); if let Some(ref prev) = last_state { let prev_ord = channel_state_ordinal(prev); @@ -2059,10 +2059,10 @@ pub fn test_funs() -> Vec<(&'static str, &'static (dyn Fn() + Send + Sync))> { let has_failed = outcome.local_uis[i].notifications.iter().any(|n| { matches!( n, - GameNotification::ChannelStatus { + GameNotification::ChannelStatus(ChannelStatusSnapshot { state: ChannelStatus::Failed, .. - } + }) ) }); assert!( @@ -2095,10 +2095,10 @@ pub fn test_funs() -> Vec<(&'static str, &'static (dyn Fn() + Send + Sync))> { let has_failed = outcome.local_uis[i].notifications.iter().any(|n| { matches!( n, - GameNotification::ChannelStatus { + GameNotification::ChannelStatus(ChannelStatusSnapshot { state: ChannelStatus::Failed, .. - } + }) ) }); assert!( @@ -3306,20 +3306,20 @@ pub fn test_funs() -> Vec<(&'static str, &'static (dyn Fn() + Send + Sync))> { assert!( p1_notifs.iter().any(|n| matches!( n, - GameNotification::ChannelStatus { + GameNotification::ChannelStatus(ChannelStatusSnapshot { state: ChannelStatus::Unrolling, .. - } + }) )), "player 1 should see Unrolling channel status, got: {p1_notifs:?}" ); assert!( p0_notifs.iter().any(|n| matches!( n, - GameNotification::ChannelStatus { + GameNotification::ChannelStatus(ChannelStatusSnapshot { state: ChannelStatus::Unrolling, .. - } + }) )), "player 0 should see Unrolling channel status, got: {p0_notifs:?}" ); @@ -3381,20 +3381,20 @@ pub fn test_funs() -> Vec<(&'static str, &'static (dyn Fn() + Send + Sync))> { assert!( p1_notifs.iter().any(|n| matches!( n, - GameNotification::ChannelStatus { + GameNotification::ChannelStatus(ChannelStatusSnapshot { state: ChannelStatus::Unrolling, .. - } + }) )), "player 1 should see Unrolling channel status, got: {p1_notifs:?}" ); assert!( p0_notifs.iter().any(|n| matches!( n, - GameNotification::ChannelStatus { + GameNotification::ChannelStatus(ChannelStatusSnapshot { state: ChannelStatus::Unrolling, .. - } + }) )), "player 0 should see Unrolling channel status, got: {p0_notifs:?}" ); @@ -3560,10 +3560,10 @@ pub fn test_funs() -> Vec<(&'static str, &'static (dyn Fn() + Send + Sync))> { .position(|event| { matches!( event, - TestEvent::Notification(GameNotification::ChannelStatus { + TestEvent::Notification(GameNotification::ChannelStatus(ChannelStatusSnapshot { state: ChannelStatus::ResolvedUnrolled, .. - }) + })) ) }) .expect("alice should observe the unroll resolution"); @@ -4748,7 +4748,7 @@ pub fn test_funs() -> Vec<(&'static str, &'static (dyn Fn() + Send + Sync))> { p0_notifs .iter() .any(|n| has_status(n, GameStatusKind::EndedError)) - || p0_notifs.iter().any(|n| matches!(n, GameNotification::ChannelStatus { state: ChannelStatus::Failed, .. })), + || p0_notifs.iter().any(|n| matches!(n, GameNotification::ChannelStatus(ChannelStatusSnapshot { state: ChannelStatus::Failed, .. }))), "player 0 should get GameError or ChannelError when coin is force-destroyed, got: {p0_notifs:?}" ); @@ -4822,19 +4822,19 @@ pub fn test_funs() -> Vec<(&'static str, &'static (dyn Fn() + Send + Sync))> { ); assert!(outcome.local_uis[0].notifications.iter().any(|notification| matches!( notification, - GameNotification::ChannelStatus { + GameNotification::ChannelStatus(ChannelStatusSnapshot { state: ChannelStatus::Unrolling, unroll_initiator: Some(UnrollInitiator::Opponent), .. - } + }) ))); assert!(outcome.local_uis[1].notifications.iter().any(|notification| matches!( notification, - GameNotification::ChannelStatus { + GameNotification::ChannelStatus(ChannelStatusSnapshot { state: ChannelStatus::Unrolling, - unroll_initiator: None, + unroll_initiator: Some(UnrollInitiator::Us), .. - } + }) ))); assert_event_sequence(&outcome.local_uis[0].events, &[ @@ -5069,10 +5069,10 @@ pub fn test_funs() -> Vec<(&'static str, &'static (dyn Fn() + Send + Sync))> { assert!( p1_notifs.iter().any(|n| matches!( n, - GameNotification::ChannelStatus { + GameNotification::ChannelStatus(ChannelStatusSnapshot { state: ChannelStatus::Failed, .. - } + }) )), "player 1 should get ChannelError for state-from-the-future, got: {p1_notifs:?}" ); @@ -5081,10 +5081,10 @@ pub fn test_funs() -> Vec<(&'static str, &'static (dyn Fn() + Send + Sync))> { .position(|n| { matches!( n, - GameNotification::ChannelStatus { + GameNotification::ChannelStatus(ChannelStatusSnapshot { state: ChannelStatus::Failed, .. - } + }) ) }) .unwrap(); @@ -5163,10 +5163,10 @@ pub fn test_funs() -> Vec<(&'static str, &'static (dyn Fn() + Send + Sync))> { assert!( p1_notifs.iter().any(|n| matches!( n, - GameNotification::ChannelStatus { + GameNotification::ChannelStatus(ChannelStatusSnapshot { state: ChannelStatus::Failed, .. - } + }) )), "player 1 should get ChannelError for wrong-parity old state, got: {p1_notifs:?}" ); @@ -5175,10 +5175,10 @@ pub fn test_funs() -> Vec<(&'static str, &'static (dyn Fn() + Send + Sync))> { .position(|n| { matches!( n, - GameNotification::ChannelStatus { + GameNotification::ChannelStatus(ChannelStatusSnapshot { state: ChannelStatus::Failed, .. - } + }) ) }) .unwrap(); @@ -5260,10 +5260,10 @@ pub fn test_funs() -> Vec<(&'static str, &'static (dyn Fn() + Send + Sync))> { assert!( !matches!( n, - GameNotification::ChannelStatus { + GameNotification::ChannelStatus(ChannelStatusSnapshot { state: ChannelStatus::Failed, .. - } + }) ), "player {i} should not get ChannelError, got: {n:?}" ); @@ -5373,10 +5373,10 @@ pub fn test_funs() -> Vec<(&'static str, &'static (dyn Fn() + Send + Sync))> { !p1_notifs.iter().any(|n| { matches!( n, - GameNotification::ChannelStatus { + GameNotification::ChannelStatus(ChannelStatusSnapshot { state: ChannelStatus::Failed, .. - } + }) ) }), "Bob should not fail when proposal arrives before channel coin report, got: {p1_notifs:?}" @@ -5860,7 +5860,7 @@ pub fn test_funs() -> Vec<(&'static str, &'static (dyn Fn() + Send + Sync))> { let p0_notifs = &outcome.local_uis[0].notifications; assert!( - p0_notifs.iter().any(|n| matches!(n, GameNotification::ChannelStatus { state: ChannelStatus::ResolvedStale, .. })), + p0_notifs.iter().any(|n| matches!(n, GameNotification::ChannelStatus(ChannelStatusSnapshot { state: ChannelStatus::ResolvedStale, .. }))), "player 0 should see ResolvedStale, got: {p0_notifs:?}" ); // The accept round-tripped, so the second game is fully live (not a @@ -5874,7 +5874,7 @@ pub fn test_funs() -> Vec<(&'static str, &'static (dyn Fn() + Send + Sync))> { "player 0 should get exactly one GameError for the fully-live second game, got: {game_errors:?}, all: {p0_notifs:?}" ); assert!( - !p0_notifs.iter().any(|n| matches!(n, GameNotification::ChannelStatus { state: ChannelStatus::Failed, .. })), + !p0_notifs.iter().any(|n| matches!(n, GameNotification::ChannelStatus(ChannelStatusSnapshot { state: ChannelStatus::Failed, .. }))), "player 0 should NOT get ChannelError, got: {p0_notifs:?}" ); })); @@ -5945,7 +5945,7 @@ pub fn test_funs() -> Vec<(&'static str, &'static (dyn Fn() + Send + Sync))> { let p0_notifs = &outcome.local_uis[0].notifications; assert!( - p0_notifs.iter().any(|n| matches!(n, GameNotification::ChannelStatus { state: ChannelStatus::ResolvedStale, .. })), + p0_notifs.iter().any(|n| matches!(n, GameNotification::ChannelStatus(ChannelStatusSnapshot { state: ChannelStatus::ResolvedStale, .. }))), "player 0 should see ResolvedStale, got: {p0_notifs:?}" ); // The redo recovers the first game, but the second game's accept @@ -5959,7 +5959,7 @@ pub fn test_funs() -> Vec<(&'static str, &'static (dyn Fn() + Send + Sync))> { "player 0 should get exactly one GameError for the fully-live second game, got: {game_errors:?}, all: {p0_notifs:?}" ); assert!( - !p0_notifs.iter().any(|n| matches!(n, GameNotification::ChannelStatus { state: ChannelStatus::Failed, .. })), + !p0_notifs.iter().any(|n| matches!(n, GameNotification::ChannelStatus(ChannelStatusSnapshot { state: ChannelStatus::Failed, .. }))), "player 0 should NOT get ChannelError, got: {p0_notifs:?}" ); })); @@ -6043,10 +6043,10 @@ pub fn test_funs() -> Vec<(&'static str, &'static (dyn Fn() + Send + Sync))> { assert!( p0_notifs.iter().any(|n| matches!( n, - GameNotification::ChannelStatus { + GameNotification::ChannelStatus(ChannelStatusSnapshot { state: ChannelStatus::ResolvedStale, .. - } + }) )), "player 0 should see ResolvedStale, got: {p0_notifs:?}" ); @@ -6063,10 +6063,10 @@ pub fn test_funs() -> Vec<(&'static str, &'static (dyn Fn() + Send + Sync))> { assert!( !p0_notifs.iter().any(|n| matches!( n, - GameNotification::ChannelStatus { + GameNotification::ChannelStatus(ChannelStatusSnapshot { state: ChannelStatus::Failed, .. - } + }) )), "player 0 should NOT get ChannelError, got: {p0_notifs:?}" ); @@ -6134,7 +6134,7 @@ pub fn test_funs() -> Vec<(&'static str, &'static (dyn Fn() + Send + Sync))> { let p0_notifs = &outcome.local_uis[0].notifications; assert!( - p0_notifs.iter().any(|n| matches!(n, GameNotification::ChannelStatus { state: ChannelStatus::ResolvedStale, .. })), + p0_notifs.iter().any(|n| matches!(n, GameNotification::ChannelStatus(ChannelStatusSnapshot { state: ChannelStatus::ResolvedStale, .. }))), "player 0 should see ResolvedStale, got: {p0_notifs:?}" ); // The second game (fully live, round-tripped) is absent → GameError. @@ -6156,7 +6156,7 @@ pub fn test_funs() -> Vec<(&'static str, &'static (dyn Fn() + Send + Sync))> { "player 0 should get exactly one EndedCancelled for the in-flight accept, got: {game_cancels:?}, all: {p0_notifs:?}" ); assert!( - !p0_notifs.iter().any(|n| matches!(n, GameNotification::ChannelStatus { state: ChannelStatus::Failed, .. })), + !p0_notifs.iter().any(|n| matches!(n, GameNotification::ChannelStatus(ChannelStatusSnapshot { state: ChannelStatus::Failed, .. }))), "player 0 should NOT get ChannelError, got: {p0_notifs:?}" ); })); @@ -6693,10 +6693,10 @@ pub fn test_funs() -> Vec<(&'static str, &'static (dyn Fn() + Send + Sync))> { let has_failed = outcome.local_uis[i].notifications.iter().any(|n| { matches!( n, - GameNotification::ChannelStatus { + GameNotification::ChannelStatus(ChannelStatusSnapshot { state: ChannelStatus::Failed, .. - } + }) ) }); assert!( @@ -6733,10 +6733,10 @@ pub fn test_funs() -> Vec<(&'static str, &'static (dyn Fn() + Send + Sync))> { let has_failed = outcome.local_uis[i].notifications.iter().any(|n| { matches!( n, - GameNotification::ChannelStatus { + GameNotification::ChannelStatus(ChannelStatusSnapshot { state: ChannelStatus::Failed, .. - } + }) ) }); assert!( @@ -6774,10 +6774,10 @@ pub fn test_funs() -> Vec<(&'static str, &'static (dyn Fn() + Send + Sync))> { let has_failed = outcome.local_uis[i].notifications.iter().any(|n| { matches!( n, - GameNotification::ChannelStatus { + GameNotification::ChannelStatus(ChannelStatusSnapshot { state: ChannelStatus::Failed, .. - } + }) ) }); assert!( diff --git a/src/simulator/tests/session_phases_sim/harness.rs b/src/simulator/tests/session_phases_sim/harness.rs index 7cbf9c16a..301ce144e 100644 --- a/src/simulator/tests/session_phases_sim/harness.rs +++ b/src/simulator/tests/session_phases_sim/harness.rs @@ -777,19 +777,12 @@ impl SimulationHarness { } GameSessionEvent::ReceiveError(error) => { eprintln!("SIM receive error p{player_index}: {error}"); - deferred_notifications.push(GameNotification::ChannelStatus { - state: ChannelStatus::Failed, - session_disposition: None, - advisory: Some(format!("error receiving peer message: {error}")), - coin: None, - our_balance: None, - their_balance: None, - game_allocated: None, - have_potato: None, - zero_payout: None, - unroll_initiator: None, - semantic_phase: None, - }); + deferred_notifications.push(GameNotification::ChannelStatus( + ChannelStatusSnapshot { + advisory: Some(format!("error receiving peer message: {error}")), + ..ChannelStatusSnapshot::new(ChannelStatus::Failed) + }, + )); } GameSessionEvent::CoinSolutionRequest(coin) => { coin_requests.push(coin.clone()); diff --git a/src/test_support/krunk_sim.rs b/src/test_support/krunk_sim.rs index 0a9a42b1d..5293850a9 100644 --- a/src/test_support/krunk_sim.rs +++ b/src/test_support/krunk_sim.rs @@ -84,7 +84,7 @@ mod sim_tests { use crate::channel_state::types::{ChannelEnv, OnChainGameState, TimeoutClaimState}; use crate::common::types::{Amount, CoinString, Hash, PuzzleHash, Timeout}; use crate::session_phases::effects::{ - ChannelStatus, GameNotification, GameStatusKind, SettlementOutcome, + ChannelStatus, ChannelStatusSnapshot, GameNotification, GameStatusKind, SettlementOutcome, }; use crate::session_phases::on_chain::{OnChainPhase, OnChainPhaseArgs}; use crate::session_phases::types::{GameAction, PotatoState}; @@ -257,10 +257,12 @@ mod sim_tests { .position(|event| { matches!( event, - TestEvent::Notification(GameNotification::ChannelStatus { - state: ChannelStatus::ResolvedUnrolled, - .. - }) + TestEvent::Notification(GameNotification::ChannelStatus( + ChannelStatusSnapshot { + state: ChannelStatus::ResolvedUnrolled, + .. + } + )) ) }) .expect("picker must observe unroll resolution"); @@ -540,10 +542,10 @@ mod sim_tests { assert!( ui.notifications.iter().any(|notification| matches!( notification, - GameNotification::ChannelStatus { + GameNotification::ChannelStatus(ChannelStatusSnapshot { state: ChannelStatus::ResolvedUnrolled, .. - } + }) )), "player {who} should resolve through the known unroll: {:?}", ui.notifications @@ -551,10 +553,10 @@ mod sim_tests { assert!( !ui.notifications.iter().any(|notification| matches!( notification, - GameNotification::ChannelStatus { + GameNotification::ChannelStatus(ChannelStatusSnapshot { state: ChannelStatus::Failed, .. - } + }) )), "player {who} should not fail unroll recognition: {:?}", ui.notifications diff --git a/src/test_support/peer/peer_harness.rs b/src/test_support/peer/peer_harness.rs index 8cd4da5ad..17c97e4d8 100644 --- a/src/test_support/peer/peer_harness.rs +++ b/src/test_support/peer/peer_harness.rs @@ -17,7 +17,9 @@ use crate::common::types::{GameID, PrivateKey, Program, Timeout}; #[cfg(test)] use crate::game_session::{MessagePeerQueue, MessagePipe, PeerLifecyclePhase}; #[cfg(test)] -use crate::session_phases::effects::{apply_effects, Effect, GameNotification}; +use crate::session_phases::effects::{ + apply_effects, ChannelStatusSnapshot, Effect, GameNotification, +}; #[cfg(test)] use crate::session_phases::game_collection; #[cfg(test)] @@ -165,9 +167,9 @@ impl ToLocalUI for Pipe { } } } - GameNotification::ChannelStatus { + GameNotification::ChannelStatus(ChannelStatusSnapshot { state, advisory, .. - } => { + }) => { use crate::session_phases::effects::ChannelStatus; if matches!( state, diff --git a/src/tests/channel_state.rs b/src/tests/channel_state.rs index 62446273d..aa4fb58ac 100644 --- a/src/tests/channel_state.rs +++ b/src/tests/channel_state.rs @@ -98,6 +98,98 @@ pub(crate) mod sim_tests { .expect("should build conditions") } + #[test] + fn unroll_target_is_one_behind_after_sending_the_potato() { + let mut allocator = AllocEncoder::new(); + let mut rng = ChaCha8Rng::from_seed([0; 32]); + let unroll_puzzle = read_unroll_puzzle(&mut allocator).unwrap(); + let nil = allocator.allocator().nil(); + let ref_coin_puz = Puzzle::from_nodeptr(&mut allocator, nil).expect("should work"); + let ref_coin_ph = ref_coin_puz.sha256tree(&mut allocator); + let standard_puzzle = get_standard_coin_puzzle(&mut allocator).expect("should load"); + let mut env = ChannelEnv { + allocator: &mut allocator, + referee_coin_puzzle: ref_coin_puz, + referee_coin_puzzle_hash: ref_coin_ph, + unroll_puzzle, + standard_puzzle, + agg_sig_me_additional_data: Hash::from_bytes(AGG_SIG_ME_ADDITIONAL_DATA), + }; + + let mut game = setup_handshake(&mut rng, &mut env); + let first_sender = if game.player(0).ch.have_potato() { + 0 + } else { + 1 + }; + empty_potato_round_trip(&mut game, &mut env, first_sender); + + let sender = if game.player(0).ch.have_potato() { + 0 + } else { + 1 + }; + let receiver = sender ^ 1; + + let before = game.player(sender).ch.state_number(); + assert!(before > 0); + assert_eq!( + game.player(sender).ch.unroll_target_state_number(), + Some(before), + "holding the potato, unroll target is the current state" + ); + + let sigs = game + .player(sender) + .ch + .send_empty_potato(&mut env) + .expect("send_empty_potato"); + assert!(!game.player(sender).ch.have_potato()); + assert_eq!(game.player(sender).ch.state_number(), before + 1); + assert_eq!( + game.player(sender).ch.unroll_target_state_number(), + Some(before), + "after sending, unroll target stays on the last co-signed state" + ); + + game.player(receiver) + .ch + .received_empty_potato(&mut env, &sigs) + .expect("received_empty_potato"); + assert_eq!( + game.player(receiver).ch.unroll_target_state_number(), + Some(game.player(receiver).ch.state_number()), + "receiver's unroll target matches the state they just co-signed" + ); + + let mut handshake_only = setup_handshake(&mut rng, &mut env); + let handshake_sender = if handshake_only.player(0).ch.have_potato() { + 0 + } else { + 1 + }; + assert_eq!( + handshake_only + .player(handshake_sender) + .ch + .unroll_target_state_number(), + Some(handshake_only.player(handshake_sender).ch.state_number()) + ); + handshake_only + .player(handshake_sender) + .ch + .send_empty_potato(&mut env) + .expect("send from handshake"); + assert_eq!( + handshake_only + .player(handshake_sender) + .ch + .unroll_target_state_number(), + Some(0), + "first send from handshake unrolls to state 0, not the bumped current state" + ); + } + /// Test the parity constraint in preemption unroll spends. /// /// After 3 round-trips of empty potato exchanges, player 0 has: From a0be356ae112c7a5d46485d66d0998b28c41d8d6 Mon Sep 17 00:00:00 2001 From: Bram Cohen Date: Thu, 13 Aug 2026 14:16:26 +0200 Subject: [PATCH 2/9] Distinguish terminal-game timeout wait versus spend in WASM. A finished on-chain move still needs its timeout claim, but the UI only said Finishing. WASM now emits wait vs spend so the hand bar can say Finalizing waiting for timeout or Finalizing spending. --- .../src/lib/session/persistencePayloads.ts | 2 + front-end/src/lib/session/presentation.ts | 30 +++++ front-end/src/lib/session/selectors.ts | 6 + front-end/src/lib/session/types.ts | 6 + .../tests/session_model.presentation.test.ts | 20 +++ .../lib/tests/session_model.restore.test.ts | 40 ++++++ front-end/src/types/ChiaGaming.ts | 2 + src/game_session.rs | 6 +- src/session_phases/effects.rs | 3 + src/session_phases/on_chain.rs | 120 ++++++++++++------ .../spend_channel_coin_phase.rs | 2 + src/simulator/tests/session_phases_sim.rs | 32 +++-- src/test_support/krunk_sim.rs | 4 +- src/test_support/spacepoker_sim.rs | 2 +- 14 files changed, 223 insertions(+), 52 deletions(-) diff --git a/front-end/src/lib/session/persistencePayloads.ts b/front-end/src/lib/session/persistencePayloads.ts index 5c5812e8d..ebcbfd5c6 100644 --- a/front-end/src/lib/session/persistencePayloads.ts +++ b/front-end/src/lib/session/persistencePayloads.ts @@ -82,6 +82,8 @@ const SAVED_GAME_PRESENTATIONS: ReadonlySet = new Set = { slashing: 'Slashing cheater', 'submitting-timeout': 'Submitting timeout claim', finishing: 'Finishing', + 'finishing-waiting-timeout': 'Finalizing waiting for timeout', + 'finishing-spending': 'Finalizing spending', ended: 'Ended', }; @@ -362,6 +364,10 @@ function selectHandStatus(model: SessionModel): HandStatus { return 'replaying-move'; case 'finishing': return 'finishing'; + case 'finishing-waiting-timeout': + return 'finishing-waiting-timeout'; + case 'finishing-spending': + return 'finishing-spending'; } } return 'active'; diff --git a/front-end/src/lib/session/types.ts b/front-end/src/lib/session/types.ts index fe8cab700..3277e887b 100644 --- a/front-end/src/lib/session/types.ts +++ b/front-end/src/lib/session/types.ts @@ -19,6 +19,8 @@ export type GameTurnState = | 'opponent-illegal-move' | 'submitting-timeout' | 'finishing' + | 'finishing-waiting-timeout' + | 'finishing-spending' | 'ended'; export type HandStatus = @@ -31,6 +33,8 @@ export type HandStatus = | 'slashing' | 'submitting-timeout' | 'finishing' + | 'finishing-waiting-timeout' + | 'finishing-spending' | 'ended'; export type GameProtocolPresentation = @@ -43,6 +47,8 @@ export type GameProtocolPresentation = | 'illegal-move' | 'submitting-timeout' | 'finishing' + | 'finishing-waiting-timeout' + | 'finishing-spending' | 'ended'; export type GameTerminalType = diff --git a/front-end/src/lib/tests/session_model.presentation.test.ts b/front-end/src/lib/tests/session_model.presentation.test.ts index 93d50cf8a..147241916 100644 --- a/front-end/src/lib/tests/session_model.presentation.test.ts +++ b/front-end/src/lib/tests/session_model.presentation.test.ts @@ -704,6 +704,8 @@ describe('session model dashboard and on-chain presentation contracts', () => { | 'playing-on-chain' | 'replaying' | 'finishing' + | 'finishing-waiting-timeout' + | 'finishing-spending' | 'opponent-illegal-move', coinHex: string | null, ) => ({ @@ -776,6 +778,24 @@ describe('session model dashboard and on-chain presentation contracts', () => { ).handStatusLabel, ).toBe('Finishing'); + expect( + selectGameDashboardView( + createSessionModel({ + channel: { status: { ...INITIAL_CHANNEL_STATUS_MODEL, state: 'ResolvedUnrolled' } }, + game: game('finishing-waiting-timeout', 'abcd'), + }), + ).handStatusLabel, + ).toBe('Finalizing waiting for timeout'); + + expect( + selectGameDashboardView( + createSessionModel({ + channel: { status: { ...INITIAL_CHANNEL_STATUS_MODEL, state: 'ResolvedUnrolled' } }, + game: game('finishing-spending', 'abcd'), + }), + ).handStatusLabel, + ).toBe('Finalizing spending'); + // Detecting the opponent's illegal on-chain move puts us in the slash flow; // the bar should say so explicitly instead of a generic "Your turn". const slashing = selectGameDashboardView( diff --git a/front-end/src/lib/tests/session_model.restore.test.ts b/front-end/src/lib/tests/session_model.restore.test.ts index 0059dcd20..b12e9c26d 100644 --- a/front-end/src/lib/tests/session_model.restore.test.ts +++ b/front-end/src/lib/tests/session_model.restore.test.ts @@ -484,6 +484,46 @@ describe('session model restore, schema, and event contracts', () => { }); }); + it('maps WASM finishing-timeout statuses to wait versus spend', () => { + const previous = { + id: '7', + amount: '100', + coin: { coinHex: 'coin', turnState: 'my-turn' as const, onChain: true }, + handStatus: 'our-turn' as const, + terminal: INITIAL_GAME_TERMINAL_MODEL, + }; + expect( + projectGameStatus({ + previous, + payload: { + id: '7', + status: 'finishing-waiting-timeout', + coin_id: 'coin', + other_params: { game_finished: true }, + }, + channelState: 'ResolvedUnrolled', + }), + ).toMatchObject({ + coin: { turnState: 'finishing-waiting-timeout', onChain: true }, + handStatus: 'finishing-waiting-timeout', + }); + expect( + projectGameStatus({ + previous, + payload: { + id: '7', + status: 'finishing-spending', + coin_id: 'coin', + other_params: { game_finished: true, submitting_timeout_claim: true }, + }, + channelState: 'ResolvedUnrolled', + }), + ).toMatchObject({ + coin: { turnState: 'finishing-spending', onChain: true }, + handStatus: 'finishing-spending', + }); + }); + it('orders readable gameplay events before the Settled marker', () => { const notification = { GameStatus: { diff --git a/front-end/src/types/ChiaGaming.ts b/front-end/src/types/ChiaGaming.ts index 36e517aeb..7b8c13313 100644 --- a/front-end/src/types/ChiaGaming.ts +++ b/front-end/src/types/ChiaGaming.ts @@ -133,6 +133,8 @@ export type GameStatusState = | 'replaying' | 'playing-move' | 'illegal-move-detected' + | 'finishing-waiting-timeout' + | 'finishing-spending' | 'ended-cancelled' | 'ended-error'; diff --git a/src/game_session.rs b/src/game_session.rs index 6fefca4a5..4aea5f0b0 100644 --- a/src/game_session.rs +++ b/src/game_session.rs @@ -985,7 +985,8 @@ impl GameSession { self.emit_channel_status_if_changed(); } } - TimeoutClaimSemantic::GameOpponentTurn { id } => { + TimeoutClaimSemantic::GameOpponentTurn { id } + | TimeoutClaimSemantic::GameFinishTimeout { id } => { let notification = self .peer .as_any_mut() @@ -1018,7 +1019,8 @@ impl GameSession { self.emit_channel_status_if_changed(); } } - TimeoutClaimSemantic::GameOpponentTurn { id } => { + TimeoutClaimSemantic::GameOpponentTurn { id } + | TimeoutClaimSemantic::GameFinishTimeout { id } => { let notification = self .peer .as_any_mut() diff --git a/src/session_phases/effects.rs b/src/session_phases/effects.rs index cd5dd773e..3d73f56a2 100644 --- a/src/session_phases/effects.rs +++ b/src/session_phases/effects.rs @@ -127,6 +127,7 @@ pub enum ChannelSemanticPhase { pub enum TimeoutClaimSemantic { ChannelTimeoutFinish, GameOpponentTurn { id: GameID }, + GameFinishTimeout { id: GameID }, } #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] @@ -139,6 +140,8 @@ pub enum GameStatusKind { Replaying, PlayingMove, IllegalMoveDetected, + FinishingWaitingTimeout, + FinishingSpending, EndedCancelled, EndedError, } diff --git a/src/session_phases/on_chain.rs b/src/session_phases/on_chain.rs index 9ca97978b..2406093c8 100644 --- a/src/session_phases/on_chain.rs +++ b/src/session_phases/on_chain.rs @@ -605,13 +605,39 @@ impl OnChainPhase { })) } - fn opponent_timeout_claim_semantic( + fn timeout_claim_semantic( game_id: GameID, our_turn: bool, game_finished: bool, + has_claim: bool, ) -> Option { - (!our_turn && !game_finished) - .then_some(TimeoutClaimSemantic::GameOpponentTurn { id: game_id }) + if !has_claim { + None + } else if game_finished { + Some(TimeoutClaimSemantic::GameFinishTimeout { id: game_id }) + } else if !our_turn { + Some(TimeoutClaimSemantic::GameOpponentTurn { id: game_id }) + } else { + None + } + } + + fn on_chain_turn_status(our_turn: bool, game_finished: bool) -> GameStatusKind { + if game_finished { + GameStatusKind::FinishingWaitingTimeout + } else if our_turn { + GameStatusKind::OnChainMyTurn + } else { + GameStatusKind::OnChainTheirTurn + } + } + + fn finishing_timeout_status(submitting: bool) -> GameStatusKind { + if submitting { + GameStatusKind::FinishingSpending + } else { + GameStatusKind::FinishingWaitingTimeout + } } pub fn timeout_claim_status( @@ -623,7 +649,21 @@ impl OnChainPhase { .game_map .iter() .find(|(_, state)| state.game_id == game_id)?; - if state.our_turn || state.game_finished { + if state.game_finished { + return Some(GameNotification::GameStatus { + id: game_id, + status: Self::finishing_timeout_status(submitting_timeout_claim), + my_reward: None, + coin_id: Some(coin.clone()), + reason: None, + other_params: Some(GameStatusOtherParams { + game_finished: Some(true), + submitting_timeout_claim: Some(submitting_timeout_claim), + ..Default::default() + }), + }); + } + if state.our_turn { return None; } Some(GameNotification::GameStatus { @@ -663,12 +703,14 @@ impl OnChainPhase { let mut effects = Vec::new(); for (coin, game_id, gt, our_turn, game_finished) in coins { let claim = self.build_timeout_claim(env, &game_id, &coin)?; + let semantic = + Self::timeout_claim_semantic(game_id, our_turn, game_finished, claim.is_some()); effects.push(Effect::RegisterCoin { coin, timeout: gt, name: Some("game coin"), spend: claim, - semantic: Self::opponent_timeout_claim_semantic(game_id, our_turn, game_finished), + semantic, }); } Ok(effects) @@ -747,7 +789,7 @@ impl OnChainPhase { effects.push(Effect::Notify(GameNotification::GameStatus { id: pending.game_id, - status: GameStatusKind::OnChainTheirTurn, + status: Self::on_chain_turn_status(false, game_over), my_reward: None, coin_id: Some(new_coin.clone()), reason: None, @@ -762,16 +804,18 @@ impl OnChainPhase { }), })); let claim = self.build_timeout_claim(env, &pending.game_id, &new_coin)?; + let semantic = Self::timeout_claim_semantic( + pending.game_id, + false, + game_over, + claim.is_some(), + ); effects.push(Effect::RegisterCoin { coin: new_coin, timeout: gt, name: Some("our on-chain move confirmed"), spend: claim, - semantic: Self::opponent_timeout_claim_semantic( - pending.game_id, - false, - game_over, - ), + semantic, }); effects.extend(self.process_queued_action(env)?); return Ok(effects); @@ -985,11 +1029,10 @@ impl OnChainPhase { self.remember_current_game_coin(game_id, new_coin.clone()); effects.push(Effect::Notify(GameNotification::GameStatus { id: old_definition.game_id, - status: if !old_definition.our_turn { - GameStatusKind::OnChainMyTurn - } else { - GameStatusKind::OnChainTheirTurn - }, + status: Self::on_chain_turn_status( + !old_definition.our_turn, + old_definition.game_finished, + ), my_reward: None, coin_id: Some(new_coin.clone()), reason: None, @@ -997,16 +1040,18 @@ impl OnChainPhase { })); let claim = self.build_timeout_claim(env, &old_definition.game_id, &new_coin)?; + let semantic = Self::timeout_claim_semantic( + old_definition.game_id, + !old_definition.our_turn, + old_definition.game_finished, + claim.is_some(), + ); effects.push(Effect::RegisterCoin { coin: new_coin, timeout: gt, name: Some("timeout-claim-armed game coin advanced by redo"), spend: claim, - semantic: Self::opponent_timeout_claim_semantic( - old_definition.game_id, - !old_definition.our_turn, - old_definition.game_finished, - ), + semantic, }); } } @@ -1146,11 +1191,7 @@ impl OnChainPhase { effects.push(Effect::Notify(GameNotification::GameStatus { id: old_definition.game_id, - status: if is_my_turn { - GameStatusKind::OnChainMyTurn - } else { - GameStatusKind::OnChainTheirTurn - }, + status: Self::on_chain_turn_status(is_my_turn, terminal), my_reward: None, coin_id: Some(new_coin_id.clone()), reason: None, @@ -1160,6 +1201,12 @@ impl OnChainPhase { }), })); let claim = self.build_timeout_claim(env, &game_id, &new_coin_id)?; + let semantic = Self::timeout_claim_semantic( + game_id, + is_my_turn, + terminal, + claim.is_some(), + ); effects.push(Effect::RegisterCoin { coin: new_coin_id.clone(), timeout: gt, @@ -1169,9 +1216,7 @@ impl OnChainPhase { "expected spend - their turn" }), spend: claim, - semantic: Self::opponent_timeout_claim_semantic( - game_id, is_my_turn, terminal, - ), + semantic, }); if auto_settle { self.game_action_queue @@ -1299,7 +1344,7 @@ impl OnChainPhase { effects.push(Effect::Notify(GameNotification::GameStatus { id: old_definition.game_id, - status: GameStatusKind::OnChainMyTurn, + status: Self::on_chain_turn_status(true, terminal), my_reward: None, coin_id: Some(new_coin_string.clone()), reason: None, @@ -1331,12 +1376,14 @@ impl OnChainPhase { }), })); let claim = self.build_timeout_claim(env, &game_id, &new_coin_string)?; + let semantic = + Self::timeout_claim_semantic(game_id, true, terminal, claim.is_some()); effects.push(Effect::RegisterCoin { coin: new_coin_string.clone(), timeout: gt, name: Some("coin gives my turn"), spend: claim, - semantic: None, + semantic, }); if auto_settle { self.game_action_queue @@ -1676,13 +1723,14 @@ impl OnChainPhase { )) })?; let mut effects = Vec::new(); - if let Some(claim) = self.build_timeout_claim(env, &game_id, ¤t_coin)? { + let claim = self.build_timeout_claim(env, &game_id, ¤t_coin)?; + if let Some(claim) = claim { effects.push(Effect::RegisterCoin { coin: current_coin.clone(), timeout: gt, name: Some("timeout claim"), spend: Some(claim), - semantic: None, + semantic: Some(TimeoutClaimSemantic::GameFinishTimeout { id: game_id }), }); } if let Some(def) = self.game_map.get_mut(¤t_coin) { @@ -1690,11 +1738,7 @@ impl OnChainPhase { } effects.push(Effect::Notify(GameNotification::GameStatus { id: game_id, - status: if my_turn == Some(true) { - GameStatusKind::OnChainMyTurn - } else { - GameStatusKind::OnChainTheirTurn - }, + status: GameStatusKind::FinishingWaitingTimeout, my_reward: None, coin_id: Some(current_coin), reason: None, diff --git a/src/session_phases/spend_channel_coin_phase.rs b/src/session_phases/spend_channel_coin_phase.rs index 8f6f9837f..39524f1aa 100644 --- a/src/session_phases/spend_channel_coin_phase.rs +++ b/src/session_phases/spend_channel_coin_phase.rs @@ -1012,6 +1012,8 @@ impl SpendChannelCoinPhase { id: state.game_id, status: if replaying_ids.contains(&state.game_id) { GameStatusKind::Replaying + } else if state.game_finished { + GameStatusKind::FinishingWaitingTimeout } else if state.our_turn { GameStatusKind::OnChainMyTurn } else { diff --git a/src/simulator/tests/session_phases_sim.rs b/src/simulator/tests/session_phases_sim.rs index de6373115..2c5a8da87 100644 --- a/src/simulator/tests/session_phases_sim.rs +++ b/src/simulator/tests/session_phases_sim.rs @@ -425,7 +425,8 @@ fn event_matches(actual: &TestEvent, expected: &ExpectedEvent) -> bool { ) => true, ( GameNotification::GameStatus { - status: GameStatusKind::OnChainTheirTurn, + status: + GameStatusKind::OnChainTheirTurn | GameStatusKind::FinishingWaitingTimeout, other_params: Some(params), .. }, @@ -443,7 +444,9 @@ fn event_matches(actual: &TestEvent, expected: &ExpectedEvent) -> bool { status: GameStatusKind::OnChainMyTurn | GameStatusKind::OnChainTheirTurn - | GameStatusKind::Replaying, + | GameStatusKind::Replaying + | GameStatusKind::FinishingWaitingTimeout + | GameStatusKind::FinishingSpending, .. }, ExpectedNotification::GameStatusOnChainTurn, @@ -496,7 +499,10 @@ fn event_shape(actual: &TestEvent) -> String { TestEvent::GameMessage { .. } => "GameMessage".to_string(), TestEvent::Notification(n) => match n { GameNotification::GameStatus { id, status, other_params, .. } => { - if matches!(status, GameStatusKind::OnChainTheirTurn) + if matches!( + status, + GameStatusKind::OnChainTheirTurn | GameStatusKind::FinishingWaitingTimeout + ) && other_params .as_ref() .and_then(|p| p.moved_by_us) @@ -757,15 +763,19 @@ impl ToLocalUI for LocalTestUIReceiver { | GameStatusKind::Replaying | GameStatusKind::PlayingMove | GameStatusKind::IllegalMoveDetected + | GameStatusKind::FinishingWaitingTimeout + | GameStatusKind::FinishingSpending ) { self.events .push(TestEvent::Notification(notification.clone())); } - if matches!(status, GameStatusKind::OnChainTheirTurn) - && other_params - .as_ref() - .and_then(|p| p.moved_by_us) - .unwrap_or(false) + if matches!( + status, + GameStatusKind::OnChainTheirTurn | GameStatusKind::FinishingWaitingTimeout + ) && other_params + .as_ref() + .and_then(|p| p.moved_by_us) + .unwrap_or(false) { // Preserve event-count parity for tests expecting a separate GameStatusMovedByUs signal. self.events @@ -1166,6 +1176,8 @@ fn run_game_container_with_action_list_with_success_predicate( GameStatusKind::OnChainMyTurn | GameStatusKind::OnChainTheirTurn | GameStatusKind::Replaying + | GameStatusKind::FinishingWaitingTimeout + | GameStatusKind::FinishingSpending ) { continue; } @@ -1188,6 +1200,8 @@ fn run_game_container_with_action_list_with_success_predicate( GameStatusKind::OnChainMyTurn | GameStatusKind::OnChainTheirTurn | GameStatusKind::Replaying + | GameStatusKind::FinishingWaitingTimeout + | GameStatusKind::FinishingSpending | GameStatusKind::EndedCancelled | GameStatusKind::EndedError ) @@ -6478,7 +6492,7 @@ pub fn test_funs() -> Vec<(&'static str, &'static (dyn Fn() + Send + Sync))> { p0_notifs.iter().any(|n| matches!( n, GameNotification::GameStatus { - status: GameStatusKind::OnChainTheirTurn, + status: GameStatusKind::FinishingWaitingTimeout, other_params: Some(params), .. } if params.moved_by_us == Some(true) diff --git a/src/test_support/krunk_sim.rs b/src/test_support/krunk_sim.rs index 5293850a9..e6eef1416 100644 --- a/src/test_support/krunk_sim.rs +++ b/src/test_support/krunk_sim.rs @@ -732,7 +732,7 @@ mod sim_tests { notification, GameNotification::GameStatus { id, - status: GameStatusKind::OnChainTheirTurn, + status: GameStatusKind::FinishingWaitingTimeout, other_params: Some(params), .. } if *id == GameID(1) && params.game_finished == Some(true) @@ -744,7 +744,7 @@ mod sim_tests { notification, GameNotification::GameStatus { id, - status: GameStatusKind::OnChainMyTurn, + status: GameStatusKind::FinishingWaitingTimeout, other_params: Some(params), .. } if *id == GameID(1) && params.game_finished == Some(true) diff --git a/src/test_support/spacepoker_sim.rs b/src/test_support/spacepoker_sim.rs index 338ccc044..32da2364d 100644 --- a/src/test_support/spacepoker_sim.rs +++ b/src/test_support/spacepoker_sim.rs @@ -159,7 +159,7 @@ mod sim_tests { notification, GameNotification::GameStatus { id: GameID(1), - status: GameStatusKind::OnChainMyTurn, + status: GameStatusKind::FinishingWaitingTimeout, other_params: Some(params), .. } if params.game_finished == Some(true) From c5e67c4e1f88adeed78b82cae06dec262997ff30 Mon Sep 17 00:00:00 2001 From: Bram Cohen Date: Thu, 13 Aug 2026 15:16:42 +0200 Subject: [PATCH 3/9] Show session mode on a banner rail and pipe health as tab link marks. --- CONNECTIVITY.md | 83 +++--- FRONTEND_ARCHITECTURE.md | 17 +- front-end/src/components/Shell.tsx | 269 +++++++++--------- front-end/src/lib/session/selectors.ts | 61 ++-- front-end/src/lib/session/types.ts | 3 + .../tests/session_model.presentation.test.ts | 53 ++++ .../lib/tests/session_model.restore.test.ts | 58 ++-- 7 files changed, 311 insertions(+), 233 deletions(-) diff --git a/CONNECTIVITY.md b/CONNECTIVITY.md index 21390a236..f92f830cc 100644 --- a/CONNECTIVITY.md +++ b/CONNECTIVITY.md @@ -472,10 +472,10 @@ The hub does not create a session. It can only advise and relay: - **Peer degradation (no auto-cascade)**: When the peer becomes unreachable (delivery failures, 30-second silence, or hub disconnect) while the - session is off-chain, `peerLiveness` moves to `'degraded'` (yellow dot). - There is no automatic go-on-chain — the user must decide to escalate. - Only explicit terminal signals (user clicks "Go On-Chain" or receives a - FOAD) mark the peer as dead. + session is off-chain, `peerLiveness` moves to `'degraded'` (yellow banner + rail; tab stays a link). There is no automatic go-on-chain — the user must + decide to escalate. Only explicit terminal signals (user clicks "Go + On-Chain" or receives a FOAD) mark the peer as dead. - **Cascade warning dialogs**: Confirmation dialogs currently warn before disconnecting or switching hubs when a peer/session would be affected. @@ -485,41 +485,48 @@ The hub does not create a session. It can only advise and relay: ## UX: Connectivity Indicators -### Tab dots +### Tab pipe marks -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. +Wallet, Hub, and Game tabs show an uncolored link (connected) or broken-chain +(disconnected) emoji to the left of the label. History and Log have no pipe +mark. The existing upper-right notification dots indicate unread activity and +are unchanged. -Separately, the existing upper-right notification dots indicate unread -activity (new game events, etc.). These are unchanged and serve a different -purpose. +Pipe marks answer only “is this pipe up?” Session mode lives on the game +dashboard banner rail, not on the tabs. -### Per-tab color semantics +| Tab | Link | Broken chain | +|-----|------|----------------| +| Wallet | Connected. | Disconnected. The **Wallet** label is also red. | +| Hub | `hubLiveness === 'connected'` | Reconnecting, inactive, disconnected, or never connected | +| Game | Live session and peer is not `dead` | `sessionPhase` none/resolved, or `peerLiveness === 'dead'` | -| Tab | Green | Yellow | Red | Gray | -|-----|-------|--------|-----|------| -| Wallet | Connected | — | Disconnected | — | -| Hub | Connected | Reconnecting | Inactive (no heartbeat) | Not connected (null / disconnected) | -| Game | Peer connected (incl. clean shutdown) | On-chain, peer degraded, or peer unreachable during clean shutdown | Error, or peer dead outside clean shutdown | No session / resolved | -| History | — | — | — | Always gray | -| Log | — | — | — | Always gray | +Handshake with `peerLiveness === null` counts as connected. `degraded` pings +stay a link; that warning is banner-only. `sessionError` does not affect the +tab mark. -### Game tab dot priority +### Game dashboard banner rail -The Game tab dot checks conditions in this order: +The session dashboard has a full-height left-edge color rail: -1. `sessionPhase === 'none' || 'resolved'` → **gray** (no active session) -2. `sessionError` → **red** (genuine error — always wins) -3. Clean shutdown in progress (`ShuttingDown` / `ShutdownTransactionPending` / - `cleanShutdownStarted`): - - peer degraded (or unexpectedly dead) → **yellow** (unreachable) - - otherwise → **green** (cooperative close in flight; keepalives continue) -4. `peerLiveness === 'dead'` → **red** (terminal — go-on-chain or FOAD) -5. `sessionPhase === 'on-chain'` or `peerLiveness === 'degraded'` → **yellow** (resolving or stale peer) -6. `peerLiveness === 'connected'` → **green** (playing normally) -7. Otherwise → **gray** +| Tone | Color | When | +|------|-------|------| +| `idle` | Gray | No session / never set up | +| `playing` | Green | Setup, handshake, off-chain play, cooperative shutdown | +| `pings-bad` | Yellow | Same as playing, but `peerLiveness === 'degraded'` | +| `on-chain` | Red | Going on-chain, unrolling, or a resolved unroll that still has games | +| `ended` | Blue | Terminal dashboard still showing (clean resolve, failed, abandoned) | + +On-chain beats yellow. Failed/stale outcomes that are actually over stay +`ended`; the Channel label still names the outcome. Yellow also shows +“Peer pings look stuck.” + +### Game tab connectedness + +`selectGameTabConnected` is true unless: + +1. `sessionPhase === 'none' || 'resolved'`, or +2. `peerLiveness === 'dead'` Clean shutdown does **not** mark the peer dead on its own. Keepalives and the small allowlist of shutdown-related peer messages continue until local @@ -528,17 +535,14 @@ shutdown completes. Successful/terminal session exit does not send does arrive during pre-active matchmaking, it is honored as an abort: cancel the attempt (including any in-flight async session start), surface cancelled/error, and do not leave an orphan handshake. When the channel -reaches a terminal state the session exits and the dot goes gray. +reaches a terminal state the session exits and the game tab shows a broken +chain. -### Game tab error conditions (red dot) +### Session error conditions -The Game tab shows a red dot when `sessionError` is true, or when -`peerLiveness === 'dead'` outside a clean shutdown (go-on-chain or FOAD). `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) - `ResolvedStale` channel state — the channel resolved but the outcome is suspect (e.g., opponent exploited a timeout) - `game-error` game terminal — a generic game-level error (`GameStatus` with @@ -549,7 +553,8 @@ The Game tab shows a red dot when `sessionError` is true, or when Normal settlements such as `accept_settlement`, `settled_cleanly`, `opponent_timed_out`, `we_accepted`, and `slashed_opponent` are **not** session -errors. +errors. These conditions do not change the tab pipe mark; terminal outcomes +use the `ended` banner rail. ### Settlement labels diff --git a/FRONTEND_ARCHITECTURE.md b/FRONTEND_ARCHITECTURE.md index d10c4f3cd..9cebda587 100644 --- a/FRONTEND_ARCHITECTURE.md +++ b/FRONTEND_ARCHITECTURE.md @@ -902,8 +902,9 @@ delivery, ack reception, and keepalive reception. Peer liveness is measured passively from relay traffic. The `PeerSession` object derives liveness indicators using a 5-second polling 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 +the **tab pipe marks** — uncolored link / broken-chain emojis to the left of +Wallet, Hub, and Game tab labels — and into the game dashboard **banner rail** +(session mode: idle / playing / pings-bad / on-chain / ended). They are also passed to `GameSession` for in-game display. Separately, Shell has a cascade rule: if the peer is marked lost while the session is still off-chain, it calls `goOnChain()` on the WASM cradle. @@ -931,12 +932,12 @@ Connected, keepalive timeout while WS is up → Inactive. **Peer indicator** (`PeerLiveness`) has four states: -| State | Meaning | Dot color | -| ----------- | ----------------------------------------------------------------------------------- | --------- | -| `connected` | Peer traffic received within the last 30 seconds | Green | -| `degraded` | Delivery failure reported by hub, or no peer traffic for 30+ seconds | Yellow | -| `dead` | Local go-on-chain or session rejection (FOAD) — terminal for this peer relationship | Red | -| `null` | No active peer session | Grey | +| State | Meaning | Tab mark | +| ----------- | ----------------------------------------------------------------------------------- | -------- | +| `connected` | Peer traffic received within the last 30 seconds | Link | +| `degraded` | Delivery failure reported by hub, or no peer traffic for 30+ seconds | Link (banner rail yellow) | +| `dead` | Local go-on-chain or session rejection (FOAD) — terminal for this peer relationship | Broken chain | +| `null` | No keepalive yet, or no active peer session | Link if a session is live (handshake); broken chain if none/resolved | `dead` is sticky: incoming messages from that peer are ignored. Only a new session start resets to `null`. diff --git a/front-end/src/components/Shell.tsx b/front-end/src/components/Shell.tsx index ce518c932..dba5707ae 100644 --- a/front-end/src/components/Shell.tsx +++ b/front-end/src/components/Shell.tsx @@ -111,18 +111,17 @@ import { import { ABANDON_WAITING_STATES, isChannelAbandonable, - isCleanShutdownInProgress, PRE_ACTIVE_CHANNEL_STATES, selectGameDashboardView, - selectGameTabDotColor, + selectGameTabConnected, selectStatusBarBalances, sessionAmountsFromSave, sessionModelFromSave, DEFAULT_CHANNEL_TIMEOUT_BLOCKS, DEFAULT_UNROLL_TIMEOUT_BLOCKS, + type BannerTone, type GameDashboardActionKind, type GameDashboardViewModel, - type GameTabDotColor, type SessionModel, type StatusBarBalanceSegment, } from '../lib/session/model'; @@ -291,6 +290,17 @@ const TAB_DEFS: { id: TabId; label: string }[] = [ { id: 'log', label: 'Log' }, ]; +const TAB_PIPE_CONNECTED = '\u{1F517}'; +const TAB_PIPE_DISCONNECTED = '\u{26D3}\u{FE0F}\u{200D}\u{1F4A5}'; + +const BANNER_TONE_BAR: Record = { + idle: 'var(--color-canvas-text-subtle)', + playing: 'var(--color-success-solid)', + 'pings-bad': 'var(--color-warning-solid)', + 'on-chain': 'var(--color-alert-solid)', + ended: 'var(--color-info-solid)', +}; + const ABANDON_DELAY_MS = 120_000n; const GRACE_DELAY_MS = 10_000n; @@ -394,112 +404,121 @@ function GameDashboard({ if (expanded) refreshProtocolState(); }, [expanded, refreshProtocolState]); + const barColor = BANNER_TONE_BAR[view.bannerTone]; return ( -
-
-
- -
-
- - Channel: - - {view.channelStatusLabel} - - {view.havePotato && ( - - 🥔 - - )} - {view.channelDetail && ( - {view.channelDetail} - )} + - {view.lifecycleRows.length === 0 && - view.handStatusLabel !== 'Active' && - view.handStatusLabel !== 'No hand' && ( - - Hand: - - {view.handStatusLabel} - - {view.handDetail && {view.handDetail}} + +
+
+ + Channel: + + {view.channelStatusLabel} - )} - {view.lifecycleRows.map((row) => ( - - {row.label}: - {row.statusLabel} - {row.detail && {row.detail}} + {view.havePotato && ( + + 🥔 + + )} + {view.channelDetail && ( + {view.channelDetail} + )} + {view.bannerTone === 'pings-bad' && ( + Peer pings look stuck + )} - ))} -
- {balances && ( -
- {balances.map((seg) => ( - - {seg.label}: - - {formatBalanceValue(seg.value)} - {seg.value2 !== undefined ? ` / ${formatBalanceValue(seg.value2)}` : ''} + {view.lifecycleRows.length === 0 && + view.handStatusLabel !== 'Active' && + view.handStatusLabel !== 'No hand' && ( + + Hand: + + {view.handStatusLabel} + + {view.handDetail && ( + {view.handDetail} + )} + )} + {view.lifecycleRows.map((row) => ( + + {row.label}: + {row.statusLabel} + {row.detail && {row.detail}} ))}
- )} + {balances && ( +
+ {balances.map((seg) => ( + + {seg.label}: + + {formatBalanceValue(seg.value)} + {seg.value2 !== undefined ? ` / ${formatBalanceValue(seg.value2)}` : ''} + + + ))} +
+ )} +
+
+
+
-
- -
-
- {expanded && ( -
- {coins.length > 0 && ( -
- {coins.map((coin) => ( - - {coin.label}: - - {coin.id} + {expanded && ( +
+ {coins.length > 0 && ( +
+ {coins.map((coin) => ( + + {coin.label}: + + {coin.id} + - - ))} + ))} +
+ )} +
+ Protocol state +
- )} -
- Protocol state - +
+              {protocolText ?? 'No active channel.'}
+            
-
-            {protocolText ?? 'No active channel.'}
-          
-
- )} + )} +
); } @@ -752,8 +771,6 @@ const Shell = () => { shellDispatchRef.current({ type: 'setSessionError', value }); }, []); - const sessionError = shellState.sessionError; - const setRestoreStatus = useCallback((value: RestoreStatus) => { shellDispatchRef.current({ type: 'setRestoreStatus', value }); }, []); @@ -3418,6 +3435,7 @@ const Shell = () => { setupPending: shouldSynthesizeSetupPending(sessionPaneTransition, hasLiveSessionModel), cleanShutdownGraceActive, abandonEnabled, + peerLiveness, }); const statusBarBalances = selectStatusBarBalances(dashboardSessionModel); const sessionConsentOverlay = pendingAdvisory ? ( @@ -3520,46 +3538,29 @@ const Shell = () => { (tab.id === 'wallet' && walletAlert) || (tab.id === 'hub' && hubAlert)); - let dotColor: string | null = null; + let pipeConnected: boolean | null = null; switch (tab.id) { case 'wallet': - dotColor = walletConnected - ? 'var(--color-success-solid)' - : 'var(--color-alert-solid)'; + pipeConnected = walletConnected; break; case 'hub': - if (hubLiveness === 'connected') { - dotColor = 'var(--color-success-solid)'; - } else if (hubLiveness === 'reconnecting') { - dotColor = 'var(--color-warning-solid)'; - } else if (hubLiveness === 'inactive') { - dotColor = 'var(--color-alert-solid)'; - } else { - dotColor = 'var(--color-canvas-text-subtle)'; - } + pipeConnected = hubLiveness === 'connected'; break; - case 'game': { - const gameDot: GameTabDotColor = selectGameTabDotColor({ - sessionPhase, - sessionError, - peerLiveness, - cleanShutdownInProgress: isCleanShutdownInProgress(dashboardSessionModel), - }); - const gameDotCss: Record = { - green: 'var(--color-success-solid)', - yellow: 'var(--color-warning-solid)', - red: 'var(--color-alert-solid)', - gray: 'var(--color-canvas-text-subtle)', - }; - dotColor = gameDotCss[gameDot]; + case 'game': + pipeConnected = selectGameTabConnected({ sessionPhase, peerLiveness }); break; - } } + const walletDisconnected = tab.id === 'wallet' && !walletConnected; + const pipeLabel = + pipeConnected === null + ? tab.label + : `${tab.label}, ${pipeConnected ? 'connected' : 'disconnected'}`; return (