Skip to content

Add permissionless BeastRegistry and StoredArtProvider factory - #19

Closed
loothero wants to merge 6 commits into
mainfrom
feat/beast-registry
Closed

Add permissionless BeastRegistry and StoredArtProvider factory#19
loothero wants to merge 6 commits into
mainfrom
feat/beast-registry

Conversation

@loothero

Copy link
Copy Markdown
Member

Summary

PR 3 of the Community Beasts plan (docs/community-beasts-design.md): the permissionless surface. Anyone can add a new Beast species — art (4 variants), name, type, tier, and a minter/dungeon address — in a single transaction. The NFT-side integration (per-species mint auth, provenance mint, token_uri routing, fan-out) lands in PR 4; the registry already drives it through a new IBeastsProvenance interface, covered here by a recording mock.

Changes

src/beast_registry.cairo — the registry contract:

  • Sequential u64 species IDs starting at 76; is_registered/species_count/get_species_traits reads for the NFT and clients.
  • Two registration paths: register_beast_with_art (factory-deploys the canonical StoredArtProvider via deploy_syscall with salt = beast_id — deterministic address per species) and register_beast (custom IBeastArtProvider address, non-zero required).
  • Per-species artist admin: set_minter (zero = paused), one-way lock_minter and lock_art, update_art (factory providers only) and notify_art_updated (custom providers) sharing one per-species refresh cooldown (1h constant, tunable pre-deploy), set_stats_source (wired for PR 4's cached kill stats), and transfer_artist_role (non-zero only).
  • Tier/type/flags pack into a single SpeciesMeta felt252 slot (custom StorePacking), keeping registration at 5 registry slots + 1 factory-address slot.
  • Definition is stored before the mint_provenance call (the NFT reads tier/type back to encode the Genesis token ID); registration reverts until the owner's one-time set_nft_address.

Security-review items implemented:

  • On-chain name guard (assert_valid_name): charset [A-Za-z0-9 ' -], no leading/trailing space, non-empty, ≤31 bytes — the injection defense for the unescaped JSON/SVG builders. No uniqueness check by design (name-squatting grief vector); species ID is the identity.
  • Factory-flag recompute: set_art_provider recomputes factory_provider against the species' canonical factory deploy on every swap, so update_art can never write through another species' provider.
  • Provider double-gate: StoredArtProvider.set_art requires registry caller AND species-ID match; get_data_uri asserts the decoded beast's species.

src/stored_art_provider.cairo — canonical per-species art provider: four stored data URIs, variant selected from the decoded beast's shiny/animated flags. Not upgradable; factory + lock_art = provably frozen art.

src/interfaces.cairoIBeastRegistry, IBeastArtProvider (takes the full PackableBeast per design decision), IStoredArtProvider, IBeastsProvenance, BeastType, BeastDefinition. The legacy u8 provider interface is untouched (genesis data contracts).

Testing

  • scarb build — clean; scarb fmt --check --workspace — clean
  • snforge test --max-n-steps 4294967295102 passed, 0 failed, 305 ignored (+39 vs main)
  • New coverage: meta-packing round trips; name-guard injection payloads (", \, <, control bytes, comma, edge spaces, empty); both registration paths incl. zero-provider/tier-bounds rejection; duplicate names allowed; sequential IDs; pre-wiring reverts; one-time set_nft_address; artist-only auth; both locks one-way and blocking; shared cooldown across both art mutators; factory-flag recompute round trip; provider set_art/render gating; artist transfer incl. rights loss and zero-target rejection; provenance-mint args verified via mock.

@codex review

🤖 Generated with Claude Code

Copilot AI review requested due to automatic review settings July 25, 2026 03:55
@gemini-code-assist

Copy link
Copy Markdown

Caution

The consumer version of Gemini Code Assist on GitHub has been sunset. All code review activity has officially ceased.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds the “permissionless surface” for Community Beasts by introducing a new on-chain registry that can register new species (including deterministic factory deployment of a canonical art provider) and by defining the interfaces needed for upcoming NFT-side integration via provenance minting and metadata refresh fan-out.

Changes:

  • Introduces BeastRegistry for permissionless community species registration + per-species admin controls (minter/art/provider/locks) and metadata refresh cooldown.
  • Adds canonical StoredArtProvider contract deployed per-species by the registry, with registry-gated updates and variant selection by shiny/animated flags.
  • Extends interfaces.cairo with community registry/art provider interfaces and an IBeastsProvenance surface (covered by a recording mock in tests).

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
src/beast_registry.cairo New permissionless community species registry contract + name guard + packed per-species metadata/flags.
src/stored_art_provider.cairo New canonical per-species art provider contract with registry-gated set_art.
src/interfaces.cairo Adds new registry/art/provenance interfaces and related types for community species.
src/beast_registry_tests.cairo Adds tests with a mock provenance NFT to validate registry flows, locks, cooldown, and provider gating.
src/lib.cairo Wires the new registry/provider modules (and test module under cfg(test)) into the crate.
Comments suppressed due to low confidence (5)

src/beast_registry.cairo:320

  • get_artist currently reads from storage without checking that the species is registered, which can silently return 0 for unknown IDs. For consistency with other registry reads (and to avoid ambiguity), assert registration before returning.
        fn get_artist(self: @ContractState, beast_id: u64) -> ContractAddress {
            self.artists.entry(beast_id).read()
        }

src/beast_registry.cairo:324

  • get_art_provider returns the default 0 address for unregistered IDs, which can later cause confusing downstream failures (e.g., calling a zero provider). Consider asserting the species is registered here to fail fast with a clear message.
        fn get_art_provider(self: @ContractState, beast_id: u64) -> ContractAddress {
            self.art_providers.entry(beast_id).read()
        }

src/beast_registry.cairo:328

  • get_stats_source currently returns 0 for unregistered species IDs, which is ambiguous with the intended “no kill stats” sentinel. To avoid treating unknown species as valid-but-disabled, assert registration before reading.
        fn get_stats_source(self: @ContractState, beast_id: u64) -> ContractAddress {
            self.stats_sources.entry(beast_id).read()
        }

src/beast_registry.cairo:347

  • is_art_locked currently returns false for unregistered IDs because unpacking an all-zero SpeciesMeta yields art_locked = false. This makes “unknown species” indistinguishable from “known but unlocked”. Consider asserting registration here for consistency with other species-level reads.
        fn is_art_locked(self: @ContractState, beast_id: u64) -> bool {
            self.metas.entry(beast_id).read().art_locked
        }

src/beast_registry.cairo:351

  • is_minter_locked currently returns false for unregistered IDs due to default storage unpacking. To avoid callers treating unknown species as “unlocked”, assert registration before reading.
        fn is_minter_locked(self: @ContractState, beast_id: u64) -> bool {
            self.metas.entry(beast_id).read().minter_locked
        }

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/beast_registry.cairo
Comment on lines +314 to +316
fn get_minter(self: @ContractState, beast_id: u64) -> ContractAddress {
self.minters.entry(beast_id).read()
}

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: f3f69d966a

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/beast_registry.cairo
Comment on lines +246 to +248
meta.factory_provider = provider == self.factory_providers.entry(beast_id).read();
self.metas.entry(beast_id).write(meta);
self.art_providers.entry(beast_id).write(provider);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Fan out metadata updates when swapping providers

When an artist swaps the provider after tokens have been indexed, this changes every token's rendered artwork but emits no NFT metadata-update fan-out, so ERC-4906-aware marketplaces can retain the old artwork. This can become permanent if the artist subsequently calls lock_art, because notify_art_updated is then blocked; trigger the same cooldown-governed NFT notification as other art changes before allowing the new pointer to be frozen.

Useful? React with 👍 / 👎.

@github-actions

github-actions Bot commented Jul 25, 2026

Copy link
Copy Markdown

GPT Code Review

lgtm

@loothero

Copy link
Copy Markdown
Member Author

Addressed all three review findings in the latest commit:

  1. Art URI validationStoredArtProvider now enforces exact data:image/png;base64, / data:image/gif;base64, prefixes per slot plus a non-empty standard-base64 payload, in both the constructor and set_art. Quotes, markup, and control bytes cannot pass the charset, so factory-stored art is provably inert.
  2. Provider-swap fan-outset_art_provider now shares the per-species refresh cooldown and calls the MetadataUpdate fan-out atomically with the pointer change.
  3. SRC5 stats gateset_stats_source verifies a non-zero source supports_interface(IBEAST_STATS_ID) at set time (interface + ID now defined in interfaces.cairo for the PR 4 cached-stats flow); zero clears without a call.

New tests cover injection payloads in art, wrong/missing prefixes, swap cooldown sharing + fan-out counts, and compliant/non-compliant stats sources — 108 passing.

@loothero

Copy link
Copy Markdown
Member Author

On the remaining HIGH finding (custom providers can brick/inject token_uri):

Partially accepted-by-design, partially deferred to the NFT-integration PR — recorded in docs/community-beasts-design.md:

  • The brick half is a deliberate design decision: custom providers exist precisely for dynamic art (per-prefix/suffix rendering) that cannot be pulled-and-cached, the blast radius is strictly the artist's own species, and the factory path + "verified art" badge exist as the safe default. Nothing in this PR calls custom providers yet — the registry only stores the pointer.
  • The injection half gets a concrete fix where the call site actually lands (PR 4): token_uri will validate custom-provider output before embedding — exact data:image/{png,gif,svg+xml,webp};base64, prefix + base64-charset body, same validator family as the factory provider with a wider mime allowlist. A misbehaving provider can revert its own species' rendering, but can never inject markup.

@loothero

Copy link
Copy Markdown
Member Author

Both findings fixed in 3d517d5.

1. Locked custom providers keep their refresh path (Medium) — correct catch, and it was an inconsistency with the design doc's own honesty note: I documented that lock_art on a custom provider freezes the pointer rather than the output, then wrote code that treated it as if it froze the output. notify_art_updated now gates on art_locked only when the species uses a factory provider, where the lock genuinely does freeze output (the sole mutator is registry-gated set_art, so a refresh could never carry new art). A locked custom species keeps notification available; the shared per-species cooldown still applies. Design doc updated to state the asymmetry explicitly.

2. GIF signature (Low) — confirmed: R0lGODAAAAAA decodes to GIF80\x00.... Validation now requires a complete encoded signature, R0lGODdh (GIF87a) or R0lGODlh (GIF89a), including the version characters and the trailing h. PNG was already complete (iVBORw0KGgo covers the full 8-byte signature plus the implied leading IHDR length byte).

New tests: locked-custom can notify / locked-factory cannot; both GIF versions accepted; invalid version character and truncated magic rejected. 116 passing, formatting clean.

loothero and others added 5 commits July 31, 2026 21:16
PR 3 of the Community Beasts plan (docs/community-beasts-design.md):
the permissionless surface. Anyone can register a new Beast species in
one transaction; the NFT integration (mint auth, provenance mint,
token_uri routing) lands next.

- BeastRegistry: sequential u64 species IDs from 76; two registration
  paths (factory-deployed StoredArtProvider with salt = beast_id, or a
  custom IBeastArtProvider address); per-species artist admin with
  set_minter (zero = paused), one-way lock_minter and lock_art,
  update_art / notify_art_updated sharing a per-species refresh
  cooldown, stats_source setter, and transferable artist role. Tier,
  type, and flags pack into one SpeciesMeta slot. Registration reverts
  until the owner wires set_nft_address (one-time).
- On-chain name guard: charset [A-Za-z0-9 ' -], no edge spaces,
  non-empty, <= 31 bytes - an injection defense for the unescaped
  JSON/SVG builders. Name uniqueness deliberately NOT enforced
  (squatting grief vector); species ID is the identity.
- set_art_provider recomputes the factory flag against the species'
  canonical factory deploy, so update_art can never write through
  another species' provider; StoredArtProvider.set_art double-gates on
  registry caller + species ID match.
- IBeastArtProvider takes the full PackableBeast so providers select
  variants themselves and can customize by prefix/suffix/tier.
- New IBeastsProvenance interface (mint_provenance,
  emit_species_metadata_update) that beasts_nft implements in the next
  PR; covered here by a recording mock.
- 39 new tests: meta packing, name-guard injection payloads, both
  registration paths, admin auth, both locks, cooldown sharing,
  factory-flag recompute, provider gating, artist transfer.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…s gate

All three MEDIUM findings from the automated Cairo review:

- StoredArtProvider now validates every URI in both the constructor and
  set_art: exact PNG/GIF data-URI prefix per slot plus a non-empty
  standard-base64 payload. The renderer embeds these verbatim in a
  single-quoted SVG attribute and factory providers carry the trusted
  "verified art" designation, so factory-stored content must be
  provably inert.
- set_art_provider now shares the per-species refresh cooldown and
  fans out MetadataUpdate atomically with the pointer change - a swap
  changes every token's rendered art, and without the fan-out a
  subsequent lock_art would leave marketplaces permanently stale.
- set_stats_source now performs the design-specified set-time check:
  a non-zero source must be a deployed contract answering SRC5 for the
  new IBEAST_STATS_ID (IBeastStats interface defined for the PR 4
  cached-stats flow; zero clears without any call).

Tests: art payload/prefix/markup rejection, swap cooldown + fan-out,
compliant/non-compliant stats sources. 108 passing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Follow-up to the Codex re-review: the charset check alone admitted
structurally malformed base64 (odd length, mid-string padding), which
could be locked permanently as broken factory art. The validator now
requires payload length % 4 == 0, '=' only in the final two positions
(never '=X'), and the encoded image signature - PNG's magic bytes
always base64-encode to the "iVBORw0KGgo" prefix and GIF87a/89a to
"R0lGOD", so magic verification is a prefix comparison with no
on-chain decoding.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Response to the final Codex review finding on custom art providers:
the species-scoped brick risk is accepted by design (self-inflicted,
contained, and the reason the factory path carries the verified
badge), but the injection half gets a concrete mitigation - token_uri
will validate custom provider output (base64 image data URI, wider
mime allowlist) before embedding, landing with the render path in the
NFT integration PR.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two review findings:

- notify_art_updated now gates on art_locked only for factory
  providers. lock_art freezes a factory provider's output completely
  (its sole mutator is registry-gated set_art), so a refresh there
  could never carry new art. For a custom provider the lock freezes
  only the pointer - the provider contract may still change what it
  returns - so blocking notification would strand ERC-4906 consumers
  on permanently stale metadata for that species. The shared cooldown
  still applies.
- GIF magic validation now requires a complete encoded signature:
  R0lGODdh (GIF87a) or R0lGODlh (GIF89a). The previous 6-character
  "R0lGOD" prefix admitted payloads such as R0lGODAAAAAA, which
  decodes to the invalid header GIF80. PNG unchanged (iVBORw0KGgo is
  already a complete signature plus the implied IHDR length byte).

Tests: locked custom provider can still notify, locked factory cannot;
GIF87a/GIF89a both accepted, invalid version and truncated magic
rejected. 116 passing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@loothero
loothero force-pushed the feat/beast-registry branch from 3d517d5 to 9078668 Compare August 1, 2026 04:18
The registry stored an artists map alongside a transferable Genesis Beast,
which is two sources of truth for one thing. Sell the creator token and an
invisible role stayed behind; every UI then has to explain which one
actually governs.

Now there is one. Permissioned entrypoints resolve the artist as
owner_of(genesis_token_id(beast_id)), so control moves with the token on
any marketplace and the two can never diverge. transfer_artist_role is
deleted — the ERC721 transfer is the transfer.

genesis_token_id is derived rather than stored: encode_token_id over the
canonical (id, 0, 0) shape with the species' registered traits. That shape
now has a single definition in beast_manager::genesis_beast, because the
registry and the NFT must compute the same token or the role would point
at nothing.

Costs taken knowingly: each permissioned write makes one owner_of call to
the NFT (owner-set and write-once, so not an untrusted call), and
mis-sending a Genesis Beast freezes that species' admin. Burning cannot
cause that — the enumeration component rejects burns — but a bad transfer
can, same as any NFT.

Saves a storage slot per species and removes an entrypoint. Also unlocks
client-side lookup: token_of_owner_by_index plus a local decode_token_id
tells a wallet which species it controls, with no registry reads and no
event scanning.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@loothero loothero closed this Aug 1, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants