Blackjack where every card you're dealt is a live Megapot lottery ticket, and the dealer's hole card is genuinely unreadable because it lives in Inco encrypted state.
Built for the Megapot x Inco buildathon. Base mainnet, real USDC, real tickets.
Play it: holecard.basedmining.xyz — live on Base mainnet. Bring a wallet with a few dollars of USDC and about a cent of ETH for gas. On a phone, open it inside Coinbase Wallet's or MetaMask's browser; a mobile browser has no wallet for the page to reach.
One caveat worth knowing before you arrive: the house funds the dealer's two cards from its own bankroll, so a hand needs $2 from you and $2 free in the vault. The bankroll is small, public and self-refilling from pots the house wins — if it is dry, Deal says so rather than failing, and anyone can top it up (see Honest limitations in WRITEUP.md).
BlackjackTable |
0xd718C7972bEfB6b9A7e01B69C239Be15E3BD36a4 |
TicketVault |
0x89cBE487361A9A7bcB23832C4D1EeE1e3E6b7bA2 |
| Megapot Jackpot | 0x3bAe643002069dBCbcd62B1A4eb4C4A397d042a2 |
| Megapot ticket NFT | 0x48FfE35AbB9f4780a4f1775C2Ce1c46185b366e4 |
A card is not a representation of a ticket. It is the same on-chain object.
Each card costs $1 and is a real Megapot ticket NFT carrying five normal numbers and a bonus ball. Rank and suit come from Inco's encrypted randomness and are layered on at render time. Whatever tickets you hold when the drawing fires are live entries in that night's jackpot.
Because every card costs a dollar, hitting is never free. This is blackjack where every card you take raises the bet.
The dealer's hole card is drawn as an Inco encrypted handle with no access grant issued to anyone. Its ticket numbers are public, because NFT state is public. Its face is not. You can see the dealer's lucky numbers all game. You just cannot see what card it is.
The rules the contract actually enforces. Nothing here is aspirational.
| Rule | Setting |
|---|---|
| Deck | Infinite shoe, drawn with replacement from Inco encrypted randomness |
| Natural blackjack | Pays 6:5, two cards only. A three-card 21 is a plain win |
| Dealer on soft 17 | Stands (S17). hitSoft17 exists and is off |
| Doubling | Any two cards. No total restriction |
| Double after hit | Not allowed |
| Splitting | Not offered |
| Insurance | Not offered |
| Late surrender | Not offered |
| Push | Each side keeps its own |
| Busting | Permitted after a bust. It only costs the player more on a hand they have already lost |
| Abandoned hands | Force-stood, then resolved on merits. The house never seizes a pot |
On doubling: the original spec restricted it to hard 9, 10 and 11 as a house-edge lever. That was dropped. Enforcing it would mean reading the player's total at settle, long after they acted, and then either voiding the hand or reclassifying the double as a hit, which produces a hand the player never played. The edge is already carried by 6:5, S17 and the infinite shoe. Doubling on a soft 13 is simply a bad bet, and the house does not need protecting from that.
Deployed and played on Base mainnet with real money. The full loop — deal, hit, stand, settle, drawing, claim — has been run end to end, and the payout reconciled to the penny against the tier table.
| Piece | State |
|---|---|
| Inco randomness and reveal on Base mainnet | Verified live |
| Megapot purchase, tagging, transfer | Verified live |
TicketVault |
Deployed, 11 fork tests plus 6 branch tests |
TicketMath live EV |
Deployed, 7 unit tests, surfaced in the UI |
BlackjackTable |
Deployed, 8 fork tests over the hand lifecycle |
BlackjackMath |
Deployed, 34 plaintext rule tests |
| Frontend | Live at holecard.basedmining.xyz (Cloudflare Workers), light and dark themes |
| Reveal and settle service | Live on Fly.io. The browser never calls it — the Worker proxies server-side |
| Megapot Data API | Live. Round activity on the table's activity strip, via /api/activity |
/claim |
Live. Claims by current holder, which Megapot's own site cannot do |
| Hole card attestation | Working. HoleCardProof.sol is the standalone demonstration |
66 tests pass. Of those, 34 are the complete rule set on plaintext cards and need no covalidator, no fork and no Docker — they run in 2 seconds, so every game rule stays demonstrable even if the attestation service is down. The other 32 are unit tests plus two Base mainnet fork suites.
Everything below is measured, not estimated. Where a number came from a single observation it says so.
Everything here was read off live contracts or compiled artifacts, never from documentation. The documentation was wrong fourteen times during this build — each one tabled in WRITEUP.md §9 with the script that re-derives it.
- Inco Lightning is on Base mainnet, chain 8453, deployment
incoLightning_12_0_3__473307884, executor0x4b9911b0191B0b6a6eA8F2Ed562e20Cff5AC8624. Megapot and Inco run on one chain, so there is no bridge and no testnet fallback. claimWinningsis gated on the current NFT holder, not the original purchaser.ownerOf(ticketId) != msg.senderis the only ownership check in the function, and the USDC goes tomsg.sender. Winnings follow the token.- Ticket transfers are unrestricted. No pause, whitelist, soulbound flag or operator filter anywhere in the NFT contract.
referralSchemeis baked into a ticket at mint and survives transfer, so the referrer keeps earning on a ticket after it changes hands._sourceis emit-only. It is the third indexed field onTicketPurchasedand is never written to state, so it is readable from logs and not from any view function.
TicketMath computes a ticket's expected value on-chain from live per-drawing tier payouts and ball bounds. Nothing is hardcoded, because both are owner-settable and change between drawings.
Read off the deployed vault on 2026-08-12, drawing 141:
gross EV 0.773465 USDC
holder value 0.696118 USDC (EV net of the 10% referrer win share)
face 1.000000 USDC
house bid 0.650000 USDC
A ticket is worth less than the dollar it cost, and that is not a haircut the house invented — it is what the maths says. So the UI shows all four figures together: the bid sits just under holder value, not just under face, and the player can check the arithmetic against the chain.
That 10% referrer share is the difference between a plausible number and a correct one. On the first live hand the tier table said $8.095228, the holder received $7.285706 — ratio exactly 0.900000 — and referralFees(treasury) then read $0.809522, the missing tenth to the penny.
Because tier payouts and ball bounds are per-drawing and owner-settable, any figure above is a snapshot with a shelf life of one drawing. The UI never caches them; it reads ticketGrossEv() and ticketHolderValue() live. An earlier build hardcoded drawing 134's numbers and was still displaying them six drawings later, which is the bug this design exists to prevent.
The contracts are the integration. The Data API is the second surface, and it
drives the activity strip along the foot of the table: GET /v1/rounds/active,
/rounds/latest-settled and /rounds/{id}/wins, called from the Worker through
/api/activity so no key and no CORS reach the browser, cached 60s, and
degrading to nothing rather than to a broken band.
It shows Megapot's round activity rather than ours, and that is the point. There are seven hands on this table and they are all the operator's; a ticker cycling one address three times would be manufactured social proof. Megapot's round is real, live, and genuinely the pot every card here is bought into. Each item carries its own source tag so a Megapot winner is never read as a Hole Card player.
Two labelling rules fall out of what the API actually returns, both verified
rather than assumed (scripts/diagnostics/megapot*.mjs):
- Wins carry the settled round's id, not the active one. An active round has no winners — it has not drawn. Showing the last round's winners under the current round's number would be the exact dishonesty the strip exists to avoid.
- They are "top" wins, never "recent".
/rounds/{id}/winssorts by amount descending, not by time.
contracts/
BlackjackTable.sol hand lifecycle, encrypted draws, timeouts, payouts
TicketVault.sol bankroll, ticket custody, reserve accounting, sellback
lib/BlackjackMath.sol the rules, on plaintext card values
lib/TicketMath.sol live expected value from drawing state
interfaces/IMegapot.sol verified Megapot surface
HoleCardProof.sol standalone: a handle nobody can read, then attested
IncoSmoke.sol day 1 gate: randBounded, reveal, ungranted handle probe
frontend/ Next.js 15 App Router, deployed to Cloudflare Workers
app/table/ the game
app/claim/ claim winnings as the CURRENT holder
app/how-it-works/ the rules and the disclosures, in full
app/api/reveal, settle server-side proxies to the Fly service
app/api/activity server-side proxy to the Megapot Data API
app/api/drawing live jackpot, tier payouts, countdown
app/api/last-drawing the last settled drawing's winning numbers
components/ActivityTicker Megapot round activity, marquee along the foot
components/ThemeToggle light / dark, persisted, follows the OS by default
lib/useTable.ts the whole client state machine for a hand
lib/abi.generated.ts addresses and ABIs, generated from artifacts
app/globals.css both themes, as one token set
service/ reveal + settle, on Fly.io
server.mjs holds the Inco SDK, so it never enters a browser
scripts/
deploy.ts deploys the vault and table, wires them together
verifyDeploy.ts deployed bytecode against this source
fund.ts move USDC bankroll into the vault (dry run by default)
approveTable.ts raise the player's allowance before a session
claim.ts claim winnings for held tickets
genFrontendAbi.ts regenerates frontend/lib/abi.generated.ts
diagnostics/ read-only probes, one per question asked of the chain
test/
blackjackMath.test.ts 34 rule tests, plaintext, no fork and no covalidator
ticketMath.test.ts EV maths against real drawing data
blackjackTable.fork.test.ts hand lifecycle against a Base mainnet fork
ticketVault.fork.test.ts vault against a pinned Base mainnet fork
Requires a Base mainnet RPC. There is deliberately no public-endpoint fallback: mainnet.base.org throttles silently and caches failures for 24 hours, which is a worse failure mode than a clean error.
npm install
cp .env.example .env # fill in by hand
npx hardhat compile
npx hardhat test # 66 tests; blackjackMath.test.ts alone needs no RPCnpx hardhat test test/blackjackMath.test.ts runs the complete rule set in about two seconds with no RPC, no fork and no covalidator. Start there.
A separate npm package from the Hardhat root, deliberately: a root-level install hoists over @inco/lightning's nested @openzeppelin/contracts 5.4.0 and breaks the contract build. Never install them together.
cd frontend
npm install
npm run dev # port 3100, not 3000It talks to the deployed mainnet contracts as-is — there is no local chain and no mock mode, because the encrypted hole card only exists on a chain with Inco on it. Reads work with no wallet: the jackpot, the prize tiers, the countdown, the last drawing and the activity strip all render for a visitor who never connects.
Two themes. Light is Megapot's system; dark takes the felt to near-black navy with the periwinkle pulled back to an accent. It is not an inversion — card faces stay white because a card is paper and a dark face reads as a card back, and the ticket number plates stay light because those digits are the smallest type in the product. The toggle has three states, not two: dark, light, or unset, where unset means follow prefers-color-scheme rather than defaulting to light. A blocking inline script applies a stored choice before first paint, so a dark-mode visitor never gets a white flash.
Contrast is measured, not assumed. Walking every element that paints its own background and computing its contrast against its resolved ground gives zero elements below WCAG AA on /table, /claim and /how-it-works, in both themes. That pass is what caught the ticket-number plates inverting in dark, and a primary button that had dropped to 3.44:1.
lib/abi.generated.ts is generated. After changing a contract, run npx hardhat run scripts/genFrontendAbi.ts from the root rather than editing it — it carries the addresses too, and hand-editing it is how an enum gets transcribed backwards.
Deploying is npx opennextjs-cloudflare build && npx wrangler deploy, which needs CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID.
cd service
npm install
KEEPER_KEY=0x... BASE_RPC_URL=... npm startThis exists so the Inco SDK never enters the browser. It holds the only key that can attest a reveal, and the frontend reaches it through /api/reveal and /api/settle, which are server-side routes in the Worker, so the service address stays out of the client bundle and there is no CORS surface.
The box is a Fly app and Fly gives it a public hostname, so treat it as reachable and not as a private network. Nothing is protected by that hostname being obscure. The hole card is gated on chain state, not on who is asking: handleReveal reads handState and only requests the dealerHole handle once the hand has reached AwaitingSettle, which is the state stand() moves it to by calling e.reveal(). Before that the handle is never fetched, and if it were, no ACL grant exists for it and the covalidator would refuse. An anonymous caller hitting /reveal on a hand in play gets holeCard: null.
It is stateless apart from a reveal cache; restarting it loses nothing.
Deploy with ./deploy-fly.sh; set its key with ./set-keeper-key.sh, which reads from .env without sourcing it.
typescript5.8.3. npm otherwise installs TypeScript 7, whichts-node10.x cannot read.@openzeppelin/contracts5.4.0 exactly, matching@inco/lightning. Any other version hoists over Inco's nested copy and breaks the build.- solc 0.8.30 with
evmVersion: "cancun". Inco's access control useststore/tload.
Every action in a hand is a separate signature — deal, each hit, stand — because
deal() and hit() pull USDC from msg.sender at the moment you act. Every card
is a real purchase from your own wallet.
The fix is an embedded wallet, and it is the first thing after the jam. Privy-style embedded wallets — the mechanism behind one-tap play in Base App — are provisioned for the user and can sign inside a scoped session without a prompt per action. The property that makes this the right answer rather than a shortcut is that an embedded wallet is exportable: the key belongs to the player, so tickets still mint to an address they genuinely own and can walk away with. Session keys scoped to this table are the same idea with a narrower grant.
That is a frontend change, not a contract change. It is absent from v1 for time, not for architecture.
What we deliberately would not do is route play through a relayer or a
project-controlled burner key. Both remove the prompts and both mean the winning
tickets belong to an address that is not the player's, which breaks the one claim
this project is built on. EIP-7702 is live on Base — measured, 51 type-4
transactions in 30 blocks (scripts/diagnostics/eip7702Probe.ts) — but it changes
what an EOA can do, not who broadcasts, so on its own it does not remove a prompt
either.
A prepaid balance — deposit once, play against it, withdraw whenever — is the
other half of the roadmap and does require a contract change, since deal() reads
msg.sender per hand and the contracts were frozen and deployed before the
deadline.
Three signatures a hand is a real product cost. It is disclosed here rather than discovered.
Numbers from scripts/diagnostics/revealBench.ts, on 13 handles:
| Approach | Time |
|---|---|
One batched attestedReveal |
42.6s |
| 13 concurrent single calls | 7.1s |
| Concurrency capped at 6 | 10.2s |
| Concurrency capped at 4 | 14.7s |
The covalidator resolves a batched array serially, so passing the whole hand in one call costs six times the latency of firing them concurrently. Both the keeper and the reveal service use concurrent single calls for this reason. Cached values are served without touching the network, so a second read of the same hand is ~0.7s.
MIT.