Skip to content

Play Money & the Claim Board

How beta players get playSKPK (perpetual play money, no cash value) — the earn-to-play claim board that replaces the faucet. Config-driven, server-authoritative, and mints exactly once per earned claim. See the chip-claim-board decision for the full rationale and playSKPK for the on-chain grant.

The model

Each user has a set of claim sections. A section is a chunk of playSKPK gated by a condition, in one of three states:

  • locked — the condition isn't met yet; the board shows what unlocks it.
  • lit — claimable now.
  • claimed — done.

Two kinds of section:

  • Baseline — 500 playSKPK, refills weekly and does not stack past 500 (don't claim → don't accumulate). The recurring reason to come back.
  • Unlock — one-time, escalating. Each lights when its condition is really met (an onboarding step now, a social task later).

The load-bearing rule: a section is lit only when its condition is really met (server-detected), and claiming a locked or already-claimed section grants nothing. Play chips are game value — the board must never be a free-mint. State is server-authoritative; the UI reflects it, never drives it.

Data model — three tables, three roles

The claim board is three tables, each with one job. Never conflate them.

TableRoleShape
claim_sectionsCatalog (config, not code)one row per section per environment
user_claim_sectionsState (per user)one row per (user_id, section_key)
claim_ledgerAudit (immutable)one row per successful claim

claim_sections — the env-scoped catalog

The section catalog is data an operator edits at runtime, not code — add a section, reorder, retune any amount, change the baseline, or disable a section with no deploy.

  • key, condition_key (null for the baseline), kind (baseline | unlock), amount, order, enabled, cadence (weekly | once), environment.
  • Unique on (environment, key).
  • Environment resolution mirrors game_settings: 'all' is the every-deployment default (what the migration seeds), and a concrete-environment row (local / staging / production) overrides the 'all' row for the same key. So staging can run different amounts than prod, and disabling a section on staging (an environment='staging' row with enabled=false) hides the seeded 'all' row there without touching prod.

Reads go through @numero/db's getClaimCatalog(db, environment) / getClaimSection(db, key, environment)env-pure (the caller passes its TABLE_ENVIRONMENT; the db package reads no process.env). Game-server's claim-board/catalog.ts resolves the deployment environment and loads through it, logging the resolved environment + loaded section count at boot so a mis-scoped or empty catalog is visible (never a silent zero-section board).

kind and cadence are IMMUTABLE once a key exists. An operator edits amount / conditionKey / order / enabled freely, but the operator write path (PATCH /admin/claim-sections) refuses a kind or cadence change on an existing (environment, key) with 409 kind_cadence_immutable. This is a fund-safety guard, not a nicety: kind/cadence determine the claim's period, which is part of claim_id = keccak(userId:sectionKey:period) — the claim_ledger UNIQUE that stops double-mint. Flipping a claimed unlock to a baseline would give the same paid section a new claimId and re-open it for a weekly re-mint. Defense-in-depth backs this at the claim path: an unlock pays a given user at most once, ever — the claim blocks (credits nothing) on any prior ledger row for that key, robust even to a delete-then-recreate. To genuinely change a section's kind, use a new key. (Amount is also validated as a non-negative integer — a fractional amount is a 400 invalid_amount.)

user_claim_sections — per-user state

The current, mutable state of each section for a user: state (locked | lit | claimed), lit_at, claimed_at, period, claim_id. Unique on (user_id, section_key).

For the recurring baseline, claimability is derived — the period column holds the window key of the last claim, and the baseline is claimable again once the current window is past that stored period. Nothing is cron-pushed; the window comes from DB time.

claim_ledger — immutable audit

One row per successful claim: user_id, section_key, amount, claim_id, grant_tx, created_at. The unique claim_id is the database-level no-double-credit floor (the cage's on-chain grant_marker PDA is the on-chain floor). The ledger is append-only; it feeds audit, PMF activation analysis, and anti-abuse.

The kill-switch

The whole feature is gated by claim_board_enabled (a game_settings global, fail-closed OFF), resolved per-environment — so the board, markConditionMet, and the claim endpoint all deploy dark, and turning it on in prod is a flag flip, not a deploy (the betting_enabled precedent). Staging can flip on while prod stays dark.

How sources light sections

A grant source (onboarding steps now, social tasks later) never touches section state. It fires a condition key through the one generic seam:

markConditionMet(userId, conditionKey, idempotencyKey)

The engine lights every enabled catalog section bound to that conditionKey (locked → lit), idempotent by state — a lit or claimed section is never re-lit, so a source firing twice is a no-op. Config decides which keys pay and how much: a section's condition_key binds it to a source's event, and adding a new reward source is a catalog row + a new condition key with zero engine change (assertion A14 proves this by lighting a section for a never-before-seen key).

In-process sources call markConditionMet directly (the six onboarding conditions are detected inside game-server). Cross-service sources and tests use the service-key seam:

POST /internal/claim-board/condition-met   { userId, conditionKey, idempotencyKey }   (x-service-key)

The condition keys the beta seed binds:

Condition keyFired whenSectionAmount
wallet_linkeda Solana wallet is linkedconnect_wallet500
agent_version_publishedthe first agent version is publishedbuild_agent500
agent_seatedthe agent takes a seatseat_agent1000
hand_flaggeda hand decision is flaggedinspect_decision1000
agent_version_republisheda new version is published after a flagtune_republish2500

Claiming — server-rechecked, never a free-mint

The claim endpoint is JWT-authed and identity comes solely from the JWT (never a body/query userId) — the claim mints money to the authenticated user.

GET  /claim-board/state              (JWT)   — the caller's board
POST /claim-board/claim {sectionKey} (JWT)   — claim a section

claimSection runs inside a transaction:

  1. The kill-switch and the section's live config are re-read (an operator's amount/enabled change takes effect here — A11/A13).
  2. The period key is computed (once for unlocks, the ISO-week for the baseline), and claim_id = keccak256(userId:sectionKey:period) is derived.
  3. If a claim_ledger row already exists for that claim_idalready_claimed, credits nothing (A8).
  4. The section is re-verified claimable — an unlock must be lit (an unmet or UI-bypassed claim is locked, credits nothing — A7/A9); the baseline must be un-claimed this period.
  5. Only on a valid claim does it mint (the cage grant — Phase 3) and write claimed + the immutable ledger row.

claim.condition_met and claim.attempt spans record the flow; claim.attempt.outcome is one of credited | locked | already_claimed | forged.

The mint: claim → cage grant → ledger

On a valid claim, the mint is the cage playSKPK grant (claim-board/grant.ts). playSKPK is Solana-only, so this targets the CHAIN=solana deploy's BANK_URL (= solana-bank):

  1. Resolve the recipient — the user's linked Solana wallet, from auth-server (GET /api/internal/user-wallets-solana/:userId). Fail-closed: no wallet (or any read failure) → no mint, no state change.
  2. GrantPOST {BANK_URL}/api/grant { recipient, amount, claimId } (service-key). The cage mints amount playSKPK to the wallet's ATA and returns the tx signature. claimId is the deterministic keccak256(userId:sectionKey:period) — the cage's on-chain grant_marker PDA makes it single-use.
  3. Record — write the claim_ledger row (with grant_tx) + mark the section claimed.

The order is load-bearing — the grant fires inside the claim transaction, and the ledger + claimed are written only on grant success:

  • no linked wallet → fail-closed, empty commit (no mint, not claimed) — the user links a wallet and retries;
  • cage / on-chain failure → throw → the tx rolls back (never claimed-without-mint) — retryable;
  • success → mint + ledger + claimed, atomic.

No double-mint has two floors: the claim_ledger.claim_id UNIQUE short-circuits a re-claim (already_claimed, no second cage call), and the cage's grant_marker PDA is the on-chain floor (a replay returns { idempotent: true } and mints nothing).

Section state machine

The weekly baseline

The baseline section (500 playSKPK) is the recurring reason to come back. Its claimability is derived, not stored: each cadence has a period keyperiodKeyFor("weekly", now) is the ISO-week key (2026-W29), "once" for unlocks. The baseline is claimable iff the user's last-claimed period differs from the current window:

claimable = !userRow || userRow.period !== currentPeriod

So at the start of a new ISO week the current period advances, the stored period is now stale, and the baseline lights up again — no cron, no scheduled job. On claim, period is stamped to the current window (and the claim_id = keccak256(userId:sectionKey:period) changes weekly, so each week's mint is distinct).

No stacking. A claim credits the section's window amount once — never a sum of skipped weeks. If you skip three weeks and then claim, you get 500, not 1500: the amount is the window's, and the period only records which window you last claimed, never how many you missed.

The period is computed from the process clock at claim time — not a hardcoded date. (A single-game-server beta; a strict DB-now() derivation is a trivial refinement if multiple game-servers ever need window-boundary consensus.)

The board UI — config-driven, affordance-gated

The player-facing board lives at /claim (linked from the profile menu) and is one component — ClaimBoard.tsx (apps/poker/src/poker/components/) over the useClaimBoard hook.

It renders whatever the catalog returns — never a hardcoded section list. The hook fetches the caller's board (GET /claim-board/state) and the component maps the returned sections: the baseline is the loud lime hero, the unlock sections are the ladder (sorted by order). A section added purely in config appears with no code change — an unknown key the component has no display copy for falls back to a humanized key, so a new reward surfaces the moment its catalog row lands (this is exactly what assertion A12 pins).

The claim action is affordance-gated on section.claimable, never validation-gated. A locked section shows its unlock copy; a claimed section shows a "claimed" badge; only a lit section (or the baseline in a fresh window) renders a claim button. There is no disabled-button state — the button is absent until the section is genuinely claimable, matching the bulletproof-state-machine affordance discipline. State is server-authoritative: the board reflects it, the UI never drives it.

The transport is the generic BFF proxy. The browser calls /api/gw/game/claim-board/{state,claim}; the same-origin proxy forwards to game-server's registerClaimBoardRoutes with the Bearer JWT unchanged (identity is JWT-only — the server never trusts a body userId). No dedicated BFF route was needed. Each claim is wrapped in a ui.claim.submit span (otel.category=ui.action, section.key, claim.outcome/error.code) — telemetry never blocks the claim; on success the hook refetches so the board reflects the new server state (claimed / period advanced).