For the conceptual overview (state channels, coin hierarchy, potato protocol),
see OVERVIEW.md. For off-chain game proposals and flow, see
GAME_LIFECYCLE.md. For CLVM denial-of-service analysis (execution cost caps,
solution constraints, trust categories), see CLVM_DOS.md.
- Going On-Chain: Dispute Resolution
- PlayingMove vs Replaying
- Clean Shutdown (Advisory)
- Preemption
- Stale Unroll Handling
- Zero-Reward Early-Out
- The Referee
- On-Chain Game State Tracking (our_turn)
When something goes wrong (opponent offline, invalid move detected, explicit
GoOnChain action), the off-chain OffChainPhase — which manages the potato
protocol, peer messages, and batch exchanges — is effectively done. A
fundamentally different component, OnChainPhase, takes over. It is
driven entirely by blockchain coin-watching events (coin created, coin spent,
timeout reached) rather than peer messages. The OffChainPhase creates an
SpendChannelCoinPhase replacement, which in turn creates the
OnChainPhase. At that point all game actions (moves, AcceptSettlement) are routed to the
on-chain handler. It maintains its own game_map tracking each game coin's
state. There is no potato, no batching, no turn-taking — just monitoring the
blockchain and submitting transactions in response to what it sees.
A key design principle is that self-initiated and opponent-initiated on-chain
transitions follow the same code path. When a player calls go_on_chain, it
builds and submits a channel coin spend from last_channel_coin_spend_info and sets a flag to stop
sending peer messages — but from that point on, the actual state machine
progression is driven entirely by coin-watching events. The player detects
its own channel coin spend the same way it would detect the opponent's: by
observing the coin disappear from the blockchain. This means there is no
separate "I started this" vs "they started this" logic for the state
transitions themselves.
go_on_chain builds a SpendBundle from last_channel_coin_spend_info
(a ChannelCoinSpendInfo containing the solution, conditions, and aggregate
signature). This info is updated by update_channel_coin_after_receive on
every potato exchange, so it always reflects the latest co-signed state.
The spend creates the unroll coin on-chain.
When the channel coin spend is detected (by either player), a ChannelStatus
notification with state Unrolling is emitted and the state number of the
unroll is extracted from the on-chain conditions.
channel_coin_spent classifies the parsed channel-coin spend (see
Preemption). Timeout and preempt both spend the unroll coin,
creating game coins and reward coins. A spend we never signed (unknown puzzle
hash) or whose conditions do not match the signed historical record is an
error at parse time — it is not turned into a timeout.
When the unroll coin spend is detected, a ChannelStatus notification with
state ResolvedUnrolled (or ResolvedStale if the unroll was stale) is
emitted.
ChannelState::set_state_for_coins matches each created game coin's puzzle
hash against known states. It searches both live_games and
pending_settlements (see AcceptSettlement Lifecycle). All
state tracking is forward-only — there is no rewind logic. Two cases:
- Coin PH matches
last_referee_puzzle_hash(the outcome/post-move PH): The game coin is at the latest known state.our_turnis set based onis_my_turn(). No redo needed. - Coin PH matches a
cached_redo_actionsentry'smatch_puzzle_hash: The game coin is at the state before our cached move. A redo is needed to replay that move on-chain (see Redo Mechanism).
Games that existed off-chain but don't match any created coin are reported as
GameError (these were accepted games that should have appeared on-chain).
The state created by the unroll is not always the most advanced state known to the potato protocol. If our last potato action has not round-tripped, the virtual game may be one step ahead locally while the latest mutually signed unroll can only materialize the previous version on-chain. Redo is the bridge between those views: replay the cached last move on-chain, then continue from the actual coin the chain created.
If the game coin landed at the pre-move state, the redo transaction is emitted
immediately during finish_on_chain_transition — before the OnChainPhase
is created. For each game with a cached move, the code temporarily restores the
referee to the post-move state, generates the spend transaction, and inserts a
PendingMoveSavedState entry into the handler's pending_moves map. The
OnChainPhase never distinguishes between redo transactions and fresh
moves; it just tracks pending spends per game coin and reconciles them when
on-chain confirmation arrives.
Both Replaying and PlayingMove indicate that a move spend has been
submitted and is awaiting on-chain confirmation, but they have different
origins and semantics:
Replayingis emitted by the redo path duringfinish_on_chain_transition. It means a cached off-chain move is being replayed as an on-chain transaction. The game coin was created by the unroll at the pre-move state, so the redo is necessary to align the chain with the latest mutually known state.PlayingMoveis emitted bydo_on_chain_movewhen the user manually submits a fresh move while already in theOnChainPhase. Before delivering the status, the host has processed watch/transport effects and accepted the spend into its submission path. In the browser, that means the spend has entered the serialized wallet RPC lane; it does not prove the asynchronous wallet call succeeded, reached a full-node mempool, or confirmed. In the simulator, submission is synchronous and the spend has reached the simulator mempool in that same host step. Once the spend lands on chain, it is followed byOnChainTheirTurn { moved_by_us: true }for the same game.
The frontend should treat PlayingMove as an authoritative "move is being
published" state and must not downgrade it to a local "your turn" state.
Timeout resolution is split into two decoupled halves: eager submission of
the timeout claim, and confirmation-driven notification when a spend is
actually observed. There is no longer a maturity callback (coin_timeout_reached
has been removed); reaching the timelock no longer triggers a spend or a
notification directly.
Eager submission. When a game coin is registered with the wallet (via
register_initial_game_coins), the handler pre-builds the timeout claim with
build_timeout_claim and attaches it to the Effect::RegisterCoin's
spend: Option<SpendBundle>. build_timeout_claim returns the bundle only
when the timeout transaction pays us (the spend creates a coin to our reward
puzzle hash); otherwise it returns None and nothing is submitted on our
behalf. The TransactionManager then becomes the sole submitter: it tracks
each coin's reorg-aware birthday and submits the stored claim once the coin
reaches birthday + game_timeout, resubmitting across reorgs (see
Eager Timeout Submission).
Handlers never build or submit timeout transactions at maturity.
Confirmation-driven notification. Terminal notifications are emitted from
handle_game_coin_spent (reached via the coin_spent → coin_puzzle_and_solution
pipeline) when the game coin's actual spend is observed, by interpreting what
the spend created:
- Our timeout claim confirmed (spend pays our reward puzzle hash): settlement
confirmed in our favor —
GameSettledwith an outcome such aswe_accepted,settled_cleanly,slashed_opponent, or a forfeit variant whenour_shareis zero (see settlement glossary). - Opponent moved/claimed (spend pays their reward puzzle hash): in the
common case our eager claim simply never confirms because the opponent spent
the coin first (a normal move advances the game; a timeout claim against our
pending move yields
GameSettled { outcome: opponent_timed_out }orattempt_to_move_failed; a timeout claim while we were attempting to slash yieldsopponent_cheated).
Because notification rides the observed spend rather than the timelock, the expected "opponent moved instead of timing out" case requires no special handling — our unconfirmed claim is just dropped and the game advances.
Accepted games (accepted == true, set by an AcceptSettlement action): the
off-chain/on-chain accept only records intent. The eager timeout claim is
registered like any other, and GameSettled is emitted when the resulting
reward-coin spend is observed — not at accept time.
Zero-reward skip: when the timeout would not pay us, build_timeout_claim
returns None, so the manager submits nothing (avoiding a pointless fee). The
terminal status still resolves from the observed spend: the opponent claims
their reward, and we notify off their confirmed spend.
After all games resolve, a ChannelStatus with state ResolvedClean is
emitted and the channel can be closed.
Key code:
src/session_phases/mod.rs—go_on_chainsrc/session_phases/spend_channel_coin_phase.rs—handle_channel_coin_spent,finish_on_chain_transitionsrc/session_phases/on_chain.rs—OnChainPhase,build_timeout_claim,register_initial_game_coins,handle_game_coin_spentsrc/transaction_manager.rs—TransactionManager(eager claim submission)src/channel_state/mod.rs—set_state_for_coins,game_coin_spent
Clean shutdown is the cooperative channel closure path: both players agree to spend the channel coin directly to reward coins, bypassing the unroll/game-coin mechanism entirely.
Clean shutdown requires that no games are active (has_active_games() is
false). The initiator's drain_queue_into_batch enforces this — attempting
CleanShutdown with active games is an error. Any pending proposals are
cancelled automatically before the shutdown signature is produced.
On the receiver side, if the batch carries clean_shutdown but the receiver
still has active games (e.g., due to a misbehaving peer), the receiver
immediately goes on-chain instead of cooperating.
- The initiator includes
clean_shutdown: Some((half_sig, conditions))in their nextBatchmessage. The half-signature signs the channel coin spend to reward conditions (each player's balance goes directly to their reward puzzle hash, with no game coins). Theclean_shutdownfield is separate from theactionslist, so it is structurally processed after all actions on the receive side. The initiator remains inOffChainPhaseafter sending this batch — it does not transition toSpendChannelCoinPhaseyet. While waiting for the response,OffChainPhaserejects any peer message other thanCleanShutdownCompleteas a protocol violation (triggering go-on-chain). - The responder receives the batch, processes any actions, then combines the
initiator's half-signature with their own to produce a complete
CoinSpend. They reply withPeerMessage::CleanShutdownComplete(coin_spend)— a standalone message outside the normal potato flow. Normally the responder transitions toSpendChannelCoinPhaseimmediately (it already has the complete spend). - The initiator receives
CleanShutdownComplete, submits the transaction, and transitions toSpendChannelCoinPhase. Either side can submit the completed spend on-chain; duplicate submissions are harmless.
Zero-payout exception. When Rust's shutdown snapshot reports
zero_payout: true, that player still completes every cooperative protocol
step, but does not submit the clean-close transaction itself. A zero-payout
responder sends the completed spend through the host's durable outbound path,
then receives a persisted terminal-handoff command. After the peer acknowledges
that handoff, Rust records session_disposition: Abandoned while retaining the
real shutdown channel status; it does not enter
long-lived chain watching or wait for the peer's transaction. A zero-payout
initiator abandons when it receives CleanShutdownComplete, because that
response proves the peer already has all required close material. The
non-zero-payout peer is responsible for any on-chain publication.
The current implementation assumes single-handing (at most one outstanding
proposal at a time). Under this assumption, when the user requests a clean
shutdown, there is never a pending proposal that could interfere — the shutdown
batch is the only thing queued. This allows the front-end to immediately report
ShuttingDown status, and allows OffChainPhase to reject any unexpected
peer messages while waiting for CleanShutdownComplete.
In a future multi-handing model, the initiator might have outstanding proposals when the user requests a shutdown. Those proposals would need to resolve (accepted, rejected, or cancelled) before the shutdown batch can be sent. This means:
- The
ShuttingDownstatus could not be emitted immediately — the system would still be processing proposals. - The message-rejection guard in
OffChainPhase(which currently rejects everything exceptCleanShutdownComplete) would need to also accept proposal-resolution messages during the wind-down phase. - The precondition check (
has_active_games()) would need to account for proposals that are still in flight.
This is noted here as a future design consideration — the current code is correct for single-handing.
Clean shutdown is advisory, not authoritative. Both players produce the
same transaction (spending the channel coin directly to reward coins), so
duplicate submissions between them are harmless. The real race is between the
clean shutdown transaction and an unroll transaction — either side might
initiate an unroll (via go_on_chain) around the same time. Both spend the
channel coin, so only one can land on-chain.
Because of this, the system never blindly trusts that the clean shutdown
landed. When SpendChannelCoinPhase is created for the clean shutdown
path, it stores the exact on-chain solution (ProgramRef) that was
co-signed for the shutdown. When the channel coin spend is detected, the
handler compares the on-chain solution directly against the stored one:
- Clean shutdown landed: The on-chain solution matches the expected
solution byte-for-byte. The handler emits
ChannelStatuswith stateResolvedClean. - An unroll landed instead: The solution does not match. The handler
runs the puzzle to extract conditions, then matches
CREATE_COINpuzzle hashes against theunroll_puzzle_hash_mapto identify which unroll state landed. Since no games are active, the unroll creates only reward coins;finish_on_chain_transitionfinds an empty game map and transitions toOnChainPhase. The outcome is the same correct balances, just with more on-chain transactions.
A malicious peer could craft a clean shutdown conditions list that includes
unrecognized opcodes (timelocks, announcements, etc.) alongside the valid
CREATE_COIN outputs. The victim's condition parser only checks that the
expected payout exists; it does not reject unknown opcodes.
This is a griefing vector bounded to time and transaction fees, not a fund-safety issue, for two reasons:
- CLVM conditions are additive. In Chialisp, conditions are a flat list
of outputs and assertions. A
CREATE_COINoutput cannot be cancelled, reduced, or redirected by any other condition in the same spend. The only effect of additional conditions is to make the entire spend fail (e.g. an unsatisfied timelock prevents the transaction from being mined). No condition can selectively remove or modify another condition's output. - The unroll fallback always exists. If the clean shutdown spend fails to land (because the attacker's extra conditions prevent mining), both sides fall back to the unroll path. The unroll path produces the same correct balance split — it just costs more time (unroll timeout + game timeouts) and more transaction fees. The attacker pays the same cost.
Reward payout destinations are separately protected by AGG_SIG_UNSAFE
signatures exchanged during the handshake (see
Reward Payout Signatures), so the attacker
cannot redirect the victim's share to a different address.
src/session_phases/mod.rs—pending_clean_shutdownfield,drain_queue_into_batch(stores shutdown metadata),process_queued_message(receivesCleanShutdownCompleteand createsSpendChannelCoinPhase)src/session_phases/spend_channel_coin_phase.rs—handle_channel_coin_spent,handle_unroll_from_channel_conditions
Preemption is the mechanism that prevents stale unrolls from succeeding. When a player sees the channel coin being spent to an unroll coin, they compare the on-chain sequence number against their own latest state:
| On-chain SN vs ours | Action | Explanation |
|---|---|---|
| On-chain < ours, opposite parity, signed preemption source | Preempt (immediate) | Spend the unroll coin immediately with our higher SN and more up-to-date conditions |
| On-chain < ours, same parity (or no signed preemption source) | Wait for timeout | The parity rule forbids preemption; use the compact historical timeout record we signed |
| On-chain == ours | Wait for timeout | The unroll is at the state we expect; wait for it to resolve |
| Unknown puzzle hash or conditions-hash mismatch | Error | Never signed, or not the unroll we signed — classified when the channel coin spend is parsed |
Preemption is immediate — no timelock. This is by design: the preempting
player gets first-mover advantage because they're correcting an out-of-date
unroll. Timeouts require waiting for unroll_timeout blocks.
(See the Parity rule in the Unroll Coin section for why only the opponent can preempt a given unroll.)
Regardless of which path resolved the unroll coin, the result is the same: game
coins and reward coins are created. The game code then uses
set_state_for_coins to determine if a redo is needed (see Step 3 above).
Key code: src/channel_state/mod.rs — channel_coin_spent,
make_preemption_unroll_spend
When preemption fails (e.g. the preemption transaction is not mined in time) and the opponent's stale unroll succeeds via timeout, the system enters stale unroll handling rather than treating it as an unrecoverable error.
Staleness is determined by comparing the on_chain_state (the sequence
number retrieved from the compact unroll_puzzle_hash_map record when the
on-chain CREATE_COIN puzzle hash is matched) against the latest received
unroll's state number from ChannelState.
The map retains every historical unroll puzzle hash because old does not mean unspendable: the opponent may still broadcast any earlier unroll carrying the signatures collected at that time. The durable minimum per hash is the state number, committed conditions hash, and timeout conditions. Historical full signatures and preemption conditions are unnecessary because preemption always uses the latest full record. The browser preserves this compact map inside the raw binary cradle stored in IndexedDB.
The latest received unroll state number comes from
self.latest_received_unroll.as_ref().map(|t| t.coin.state_number), and
staleness is computed as:
is_stale = latest_received_state.is_some_and(|t| on_chain_state + 1 < t)
| Condition | Classification |
|---|---|
timeout_state_number() is None, or on_chain_state + 1 >= timeout_state_number() |
Current or Redo — use existing logic |
on_chain_state + 1 < timeout_state_number() |
Stale — opponent unrolled to an outdated state |
This means staleness is judged relative to the local timeout snapshot state,
not from a separate last_received_state tracker.
Current and redo states use the normal on-chain flow described above (Steps
3–5). When the state is stale, finish_on_chain_transition takes a
different path:
- A
ChannelStatusnotification with stateResolvedStaleis emitted, reporting the actual amount in our reward coin (found by scanning the unroll output conditions for our reward puzzle hash). - Each on-chain game coin is matched against live games and pending accepts
by puzzle hash and amount:
- If
coin_ph == live_game.last_referee_puzzle_hashand amounts match → game is alive at its current state. - If
coin_phmatches acached_redo_actionsentry'smatch_puzzle_hashfor the same game and amounts match → game needs a redo move. - Otherwise →
GameError(the coin is present but we can't identify what state it's in).
- If
- Games not found in the unroll outputs receive one of two notifications
depending on whether the game was fully established or still in-flight:
**EndedCancelled** — the game was a recently accepted proposal whose potato round-trip hadn't completed (tracked as aProposalAcceptedentry incached_redo_actions). The opponent hadn't acknowledged the accept when they published the stale unroll, so the game coin never existed in that state. The accept was simply rolled back.**GameError** — the game was an established live game (its accept was acknowledged by a complete round-trip) that should have been present in the unroll but wasn't. This indicates genuinely adversarial or buggy behavior.
- The channel handler does not enter
Failedstate; remaining games continue on-chain.
When a game coin appears in the stale unroll outputs but at an older state than what was played off-chain, the system treats it as a terminal error rather than attempting to resume the game from the stale state. This is intentional for several reasons:
- Rarity. This can only happen when you're having trouble posting transactions (preemption failed, your timeout was too slow, etc.). In that situation you're likely unable to transact reliably anyway.
- Opponent divergence. If the game were resumed from the stale state, the opponent could play a different move than they did off-chain. The code logic and UX for handling that divergence (re-validating a different move history, surfacing "they changed their move" to the player) are complex and error-prone.
- One terminal condition. The current approach guarantees exactly one
terminal event per game — either the game was matched and continues
normally, or it gets a
GameError/EndedCancelled. There is no ambiguous middle state where a game is "maybe recoverable."
| Notification | When |
|---|---|
ChannelStatus { state: ResolvedStale, ... } |
Always emitted when is_stale is true; balances reflect the actual unroll outcome |
GameError { id, reason } |
Per-game: coin present but unrecognizable, or established live game missing from outputs |
EndedCancelled { id } |
Per-game: pending accept (in-flight) absent from outputs — the accept was rolled back |
Key code: src/session_phases/spend_channel_coin_phase.rs —
finish_on_chain_transition, src/channel_state/mod.rs —
set_state_for_coins
When our share of a game is zero, there is no reason to wait for on-chain
timeouts, submit transactions, or perform redo moves — those operations cost
time and transaction fees for no reward. In these cases the system immediately
emits GameSettled { our_share: 0, outcome: … } (a forfeit outcome from the
settlement glossary) and removes the
game from tracking.
Conversely, when our share is the full game amount or the game is terminal with
a positive share, there is no reason to make another move — the best possible
outcome is already available via timeout. In these cases the system
auto-accepts: it queues an AcceptSettlement action and marks the game as
game_finished = true, triggering a clean end.
- No rational incentive (zero reward). When our share is zero the opponent has nothing to gain by playing (they already have everything) and we have nothing to claim. Waiting for a timeout is pure overhead.
- Optimal outcome already reached (auto-accept). When we get 100% on timeout or the game is over and we get a positive share, making a move can only decrease our share or cost fees for no benefit.
- Avoids unnecessary transactions. Submitting a redo move or timeout claim that yields zero reward wastes block space and fees.
- Clean terminal signal. The UX immediately learns the game settled, rather than waiting many blocks for a timeout that produces nothing.
should_auto_settle(game_id, is_my_turn) returns true when:
is_my_turnis true, ANDour_share == game_amount(claim — we get 100%), ORis_game_over() && our_share > 0(terminal clean end — game is finished and we get a positive share).
When both conditions are true (game is over AND our share is the full amount), the result is the same: auto-accept fires.
Auto-accept detection runs at two sites in handle_game_coin_spent:
Expectedpath — opponent moved or a timeout confirmed, creating a new game coin. If the new coin is our turn and auto-accept triggers, the game is markedgame_finished = trueandAcceptSettlementis queued.Movedpath — opponent made a move that advances the game. Same auto-accept check as the Expected path.
build_timeout_claim is self-gating: it returns None when the timeout
transaction would not pay us (our reward puzzle hash is absent from the
spend's output conditions). This means timeout claims are only registered
for coins where the timeout actually benefits us — no explicit skip logic is
needed for our-turn coins where the timeout favors the opponent.
The zero-reward early-out fires at five distinct points.
At unroll completion (scanned in finish_on_chain_transition right after
set_state_for_coins populates the game_map):
- Pending redo with zero reward (intentional). A move or accept was
prepared off-chain but the potato hadn't come back. The unroll lands at the
pre-move state and a redo would otherwise be queued. If the post-move
our_current_sharewould be zero, replaying on-chain is not worth fees — the redo is skipped andGameSettled { outcome: forfeited_skipped_reveal, our_share: 0 }fires. This is correct product behavior, not an error path. Checked viais_redo_zero_reward()(see alsohas_cached_move_for_gamefor whether a non-zero-reward redo will actually be spent). - Pending AcceptSettlement with zero share. An
AcceptSettlementwas called off-chain but the potato round-trip hadn't completed. The coin matches viapending_settlementswithtimeout_claim_armed = true. If our share is zero,GameSettled { outcome: forfeited_we_accepted, our_share: 0 }fires immediately instead of waiting for the on-chain timeout. - Opponent's turn, mover_share == coin_amount. The move was
acknowledged (no redo needed). It's the opponent's turn and
mover_share == coin_amount, meaning the opponent gets everything on timeout and has no incentive to move.GameSettled { outcome: lost, our_share: 0 }fires. This only applies when it's the opponent's turn — when it's our turn andmover_share == coin_amount, we get everything and auto-accept fires instead (see above).
During on-chain play (action requested by UX):
- On-chain move would produce mover_share == coin_amount. In
do_on_chain_move, after computing the move result, if the newmover_share == game_amount(we as the new waiter get zero), the move is not submitted andGameSettled { outcome: forfeited_skipped_reveal, our_share: 0 }fires. This applies to terminal moves too: if playing the terminal move gives the opponent everything, we have no reward to claim and no incentive to spend fees or reveal more state. Games that need to prevent a player from withholding a losing terminal move must encode that incentive in the prior state'smover_share. - On-chain AcceptSettlement with zero share. In
do_on_chain_action'sAcceptSettlementhandler, ifget_game_our_current_share() == 0, the game is removed andGameSettled { outcome: forfeited_we_accepted, our_share: 0 }fires instead of building a timeout claim.
The AcceptSettlement handler covers three cases:
- Zero share (forfeit):
our_share == 0— game is removed,GameSettled { outcome: forfeited_we_accepted, our_share: 0 }is emitted. No timeout claim is built. - Nonzero share (voluntary accept or auto-accept): The handler builds a
timeout claim via
build_timeout_claimand registers it with the wallet (viaRegisterCoin) for submission at maturity. When the claim confirms,GameSettledarrives withwe_acceptedorsettled_cleanly. - Not our turn: The handler marks
timeout_claim_armed = true(the timeout claim was already registered eagerly at coin registration time).
Off-chain AcceptSettlement with zero reward is already handled by
drain_cached_accept_settlements in src/channel_state/mod.rs, which emits
GameSettled { outcome: accept_settlement, our_share } with whatever share
amount applies, including zero.
Key code: src/session_phases/spend_channel_coin_phase.rs —
finish_on_chain_transition (unroll scan),
src/session_phases/on_chain.rs — should_auto_settle, do_on_chain_move
(scenario 4), do_on_chain_action (scenario 5),
build_timeout_claim, register_initial_game_coins,
src/channel_state/mod.rs — is_redo_zero_reward,
get_game_our_current_share, get_game_amount
The referee is the on-chain puzzle that enforces game rules. Each game coin is
curried with RefereePuzzleArgs that encode the full game state.
RefereePuzzleArgs {
mover_pubkey, // public key of the player whose turn it is
waiter_pubkey, // public key of the player waiting
timeout, // blocks before timeout can be claimed
amount, // total amount in the game
game_move: GameMoveDetails {
basic: GameMoveStateInfo {
move_made, // the actual move data
max_move_size, // maximum allowed move size
mover_share, // how much the mover gets if the result settles
},
validation_info_hash, // ValidationInfoHash commitment, or None for terminal
validation_program_hash, // optional raw validator program hash when known
},
previous_validation_info_hash, // ValidationInfoHash: Initial sentinel, or previous validation info hash
validation_program, // the chialisp program that validates moves
nonce, // role-namespaced counter; also serves as the GameID
referee_coin_puzzle_hash, // puzzle hash of the referee puzzle itself
}Players are identified by public keys (not puzzle hashes) in the referee
args. After each move, the new game coin swaps mover_pubkey and
waiter_pubkey — the previous mover becomes the waiter, and vice versa. This
is how the referee enforces alternating turns.
**mover_share semantics.** On any game coin, mover_share is the amount the
current mover receives if the current result is accepted, folded, or claimed by
timeout (the waiter receives amount - mover_share). These are the same
settlement operation reached from different contexts: a player may accept/fold
off-chain, or the opponent may claim the same result on-chain after the relative
timelock expires. However, mover_share is set by the previous move: when a
player moves, they declare new_mover_share as part of their move, and because
roles swap, the value they declare becomes what their opponent (the new
mover) would receive if that result is settled. In other words, when you set
mover_share in your move you are choosing how much to leave the other player
if they accept/fold or fail to respond. A game handler that wants to maximize
its own settlement reward sets mover_share to zero (giving the opponent
nothing); a fair split sets it to whatever the game rules dictate.
The reward destination puzzle hashes are not curried into the referee — instead
they are revealed at timeout or slash via AGG_SIG_UNSAFE (see
Reward Payout Signatures).
A GameID is the nonce — a u64 that serves as both the referee puzzle
differentiator and the canonical identifier used by the API and UI. When
serialized to CLVM for referee puzzle hashes, it goes through the standard
CLVM integer encoding (the same encoding usize::to_clvm uses).
Nonces are role-namespaced: the initiator (the player who starts with the potato) allocates even nonces (0, 2, 4, …) and the responder allocates odd nonces (1, 3, 5, …). Each player increments by 2, so their nonces never collide with the opponent's. Because the nonce is curried into the referee puzzle, distinct nonces guarantee distinct puzzle hashes even for otherwise identical game parameters.
When receiving a proposal, the ChannelState validates that the incoming
nonce has the correct parity for the sender's role and is monotonically
increasing (nonces may be skipped if the sender proposed and cancelled
a game before the potato arrived). Both players use the same GameID to
refer to the game for its entire lifecycle.
The referee runs validator programs to enforce game rules on-chain. For how
validators relate to off-chain handlers, see HANDLER_GUIDE.md.
The referee puzzle (referee.clsp) accepts three types of solutions:
- Timeout (
args = (mover_payout_ph, waiter_payout_ph)):
- Requires
ASSERT_HEIGHT_RELATIVE >= TIMEOUT - Creates a coin of
MOVER_SHAREtomover_payout_ph(if nonzero) - Creates a coin of
AMOUNT - MOVER_SHAREtowaiter_payout_ph(if nonzero) - Requires
AGG_SIG_UNSAFEfrom each player for their respective"x" || payout_ph(only for nonzero shares) - Used when the current mover fails to act in time
- Both players' reward coins are created in a single transaction — whichever player submits the timeout spend creates coins for both sides
- Move (
args = (new_move, infohash_c, new_mover_share, new_max_move_size)):
- Runs the mover's puzzle to authorize the spend
- Creates a new game coin with swapped mover/waiter and updated state;
new_mover_sharebecomes the opponent's (new mover's) share on timeout - Requires
ASSERT_BEFORE_HEIGHT_RELATIVE(must move before timeout) - Requires
AGG_SIG_ME MOVER_PUBKEY (shatree args)on the move args
- Slash (
args = (previous_state, previous_validation_program, evidence, mover_payout_ph)):
- Proves a previous move was invalid by running the validation program
- Slash succeeds (creates
CREATE_COIN mover_payout_ph AMOUNT, slasher takes the full game amount) when the validator returns nil or returns values whose infohash or max_move_size don't match the curried commitments - If the validator raises (CLVM exception), the slash transaction itself
fails to mine — this is why validators must classify malicious moves as
slashable before any evidence-sensitive code can raise (see
CLVM_DOS.md, "Game-Specific Responsibilities") - Validator raises are acceptable only for invalid slash attempts, such as malformed evidence against an otherwise valid move. A malicious move itself must be classified as slashable before evidence-sensitive code can raise.
- Requires
AGG_SIG_UNSAFE MOVER_PUBKEY ("x" || mover_payout_ph)— the same pre-signed payout authorization used by timeouts, so no additional signing is needed at slash time
The referee maintains two sets of args at all times:
**args_for_this_coin()** — the args used to curry the current coin puzzle. This is whaton_chain_referee_puzzle_hash()computes.**spend_this_coin()** — the args used to curry the next coin puzzle (the coin created when this one is spent). This is whatoutcome_referee_puzzle_hash()computes.
These accessors are exposed on Referee (and delegated through
MyTurnReferee / TheirTurnReferee). Internally, my-turn/their-turn state
stores corresponding current/next args fields.
Off-chain, game coins are virtual: they don't exist on the blockchain, but their spends are validated by running real chialisp execution exactly as if they were on-chain. The two sets of args stay in sync because the unroll commitment is updated at each move. If the channel unrolls, these virtual coins become real on-chain coins.
On-chain after unroll, the real coin is created at the state captured by
the last unroll commitment. But off-chain moves may have advanced the virtual
coin beyond that point. A redo replays those subsequent moves on-chain to
bring the real coin up to date. After a redo, the actual coin's puzzle hash
corresponds to outcome_referee_puzzle_hash() rather than
on_chain_referee_puzzle_hash(), because the on-chain moves create coins
with the "next state" args while the referee's internal create_this_coin
still reflects the original unroll-state args.
The get_transaction_for_timeout function handles this by checking the coin's
actual puzzle hash against both accessors and using whichever matches.
last_referee_puzzle_hash on LiveGame always tracks
outcome_referee_puzzle_hash() — the post-move puzzle hash. This is the
expected "latest state" puzzle hash used by set_state_for_coins to determine
whether the game coin is at the latest state or needs a redo.
Key code: src/referee/mod.rs — args_for_this_coin,
spend_this_coin, on_chain_referee_puzzle_hash, outcome_referee_puzzle_hash
Reward destination puzzle hashes are not curried into the referee puzzle.
Instead, they are revealed at timeout or slash as solution arguments, and each
player proves they authorized their payout destination via AGG_SIG_UNSAFE.
Each player caches the opponent's signature during the handshake, so either
player can submit the timeout transaction and it will pay both sides correctly.
A player cannot redirect the opponent's reward because only the opponent's
private key can produce a valid signature for a given puzzle hash.
How it works:
- During the handshake, each player signs
"x" || reward_puzzle_hash(a 33-byte message: the ASCII byte'x'followed by their 32-byte reward puzzle hash) and sends the signature to the other player. - The
RefereeFixedContextstruct stores bothreward_puzzle_hash(ours) andtheir_reward_puzzle_hashalong withtheir_reward_payout_signature. - When a timeout is submitted, the solution provides both payout puzzle
hashes. The referee puzzle emits
AGG_SIG_UNSAFEconditions requiring the mover's signature on"x" || mover_payout_phand the waiter's signature on"x" || waiter_payout_ph(only for nonzero shares). get_transaction_for_timeoutinsrc/referee/mod.rsassembles the aggregate signature:
- If our share is nonzero: sign our own
reward_puzzle_hashwith our private key - If their share is nonzero: include the pre-exchanged
their_reward_payout_signature - Shares of zero are omitted from both the conditions and the aggregate signature
This design means reward puzzle hashes don't need to be curried into every game coin — they're only revealed once, in the final timeout or slash spend.
Both players' reward payout signatures are cached in RefereeFixedContext at game
creation time: my_reward_payout_signature (signed by us) and
their_reward_payout_signature (received during handshake). This avoids
redundant BLS signing during timeout and slash construction — the cached
signatures are simply aggregated into the spend bundle.
The slash path also uses AGG_SIG_UNSAFE with the same "x" || payout_ph
format, so the pre-exchanged reward signature covers both timeout and slash
payouts.
Key code: src/common/standard_coin.rs — sign_reward_payout,
reward_payout_message, verify_reward_payout_signature;
src/referee/types.rs — RefereeFixedContext (caches both signatures)
There is no special "off-chain mode" in RefereePuzzleArgs. Earlier versions
used a sentinel argument to tell handlers they were being run off-chain, but
that made handler logic hard to follow. The current code constructs normal
referee arguments with real mover and waiter pubkeys, substitutes the incoming
move and validation program, and uses the same state-update program that would
be used by the on-chain referee when validating peer moves.
When a move has a follow-on state, the state update is run off-chain first to validate the move and derive that state before the receiving player's their-turn handler interprets it. A terminal move is still a normal move, but it sets the next validation program to nil, so there is no follow-on state to derive for future moves. The their-turn handler still interprets the move and may provide slash evidence. Any evidence it provides is checked by running the normal state-update program with that evidence.
This keeps off-chain and on-chain validation semantics aligned. Some games may repeat a small amount of logic between their on-chain validator and off-chain handler code, but avoiding a separate off-chain signal keeps the handler contract simpler.
The terminology is easy to mix up:
- A
validation_program_hashis the tree hash of a validator program by itself. - A validation info hash is
sha256(validation_program_hash, shatree(state)). It commits to both the validator program and the state that program validates.
The referee coin stores validation info hashes, not bare program hashes. The
current move's commitment is stored in game_move.validation_info_hash, and the
previous move's commitment is stored in previous_validation_info_hash. These
fields use the ValidationInfoHash enum, which has three variants with
distinct CLVM encodings:
| Variant | CLVM encoding | Truthy? | When used |
|---|---|---|---|
None |
() (nil / empty atom) |
No | Game over — no further moves, only slash or timeout |
Initial |
0x78 ('x', 1 byte) |
Yes | Initial game coin — no previous move exists |
Hash(h) |
32-byte atom | Yes | Normal play — validation info hash |
The initial game coin's previous_validation_info_hash is set to Initial
(the single-byte sentinel 0x78). This is truthy in CLVM, which matters
because the referee puzzle checks truthiness of previous_validation_info_hash
to decide whether moves are allowed — a falsy (nil) value means the game can
only slash or timeout. The sentinel is a single byte rather than a full hash
to save on-chain space, since there is no real previous validation program to
reference and slashing is impossible when no moves have been made.
Each move propagates the current validation info hash (INFOHASH_B) into the
next coin's previous_validation_info_hash (INFOHASH_A). The move solution
also supplies infohash_c for the next validator/state pair; on the move path
the referee accepts this optimistically, and on the slash path it recomputes
the infohash from the validator return and checks that it matches. There is no
separate code path for the initial state — the same derivation logic applies
uniformly to all moves.
The raw validation program hash is not always carried alongside the referee
coin. It is available when the code has the validation program itself (for
example when invoking a their-turn handler or constructing a slash), but the
durable on-chain commitment is the validation info hash. GameMoveDetails
therefore keeps the raw program hash in the optional
validation_program_hash field separately from the required
validation_info_hash commitment.
This is an on-chain size/cost optimization. Honest move spends present only the compact validation info hash for the next validator/state pair; they do not reveal the validation program or state separately. Those larger pieces are revealed only on the slash path, where the referee needs them to recompute the infohash and prove that the optimistic move commitment was invalid.
Key code: src/referee/types.rs — ValidationInfoHash,
RefereePuzzleArgs;
src/referee/my_turn.rs, src/referee/their_turn.rs;
clsp/referee/onchain/referee.clsp
The OnChainPhase maintains a game_map: HashMap<CoinString, OnChainGameState> that tracks each game coin's state, including an our_turn
flag.
- Initial game coin (from unroll): Set by
set_state_for_coins. If a redo is needed,our_turn = true(we need to submit the redo). Otherwise,our_turn = is_my_turn()based on the referee state. - After opponent's move (
TheirSpend::ExpectedorTheirSpend::Moved):our_turn = true— the opponent just moved, so now it's our turn.
In the TheirSpend(Expected) path of handle_game_coin_spent, the channel
handler's referee may be one step ahead of the on-chain state (because we
already processed the next move off-chain but the response was dropped). In
this case, game_is_my_turn() returns false (the referee thinks it is the
opponent's turn), but on-chain it is actually our turn to submit the redo.
When a redo is generated via take_cached_move_for_game, our_turn is set to
true to reflect the on-chain reality. Without this correction, the spend that
resolves the intermediate redo coin would be misread (see below), producing
wrong notifications.
Notifications are confirmation-driven: they are emitted from
handle_game_coin_spent when the game coin's actual spend is observed, not when
the timelock matures. our_turn records whose move the on-chain state is
waiting on, and the handler combines it with what the observed spend created
to decide the terminal status:
- Spend pays our reward puzzle hash → our claim confirmed:
GameSettledwith outcomes such aswe_accepted,settled_cleanly, orslashed_opponent. - Spend pays the opponent's reward puzzle hash → the opponent acted first:
a normal move advances the game, a timeout claim against our pending move
yields
opponent_timed_outorattempt_to_move_failed, and a timeout claim while we were attempting to slash yieldsopponent_cheated.
our_turn is a key input, but not the only one: the accepted-state and the
pending-slash / pending-move bookkeeping on OnChainGameState also steer the
branch. In particular, an opponent's timeout claim against our pending OurMove
coin can yield attempt_to_move_failed (rather than being misread as a win
against a stale, optimistically-advanced referee state).
Both players maintain independent game_maps, and both should have
complementary our_turn values for the same game coin.
timeout_claim_armed: true means this side has already armed the on-chain
timeout-claim path for the game coin (eager register or explicit
AcceptSettlement). It does not mean the observed on-chain coin is already
terminal. The real chain coin might still represent the version captured by
the unroll, which can be one step behind the local potato state, so an armed
coin may still advance to another game coin before timeout finality.
When that happens, handle_game_coin_spent keeps tracking the created coin and
registers the next timeout claim under "timeout-claim-armed game coin advanced by redo".
The timeout_claim_armed flag is carried forward: once the actual chain state
reaches a reward-coin spend, the terminal GameSettled notification is emitted
from the observed conditions.
When a game coin's resolving spend is observed, the game is removed from
game_map and live_games. If a user-queued Move for that game is still on
the game_action_queue, it is discarded when popped: do_on_chain_action checks
get_current_coin and falls through to process_queued_action if the game is gone, and
do_on_chain_move checks my_move_in_game — returning None (game absent)
causes a discard, while Some(false) (game alive, not our turn) causes a
requeue. This prevents stale moves from crashing or looping after a legitimate
timeout.