Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
126 changes: 126 additions & 0 deletions DEPLOYMENT.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
# Deployment Guide

This guide covers deploying:

1. Smart contracts to Monad testnet (`monadTestnet`)
2. Next.js frontend to Vercel

## 1. Prerequisites

- Node `>=20.18.3`
- Yarn `3.2.3` (via Corepack)
- A funded Monad testnet wallet for contract deployment
- Optional but recommended:
- `NEXT_PUBLIC_ALCHEMY_API_KEY`
- `NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID`

From repo root:

```bash
yarn install
```

## 2. Configure Environment Variables

### Hardhat env (`packages/hardhat/.env`)

```bash
cp packages/hardhat/.env.example packages/hardhat/.env
```

Set API keys in `packages/hardhat/.env`:

```bash
ALCHEMY_API_KEY=...
ETHERSCAN_MAINNET_API_KEY=...
```

Create or import deployer key (writes `DEPLOYER_PRIVATE_KEY_ENCRYPTED`):

```bash
yarn account:generate
# or
yarn account:import
```

### Next.js env (`packages/nextjs/.env.local`)

```bash
cp packages/nextjs/.env.example packages/nextjs/.env.local
```

Set:

```bash
NEXT_PUBLIC_ALCHEMY_API_KEY=...
NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID=...
```

## 3. Deploy Contracts to Monad Testnet

Check deployer wallet and balance:

```bash
yarn account
```

Deploy:

```bash
yarn deploy --network monadTestnet
```

Notes:

- You will be prompted for the password used to encrypt `DEPLOYER_PRIVATE_KEY_ENCRYPTED`.
- Deployment artifacts are written to `packages/hardhat/deployments/monadTestnet`.
- The frontend contract map is auto-generated at `packages/nextjs/contracts/deployedContracts.ts`.

## 4. Verify Contracts

Recommended command:

```bash
yarn hardhat:hardhat-verify --network monadTestnet <CONTRACT_ADDRESS>
```

You can also use:

```bash
yarn verify
```

This runs `packages/hardhat/scripts/verifyContract.ts` and attempts verification from the latest deployment artifact.

## 5. Deploy Frontend to Vercel

From repo root:

```bash
yarn vercel:login
yarn vercel
```

For production deployment:

```bash
yarn vercel --prod
```

Set these Vercel project env vars before production deploy:

- `NEXT_PUBLIC_ALCHEMY_API_KEY`
- `NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID`
- `GAME_ENGINE_URL` (optional; only if you run an external game engine API)

## 6. Post-Deploy Checklist

1. Open deployed app URL and connect wallet.
2. Switch to Monad testnet in wallet.
3. Confirm contracts appear and reads succeed.
4. If tournament feed is needed, ensure `GAME_ENGINE_URL` points to a reachable API.

## 7. Important Caveat (Game Engine)

`yarn run-game` currently runs on `localhost` and reads `packages/hardhat/deployments/localhost`.
For a production game-engine service, you will need to adapt `packages/hardhat/scripts/runGame.ts` and deployment wiring for non-local networks.
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,8 +107,10 @@ Visit our [docs](https://docs.scaffoldeth.io) to learn how to start building wit

To know more about its features, check out our [website](https://scaffoldeth.io).

For deployment steps in this repo, see [DEPLOYMENT.md](./DEPLOYMENT.md).

## Contributing to Scaffold-ETH 2

We welcome contributions to Scaffold-ETH 2!

Please see [CONTRIBUTING.MD](https://github.com/scaffold-eth/scaffold-eth-2/blob/main/CONTRIBUTING.md) for more information and guidelines for contributing to Scaffold-ETH 2.
Please see [CONTRIBUTING.MD](https://github.com/scaffold-eth/scaffold-eth-2/blob/main/CONTRIBUTING.md) for more information and guidelines for contributing to Scaffold-ETH 2.
134 changes: 131 additions & 3 deletions packages/nextjs/app/tournaments/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,16 @@

import { use, useEffect, useRef, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useRouter, useSearchParams } from "next/navigation";
import { toast } from "sonner";
import { formatEther } from "viem";
import { useAccount } from "wagmi";
import { ArrowLeftIcon } from "@heroicons/react/24/outline";
import { BettingPanel } from "~~/components/poker/BettingPanel";
import { EnterAgentModal } from "~~/components/poker/EnterAgentModal";
import { GameFeed } from "~~/components/poker/GameFeed";
import { LastTournamentResultsModal } from "~~/components/poker/LastTournamentResultsModal";
import type { LastTournamentResult } from "~~/components/poker/LastTournamentResultsModal";
import { PokerTable } from "~~/components/poker/PokerTable";
import { TournamentStatusBadge } from "~~/components/poker/TournamentStatusBadge";
import {
Expand All @@ -18,6 +20,7 @@ import {
useScaffoldWriteContract,
} from "~~/hooks/scaffold-eth";
import { useGameFeed } from "~~/hooks/useGameFeed";
import type { GameEvent } from "~~/hooks/useGameFeed";
import { useTournament } from "~~/hooks/useTournaments";

function toChipNumber(value: unknown): number {
Expand All @@ -30,18 +33,63 @@ function toChipNumber(value: unknown): number {
return 0;
}

function toOptionalNumber(value: unknown): number | null {
if (typeof value === "number" && Number.isFinite(value)) return value;
if (typeof value === "bigint") return Number(value);
if (typeof value === "string") {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
}
return null;
}

function parseResultFromEvents(tournamentId: number, eventList: GameEvent[]): LastTournamentResult | null {
const winnerEvent = [...eventList].reverse().find(event => event.type === "winner");
if (!winnerEvent) return null;

let winnerName = "Unknown winner";
let winnerSeat: number | null = null;
let totalPot: number | null = null;

try {
const parsed = JSON.parse(winnerEvent.data) as Record<string, unknown>;
if (typeof parsed.name === "string" && parsed.name.trim()) winnerName = parsed.name.trim();
winnerSeat = toOptionalNumber(parsed.seat);
totalPot = toOptionalNumber(parsed.totalPot);
} catch {
// Ignore malformed winner payloads
}

const lastHandStartEvent = [...eventList].reverse().find(event => event.type === "hand_start");
let handsPlayed: number | null = null;
if (lastHandStartEvent) {
try {
const parsed = JSON.parse(lastHandStartEvent.data) as Record<string, unknown>;
handsPlayed = toOptionalNumber(parsed.hand);
} catch {
// Ignore malformed hand_start payloads
}
}

return { tournamentId, winnerName, winnerSeat, totalPot, handsPlayed };
}

export default function TournamentDetail({ params }: { params: Promise<{ id: string }> }) {
const { id } = use(params);
const router = useRouter();
const searchParams = useSearchParams();
const tournamentId = BigInt(id);

const { tournament, agents, refetch } = useTournament(tournamentId);
const { events, isLoading: feedLoading } = useGameFeed(id);
const { address: connectedAddress } = useAccount();
const [showEnterModal, setShowEnterModal] = useState(false);
const [showLastTournamentResultModal, setShowLastTournamentResultModal] = useState(false);
const [lastTournamentResult, setLastTournamentResult] = useState<LastTournamentResult | null>(null);
const [autoStarting, setAutoStarting] = useState(false);
const notifiedEventCountRef = useRef<number | null>(null);
const autoFollowRef = useRef(false);
const handledRedirectRef = useRef<string | null>(null);

const { data: operatorAddress } = useScaffoldReadContract({
contractName: "PokerVault",
Expand Down Expand Up @@ -121,12 +169,85 @@ export default function TournamentDetail({ params }: { params: Promise<{ id: str
const currentId = Number(id);
if (latestTournamentId <= currentId) return;

const query = new URLSearchParams({ fromEndedTournament: String(currentId) });
const previousTournamentResult = parseResultFromEvents(currentId, events);
if (previousTournamentResult) {
query.set("winnerName", previousTournamentResult.winnerName);
if (previousTournamentResult.winnerSeat !== null) {
query.set("winnerSeat", String(previousTournamentResult.winnerSeat));
}
if (previousTournamentResult.totalPot !== null) {
query.set("winnerPot", String(previousTournamentResult.totalPot));
}
if (previousTournamentResult.handsPlayed !== null) {
query.set("handsPlayed", String(previousTournamentResult.handsPlayed));
}
}

autoFollowRef.current = true;
toast("Next tournament is live", {
description: `Moving to Tournament #${latestTournamentId}`,
});
router.push(`/tournaments/${latestTournamentId}`);
}, [id, isFinished, nextTournamentId, router]);
router.push(`/tournaments/${latestTournamentId}?${query.toString()}`);
}, [events, id, isFinished, nextTournamentId, router]);

useEffect(() => {
const redirectedFrom = searchParams.get("fromEndedTournament");
if (!redirectedFrom) return;

const key = `${id}:${redirectedFrom}`;
if (handledRedirectRef.current === key) return;
handledRedirectRef.current = key;

const redirectedFromId = Number(redirectedFrom);
const currentId = Number(id);
const clearRedirectQuery = () => router.replace(`/tournaments/${id}`);

if (!Number.isInteger(redirectedFromId) || redirectedFromId <= 0 || redirectedFromId >= currentId) {
clearRedirectQuery();
return;
}

const winnerName = searchParams.get("winnerName")?.trim() || "";
const winnerSeat = toOptionalNumber(searchParams.get("winnerSeat"));
const winnerPot = toOptionalNumber(searchParams.get("winnerPot"));
const handsPlayed = toOptionalNumber(searchParams.get("handsPlayed"));

const hasQueryResult = winnerName.length > 0 || winnerSeat !== null || winnerPot !== null || handsPlayed !== null;

if (hasQueryResult) {
setLastTournamentResult({
tournamentId: redirectedFromId,
winnerName: winnerName || (winnerSeat !== null ? `Seat ${winnerSeat}` : "Unknown winner"),
winnerSeat,
totalPot: winnerPot,
handsPlayed,
});
setShowLastTournamentResultModal(true);
clearRedirectQuery();
return;
}

let cancelled = false;
fetch(`/api/game/${redirectedFromId}`)
.then(response => (response.ok ? response.json() : { events: [] }))
.then(json => {
if (cancelled) return;
const parsedResult = parseResultFromEvents(redirectedFromId, (json?.events as GameEvent[] | undefined) ?? []);
if (parsedResult) {
setLastTournamentResult(parsedResult);
setShowLastTournamentResultModal(true);
}
})
.catch(() => {})
.finally(() => {
if (!cancelled) clearRedirectQuery();
});

return () => {
cancelled = true;
};
}, [id, router, searchParams]);

useEffect(() => {
if (events.length === 0) return;
Expand Down Expand Up @@ -462,6 +583,13 @@ export default function TournamentDetail({ params }: { params: Promise<{ id: str
onClose={() => setShowEnterModal(false)}
/>
)}
{lastTournamentResult && (
<LastTournamentResultsModal
open={showLastTournamentResultModal}
onOpenChange={setShowLastTournamentResultModal}
result={lastTournamentResult}
/>
)}
</div>
);
}
Loading
Loading