Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
76501f0
init draft
amilz Jul 24, 2026
7dd211c
feat(gacha): rework program around cc-vrf registry commit-reveal
amilz Jul 30, 2026
e908626
test(gacha): rework LiteSVM integration suite
amilz Jul 30, 2026
9ef21d1
test(gacha): add Light-stack suite exercising the real cc-vrf settle …
amilz Jul 30, 2026
d2ab2ff
docs(gacha): trust-model docs, READMEs, and CI wiring
amilz Jul 30, 2026
928ca9d
chore: cleanup
amilz Jul 31, 2026
124a4da
feat(gacha): frontend wip
amilz Aug 1, 2026
345b805
feat(gacha): devnet e2e working
amilz Aug 1, 2026
10080e7
chore: migrate scripts to plugins
amilz Aug 1, 2026
d54cb1b
chore: leverage gacha plugin
amilz Aug 1, 2026
801b58e
chore: leverage gacha plugin for webapp
amilz Aug 1, 2026
1b729a9
feat(gacha-webapp): simd-allstars
amilz Aug 1, 2026
5f02c9b
feat(gacha-webapp): improve reveal
amilz Aug 1, 2026
f9f0701
wip
amilz Aug 1, 2026
53523b7
chore: fix flow
amilz Aug 1, 2026
5156643
chore: cleanup
amilz Aug 3, 2026
7fb099d
chore: update weights
amilz Aug 3, 2026
9c7d14d
chore: remove web3js
amilz Aug 3, 2026
d946fca
chore: add gacha reveal modal
amilz Aug 3, 2026
4b49b09
chore: nit cleanup
amilz Aug 3, 2026
a70e396
chore: cleanup unused deps
amilz Aug 3, 2026
782d697
chore: docs
amilz Aug 3, 2026
dd1f2e2
Merge branch 'main' into feat/add-gacha-pino
amilz Aug 3, 2026
ed15bc7
ci: register gacha crates in workspace-ignore
amilz Aug 3, 2026
3fa12a5
style: prettier format gacha example files
amilz Aug 3, 2026
4b416ae
chore: bump rpc-websocket for vercel deployment
amilz Aug 3, 2026
c76e2d6
fix(gacha): regenerate IDL to codama format
amilz Aug 3, 2026
40922c5
ci: prettier-ignore generated gacha IDL
amilz Aug 3, 2026
9b391a6
refactor(gacha): apply PR review feedback
amilz Aug 4, 2026
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
2 changes: 2 additions & 0 deletions .github/.ghaignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,5 @@ compression/cnft-burn/anchor
# justfile-driven example: built and tested by .github/workflows/just.yml,
# not the legacy pinocchio CI (it has no build-and-test script)
games/world-cup/pinocchio
games/gacha/pinocchio

4 changes: 4 additions & 0 deletions .github/.workspace-ignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ basics/favorites/anchor/programs/favorites
compression/cnft-burn/anchor/programs/cnft-burn
compression/cnft-vault/anchor/programs/cnft-vault
compression/cutils/anchor/programs/cutils
games/gacha/pinocchio/clients/rust
games/gacha/pinocchio/program
games/gacha/pinocchio/tests/integration-tests
games/gacha/pinocchio/tests/light-integration-tests
games/world-cup/pinocchio/clients/rust
games/world-cup/pinocchio/program
games/world-cup/pinocchio/tests/integration-tests
Expand Down
1 change: 1 addition & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,5 @@ pnpm-lock.yaml
Cargo.lock
**/Assets/
games/world-cup/
games/gacha/pinocchio/idl/
tokens/token-2022/transfer-hook/block-list/pinocchio/sdk/
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -336,4 +336,10 @@ A bracket-prediction game: entrants pay a fee to submit a 32-game bracket, an or

[pinocchio](./games/world-cup/pinocchio)

### Gacha (provably-fair pack pulls)

A provably-fair gacha / loot-box game — the on-chain mechanic behind RWA pack platforms like Collector Crypt and Phygitals. Buyers open pulls revealed with an RFC 9381 ECVRF anchored in the deployed [`cc-vrf`](https://vrf.collectorcrypt.com) registry by CPI; the VRF input binds buyer entropy, reveals are publicly verifiable off-chain, unsettled pulls are refundable, and prizes are minted as Token-2022 NFTs carrying a `rarity` metadata field. A full Pinocchio + Codama project with TypeScript + Rust clients.

[pinocchio](./games/gacha/pinocchio)

---
20 changes: 20 additions & 0 deletions games/gacha/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# Gacha

A provably-fair **gacha** (loot-box / pack-pull) game — the on-chain mechanic behind
Solana RWA pack platforms . An admin configures a
pool of fixed-weight reward tiers and a fixed entry fee; buyers open pulls that are
revealed with a verifiable random function (RFC 9381 ECVRF) anchored in Collector
Crypt's deployed [`cc-vrf`](https://vrf.collectorcrypt.com) registry by CPI, and each
prize is minted as a Token-2022 NFT carrying its `rarity` in the token metadata.

The VRF input binds buyer-supplied entropy (`SHA-256(pull || client_seed)`), so no
one — including the operator — can predict an outcome before the buy lands, and
every reveal is publicly verifiable off-chain. Unsettled pulls are refundable after
a deadline.

| Framework | Path |
| --------- | ---------------------------- |
| Pinocchio | [`./pinocchio`](./pinocchio) |

The Pinocchio example is a self-contained nested workspace: it pins its own toolchain
and dependencies and builds/tests via its own `justfile`.
40 changes: 40 additions & 0 deletions games/gacha/pinocchio/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
**/target
.idea
**/node_modules
dist
**/.next/
**/*.tsbuildinfo
bun.lockb

**/generated/

# Solana test validator ledger data
test-ledger/
**/test-ledger/
.validator-ledger

# Log files
*.log
**/cu_report.md

# Local config (contains generated mint addresses)
config.json

# Environment secrets
.env
.env.local
**/.env.local

keys/

# Dumped mainnet program fixtures
tests/fixtures/

# TypeDoc generated API docs
clients/typescript/docs/

.claude/

# Editor / tool config
.emdash.json
.vscode/
1 change: 1 addition & 0 deletions games/gacha/pinocchio/.nvmrc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
24.13.0
25 changes: 25 additions & 0 deletions games/gacha/pinocchio/.prettierignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# IDL files
idl/

# Generated files
clients/typescript/src/generated/
webapp/next-env.d.ts

# Build outputs
dist/
build/
target/
webapp/.next/

# Dependencies
node_modules/
package.json
pnpm-lock.yaml

# TypeDoc generated API docs
clients/typescript/docs/

# Editor / tool config
.emdash.json
.vscode/
.remember/
1 change: 1 addition & 0 deletions games/gacha/pinocchio/.prettierrc.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"@solana/prettier-config-solana"
198 changes: 198 additions & 0 deletions games/gacha/pinocchio/CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## What this is

A provably-fair **gacha** (loot-box / pack-pull) game on Solana, modeled on how
platforms like Collector Crypt and Phygitals actually work — and wired into
Collector Crypt's real [`cc-vrf`](https://vrf.collectorcrypt.com) registry
program by CPI. An admin configures a pool of fixed-weight reward tiers and a
fixed entry fee, and registers an off-chain VRF operator. A buyer pays the fee
to open a pull, committing buyer-supplied entropy into the VRF input. The
operator reveals the ECVRF output (`beta`); the program anchors the proof in the
cc-vrf registry, expands `beta` into a weighted tier, and the prize — a
Token-2022 NFT whose metadata carries a `rarity` field — is minted to the buyer.

## Randomness model (important)

RFC 9381 `ECVRF-EDWARDS25519-SHA512-TAI`, following Collector Crypt's cc-vrf.
**Solana cannot verify an ECVRF proof on-chain** (no precompile), so the trust
model is _detection, not prevention_: on-chain the program accepts the
registered operator's signed `beta`; off-chain anyone can prove cheating. The
design closes every gap that detection alone leaves open:

1. **Fixed ≠ unpredictable.** `alpha = SHA-256(pull_address || client_seed)`
where `client_seed` is 32 random bytes chosen by the buyer at commit. An
alpha that is merely _fixed_ (say, the pull address alone) is worthless
against the operator: `beta = VRF(operator_key, alpha)` is deterministic, so
a predictable alpha lets the operator precompute every outcome before anyone
buys. Buyer entropy is what makes the outcome unknowable at commit time.
2. **Fixed weights ⇒ order-independence.** Tier odds never change after init,
so a pull's outcome depends only on its `beta` — not on supply counters or
the order in which the operator settles. (Selection against a mutable
remaining-supply table would let an operator who knows every pending `beta`
route rare tiers to favored wallets purely by choosing settle order, and
per-pull proof verification would never catch it.)
3. **One reveal per pull, enforced by cc-vrf.** `settle_pull` CPIs cc-vrf's
`commit_proof_with_beta`, whose Light Protocol compressed account derives
from `(authority, memo_hash = SHA-256(pull_address))` — a second commit for
the same pull fails at the Light system program. The commit also proves (via
validity proof against the registry) that the operator's authority record is
**frozen**, unrevoked, and keyed by exactly `pool.operator`.
4. **Liveness has an escape hatch.** An operator can withhold a reveal (e.g.
after privately computing an unfavorable `beta`), but `refund_pull` returns
the buyer's entry fee and rent after `settle_deadline_slots`, and
`withdraw_fees` can never touch pending buyers' escrow (the vault reserves
`pending_pulls × entry_fee`). Withholding delays; it never steals.
5. **Verification story.** From the emitted events anyone can: recompute
`alpha` from `(pull, client_seed)`, verify the proof with
`@collectorcrypt/ecvrf`, reproduce the tier with `selectTier`, and check the
registry commit. The operator's 32-byte Ed25519 seed is **both** its Solana
signing key and its ECVRF key, so `pool.operator` equals the ECVRF public key.

Comparison: oracle VRFs (Switchboard On-Demand, MagicBlock VRF, ORAO) verify the
randomness proof **on-chain** at the cost of oracle fees, extra latency, and an
oracle-network liveness dependency. The cc-vrf pattern is cheaper and
self-operated, but trust is detection-based. Trust notes: cc-vrf's upgrade
authority was **not** renounced as of 2026-07-29 (contradicting its docs), and
its repo declares MIT but commits no LICENSE file.

## Required Versions

- **Rust**: See `rust-toolchain.toml`
- **Node.js**: See `.nvmrc`
- **pnpm**: See `package.json` `packageManager` field
- **Light CLI**: pinned in `justfile` (`zk_cli_version`), installed by `just setup`

## Build Commands

```bash
just build # program .so → IDL → TS client → dist
just generate-idl # Generate IDL via Codama (cargo build with build.rs)
just generate-clients # Generate TypeScript + Rust clients from IDL
just build-program # Build .so binary only (cargo build-sbf)
just test # unit + integration + light + client tests
just unit-test # Rust host unit tests (selection + alpha derivation)
just integration-test # LiteSVM integration tests (builds the .so first)
just light-test # Light-stack tests: real settle -> cc-vrf CPI w/ proofs
just dump-cc-vrf # Fetch the mainnet cc-vrf binary for light-test
just client-test # TypeScript client tests (parity + ECVRF + forged-reveal)
just burst-test 200 # Devnet: buy+settle 200 pulls, score the reveals + distribution
just burst-report # Re-score every pull the burst pool has recorded (no txs)
just demo # Off-chain operator/verifier demo (no RPC)
just fmt # cargo fmt + prettier
just check # fmt-check + lint-check
```

## Architecture

Solana program using **Pinocchio** (lightweight `no_std` framework) with **Codama**
for IDL-driven client generation.

### Client generation pipeline

```
Rust code with #[codama(...)] attributes
program/build.rs → idl/gacha.json
scripts/generate-clients.ts
clients/{typescript,rust}/src/generated/ (gitignored; re-exported from src/index.ts / lib.rs)
```

### Program

- `program/src/lib.rs` — declares the program ID, wires modules
- `program/src/gacha.rs` — pure logic: `select_tier`, `derive_alpha`, prize constants (host unit-tested)
- `program/src/ccvrf.rs` — hand-built Anchor CPI to cc-vrf `commit_proof_with_beta` (program IDs, wire layout, account order)
- `program/src/instructions/` — `init_pool`, `buy_pull`, `settle_pull`, `refund_pull`, `withdraw_fees`, `claim_prize`, `emit_event` (self-CPI target) + `helpers/` (`checks`, `account`, `prize_nft` — the Token-2022 NFT mint + metadata CPIs)
- `program/src/state/` — `Pool`, `Pull` PDA structs + `Vault` / `PrizeMint` markers + `common.rs` (discriminator, pull status, PDA derivation)
- `program/src/event_engine.rs` — Anchor-compatible self-CPI event emission
- `program/src/events/` — one event struct per state-changing instruction
- `program/src/errors.rs` — error codes (100s generic / 200s pool / 300s pull / 400s settle+claim / 500s vault / 600s event)
- `program/src/tests.rs` — host unit tests for the pure logic

### Accounts

- **Pool** — PDA `["pool", admin]`. One machine per admin: `operator` (+ its cc-vrf
`authority_label`), `entry_fee`, `settle_deadline_slots`, `tier_count`, fixed
`weights`, monotonic `pulls_count`, and `pending_pulls` (open refund liabilities).
- **Pull** — PDA `["pull", pool, buyer, index_le]`. One per pull: `client_seed`,
`alpha` (= `SHA-256(pull || client_seed)`), `beta` (set on reveal),
`tier_selected`, `status` (Pending → Settled → Claimed), `requested_slot`,
`settled_slot`. Closed on refund.
- **Vault** — program-owned, zero-data PDA `["vault", admin]` that escrows entry
fees; invariant: balance ≥ rent floor + `pending_pulls × entry_fee`.
- **PrizeMint** — Token-2022 mint PDA `["mint", pull]`, created at claim:
decimals 0, supply 1, mint authority discarded, `MetadataPointer` pointing at
itself, `TokenMetadata` with `additional_metadata: [("rarity", <tier label>)]`.

### Lifecycle

`init_pool` (admin sets tiers, fee, deadline, operator + cc-vrf label) →
`buy_pull` (buyer pays fee + pull rent, supplies `client_seed`, status `Pending`) →
either `settle_pull` (operator: cc-vrf commit CPI + tier selection, status `Settled`)
or, past the deadline, `refund_pull` (buyer: fee + rent back, pull closed) →
`claim_prize` (anyone: mints the prize NFT to the buyer, status `Claimed`).
`withdraw_fees` (admin) drains settled revenue only. The 80-byte proof is never
stored on-chain — it is hashed into the cc-vrf commit and emitted in
`PullSettledEvent` for off-chain verification.

`settle_pull` and `claim_prize` are separate instructions because a settle
carries ~10 Light passthrough accounts + a 129-byte validity proof, and adding
the mint CPI stack would exceed the 1232-byte transaction limit. (A production
client could recombine them with an address lookup table.)

### Testing layers

- `tests/integration-tests` — LiteSVM 0.12: everything that does not need a live
cc-vrf (init/buy/refund/withdraw, settle/claim negatives, claim decode via a
fabricated settled pull with `set_account`). PDAs are derived through
`gacha_client`'s generated `find_pda` helpers, so a seed the IDL gets wrong
fails the suite rather than shipping to clients. Requires
`just generate-clients` first — hence the recipe dependency.
- `tests/light-integration-tests` — `light-program-test`: the real
`settle_pull` → cc-vrf → Light CPI chain with genuine validity proofs (local
gnark prover, prepared by `just light-bootstrap`; binary + keys cached in
`~/.config/light`). Runs against the mainnet-dumped `tests/fixtures/cc_vrf.so`.
Single-threaded (shared prover port).
- `scripts/burst-randomness.ts` (`just burst-test <n>`) — devnet statistical test:
opens and settles `n` pulls against a throwaway 1-lamport pool owned by
`keys/burst-admin-keypair.json`, re-derives every reveal off-chain (alpha, the
operator's ECVRF output, the selected tier, proof verification), and scores the
tier distribution, beta bit balance, and beta byte uniformity, failing at
p < 0.001. `just burst-report` re-scores the pool's whole history without
spending anything. Costs ~0.0025 SOL of pull rent per pull, unrecoverable once
a pull is settled — pull accounts are only closable while pending, via
`refund_pull`.

## Conventions

- **Pinocchio, not Anchor**: use `pinocchio::AccountView`, `Address`, `ProgramResult`.
- **Packed state**: `#[repr(C, packed)]`, byte-0 discriminator, zero-copy `transmute`.
Never take a reference to a packed field whose type has alignment > 1 (u32/u64
arrays) — copy the field into a local first.
- **Light Protocol addresses come from `light-sdk-types`**, not local literals.
cc-vrf publishes no crate, so its program ID, CPI authority, and instruction
discriminator are still declared in `ccvrf.rs`.
- **Foreign CPIs are hand-serialized**: Anchor programs (cc-vrf) take an 8-byte
`sha256("global:<name>")` discriminator + borsh args; SPL interface programs
(token-metadata) take an 8-byte `SplDiscriminate` hash + borsh args. Program
IDs and discriminators are constants next to the builder that uses them.
- **No `mod.rs` business logic**: module declarations and re-exports only.
- **No code comments** for logic — prefer clear names; use `///` doc comments.
- **Codama attributes drive IDL**: array field types must use a **literal** size
(`[u32; 8]`, not `[u32; MAX_TIERS]`), and Codama cannot express arrays of custom
structs — hence primitive tier arrays. `just generate-idl && git diff` catches drift.
- **Cross-language parity**: `select_tier`/`selectTier` and
`derive_alpha`/`pullAlpha` must stay byte-for-byte identical; both pairs are
pinned by shared fixtures in their respective test suites.

When extending: keep `#[codama(...)]` attributes in sync, emit an event per new
instruction, and add an integration test per instruction in `tests/integration-tests/`.

## Program ID

`Bv65bJKK9kwTERuyHdCrXqWf2gKBFwkTx2rnscXaBZsS` (keypair in `keys/`, gitignored)
45 changes: 45 additions & 0 deletions games/gacha/pinocchio/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
[workspace]
members = [
"clients/rust",
"program",
"tests/integration-tests",
]
resolver = "2"

[workspace.package]
version = "0.1.0"
edition = "2021"
license = "MIT"
repository = "https://github.com/solana-foundation/program-examples"

[workspace.metadata.cli]
solana = "3.1.10"

[workspace.lints.rust]
unused_imports = "deny"
dead_code = "warn"
unused_variables = "warn"
deprecated = "warn"
unexpected_cfgs = { level = "warn", check-cfg = [
'cfg(target_os, values("solana"))',
] }

[workspace.dependencies]
codama = "=0.9.3"
const-crypto = "0.3"
light-sdk-types = { version = "0.23", default-features = false, features = ["v2"] }
pinocchio = { version = "0.11.1", features = ["cpi", "copy"] }
pinocchio-associated-token-account = "0.4.0"
pinocchio-system = "0.6.1"
pinocchio-token-2022 = "0.3.1"
serde_json = "1"
solana-address = { version = "2", features = ["curve25519"] }
solana-sha256-hasher = { version = "3", default-features = false, features = ["sha2"] }
solana-security-txt = "1.1.3"
thiserror = { version = "2", default-features = false }

[profile.release]
overflow-checks = true
opt-level = 3
lto = true
codegen-units = 1
Loading
Loading