Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
82 changes: 82 additions & 0 deletions .claude/rules/contracts-vrf-treasury.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
---
paths:
- 'packages/contracts/**'
- 'packages/sdk/src/vrf/**'
- 'packages/sdk/src/clients/**'
---

# Contracts, VRF and treasury — money-path invariants

Loaded automatically by Claude Code when touching contract/VRF/generated-client code. Codex does not
auto-load this file — the same content lives in `AGENTS.md`'s Critical Invariants section; read that
manually before touching these paths from Codex.

- **Never hardcode the VRF beacon app ID.** `947957720` (2022 beacon) is dead — three files in this
repo already say so explicitly (`packages/sdk/src/vrf/beacon.ts`, `packages/types/src/version.ts`,
`packages/contracts/smart_contracts/deploy_config.py`). Read the live ID from
`packages/types/src/version.ts` / the deployed `.env`, every time, including inside tests.
**Testnet is separately disputed**: `packages/sdk/src/vrf/beacon.ts`'s own comment says the testnet
ID `110096026` is "UNVERIFIED — confirm via indexer before testnet use", while `BOARD.md` FG-011
claims it's "on-chain verified (identical bytecode)". Before configuring TestNet or writing
on-chain integration tests, verify that ID against an indexer yourself — don't trust either source
as settled, and don't treat the mainnet registry as TestNet evidence.
- **Commit round:** `ceil8(current_round + BEACON_DELAY)`, `BEACON_DELAY = 8`
(`packages/contracts/smart_contracts/coinflip/contract.py`). **Settle buffer:**
`BEACON_SETTLE_BUFFER = 4` — `resolve()` must assert `current_round >= commit_round + BEACON_SETTLE_BUFFER`
before calling `must_get()`. Do not reduce either constant. A premature `must_get()` (round not yet
written) reverts the whole transaction — the box is untouched and the keeper retries later
(`packages/keeper/src/resolver.ts` has explicit retry/reconcile handling); this is not a stranded
session. The real permanent-loss mechanism is different: the beacon only retains its last 189
outputs (~70 min) — if `resolve()` isn't called before that window closes, the round is evicted and
`must_get()` panics forever (keeper marks the session `beacon_expired`; the player still has the 48h
refund). Don't reduce `BEACON_SETTLE_BUFFER` toward that 70-minute ceiling.
- **Refund independence is load-bearing.** `refund()` pays `bet + BOX_MBR` via a direct `Payment` from
the game contract's own balance — it must never read or debit `house_treasury`. This is what keeps
the 48h player backdoor (`REFUND_WINDOW_ROUNDS = 61_714` rounds) working when the treasury is paused
or insolvent. A change that routes any part of `refund()` through the treasury breaks the safety
property the whole design depends on.
- **Solvency check timing and formula:** `HouseTreasury.pay_winner()` (not `get_available_balance()`)
is what `resolve()` actually calls at settlement time, and it enforces
`payout <= (balance - min_balance) * max_payout_bps // 10000` — spendable balance (gross balance
minus the account's own minimum-balance requirement), not gross `live_balance`.
`get_available_balance()` is a separate, advisory read (`simulate()` before accepting a bet) and is
not itself the settlement guard — don't substitute it for `pay_winner()`'s check when reasoning
about solvency.
- **Box deletion order — this is asymmetric between `resolve()` and `refund()`, don't generalize a
single "always delete last" rule:**
- `resolve()`: submit every inner transfer for that resolution (referral, jackpot accrual, treasury
sweep, `pay_winner()` payout) first, **then** delete the session box, **then** return the box MBR
to the player as the last payment. Deleting frees the box's reserved MBR from the contract's
minimum-balance requirement — returning it before deletion can fail or misaccount, since that
balance isn't spendable while the box still exists.
- `refund()`: delete the box **before** its single combined `bet + BOX_MBR` payment (there's no
separate MBR-return step here).
- Either way, box deletion is the only idempotency guard (no `claimed` field) — a resolve/refund
that runs against a missing box reverts instead of silently no-op'ing, which is what makes
double-resolution impossible. Read the actual transfer/delete sequence in
`coinflip/contract.py`'s `resolve()`/`refund()` before changing it; don't infer an ordering from a
general "delete after transfers" heuristic.
- **Box MBR is a per-contract `typing.Final` constant, not a number to copy into a PR description or a
test literal from memory.** Read it from the contract file you're editing
(`grep -n "BOX_MBR\|_BOX_MBR" packages/contracts/smart_contracts/<contract>/contract.py`) — different
contracts have different values (coinflip 49,300; house_treasury's game-registry box 20,500;
leaderboard's caller box 21,300 as of this check) and they change if the box layout changes.
- **When changing transaction construction** (SDK group-building code, keeper resolve calls): the
grouped payment into `flip()` must include the exact box MBR on top of the bet
(`coinflip/contract.py`'s `flip()` asserts `pay.amount > BOX_MBR`); a resolve/refund group must pool
enough fee to cover every inner transaction it triggers (referral, jackpot, treasury sweep, payout,
MBR return can total several inner txns — `packages/keeper/src/resolver.ts` sets extra fee
accordingly). Pending stakes stay escrowed in the game contract, not the treasury, until settlement.
`resolve()` is permissionless by design — any account can call it, not only the keeper — so don't
add an owner/caller check that would block a player or third party from settling their own session.
- **House edge / referral / jackpot / min-max bet / `max_payout_bps` are env/deploy-config values**
(`packages/contracts/smart_contracts/deploy_config.py`, live `.env`), not contract-code constants and
not numbers to trust from `docs/tokenomics.md` or the `fairground-contract` skill's
`reference/house-pool-math.md` — both disagree with each other and with `deploy_config.py`'s
defaults (`HOUSE_EDGE_BPS=500` / 5%, `REFERRAL_BPS=100` / 1%, `JACKPOT_BPS=150` / 1.5%;
`docs/tokenomics.md` says 2%/0.5%, `house-pool-math.md` computes referral at 25 bps / 0.25%). Query
the deployed contract's global state for the real value when it matters (e.g. writing payout-math
tests or UI copy).
- **Generated clients** (`packages/sdk/src/clients/**`) come from `algokit generate client`; never
hand-edit. Regenerate after any ABI change and re-run `packages/contracts/tests/` first.
- **`simulate()` before `send()`** on any non-trivial group built from these clients.
26 changes: 15 additions & 11 deletions .claude/skills/fairground-contract/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,12 @@ Load before writing or modifying any Puya contract in `packages/contracts/smart_

## Read these references before acting

| File | When to load |
|------|-------------|
| `reference/avm-constraints.md` | Always -- box MBR, group limits, inner txn limits |
| File | When to load |
| ------------------------------ | --------------------------------------------------- |
| `reference/avm-constraints.md` | Always -- box MBR, group limits, inner txn limits |
| `reference/vrf-integration.md` | Any VRF-related code (commit, resolve, beacon call) |
| `reference/house-pool-math.md` | Solvency invariant, payout math, bet limits |
| `reference/compliance-ops.md` | Foundation framing, geo-block, grant strategy |
| `reference/house-pool-math.md` | Solvency invariant, payout math, bet limits |
| `reference/compliance-ops.md` | Foundation framing, geo-block, grant strategy |

Always invoke `/puya-gotchas` before writing any new Puya code.

Expand All @@ -37,18 +37,22 @@ Always invoke `/puya-gotchas` before writing any new Puya code.

3. Write/modify contract
- Use arc4.Address for BoxMap keys (not Bytes) -- avoids ARC-4 length prefix mismatch
- Execute all inner txns BEFORE deleting box (idempotency guard)
- Box deletion order is asymmetric, don't generalize a single rule: resolve() submits every
inner transfer, THEN deletes the box, THEN returns its MBR last; refund() deletes the box
BEFORE its one combined payment. See AGENTS.md Critical invariants for the code-verified
sequence before changing either.
- MBR = 2500 + 400*(prefix_len + key_len + val_len) -- include prefix

4. Verify compilation
cd packages/contracts && algokit compile python smart_contracts/
4. Verify compilation (per contract, with --out-dir -- without it puyapy 5.8.1 writes beside
contract.py, not into artifacts/)
cd packages/contracts && algokit compile python smart_contracts/<contract>/contract.py --out-dir artifacts

5. Run tests
python -m pytest tests/ -v
# Kill-the-mutant check: comment out a key assertion, confirm at least one test fails

6. Generate TypeScript clients (after any ABI changes)
algokit generate client packages/contracts/artifacts/ --output packages/sdk/src/clients/
6. Generate TypeScript clients (after any ABI changes), from repo root
make contracts-generate

7. Commit with update to BOARD.md task status
```
Expand All @@ -74,7 +78,7 @@ LeaderboardContract (optional, enabled via set_leaderboard_app_id)

## Key Invariants

1. Box deletion is ALWAYS last -- non-existence = idempotency guard on keeper retry
1. Box deletion is the idempotency guard (non-existence = already settled), but it is NOT the last step: resolve() submits every payout transfer, deletes the box, then returns the box MBR; refund() deletes the box, then makes one combined bet+MBR payment (coinflip/contract.py:264-265, 285-286)
2. Solvency check at resolve() time, not at flip() time
3. Beacon round: commit to N+8 (ceil8 semantics), resolve after N+10 (N+8+2 buffer)
4. 48h refund backdoor in every game contract -- keeper failure must never lock funds
Expand Down
10 changes: 6 additions & 4 deletions .claude/skills/puya-gotchas/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -173,10 +173,12 @@ itxn.ApplicationCall(pay_winner...).submit() # too late if this panics
```

```python
# CORRECT -- execute all inner txns, then delete box last
# CORRECT (coinflip resolve) -- payout transfers first, then delete the box, then return its MBR
itxn.ApplicationCall(pay_winner...).submit() # pay winner first
# Box deletion is the idempotency guard -- non-existence = already resolved
del self.flips[player] # delete LAST
del self.flips[player]
itxn.Payment(receiver=player.native, amount=UInt64(BOX_MBR)).submit() # MBR only after deletion
# refund(): del self.flips[player], then ONE payment of bet + BOX_MBR
```

---
Expand Down Expand Up @@ -231,8 +233,8 @@ vim packages/sdk/src/clients/CoinflipContractClient.ts # manual edit
```

```bash
# CORRECT -- regenerate from ARC-56 artifacts
algokit generate client packages/contracts/artifacts/ --output packages/sdk/src/clients/
# CORRECT -- regenerate from ARC-56 artifacts, from repo root
make contracts-generate
```

---
Expand Down
Loading
Loading