Reveal adjacent-mine counts, redeploy, and fix silent event-watching failure - #1
Merged
Conversation
Safe tiles now publish their neighbour-mine count to every player while the reward still goes only to the revealer. The count is committed inside the Merkle leaf (tileIndex, isMine, adjacentMines, nonce), so it cannot be forged -- a wrong count fails proof verification -- and revealBoard recomputes every count onchain from the published layout, proving the hints served during play were honest. This required storing width/height, since neighbour counts are undefined without the grid shape. Before this the board had no adjacency numbers at all, so no deduction was possible and every click was a blind coin flip. Also in this change: - MerkleInterop.t.sol: differential test feeding the contract a root computed by the TypeScript, pinning both implementations together. - createRound bounds width*height to uint16, since tile indices are uint16 and larger boards would contain unrevealable tiles. - useBoardSync polls revealedTiles() instead of watching TileRevealed. Monad's public RPC has no eth_newFilter and caps eth_getLogs at 100 blocks, so viem's event watching fails silently: reveals landed onchain while the UI never updated. - startRound marks the round started immediately after the tx is mined. It previously did so only after a follow-up entrantsOf read, which hit the RPC rate limit and left the broker believing an InProgress round hadn't started -- unrecoverable, since cancelRound is Open-only. - Broker refuses proofs to non-entrants, matching the contract's NotEntered check; otherwise a non-entrant gets a guaranteed-revert tx and still pays gas_limit * price. - Frontend: wrong-network guard with a switch button, per-connector connect buttons via EIP-6963, and surfaced connect/broker errors that were previously swallowed. - NotEntered and InvalidDimensions added to both hand-maintained ABIs. - Redeployed to 0x1970bA7FceE762a529ED61D22880859F7a0E3Ab7, verified on Monadscan and MonadVision. Earlier addresses are ABI-incompatible. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011RD82Rtpo7UgwF8urKXAZA
origin/main's changes (entry-fee enforcement, two-step round lifecycle, receipt-waiting, roundId from RoundCreated) were already applied by hand in this branch, so every conflict resolved to this side -- which contains both. Verified afterwards that each of those changes is still present, and that forge test (17) plus both typechecks pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011RD82Rtpo7UgwF8urKXAZA
There was a problem hiding this comment.
🔵 Needs a closer look
A confirmed address-normalization bug in server/src/roundManager.ts allows bypassing per-player freeze state by reconnecting with different address casing.
Pull request overview
This PR upgrades the Minesweeper tournament protocol to publish verifiable adjacent-mine hints for revealed safe tiles (by committing adjacentMines into each Merkle leaf) and updates the broker + frontend to stay in sync on Monad’s limited public RPC by polling on-chain state instead of relying on log filters.
Changes:
- Commit and surface
adjacentMineshints end-to-end (Solidity leaf schema + server Merkle implementation + UI rendering). - Add on-chain round dimensions and a
revealedTiles(roundId)view for reliable mid-round state reconstruction. - Harden broker/frontend operational behavior (start-round semantics, entrant-gating, wallet/network UX, and improved error surfacing).
File summaries
| File | Description |
|---|---|
| web/lib/abi.ts | Updates hand-maintained frontend ABI for new params/fields/errors and new views. |
| web/hooks/useGameSocket.ts | Extends WS message types to include adjacentMines where needed. |
| web/hooks/useBoardSync.ts | Switches board sync to polling revealedTiles(); keeps event watch best-effort for attribution. |
| web/components/Tile.tsx | Renders classic Minesweeper hint numbers with per-count styling. |
| web/components/Board.tsx | Plumbs per-tile adjacentMines into Tile. |
| web/app/page.tsx | Adds wrong-network gating + per-connector connect buttons; submits adjacentMines with proofs; improves status/errors. |
| server/src/roundManager.ts | Adds dimensions to round creation, entrant caching/gating, includes adjacentMines in safe replies, and broadcasts hints on reveal. |
| server/src/merkle.ts | Commits adjacentMines into leaf hashing and board commitments. |
| server/src/chain.ts | Updates chain calls/events to include dimensions and adjacentMines; adds entrant reads. |
| server/src/board.ts | Computes adjacentMines for all tiles to match on-chain adjacency logic. |
| server/src/abi.ts | Updates hand-maintained server ABI for new params/fields/errors and new views. |
| README.md | Updates deployed address and documents ABI-breaking redeploy rationale. |
| contracts/test/MinesweeperTournament.t.sol | Updates tests for new leaf schema, dimensions, and hint behavior; adds new coverage for hint integrity. |
| contracts/test/MerkleInterop.t.sol | Adds differential test to pin Solidity/TypeScript Merkle + adjacency logic byte-for-byte. |
| contracts/src/MinesweeperTournament.sol | Implements adjacentMines commitment, dimensions, tileHint, revealedTiles, updated events/errors, and on-chain adjacency recomputation in revealBoard. |
| AGENT_CONTEXT.md | Updates agent documentation to the new commitment schema, deploy, and RPC-driven design constraints. |
Review details
Suppressed comments (1)
server/src/roundManager.ts:184
playeris normalized for the entrant check, butfrozenUntilis keyed by the un-normalized address. A client can bypass the freeze timer (and potentially other per-player state keyed by address) by reconnecting with a differently-casedplayerquery param. Normalize once and use the normalized value consistently for map lookups/updates.
if (!round.entrants.has(normalizeAddress(player))) {
return { type: "error", message: "you have not entered this round" };
}
const now = Date.now();
const frozenUntil = round.frozenUntil.get(player) ?? 0;
if (frozenUntil > now) {
return { type: "frozen", remainingMs: frozenUntil - now };
}
if (round.revealed.has(tileIndex)) {
return { type: "already-revealed", tileIndex };
}
if (tileIndex < 0 || tileIndex >= round.board.totalTiles) {
return { type: "error", message: "tile index out of range" };
}
if (round.board.isMine[tileIndex]) {
const freezeUntil = now + env.freezeMs;
round.frozenUntil.set(player, freezeUntil);
- Files reviewed: 16/16 changed files
- Comments generated: 0
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
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.
What this does
Safe tiles now publish their adjacent-mine count to every player, while the reward still goes only to the revealer. Before this the board had no numbers at all — no deduction was possible and every click was a blind coin flip.
The count is committed inside the Merkle leaf (
tileIndex, isMine, adjacentMines, nonce), so it cannot be forged: a wrong count fails proof verification.revealBoardrecomputes every count on-chain from the published layout, so the end-of-round reveal proves the hints served during play were honest, not just that the mine layout was unaltered. That required storingwidth/height, since neighbour counts are undefined without the grid shape.Also included
MerkleInterop.t.sol— differential test that feeds the contract a Merkle root computed by the TypeScript broker. It only passes if the leaf encoding, tree shape, odd-node promotion and neighbour-count rule all agree byte-for-byte. Verified it fails when the Solidity adjacency is deliberately broken.createRoundboundswidth * heighttouint16— tile indices areuint16, so larger boards would contain tiles nobody could ever reveal, leaving the round unable to finish.useBoardSyncpollsrevealedTiles()instead of watchingTileRevealed. Monad's public RPC has noeth_newFilterand capseth_getLogsat 100 blocks, so viem's event watching fails silently — reveals landed on-chain and paid out while the UI never updated.startRoundmarks the round started immediately after the tx is mined. It previously did so only after a follow-upentrantsOfread, which hit the RPC's 15/sec limit and left the broker believing anInProgressround hadn't started — unrecoverable, sincecancelRoundisOpen-only.NotEnteredcheck; otherwise a non-entrant gets a guaranteed-revert transaction and still paysgas_limit * priceon Monad.NotEnteredandInvalidDimensionsadded to both hand-maintained ABIs.AGENT_CONTEXT.mdcorrected — its leaf-schema formula was stale, which the file itself warns means "every proof breaks".Deployment
Redeployed to
0x1970bA7FceE762a529ED61D22880859F7a0E3Ab7, verified on Monadscan and MonadVision. Earlier addresses are ABI-incompatible and abandoned.Verification
forge test— 17 passed, 0 failedtsc --noEmitclean in bothserver/andweb/tileHintpublicly readable, non-entrant refusedKnown-unfixed (documented in AGENT_CONTEXT.md)
Board seed lives only in RAM — restarting the broker mid-round strands the pool permanently, and
cancelRoundisOpen-only so there's no escape hatch. This destroyed live testnet rounds twice during development. Worth fixing before this handles anything real.🤖 Generated with Claude Code
https://claude.ai/code/session_011RD82Rtpo7UgwF8urKXAZA