feat(app): consume structured pushflip:* events in EventFeed (Pre-Mainnet 5.0.9 PR 2)#1
Merged
Merged
Conversation
…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.
Member
|
Nicely done! Good catch on that gap. Love the tests, thank you sir! |
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.
7 tasks
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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/):
parseEventLog()+parseTransactionEvents()+ GameEvent / GameEventKind types. Handles both the raw "Program log: pushflip:..." form as it appears inmeta.logMessagesandlogsNotifications.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.App (app/src/):
renderEventMessage(GameEvent)maps each of the 16 kinds to feed text. Pubkey hex → base58 via Kit'sgetAddressDecoder(HexPubkey on-chain emits lowercase hex; regex is case-insensitive as defense against a future format drift). Stake / pot / amount fields route throughparseU64rather than rawBigInt()— Lesson #42 applies even to program-authored strings; keeps the rule "no raw BigInt() on external input" uniform across the workspace. ExportsexplorerTxUrl+EVENT_CATEGORY(8-bucket palette keyed offRecord<GameEventKind, ...>so adding a new kind without a case here is a compile error).diffGameSessions. OpenslogsNotifications({mentions: [gamePda]})FIRST, then kicks off backfill viagetSignaturesForAddress({limit: 50})getTransactionin concurrent batches of 10 (Promise.allSettledso 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. ExposesisBackfillingso EventFeed can show skeletons. Subscribe-first ordering is load-bearing: opening in reverse would leave a silent gap betweengetSignaturesForAddressreturning and the WebSocket connecting, dropping any event that fired in that window.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 rarenullblockTime case (old / pruned blocks).Design decisions worth flagging:
blockTimesemantics are "best-effort Unix seconds" — chain truth for backfilled rows (viagetTransaction), wall-clock- on-arrival for live rows (thelogsNotificationspayload does NOT include blockTime, and an extragetBlockTime(slot)per live event isn't worth the RPC round-trip for log display). Documented inline on theGameEventinterface.logIndexis the position in the FULLlogMessagesarray, 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.(slot DESC, logIndex DESC), NOTDate.now(). Client mount time has no relation to backfill event time; a wall-clock sort would scramble the feed every page load.Promise.allSettledoverPromise.allin the backfill loop: one rejectedgetTransactionshouldn't drop the other 9 in the batch. Rejections are logged vialogErrorand the batch continues.Known limitations (acceptable for now):
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 indrainSubscriptionif it ever bites.Verified:
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.