Skip to content

fix(program): validate end_round token-account destinations + align tie-break with spec#2

Merged
georgedonnelly merged 1 commit into
mainfrom
fix/end-round-token-account-validation
Apr 25, 2026
Merged

fix(program): validate end_round token-account destinations + align tie-break with spec#2
georgedonnelly merged 1 commit into
mainfrom
fix/end-round-token-account-validation

Conversation

@RamirezAlex
Copy link
Copy Markdown
Collaborator

end_round accepted caller-supplied winner_token_account and treasury_token_account without verifying their SPL Token "owner" field matched the computed winner / configured treasury. SPL Token's Transfer CPI only checks mint-match between from/to, so anyone authorized to call end_round (any player in turn_order, not just authority/dealer) could redirect both the pot and the rake to their own ATA for the same mint.

  • Adds verify_token_account helper in utils/accounts.rs (checks SPL Token program ownership, mint at bytes 0..32, and owner field at bytes 32..64).
  • Wires it into end_round at all three Transfer sites: rake -> treasury, winner payout -> winner, rollover sweep -> treasury.
  • Adds InvalidTokenAccount error variant.

Tie-break alignment (related, same redirect class):

The winner-selection loop used strict >, leaving winner_index = None on ties or all-zero scores. The unvalidated transfer then paid out to whatever winner_token_account the caller passed -- a second instance of the same redirect vulnerability. Switching to winner_index.is_none() || score > highest_score makes the first stayed player in turn_order win ties, which is the documented design intent (EXECUTION_PLAN.md Task 1.12.2 step 3, Task 1.12.3 test #2). The implementation had drifted from the spec; this realigns it.

Verified: cargo fmt + cargo build --all-targets clean, 42/42 lib tests pass, 11/11 phase2 integration tests pass. The three previously-passing integration tests (test_full_round_with_prize_distribution, test_vault_empty_after_payout, test_full_game_lifecycle_with_all_mechanics) had relied on the redirect bug to pay out under tied zero-scores; they now pass through the documented tie-break path.

…ie-break with spec

end_round accepted caller-supplied winner_token_account and treasury_token_account
without verifying their SPL Token "owner" field matched the computed winner /
configured treasury. SPL Token's Transfer CPI only checks mint-match between
from/to, so anyone authorized to call end_round (any player in turn_order, not
just authority/dealer) could redirect both the pot and the rake to their own
ATA for the same mint.

- Adds verify_token_account helper in utils/accounts.rs (checks SPL Token program
  ownership, mint at bytes 0..32, and owner field at bytes 32..64).
- Wires it into end_round at all three Transfer sites: rake -> treasury,
  winner payout -> winner, rollover sweep -> treasury.
- Adds InvalidTokenAccount error variant.

Tie-break alignment (related, same redirect class):

The winner-selection loop used strict `>`, leaving winner_index = None on ties
or all-zero scores. The unvalidated transfer then paid out to whatever
winner_token_account the caller passed -- a second instance of the same redirect
vulnerability. Switching to `winner_index.is_none() || score > highest_score`
makes the first stayed player in turn_order win ties, which is the documented
design intent (EXECUTION_PLAN.md Task 1.12.2 step 3, Task 1.12.3 test #2). The
implementation had drifted from the spec; this realigns it.

Verified: cargo fmt + cargo build --all-targets clean, 42/42 lib tests pass,
11/11 phase2 integration tests pass. The three previously-passing integration
tests (test_full_round_with_prize_distribution, test_vault_empty_after_payout,
test_full_game_lifecycle_with_all_mechanics) had relied on the redirect bug to
pay out under tied zero-scores; they now pass through the documented tie-break
path.
@georgedonnelly georgedonnelly force-pushed the fix/end-round-token-account-validation branch from bee53b6 to cc70c71 Compare April 25, 2026 17:17
@georgedonnelly
Copy link
Copy Markdown
Member

Beautiful catch, thank you sir! Really appreciate it!

@georgedonnelly georgedonnelly merged commit 7cc8700 into main Apr 25, 2026
georgedonnelly added a commit that referenced this pull request Apr 25, 2026
…follow-up)

Add two litesvm rejection tests that hold PR #2's verify_token_account
fix in place. PR #2 closed a Critical token-account-redirect vulnerability
in `end_round` (caller-supplied destination + no owner check → any player
in turn_order could redirect the pot or rake to their own same-mint ATA).
Without these tests, a future refactor that drops a verify_token_account
call would still pass `cargo test`.

Tests:
- `test_end_round_rejects_wrong_owner_winner_ata` — covers the winner
  payout wire (`end_round.rs:200`). Passes a legitimate finished round
  but substitutes an attacker-owned ATA as `winner_token_account`.
- `test_end_round_rejects_wrong_owner_treasury_ata` — covers the rake
  wire (`end_round.rs:187`) independently. Passes a legitimate winner
  ATA but a wrong-owner treasury. The rollover-sweep wire (line 223)
  shares the same helper invocation pattern and is covered by extension.

Both tests:
- Assert `Custom(33)` (`InvalidTokenAccount`) via the centralized
  `INVALID_TOKEN_ACCOUNT_CODE` constant — extracts the magic discriminant
  to one place with a doc-comment explaining the position-in-enum coupling
  to `program/src/errors.rs`.
- Assert post-rejection balances on vault, attacker ATA, and treasury.
  The rake CPI fires and succeeds before the winner-side check rejects,
  so the treasury balance assertion is the strongest invariant locked
  down: if Solana's atomic rollback ever broke, this would catch it.

Setup deduplicated into a `setup_finished_round` helper.

Also records the audit completion in docs/EXECUTION_PLAN.md as item 5 of
the PR follow-ups block — sweep of all four SPL Token Transfer CPIs in
the program found no other vulnerabilities of this class (3 in end_round
now guarded; 1 in join_round transfers to a deterministic vault PDA, not
caller-supplied).

Verified: cargo build --all-targets clean, phase2 suite 11/11 → 13/13.
No source files in program/ change — the fix is already on main from PR #2.
georgedonnelly added a commit that referenced this pull request Apr 27, 2026
…e 4.3 fix v2)

Phase 4.3 dry-run #2 reported failure even though the deploy was fine:

  ✓ pushflip-pod active
  ✓ pushflip-vite active
  ✓ pushflip-faucet active
  [deploy] public-URL smoke check
  ✗ https://play.pushflip.xyz/ returned HTTP 502 (expected 200)

Root cause: systemd reports `is-active` the moment the podman ExecStart
returns (container in init), but the process inside (`serve` for vite,
Hono for faucet) needs another 1-3 seconds to bind to its TCP port.
nginx hits the upstream before the bind completes → 502 → script
fails the smoke check → user runs the rollback command unnecessarily.

Fix: replace the single curl with a smoke_check() function that
retries up to 5 times with 2s / 4s / 6s / 8s backoff (max 20s extra
wait). Same defensive pattern as the existing systemctl is-active
loop above. The backoff doesn't add wallclock to a healthy deploy
(first attempt succeeds), only to the timing-tight cases.

Verified: site is up at https://play.pushflip.xyz/ — the previous
"failed" deploy was fully successful, just smoke-checked too eagerly.
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 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