diff --git a/.github/workflows/image-generator.yml b/.github/workflows/image-generator.yml index 54a75cdc..cdc2e9fc 100644 --- a/.github/workflows/image-generator.yml +++ b/.github/workflows/image-generator.yml @@ -4,17 +4,30 @@ name: Image generator # image-generator/README.md), so the root `pnpm lint` / `pnpm test` aggregates and # the Coverage workflow do not reach it. Without this workflow nothing runs its # suite at all. +# The path filter must also list the files `src/solanaLayout.test.ts` reads, not just +# this package. That suite pins the PetAccount byte layout against the Anchor IDL and +# `pet.rs`, so a Solana account change breaks it while touching nothing under +# `image-generator/`. Filtered on this package alone the suite simply does not run, which +# is how `open_to_challenges` was removed from the program while the decoder kept its +# byte — every field after it misaligned, and a pet rendered as a different pet. +# +# The two lists below are duplicated on purpose: GitHub Actions does not support YAML +# anchors, so factoring them out would silently disable the filter rather than share it. on: pull_request: branches: [main] paths: - 'image-generator/**' - '.github/workflows/image-generator.yml' + - 'contracts/solana/cryptopets/programs/cryptopets/src/state/**' + - 'indexer-go/internal/solana/idl/**' push: branches: [main] paths: - 'image-generator/**' - '.github/workflows/image-generator.yml' + - 'contracts/solana/cryptopets/programs/cryptopets/src/state/**' + - 'indexer-go/internal/solana/idl/**' permissions: contents: read diff --git a/.github/workflows/parity.yml b/.github/workflows/parity.yml new file mode 100644 index 00000000..16d4a379 --- /dev/null +++ b/.github/workflows/parity.yml @@ -0,0 +1,102 @@ +name: Combat parity + +# The golden vectors in `contracts/test-vectors/` are what AGENTS.md calls the +# cross-language enforcement for combat-simulator parity — and until this workflow +# existed, nothing ran them. Coverage covers backend/frontend/shared, Verifier covers +# `verifier`, and the three suites that actually replay the vectors were covered by +# neither: +# +# protocol tests/combat/goldenVectors.test.ts (the canonical TS engine) +# indexer-go internal/combat/combat_golden_test.go (the independent Go port) +# contracts/ethereum test/XpFormula.test.ts (the XP fixture) +# +# §F's circuit breaker only has value while the TS and Go ports are independent and both +# match the vectors. A drift that CI never runs is a circuit breaker nobody armed. +# +# Deliberately not path-filtered. A parity break is caused precisely by changing one side +# and not the other, so filtering on either side's paths would skip the run that matters. +# See the image-generator workflow for what path filtering costs here. +# +# Anchor's frozen Rust suite is the fourth witness and is NOT run here: it needs a Solana +# toolchain this runner does not have. That gap is real — those tests are the only +# remaining independent evidence that the vectors describe what actually settled on chain. +on: + pull_request: + branches: [main] + push: + branches: [main] + +permissions: + contents: read + +concurrency: + group: parity-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + typescript: + name: protocol + contracts + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + + - uses: actions/setup-node@v4 + with: + # Hardhat 3 needs >= 22.10. It calls `.flatMap` on the iterator from + # `Map.values()`, which is an Iterator Helpers method that does not exist + # before Node 22, so `compile` dies with a TypeError rather than a version + # check. Do not drop this back to 20 to match the other workflows. + node-version: 22 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + # The canonical engine, including tests/combat/goldenVectors.test.ts. + - name: Protocol tests + run: pnpm --filter @cryptopets/protocol test + + - name: Protocol lint + if: always() + run: pnpm --filter @cryptopets/protocol lint + + # MIT boundary: protocol must not import from a PolyForm package, or the public + # verifier that depends on it cannot be distributed. Enforced by its own test, run + # above — this step exists so the typecheck failure is separately legible. + - name: Protocol typecheck + if: always() + run: pnpm --filter @cryptopets/protocol typecheck + + - name: Compile contracts + if: always() + run: pnpm --prefix contracts/ethereum compile + + - name: Contract tests + run: pnpm --prefix contracts/ethereum test + + go: + name: indexer-go + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version-file: indexer-go/go.mod + cache-dependency-path: indexer-go/go.sum + + - name: Vet + working-directory: indexer-go + run: go vet ./... + + # Unit tests only. The Postgres-backed tests are gated on TEST_DATABASE_URL and + # truncate tables, so they are deliberately not given one here. + - name: Test + working-directory: indexer-go + run: go test ./... + + - name: Build + working-directory: indexer-go + run: go build -o /dev/null ./cmd/indexer diff --git a/.github/workflows/verifier.yml b/.github/workflows/verifier.yml new file mode 100644 index 00000000..6f465d39 --- /dev/null +++ b/.github/workflows/verifier.yml @@ -0,0 +1,59 @@ +name: Verifier + +on: + pull_request: + branches: [main] + push: + branches: [main] + +permissions: + contents: read + +concurrency: + group: verifier-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + verify: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + # The verifier's suite builds real receipts through @cryptopets/protocol — real + # signatures, real drand fixtures, real combat simulation — so a protocol regression + # surfaces here too, without needing a separate step for it. + - name: Verifier tests + run: pnpm --filter @cryptopets/verifier test + + - name: Verifier lint + if: always() + run: pnpm --filter @cryptopets/verifier lint + + # The committed corpus, run through the actual CLI rather than the library, so the + # thing a third party would run is the thing CI proves still works. No network + # access: the ruleset these battles were fought under is pinned in the checkout. + - name: Honest corpus must verify + if: always() + run: pnpm --filter @cryptopets/verifier cli -- fixtures/corpus.json --keys fixtures/signing-keys.json + + # The half that stops this from being theatre. "The honest corpus verifies" is also + # true of a verifier that has quietly degraded into always passing; only this step + # notices that. + - name: Tampered corpus must be rejected + if: always() + run: | + if pnpm --filter @cryptopets/verifier cli -- fixtures/corpus-tampered.json --keys fixtures/signing-keys.json; then + echo "::error::The tampered corpus verified successfully. The verifier is not actually checking anything." + exit 1 + fi + echo "Tampered corpus rejected, as expected." diff --git a/AGENTS.md b/AGENTS.md index 18ae81ce..f8af8216 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,10 +11,11 @@ Normative language: `MUST`/`MUST NOT` are mandatory. `SHOULD`/`SHOULD NOT` are e ## Non-Negotiables -- `MUST NOT` edit the golden test vectors in `contracts/test-vectors/{battle,xp}.json` to make a failing test pass. If a vector fails, the Go or Rust port has drifted from the Solidity contract; fix the drifted port, never the vector. -- `MUST` update all four combat-simulator ports together (`contracts/ethereum/src/CombatSim.sol`, Solana's `combat.rs`, `indexer-go/internal/combat/`, `shared/src/utils/combat/`) when changing combat logic. Never patch one leg alone. The TS port (`shared/src/utils/combat/`) covers fight math only, not XP — see its package doc. +- `MUST NOT` change Solana's frozen combat port (`game/battle_sim.rs`, `game/xp.rs`). It has no caller left in the program, but its golden-vector tests are what still prove `contracts/test-vectors/{battle,xp}.json` describe what actually settled on that chain. A bug found there is fixed forward in the live ports below, under a new `rulesetVersion`, never by patching the frozen one. **The Solidity port is gone**: `CombatSim.sol` was deleted once it had no on-chain caller, which also removed `battle.json`'s Solidity generator and validator. `battle.json` itself is unchanged and still gates the live ports. +- `MUST` keep the two **live** combat ports in step with each other and with the golden vectors: `protocol/src/combat/` (the canonical engine, re-exported from `shared/src/utils/combat` for existing importers) and `indexer-go/internal/combat/` (the independent verifier). Changing one without the other re-breaks the circuit breaker in §F, whose whole value is that the two were written to disagree if either drifts. This covers XP and level progression too (`protocol/src/combat/xp.ts`, validated against `contracts/test-vectors/xp.json`), so an XP or decay change is a both-ports change. `indexer-go/internal/combat/xp.go` still covers the formula and the decay but not level-up. +- `MUST NOT` edit `contracts/test-vectors/{battle,xp}.json` to make a failing test pass — this holds more strongly now, not less. The vectors are the only mechanical link left between the frozen ports and the live ones. A live port that fails them has drifted away from the rules real battles were settled under. - `MUST NOT` assume the `ChainAdapter` interface (`shared/src/hooks/adapters/`) covers more than pet-action mutations and reads. It is a real, shared interface (`useEvmAdapter`/`useSolanaAdapter` both implement it) and every public pet-action hook consumes it chain-blind, but the low-level chain wiring in `frontend/src/chains/{ethereum,solana}/`, the async battle/breed VRF flows, and the combat simulator remain intentionally separate per chain. See CLAUDE.md's cross-chain interfaces section for the exact boundary. -- `MUST` match the license of the package being edited when adding new files: `contracts/ethereum`, `contracts/solana`, `indexer-go`, and `proto` are MIT; everything else, `image-generator` included, is PolyForm Noncommercial 1.0.0 (root `LICENSE`). See the table in `README.md`. +- `MUST` match the license of the package being edited when adding new files: `contracts/ethereum`, `contracts/solana`, `indexer-go`, `proto`, `protocol`, and `verifier` are MIT; everything else, `image-generator` included, is PolyForm Noncommercial 1.0.0 (root `LICENSE`). See the table in `README.md`. `protocol` is MIT on purpose (third parties have to be able to replay signed battle receipts), so it `MUST NOT` import from a PolyForm package; a test in that package enforces it. `verifier` is MIT for the same reason and depends on nothing but `protocol`. - `MUST NOT` assume the root `pnpm lint` / `pnpm test` cover `image-generator`, and `MUST NOT` verify it with `pnpm --filter image-generator