Skip to content
Closed
Show file tree
Hide file tree
Changes from 2 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
3 changes: 3 additions & 0 deletions .github/.workspace-ignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ games/gacha/pinocchio/clients/rust
games/gacha/pinocchio/program
games/gacha/pinocchio/tests/integration-tests
games/gacha/pinocchio/tests/light-integration-tests
games/gacha/pinocchio-simple/clients/rust
games/gacha/pinocchio-simple/program
games/gacha/pinocchio-simple/tests/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 @@ -4,4 +4,5 @@ Cargo.lock
**/Assets/
games/world-cup/
games/gacha/pinocchio/idl/
games/gacha/pinocchio-simple/idl/
tokens/token-2022/transfer-hook/block-list/pinocchio/sdk/
18 changes: 11 additions & 7 deletions games/gacha/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,22 @@
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.
revealed with a verifiable random function (RFC 9381 ECVRF), 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) |
Two variants share the same draw semantics (`select_tier`/`derive_alpha` are
byte-identical, pinned by shared test fixtures) and differ in how reveals are
evidenced:

The Pinocchio example is a self-contained nested workspace: it pins its own toolchain
| Variant | Reveal evidence |
| ------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| [`./pinocchio`](./pinocchio) | Each reveal is anchored in Collector Crypt's deployed [`cc-vrf`](https://vrf.collectorcrypt.com) registry by CPI (Light Protocol compressed accounts) |
| [`./pinocchio-simple`](./pinocchio-simple) | Each prize NFT carries its full reveal provenance (`pull`, `client_seed`, `beta`, `proof`) in its own Token-2022 metadata and names its pool via the metadata update authority — verifiable from live accounts, no transaction history |

Both examples are self-contained nested workspaces: each pins its own toolchain
and dependencies and builds/tests via its own `justfile`.
36 changes: 36 additions & 0 deletions games/gacha/pinocchio-simple/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
**/target
.idea
**/node_modules
dist
**/*.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/

# TypeDoc generated API docs
clients/typescript/docs/

.claude/

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

# Generated files
clients/typescript/src/generated/

# Build outputs
dist/
build/
target/

# 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-simple/.prettierrc.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"@solana/prettier-config-solana"
192 changes: 192 additions & 0 deletions games/gacha/pinocchio-simple/CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
# 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 — the simplified
sibling of `../pinocchio`. Where that variant anchors every reveal in Collector
Crypt's cc-vrf registry through a Light Protocol CPI, this one replaces the
whole external stack with a **self-certifying Token-2022 prize NFT**: the mint's
metadata carries the rarity plus the complete reveal provenance, so the evidence
travels with the prize. An admin configures a pool of fixed-weight reward tiers
and a fixed entry fee, and records 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`) with `settle_and_distribute`, which
selects the tier, mints the prize NFT straight to the buyer, and closes the
pull.

## Randomness model (important)

RFC 9381 `ECVRF-EDWARDS25519-SHA512-TAI`. **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.
3. **One reveal per pull, enforced structurally.** Pull PDAs are seeded by a
monotonic pool index, so `buy_pull` can never re-derive an old address; the
prize mint is a PDA of the pull whose creation fails if it already exists;
and `settle_and_distribute` closes the pull account. No registry needed.
4. **The operator key is pinned.** `pool.operator` is fixed at init and doubles
as the ECVRF public key, so the key can never be swapped mid-pool. (This is
what replaces the sibling's frozen cc-vrf registry record — the registry
additionally proved the record was frozen/unrevoked, but with the key pinned
in immutable pool state that guarantee is redundant here.)
5. **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.
6. **Verification story — the NFT is the evidence.** The prize mint's
`additional_metadata` carries `rarity`, `pull`, `client_seed`, `beta`, and
`proof` (lowercase hex), and its metadata `update_authority` is the pool
PDA — the NFT names its pool, and the pool account supplies the operator
key, weights, and tier count. From those two live accounts (no transaction
history) anyone can recompute `alpha`, verify the proof with
`@collectorcrypt/ecvrf` against `pool.operator`, and reproduce the tier
with `selectTier` — the TS client ships this as `verifyPrizeProvenance`.
The same data is emitted in `PullSettledEvent`. 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 `../pinocchio` sibling anchors reveals
in the cc-vrf registry via Light Protocol compressed accounts — stronger
third-party attestation, much heavier stack. This variant trades that
attestation for radical simplicity: the provenance lives in the prize itself,
at ~0.007 SOL of metadata rent per settle, paid by the operator.

## Required Versions

- **Rust**: See `rust-toolchain.toml`
- **Node.js**: See `.nvmrc`
- **pnpm**: See `package.json` `packageManager` field

## 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 + client tests
just unit-test # Rust host unit tests (selection + alpha + hex)
just integration-test # LiteSVM integration tests (builds the .so first)
just client-test # TypeScript client tests (parity + ECVRF + provenance)
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_simple.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`, `format_hex`, prize constants + metadata keys (host unit-tested)
- `program/src/instructions/` — `init_pool`, `buy_pull`, `settle_and_distribute`, `refund_pull`, `withdraw_fees`, `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, 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 / 500s vault / 600s event); codes are stable, gaps from the sibling's removed cc-vrf errors are intentional
- `program/src/tests.rs` — host unit tests for the pure logic

### Accounts

- **Pool** — PDA `["pool", admin]`. One machine per admin: `operator` (fixed at
init; doubles as the ECVRF public key), `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 _pending_ pull:
`client_seed`, `alpha` (= `SHA-256(pull || client_seed)`), `requested_slot`.
The account existing ⇔ the pull is pending; both settle and refund close it
(rent back to the buyer). No status byte, no stored `beta`.
- **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 settle:
decimals 0, supply 1, mint authority discarded, `MetadataPointer` pointing at
itself, `TokenMetadata` with `additional_metadata`:
`rarity`, `pull`, `client_seed`, `beta`, `proof` (all but `rarity` lowercase
hex; `alpha` is omitted because it is derivable from `pull` + `client_seed`).
Its existence doubles as the once-only settle guard.

### Lifecycle

`init_pool` (admin sets tiers, fee, deadline, operator) →
`buy_pull` (buyer pays fee + pull rent, supplies `client_seed`) →
either `settle_and_distribute` (operator: tier selection + prize NFT mint to the
buyer + pull close, ~82k CU) or, past the deadline, `refund_pull` (buyer: fee +
rent back, pull closed). `withdraw_fees` (admin) drains settled revenue only.

Unlike the sibling, settle and claim are a single instruction: without the ~10
Light passthrough accounts and 129-byte validity proof, the whole flow fits in
one ~615-byte transaction with 11 accounts.

### Testing layers

- `tests/integration-tests` — LiteSVM: the **entire** lifecycle including the
settle-and-mint happy path (the sibling needs light-program-test + a local
gnark prover for that). PDAs are derived through `gacha-simple-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. The settle happy path also replays the full
provenance verification from the minted metadata. The program never verifies
the ECVRF proof on-chain, so tests pass arbitrary proof bytes.
- `clients/typescript/test` — parity fixtures (pinned against the Rust unit
tests), real ECVRF prove/verify round-trips, forged-reveal detection, and
`verifyPrizeProvenance` acceptance/tampering cases.
- `CU_REPORT=1 cargo test -p tests-gacha-simple` writes per-instruction minimum
CU to `cu_report.md` (settle_and_distribute ≈ 82k of the 200k default).

## 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.
- **Foreign CPIs are hand-serialized**: 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. The fixtures are
also byte-identical to `../pinocchio`'s — the two programs share the same
draw semantics by design.

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

`2nAHovvq1Ju2VZtZWvaAyvTrD18DRzG5pBEUwwGQDAWS` (keypair in `keys/`, gitignored)
44 changes: 44 additions & 0 deletions games/gacha/pinocchio-simple/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
[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"
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