GachaStocks

The keeper

The keeper is the automated process that runs the game. It opens rounds, claims fees, snapshots holders, rolls the draw, buys the stock and pays the winner. Everything else on this site is a view of what it did.

The loop

It is a single long-lived process, not a serverless function, because the round cycle has to keep running whether or not anyone is looking at the site. Each tick it:

  1. Finds the live round, or opens one.
  2. If the timer has expired, runs settlement.
  3. Otherwise, refreshes the live preview of pot size and player count.

Only one keeper

Two keepers would open competing rounds and could double-spend the pot. A unique partial index in the database makes a second one fail rather than succeed:

create unique index rounds_single_live_idx
  on rounds ((status in ('open', 'drawing')))
  where status in ('open', 'drawing');

That constraint guarantees at most one round is accepting entries at any moment. It is a safety net, not a scaling strategy.

Restart safety

The keeper can be restarted at any point without invalidating a published commitment. Round seeds are not random values held in memory; they are derived deterministically from a master secret:

serverSeed = HMAC-SHA256(masterSecret, "gacha-stocks:round:" + roundNumber)
commitment = SHA256(serverSeed)

A restarted keeper re-derives the same seed and honours the commitment it published before the restart. Revealing one round's seed tells an observer nothing about any other round, because the master secret never leaves the keeper.

If a keeper dies mid-draw, the next one finds the round stuck in the drawing state and retries it after a timeout rather than stranding it.

Failure handling

Every settlement step is wrapped. If anything throws, the round is marked void with the error recorded, and its SOL rolls into the next round. The failure modes it handles explicitly:

SituationBehaviour
Pot below the gas reserve or minimumVoid, roll the SOL forward
No eligible holdersVoid, roll forward
Keeper wallet below its operating floorRefuse to trade, void, roll forward
No route clears the price-impact capFall back to the three cheapest routes, log a warning
Swap or payout transaction failsVoid with the error recorded, roll forward
Snapshot write fails after payoutLog and continue, the prize is already delivered

Data it reads and writes

  • Solana RPCfor balances, the holder snapshot, and sending transactions. The snapshot uses Helius's paginated token-accounts endpoint, with a provider-agnostic fallback.
  • Meteora DBC for reading unclaimed fees and claiming them.
  • Jupiter for routing quotes and the swap itself.
  • Supabase for the round ledger, the entrant snapshots and the event log.

Dry run

Setting KEEPER_DRY_RUN=true simulates every transaction instead of sending it. Rounds still open, snapshot, roll and record, so the whole pipeline can be exercised against mainnet state without spending anything. A simulation that would fail raises rather than silently recording a fake success.

Preflight

Before going live, pnpm preflightchecks the database schema, RPC access, the keeper's balance against its floor, pool visibility, Jupiter routing, that the configured token decimals match the chain, and that every prize mint is still transferable. It exits non-zero if anything would stop a round settling.

The keeper is trusted to execute honestly, but not trusted to pick winners. That is what provable fairness is for: the draw itself is verifiable by anyone, independently of the operator.