Skip to content

feat(app): consume structured pushflip:* events in EventFeed (Pre-Mainnet 5.0.9 PR 2)#1

Merged
georgedonnelly merged 1 commit into
mainfrom
feat/event-feed-rewrite
Apr 25, 2026
Merged

feat(app): consume structured pushflip:* events in EventFeed (Pre-Mainnet 5.0.9 PR 2)#1
georgedonnelly merged 1 commit into
mainfrom
feat/event-feed-rewrite

Conversation

@RamirezAlex
Copy link
Copy Markdown
Collaborator

Replaces the state-diff event feed with an authoritative stream reconstructed from the on-chain pushflip:<kind>:... log lines every state-changing instruction emits (Pre-Mainnet 5.0.9 PR 1, deployed 2026-04-15 at tx 3i6KEFmMysL...). Three problems the diff-based hook couldn't solve: the feed wasn't authoritative (a second device saw a different log), it died on refresh (held only in React state), and it couldn't answer "what happened before I opened this tab?". All three are fixed here.

Client parser (clients/js/):

  • New src/events.ts — framework-agnostic parseEventLog() + parseTransactionEvents() + GameEvent / GameEventKind types. Handles both the raw "Program log: pushflip:..." form as it appears in meta.logMessages and logsNotifications.value.logs, and the already-stripped form. Returns null for non-pushflip lines, unknown kinds, malformed k=v pairs, runtime "Program success" lines, CPI interleaved chatter.
  • New src/events.test.ts — golden fixtures pinned verbatim to the 16 format strings in program/src/instructions/. 46 new cases covering all kinds (with + without the "Program log: " prefix), id composition, and negatives (SPL token CPI interleaved, runtime lines, unknown kind, truncated, malformed segment, empty key, empty tail, batched-instruction future case, blockTime=null). Client test count 35 → 81.
  • src/index.ts re-exports the new public surface.

App (app/src/):

  • New lib/event-render.ts — renderEventMessage(GameEvent) maps each of the 16 kinds to feed text. Pubkey hex → base58 via Kit's getAddressDecoder (HexPubkey on-chain emits lowercase hex; regex is case-insensitive as defense against a future format drift). Stake / pot / amount fields route through parseU64 rather than raw BigInt() — Lesson #42 applies even to program-authored strings; keeps the rule "no raw BigInt() on external input" uniform across the workspace. Exports explorerTxUrl + EVENT_CATEGORY (8-bucket palette keyed off Record<GameEventKind, ...> so adding a new kind without a case here is a compile error).
  • Rewritten hooks/use-game-events.ts — drops diffGameSessions. Opens logsNotifications({mentions: [gamePda]}) FIRST, then kicks off backfill via getSignaturesForAddress({limit: 50})
    • getTransaction in concurrent batches of 10 (Promise.allSettled so a flaky RPC / 429 / pruned tx doesn't drop the whole batch; rejections logged and skipped). Merges into an id-keyed Map, sorts by (slot DESC, logIndex DESC), slices to MAX_EVENTS=200. Toast only live events — backfill inserts silently so a page load doesn't spray 50 toasts. Exposes isBackfilling so EventFeed can show skeletons. Subscribe-first ordering is load-bearing: opening in reverse would leave a silent gap between getSignaturesForAddress returning and the WebSocket connecting, dropping any event that fired in that window.
  • components/game/event-feed.tsx — Explorer "tx" link per row (target="_blank" + rel="noopener noreferrer"), 4 skeleton rows during backfill, 8-category color palette replacing the previous 6 (16 kinds → coarse buckets: admin / lifecycle / deck / join-leave / hit / stay / burn / bounty). blockTime-aware timestamp with slot-number fallback for the rare null blockTime case (old / pruned blocks).

Design decisions worth flagging:

  • blockTime semantics are "best-effort Unix seconds" — chain truth for backfilled rows (via getTransaction), wall-clock- on-arrival for live rows (the logsNotifications payload does NOT include blockTime, and an extra getBlockTime(slot) per live event isn't worth the RPC round-trip for log display). Documented inline on the GameEvent interface.
  • logIndex is the position in the FULL logMessages array, not the position among pushflip matches — so event ids stay stable if future program changes add or remove surrounding CPI calls around the emission site.
  • Sort key is (slot DESC, logIndex DESC), NOT Date.now(). Client mount time has no relation to backfill event time; a wall-clock sort would scramble the feed every page load.
  • Promise.allSettled over Promise.all in the backfill loop: one rejected getTransaction shouldn't drop the other 9 in the batch. Rejections are logged via logError and the batch continues.

Known limitations (acceptable for now):

  • Pre-existing lint errors on main (constants.ts noBarrelFile, use-faucet.ts trailing-comma format, no-publickey-in-hook-deps .grit format) are NOT in scope for this diff — confirmed on HEAD before this branch was cut.
  • logsNotifications({mentions: [pda]}) support varies across RPC providers. The public devnet endpoint supports it (per Kit's typings overloading the filter variant). If a private RPC rejects, fallback is 'all' + client-side filter — a 3-line change in drainSubscription if it ever bites.
  • RPC backfill returns up to 50 signatures. Games with deep history will truncate; the typical game tree is <20 events so this is comfortable for now.

Verified:

  • pnpm --filter @pushflip/client test → 81/81 passing (was 35)
  • pnpm --filter @pushflip/app typecheck clean
  • pnpm --filter @pushflip/app lint clean on the 3 changed + 3 new files introduced by this diff
  • pnpm --filter @pushflip/app build clean (~430 ms, same bundle size as before)
  • pnpm --filter @pushflip/scripts exec tsc --noEmit clean
  • pnpm --filter @pushflip/faucet typecheck clean
  • pnpm --filter @pushflip/dealer test → 11/11 passing
  • cargo check --all-targets clean (same pre-existing warnings, nothing new)

docs/EXECUTION_PLAN.md update recording PR 2 completion will land in a follow-up docs(plan) commit per the usual session- closing pattern, once this has had a devnet bake.

…nnet 5.0.9 PR 2)

Replaces the state-diff event feed with an authoritative stream
reconstructed from the on-chain `pushflip:<kind>:...` log lines every
state-changing instruction emits (Pre-Mainnet 5.0.9 PR 1, deployed
2026-04-15 at tx 3i6KEFmMysL...). Three problems the diff-based hook
couldn't solve: the feed wasn't authoritative (a second device saw a
different log), it died on refresh (held only in React state), and
it couldn't answer "what happened before I opened this tab?". All
three are fixed here.

Client parser (clients/js/):
- New src/events.ts — framework-agnostic `parseEventLog()` +
  `parseTransactionEvents()` + GameEvent / GameEventKind types.
  Handles both the raw "Program log: pushflip:..." form as it
  appears in `meta.logMessages` and `logsNotifications.value.logs`,
  and the already-stripped form. Returns null for non-pushflip
  lines, unknown kinds, malformed k=v pairs, runtime
  "Program <id> success" lines, CPI interleaved chatter.
- New src/events.test.ts — golden fixtures pinned verbatim to the
  16 format strings in program/src/instructions/. 46 new cases
  covering all kinds (with + without the "Program log: " prefix),
  id composition, and negatives (SPL token CPI interleaved,
  runtime lines, unknown kind, truncated, malformed segment,
  empty key, empty tail, batched-instruction future case,
  blockTime=null). Client test count 35 → 81.
- src/index.ts re-exports the new public surface.

App (app/src/):
- New lib/event-render.ts — `renderEventMessage(GameEvent)` maps
  each of the 16 kinds to feed text. Pubkey hex → base58 via
  Kit's `getAddressDecoder` (HexPubkey on-chain emits lowercase
  hex; regex is case-insensitive as defense against a future
  format drift). Stake / pot / amount fields route through
  `parseU64` rather than raw `BigInt()` — Lesson #42 applies
  even to program-authored strings; keeps the rule "no raw
  BigInt() on external input" uniform across the workspace.
  Exports `explorerTxUrl` + `EVENT_CATEGORY` (8-bucket palette
  keyed off `Record<GameEventKind, ...>` so adding a new kind
  without a case here is a compile error).
- Rewritten hooks/use-game-events.ts — drops `diffGameSessions`.
  Opens `logsNotifications({mentions: [gamePda]})` FIRST, then
  kicks off backfill via `getSignaturesForAddress({limit: 50})`
  + `getTransaction` in concurrent batches of 10
  (`Promise.allSettled` so a flaky RPC / 429 / pruned tx doesn't
  drop the whole batch; rejections logged and skipped). Merges
  into an id-keyed Map, sorts by (slot DESC, logIndex DESC),
  slices to MAX_EVENTS=200. Toast only live events — backfill
  inserts silently so a page load doesn't spray 50 toasts.
  Exposes `isBackfilling` so EventFeed can show skeletons.
  Subscribe-first ordering is load-bearing: opening in reverse
  would leave a silent gap between `getSignaturesForAddress`
  returning and the WebSocket connecting, dropping any event
  that fired in that window.
- components/game/event-feed.tsx — Explorer "tx" link per row
  (`target="_blank"` + `rel="noopener noreferrer"`), 4 skeleton
  rows during backfill, 8-category color palette replacing the
  previous 6 (16 kinds → coarse buckets: admin / lifecycle /
  deck / join-leave / hit / stay / burn / bounty).
  blockTime-aware timestamp with slot-number fallback for the
  rare `null` blockTime case (old / pruned blocks).

Design decisions worth flagging:
- `blockTime` semantics are "best-effort Unix seconds" — chain
  truth for backfilled rows (via `getTransaction`), wall-clock-
  on-arrival for live rows (the `logsNotifications` payload does
  NOT include blockTime, and an extra `getBlockTime(slot)` per
  live event isn't worth the RPC round-trip for log display).
  Documented inline on the `GameEvent` interface.
- `logIndex` is the position in the FULL `logMessages` array,
  not the position among pushflip matches — so event ids stay
  stable if future program changes add or remove surrounding
  CPI calls around the emission site.
- Sort key is `(slot DESC, logIndex DESC)`, NOT `Date.now()`.
  Client mount time has no relation to backfill event time; a
  wall-clock sort would scramble the feed every page load.
- `Promise.allSettled` over `Promise.all` in the backfill loop:
  one rejected `getTransaction` shouldn't drop the other 9 in
  the batch. Rejections are logged via `logError` and the
  batch continues.

Known limitations (acceptable for now):
- Pre-existing lint errors on main (constants.ts noBarrelFile,
  use-faucet.ts trailing-comma format, no-publickey-in-hook-deps
  .grit format) are NOT in scope for this diff — confirmed on
  HEAD before this branch was cut.
- `logsNotifications({mentions: [pda]})` support varies across
  RPC providers. The public devnet endpoint supports it (per
  Kit's typings overloading the filter variant). If a private
  RPC rejects, fallback is `'all'` + client-side filter — a
  3-line change in `drainSubscription` if it ever bites.
- RPC backfill returns up to 50 signatures. Games with deep
  history will truncate; the typical game tree is <20 events
  so this is comfortable for now.

Verified:
- pnpm --filter @pushflip/client test → 81/81 passing (was 35)
- pnpm --filter @pushflip/app typecheck clean
- pnpm --filter @pushflip/app lint clean on the 3 changed + 3 new
  files introduced by this diff
- pnpm --filter @pushflip/app build clean (~430 ms, same bundle
  size as before)
- pnpm --filter @pushflip/scripts exec tsc --noEmit clean
- pnpm --filter @pushflip/faucet typecheck clean
- pnpm --filter @pushflip/dealer test → 11/11 passing
- cargo check --all-targets clean (same pre-existing warnings,
  nothing new)

docs/EXECUTION_PLAN.md update recording PR 2 completion will
land in a follow-up `docs(plan)` commit per the usual session-
closing pattern, once this has had a devnet bake.
@georgedonnelly
Copy link
Copy Markdown
Member

Nicely done! Good catch on that gap. Love the tests, thank you sir!

@georgedonnelly georgedonnelly merged commit 53f6cab into main Apr 25, 2026
georgedonnelly added a commit that referenced this pull request Apr 28, 2026
Pre-Mainnet 5.2 / Phase 4. Wraps the existing Dealer class
(dealer/src/dealer.ts) in a Hono daemon that signs commit_deck on
chain and serves card reveals to the frontend. Decisions locked in
2026-04-28 per docs/wiki/operations/dealer-runbook.md.

Dealer service:
- dealer/src/service.ts: Hono daemon. Endpoints GET /health,
  GET /round/:gameId, GET /reveal/:gameId/:roundNumber/:leafIndex.
  Loads env config, opens RPC + WS, holds a single Dealer instance
  for one game_id. Boot-time SOL balance check + ZK-artifact
  pre-flight, fail-fast on missing env or missing keypair. Binds
  explicitly to 127.0.0.1 so nginx is the only ingress.
- dealer/src/commit-tx.ts: extracts the commit_deck submission path
  (shuffle -> build ix with COMMIT_DECK_COMPUTE_LIMIT=400_000 CU
  bump -> blockhash -> sign -> confirm) so the auto-commit loop and
  any future caller share one implementation.
- dealer/Dockerfile: same install discipline as faucet/Dockerfile
  after Lessons #54-#56 (npm install, --network=host on build,
  --ignore-scripts, workspace:* rewrite, retry loop, cache mount).
  ZK artifacts baked into the image at /app/zk-artifacts.
- dealer/package.json: adds @hono/node-server, @solana/kit,
  @solana-program/compute-budget, hono, @pushflip/client.

Auto-commit poll loop (Decision #2):
- Polls GameSession every 5s; commits when !round_active &&
  !deck_committed && active_player_count >= 2 && round_number !=
  committedRoundNumber.
- commitInFlight mutex is claimed BEFORE the first await so two
  setInterval ticks cannot race past the check while one is mid-
  fetch (heavy-duty review H2).
- Reset branch checks !roundActive && !deckCommitted to distinguish
  "round ended" from "committed-waiting-for-start"; resetting on
  roundActive alone would wipe the local Merkle tree the moment
  commit_deck lands but before start_round runs (H1).
- Manual POST /commit/:gameId deliberately NOT exposed: it would
  have been reachable unauthenticated via the public /api/dealer/*
  nginx prefix and lacked the player-count guard the auto-loop has
  (H3). Re-add behind a shared-secret header if ever needed.

Frontend wiring (Decision #4):
- app/src/hooks/use-game-actions.ts: hit() now reads on-chain
  round_number + draw_counter, fetches the matching reveal from the
  dealer (5s timeout, hex-decoded Merkle proof, schema validated),
  then submits the hit instruction. resolveDealerUrl mirrors
  resolveFaucetUrl - VITE_DEALER_URL required in prod, rejected if
  cross-origin, localhost:3002 default in dev.
- HEX_SIBLING_HASH regex hoisted to module scope per biome's
  useTopLevelRegex.

init_game configurability (Decision #1):
- scripts/init-game.ts accepts DEALER_PUBKEY env var. When set, the
  on-chain dealer field of GameSession points at the dedicated
  dealer keypair instead of the CLI wallet. Mirrors the 5.0.7 faucet
  pattern: tucker compromise yields "can re-shuffle this game's
  deck", not arbitrary token movement. Defaults to wallet.address.

Heavy-duty review fixes applied before commit:
- C1: dealer/Dockerfile rewrite list now includes dealer/package.json
  (was missing - npm install would have failed with
  EUNSUPPORTEDPROTOCOL on workspace:*, Lesson #55 redux).
- M3: Dockerfile comment env var corrected to ZK_ARTIFACTS_DIR.
- M2: hex regex no longer case-insensitive.
georgedonnelly added a commit that referenced this pull request Apr 28, 2026
Update the dealer runbook now that the five open decisions are
resolved and the implementation has landed:

- Decision #1: dedicated dealer keypair (Option C - new game with
  pushflip-dealer.json, separate blast radius from CLI wallet).
- Decision #2: polling auto-commit (5s tick, MIN_PLAYERS_TO_COMMIT
  guard). Manual /commit endpoint NOT exposed (heavy-duty review H3).
- Decision #3: commit_deck submission extracted to
  dealer/src/commit-tx.ts.
- Decision #4: frontend hit() fetches reveal from
  /api/dealer/reveal/:gameId/:roundNumber/:leafIndex.
- Decision #5: nginx /api/dealer/* proxy block. Full diff against
  the live play.pushflip.xyz.conf inlined in the new "nginx diff"
  section so a future deploy doesn't need to re-derive it.

Also notes the explicit 127.0.0.1 binding in service.ts (so nginx
is the only ingress despite Network=host) and updates the deploy
steps to point at the inline diff.
RamirezAlex added a commit that referenced this pull request May 4, 2026
… (GO_TO_SEEKER Phase 1)

Adds the PWA foundation that the Solana Seeker / dApp Store TWA path
inherits (per docs/GO_TO_SEEKER.md prerequisite #1).

- vite-plugin-pwa@1.2.0 with registerType:"prompt" — SW activates only
  on user confirmation via <UpdateBanner>, avoiding surprise reloads
  mid-game. devOptions disabled so HMR doesn't fight the cache.
- Web manifest matches the brand spine (#0a0a0f), display:standalone,
  orientation:portrait (per Pre-Mainnet 5.0.x responsive sweep),
  categories:["games"]. Workbox precache filtered to skip /api/* so
  RPC + dealer + faucet calls bypass any stale-cache hit.
- Icon set generated from public/favicon.svg via @vite-pwa/assets-generator
  (config in pwa-assets.config.ts; reproducible via `pnpm pwa-assets`).
  Adds 64/192/512/1024 + maskable-512 + apple-touch-180 + favicon.ico.
  The 1024 source asset is what the Solana dApp Store listing expects
  in Phase 4 (GO_TO_SEEKER.md).
- <UpdateBanner> uses useRegisterSW from virtual:pwa-register/react;
  emerald palette to distinguish from <DemoStageBanner> (slate, info)
  and <ClusterHint> (amber, warning). No dismissal persistence — a
  user who clicks X sees the banner reappear on the next deploy, since
  stale code can mean wrong tx semantics in a crypto app.
- index.html: standardized mobile-web-app-capable meta (apple-prefixed
  form is deprecated in Chrome ≥122) — both included for backward iOS
  compat. Apple-touch-icon link added explicitly since vite-plugin-pwa
  only auto-injects <link rel="manifest">.
- workbox-window added as explicit devDep — pnpm doesn't hoist peer
  deps, and virtual:pwa-register/react imports it.

Verified: typecheck + lint + production build all clean. dist/ now
emits sw.js + workbox-*.js + manifest.webmanifest + the icon set;
Chrome's Application → Manifest panel reports all fields green and
offers the Install action in the URL bar. Lighthouse PWA install
audit not yet run end-to-end (requires VITE_DEALER_URL / FAUCET_URL /
NICKNAME_URL set at build time to bypass the production guards in
preview).
RamirezAlex added a commit that referenced this pull request May 8, 2026
…O_TO_SEEKER Phase 2)

Adds the MWA adapter to the wallet provider so a TWA-wrapped pushflip
running on Seeker / Saga can authorize via Seed Vault, satisfying
prerequisite #2 of `docs/GO_TO_SEEKER.md`.

- `@solana-mobile/wallet-adapter-mobile@2.2.8` added as runtime dep.
  RN peer-dep warning is harmless: the package has separate browser
  and react-native conditional exports; we only resolve the browser
  bundle. Bundle delta is ~220 bytes (most code was already pulled in
  via shared @Solana deps).
- `wallet-provider.tsx` constructs `SolanaMobileWalletAdapter` inside
  `useMemo([])` — `BaseWalletProvider` tears down internal state when
  the wallets array reference changes, so a stable instance is load-
  bearing. Defaults from the adapter's helper factories
  (`createDefaultAddressSelector` / `createDefaultAuthorizationResultCache`
  / `createDefaultWalletNotFoundHandler`) cover the Seeker happy path
  without bespoke config.
- `appIdentity.uri = window.location.origin` so dev (localhost:5173)
  and production (play.pushflip.xyz) both work without an env-var
  step. MUST match the assetlinks.json domain Phase 3 publishes —
  mismatch fails the MWA handshake silently (same shape as Lesson #46).
- Adapter is registered unconditionally — its `readyState` reports
  `Unsupported` on non-Android contexts, so the wallet modal hides it
  automatically. Following Solana Mobile's documented pattern; gating
  ourselves would duplicate detection the adapter already does.
- New `MWA_CHAIN = "solana:devnet"` constant in `lib/constants.ts`
  decoupled from `RPC_ENDPOINT` (a private mainnet RPC + `solana:mainnet`
  is a valid combo). Becomes a build-time env when the mainnet
  decision (Open Question #1 in GO_TO_SEEKER.md) lands.

Spike conclusion (the M2.1 question that blocked starting Phase 2):
**Lesson #46 fix is adapter-agnostic.** `wallet-bridge.ts` rebuilds
`lifetimeConstraint` from the original Kit message, not from the
signed result, so MWA-returned `VersionedTransaction` instances flow
through the same `compile → sign → fromVersionedTransaction → re-merge
lifetime` path as Phantom/Solflare. No bridge changes required.

Verified: typecheck + full-repo lint + production build + 5/5 tests
all clean. Bundle size: 930.32 → 930.54 KB (gzip 286.27 → 286.27 KB).

Deferred to Phase 2 acceptance (per GO_TO_SEEKER.md, requires Android
hardware/emulator):
- Real-device test on Saga (compatibility floor).
- M2.1 spike's runtime validation — sign one joinRound tx end-to-end
  via the MWA fake-wallet emulator.
- Confirm `appIdentity.uri` matches assetlinks.json once Phase 3
  publishes it.
RamirezAlex added a commit that referenced this pull request May 8, 2026
Status flips from PLANNED to IN PROGRESS. Adds a "Where we are
(2026-05-08)" section per the EXECUTION_PLAN living-status pattern;
marks prerequisites #1 (PWA shell) and #2 (MWA code-side) as ✅ DONE
with commit refs (f132dcb, e3585cd); appends ✅ DONE markers to the
Phase 1 + Phase 2 section headings.

Phase 2 section gains an implementation-deviations subsection
documenting two intentional departures from the original spec, each
with reason:
  1. appIdentity.uri derived from window.location.origin instead of
     a hardcoded play.pushflip.xyz — strictly more flexible (dev +
     prod + future staging hosts auto-track), same security property
     since the assetlinks contract is enforced at the production
     deploy domain level.
  2. Adapter registered unconditionally instead of behind an Android-
     detection gate — adapter's readyState reports Unsupported on
     non-Android, modal hides it; gating ourselves would duplicate
     detection the adapter already does.

M2.1 spike conclusion captured: Lesson #46 fix is adapter-agnostic
(wallet-bridge.ts rebuilds lifetimeConstraint from the original Kit
message, not the signed result), so MWA-returned VersionedTransaction
flows through the same path as Phantom/Solflare. No bridge changes
required.

"Biggest unknowns at draft time" → "Biggest unknowns now"; the Seed
Vault integration cost item is downgraded to "partially answered"
with strikethrough preserving the original — code-level integration
is mechanical (~30 lines + one constant); runtime cost still
unverified pending Android hardware.

Phase 5 brand-asset prerequisite gets a partial-credit note: the
1024×1024 source asset called out as a Phase 4 dApp Store requirement
is already generated as part of the Phase 1 icon set
(app/public/pwa-1024x1024.png), ahead of schedule.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants