GeObts/holecard

Blackjack where every card is a live Megapot lottery ticket and the dealer hole card lives in Inco encrypted state. Base mainnet.

★ 1Forks 0TypeScriptGitHub ↗Compare

README

Hole Card

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

The idea

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.

House rules

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.

Status

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.

Verified on-chain facts

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, executor 0x4b9911b0191B0b6a6eA8F2Ed562e20Cff5AC8624. Megapot and Inco run on one chain, so there is no bridge and no testnet fallback.
  • claimWinnings is gated on the current NFT holder, not the original purchaser. ownerOf(ticketId) != msg.sender is the only ownership check in the function, and the USDC goes to msg.sender. Winnings follow the token.
  • Ticket transfers are unrestricted. No pause, whitelist, soulbound flag or operator filter anywhere in the NFT contract.
  • referralScheme is baked into a ticket at mint and survives transfer, so the referrer keeps earning on a ticket after it changes hands.
  • _source is emit-only. It is the third indexed field on TicketPurchased and is never written to state, so it is readable from logs and not from any view function.

Live ticket expected value

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.

Two Megapot surfaces, not one

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}/wins sorts by amount descending, not by time.

Repo layout

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

Running it

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.

Contracts

npm install
cp .env.example .env      # fill in by hand
npx hardhat compile
npx hardhat test          # 66 tests; blackjackMath.test.ts alone needs no RPC

npx 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.

Frontend

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 3000

It 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.

Reveal and settle service

cd service
npm install
KEEPER_KEY=0x... BASE_RPC_URL=... npm start

This 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.

Toolchain pins that are not optional

  • typescript 5.8.3. npm otherwise installs TypeScript 7, which ts-node 10.x cannot read.
  • @openzeppelin/contracts 5.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 uses tstore/tload.

Known next step: embedded wallets

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.

Measured latency

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.

Licence

MIT.

Issues