Skip to content

Add an agent NFT extension with ERC-8048 onchain metadata - #212

Open
ryanio wants to merge 16 commits into
mainfrom
ryan/agent-nft-extension
Open

ryanio wants to merge 16 commits into
mainfrom
ryan/agent-nft-extension

Conversation

@ryanio

@ryanio ryanio commented Nov 7, 2026 •

Copy link
Copy Markdown
Collaborator

Adds ERC721SeaDropAgents, an extension for collections whose tokens are AI agents, modeled on what MUSEGOD's Muses contract added to ERC721SeaDrop on Robinhood Chain. The ERC-8048 code is a separate module in src/lib, so other token contracts can reuse it.

Agent registration

registerAgents(firstTokenId, lastTokenId, prefix, suffix) registers an ERC-8004 agent for each token through an ERC-8217 adapter, with agent URI prefix + id + suffix. Whoever holds the token controls the agent. The adapter is a constructor argument, since it is only deployed on some networks.

  • Owner only, and only for tokens not minted yet (up to the max supply). The adapter lets a token contract register only tokens without an owner. MUSEGOD's version allowed it only before the first mint; this allows registering in ranges as a drop goes.
  • The adapter doesn't stop a token from being registered twice, so the contract records each agent id and skips tokens already registered. agentOf(tokenId) returns it.

ERC-8048 onchain metadata

ERC8048OnchainMetadata stores string keys with bytes values per token and implements ERC-8048's optional Metadata Authority extension in its collection-wide form (ethereum/ERCs#2174). The authority and three settings pack into one storage slot.

  • tokenMetadataAuthority() returns the one address that can write every token, including before mint, or zero when none is set. While it is set it is the only writer, as the ERC requires: the owner and holders write only through it. supportsInterface(0x04f90b41) returns true, and every change of the address emits TokenMetadataAuthoritySet(address indexed authority).
  • While the authority is blank, the settings decide. The owner (owner(), so it follows ownership) writes any token unless ownerCannotWrite is on. holderCanWrite lets a token's current holder (ownerOf at write time) write that token. Transfers need no claim: the buyer writes right away and the seller can't. ERC-721 operators can't write for the holder.
  • authorityLocked stops the owner from calling setCollectionAuthority, including while the owner writes as the blank authority. A set authority can still call it, so a locked policy can hand itself on. A blank authority can be locked, which fixes the settings for good and blocks adding a policy later.
  • setCollectionAuthority(authority, holderCanWrite, ownerCannotWrite, authorityLocked) sets everything and emits CollectionAuthoritySettingsUpdated(holderCanWrite, ownerCannotWrite, authorityLocked), so indexers see flag changes too. collectionAuthoritySettings() returns the flags. They default to false, so a new collection starts owner-only.
  • There are no key locks. Prem's follow-up pointed out that a key lock also freezes the key for tokens not minted yet: an artist who signs the first tokens and locks artist.signature leaves every later token unsigned. Freezing is left to a policy set as the authority, which can freeze per token. A collection that wants every record frozen locks a blank authority with ownerCannotWrite on and holderCanWrite off (testFreezeEverything).
  • Every write emits MetadataSet. A write to a minted token also emits ERC-4906 MetadataUpdate, since OpenSea refreshes on ERC-4906 events. The library calls a _emitMetadataUpdate hook and the token declares the event, so the library also works next to OpenZeppelin's IERC4906. Writes before mint skip it. A batch emits one MetadataUpdate per run of entries for the same token, and reverts if the caller can't write any entry.

The settings cover these states, each with a test:

State Authority, settings Test
Owner only. The owner writes and can change the settings blank, all off testOwnerOnlyByDefault
Owner and holders. The owner can change the settings blank, holderCanWrite testHoldersWrite
Locked blank, owner and holders for good blank, holderCanWrite, authorityLocked testLockedBlankOwnerAndHolders
Locked blank, holders only for good blank, holderCanWrite, ownerCannotWrite, authorityLocked testLockedBlankHoldersOnly
Locked blank, nobody writes blank, ownerCannotWrite, authorityLocked testFreezeEverything
A policy. Only the policy writes, the owner can replace it policy testPolicyAuthority, testAuthorityIsOnlyWriter
A locked policy. Only the policy writes and hands it on policy, authorityLocked testLockedPolicy

This replaces the earlier per-token Metadata Authority design. lockMetadataKey, metadataKeyLocked, MetadataKeyLocked, MetadataKeyIsLocked, setMetadataAuthority, the per-token authority mapping, metadata writers (setMetadataWriter), the clear-on-transfer hook, and ERC-165 support for the per-token Metadata Authority draft (0xf9cb127d) are gone. The token reports ERC-8048 (0xdf670be1), its collection-wide Metadata Authority extension (0x04f90b41), and ERC-4906 (0x49064906). Transfers no longer touch metadata storage.

Reference policy

src/lib/MetadataWriterPolicy.sol is a small example to set as the collection authority, so nothing the per-token design offered is lost. The token doesn't depend on it.

  • Its admin (TwoStepOwnable owner) allows several writer addresses with setWriter, replacing setMetadataWriter.
  • A token's current holder delegates their token's writes to one address with setDelegate, such as their agent's wallet. The delegation records the holder who set it and counts only while that address is still ownerOf, so it lapses on transfer (and counts again if the token returns to that holder).
  • Write-once keys, from Prem's follow-up. The admin marks a key with setWriteOnceKey(key, bool), and a write to that key reverts with WriteOnceValueSet(tokenId, key) when the token already has a non-empty value, read through the token's metadata(tokenId, key). setMetadataBatch applies the same rule and also counts earlier entries in the batch, so a batch can't set a value and overwrite it. Tokens minted later can still be written once (testPolicyWriteOnceKey, testPolicyWriteOnceKeyBatch). The admin can unmark a key, so a lasting freeze locks the authority to the policy and renounces the policy. While the policy is the authority, holders can't write past it whatever holderCanWrite says.
  • setMetadata and setMetadataBatch check the caller and forward to the token. The batch checks array lengths first (BatchLengthMismatch), since it now reads each entry. setCollectionAuthority is an admin passthrough, so a policy can hand the authority on. renounceOwnership also cancels a pending ownership transfer, as the token does, so a renounced policy stays renounced.

The module reserves no keys. ERC-8048 is a draft and its agent profile (ERC-721T) may still change, so the keys are only listed in the doc. Token id 0 is not special.

Docs

docs/AgentNFTs.md covers deployment, the adapter addresses on Ethereum, Base, Robinhood Chain and Sepolia (each confirmed to have code), registration, the metadata authority and its common setups, the reference policy, and how OpenSea shows agent NFTs. The README and the bring-your-own-token doc link to it.

Size and gas

  • ERC721SeaDropAgents runtime code is 23,472 bytes, 1,104 bytes under EIP-170.
  • Registration costs about 200,000 gas per token, or about 240,000 once the agent URI is longer than 31 bytes. Ethereum and Base cap a transaction at 16,777,216 gas (EIP-7825), so the docs say to register about 60 tokens per transaction.

Tests

  • test/foundry/ERC721SeaDropAgents.t.sol: 32 tests (31 run, 1 fork test skipped in CI) covering the states above, settings permissions, when TokenMetadataAuthoritySet fires, a set authority as the only writer, ownerCannotWrite while unlocked, a locked authority clearing itself to a locked blank state, writes before mint, holder writes across transfer, safe transfer and burn, batches, the reference policy's writers, holder delegation and write-once keys (single and batch) through the token, registration ranges and skips (including a collection whose ids start past 1), interface ids, and the library next to OpenZeppelin's ERC721URIStorage. forge coverage --ir-minimum reports 100% line and branch coverage of ERC8048OnchainMetadata and ERC721SeaDropAgents, and 100% branch coverage of MetadataWriterPolicy (its two-line renounceOwnership shows as unhit lines under --ir-minimum, though testPolicyRenounceCancelsPendingOwner calls it).
  • src/test/MockAgentAdapter.sol applies the real adapter's registration rule.
  • testRegisterAgentsWithDeployedAdapter forks the chain AGENT_ADAPTER_FORK_URL names and registers through that chain's deployed adapter. It is skipped in CI. It passes locally on Ethereum, Base, Robinhood Chain and Sepolia against public RPCs.

Audit

An independent review of the earlier version found no critical, high or medium issues. Its changes that still apply: the library doesn't declare MetadataUpdate (so it compiles next to OpenZeppelin's IERC4906), and the docs cover the 16,777,216-gas transaction cap, that nobody can change an agent URI before mint, that agent URIs are public once registered, that the agent's URI and wallet carry over a sale, and that the adapters are upgradeable proxies a 3-of-4 Safe controls on Ethereum and Base. Its findings about clearing per-token authorities on transfer, batches failing on tokens with an authority, and writers surviving an ownership transfer no longer apply: there is no per-token authority, the collection authority can write every batch entry, key locks are gone, and writers now live in a policy contract with its own admin.

Not in this PR

  • Upgradeable and clone versions.

🤖 Generated with Claude Code

…metadata

ERC721SeaDrop plus:
- registerAgents: the owner registers an ERC-8004 agent for each unminted
  token through an ERC-8217 adapter, skipping tokens already registered.
  agentOf returns the agent id.
- ERC8048OnchainMetadata (src/lib): ERC-8048 records with the Metadata
  Authority extension. Owner and allowed writers write while a token has
  no authority; the holder can set an authority to take over; transfers
  and burns clear it; the owner can lock a key for good. Every write
  emits MetadataSet and ERC-4906 MetadataUpdate.

Tests use a mock adapter with the real adapter's registration rule, plus
a fork test against the Base adapter that runs when
AGENT_ADAPTER_FORK_URL is set. docs/AgentNFTs.md covers deployment,
adapter addresses and how OpenSea shows agent NFTs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

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.

🟢 Approval recommended

No active review issues block approval of the agent NFT extension.

No open issues 🎉

What changed in this PR

Adds an agent NFT extension to SeaDrop, combining pre-mint agent registration with reusable ERC-8048 onchain metadata while retaining existing minting and token URI behavior.

Changes:

  • Adds range-based agent registration through an ERC-8217 adapter, with tracking to skip previously registered tokens.
  • Introduces metadata writers, holder-controlled authorities, permanent key locks, and authority clearing on transfers and burns.
  • Adds tests, an optional Base fork integration test, and deployment and usage documentation.
File Description
test/​foundry/​ERC721SeaDropAgents.t.sol Tests metadata permissions, authority lifecycle, registration, and interface support.
src/​test/​MockAgentAdapter.sol Provides an adapter mock enforcing registration permissions.
src/​lib/​ERC8048OnchainMetadata.sol Implements reusable metadata storage, permissions, locks, and update events.
src/​interfaces/​IERC8048MetadataAuthority.sol Defines the metadata authority getter and event.
src/​interfaces/​IERC8048Metadata.sol Defines the metadata getter and write event.
src/​interfaces/​IAgentAdapter.sol Defines the adapter registration interface.
src/​extensions/​ERC721SeaDropAgents.sol Integrates agent registration and metadata into SeaDrop tokens.
README.md Links to the agent NFT guide.
docs/​BringYourOwnTokenContract.md Introduces the Agents extension and links its guide.
docs/​AgentNFTs.md Documents deployment, registration, metadata rules, and OpenSea behavior.

Review effort: Balanced

…fer behavior

Writes before mint still emit MetadataSet but no longer emit
MetadataUpdate for a token that doesn't exist, so pre-mint setup of a
large collection doesn't send a refresh per record. A new
_metadataTokenExists hook decides. Notes that a batch reverts if any
token in it has an authority, and that records stay with a token when
a transfer clears its authority.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

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.

🔵 Needs a closer look

Network-specific ERC-8217 adapter compatibility and the narrow EIP-170 bytecode margin require independent runtime validation, while dependency-based and fork tests could not be run in this review environment.

No open issues 🎉

Review effort: Balanced

ryanio and others added 5 commits November 6, 2026 19:24
Picks the adapter by chain id, so the test covers Ethereum, Base,
Robinhood Chain and Sepolia. All four pass against public RPCs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Leave ERC-4906's MetadataUpdate to the token so the ERC-8048 library can
sit next to OpenZeppelin's IERC4906, clear the authority of every token a
transfer moves, and document the gas cap, what survives a sale, writer
persistence, key locks and the adapter's upgradeability.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

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.

🔵 Needs a closer look

Irreversible registrations through external upgradeable adapters and narrow EIP-170 headroom require deployed-adapter compatibility and compiled-bytecode size validation unavailable in this source-only review.

No open issues 🎉

Review effort: Balanced

@ryanio

ryanio commented Nov 9, 2026

Copy link
Copy Markdown
Collaborator Author

On the two points Copilot's latest review couldn't check from source alone (head 6f2cb27):

  • Deployed adapter compatibility: testRegisterAgentsWithDeployedAdapter forks the chain and registers through that chain's live ERC-8217 adapter. On this head it passes against Base, Ethereum and Sepolia. Robinhood Chain passed on an earlier head, and registerAgents hasn't changed since. The test checks the binding and that the holder controls the agent after mint.
  • Size: with Foundry 1.9.0, forge build --sizes gives ERC721SeaDropAgents 23,564 bytes of runtime code, 1,012 under EIP-170, and 24,980 bytes of init code.

Nothing to change.

ryanio added a commit that referenced this pull request Nov 9, 2026
… commit (#220)

## Motivation

- On 2026-11-09 a GitHub releases outage returned 504 for Foundry's
download, and every job on #219 failed at "Install Foundry" before
building anything. Each job downloads Foundry again on every run.
- Nothing in CI checks contract sizes. Several contracts are within a
few kilobytes of EIP-170, and the agent extension in #212 is about 1 KB
under it.
- `cancel-in-progress: true` also applies to pushes on `main`, so when
two PRs merge close together, the first merge commit's run is cancelled
and never gets a result.

## Solution

- `.github/actions/setup-foundry` restores `~/.foundry/bin` and
`~/.foundry/versions` (foundryup keeps the binaries in `versions/` and
links them from `bin/`) from the Actions cache, keyed on the runner OS
and `FOUNDRY_VERSION`. It installs with `foundry-toolchain` only on a
cache miss. All three jobs use it.
- `scripts/check_sizes.sh` builds `src/` (skipping tests and scripts)
with `forge build --sizes --json`. It fails if a contract it compiles is
over 24,576 runtime bytes or 49,152 init code bytes. It prints the ten
tightest margins, and writes them to the job summary, before failing.
The test job runs it before the tests.
- A new push to a pull request still cancels its older run. Every other
run gets its own concurrency group (`github.run_id`), so no run on
`main` or a tag is cancelled or replaced while it waits.
- Jobs get timeouts: 15 minutes for lint and 20 for tests and coverage.
- The `GOERLI_RPC_URL` secret is gone from the jobs, since Goerli is
shut down and nothing reads it.
- Third-party actions (`foundry-rs/foundry-toolchain`,
`coverallsapp/github-action`) are pinned to commit SHAs. GitHub's own
actions stay on major tags.

## Testing

- `./scripts/check_sizes.sh` passes locally on `main` with Foundry
1.9.0. Its tightest margin is `ERC721SeaDropCloneable`, with 3,522
runtime bytes left.
- `biome ci .` passes.
- This PR's first run fills the Foundry cache, and a re-run should
restore it. I'll confirm that on this PR.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
ryanio added a commit that referenced this pull request Nov 9, 2026
## Motivation

The README's diagram was a PNG of ASCII art. It showed `mintAllowList`
with a Merkle proof as the second mint path, and it left out
`mintSigned`, the path OpenSea uses for allow lists. The README also
said ERC-20 payments were a future idea, though v2 has them.

## Solution

- The README's diagram is now Mermaid, so GitHub renders it and it can
be edited as text. It shows the creator configuring the token, the token
forwarding settings to `SeaDrop`, a minter paying `SeaDrop`, and
`SeaDrop` paying out and calling `mintSeaDrop()`. A short paragraph
under it names the functions. `img/seadrop-diagram.png` is deleted, and
nothing else links to it, opensea-docs included.
- Background now points to v2 for ERC-1155, ERC-20 payments and multiple
stages, in place of the old "future" sentence.
- `docs/SeaDropTokenDeployment.md`:
- The `forge create` example gets `--broadcast`, which Foundry 1.x needs
to send the transaction.
  - The section heading level is fixed.
  - The drop URI sentence uses "onchain" and has no dashes.
- The README is shorter, 158 lines down to 94. It drops the table of
contents and tightens Background and Deployments. Install, usage, the
commands and the pull request checklist move to a new
`docs/Development.md`, and the README keeps a three-command quick start
that links it. The docs list is unchanged, so this doesn't conflict with
#212's new line.

## Testing

- I checked the rendered README on this branch on GitHub. The diagram
renders and its labels don't overlap.
- `biome ci .` passes.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

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.

🟢 Approval recommended

No active review issues remain for the agent registration and onchain metadata changes.

No open issues 🎉

Review effort: Balanced

ryanio and others added 2 commits November 12, 2026 14:32
One collection authority (the owner when blank) writes any token, and
two settings in the same slot control the rest: holderCanWrite lets a
token's current holder write it, and authorityLocked stops the owner
from changing the settings while the authority can still hand itself
on. Drops setMetadataAuthority, metadata writers, the transfer hook and
ERC-8048 Metadata Authority interface support.

Adds MetadataWriterPolicy, a reference policy that allows several
writers and lets a holder delegate their token's writes to one address
until the token moves.

Based on Prem's proposal.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

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.

🟡 Changes recommended

Renouncing ownership leaves a pending successor able to reclaim policy administration and modify a locked collection’s writers, key locks, and authority.

1 open issue

Review effort: Balanced

Comment thread src/lib/MetadataWriterPolicy.sol Resolved
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

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.

🔵 Needs a closer look

Irreversible metadata locks and authority policies cross an upgradeable-adapter trust boundary, warranting human security review, with deployed-adapter integration and the narrow EIP-170 code-size margin not independently validated.

No open issues 🎉

1 resolved since last review

Review effort: Balanced

ryanio and others added 2 commits November 13, 2026 10:50
A key lock also froze the key for tokens not minted yet, so a token
minted after the lock could never get a value. Drops lockMetadataKey
from the module and leaves freezing to a controller set as the
collection authority. MetadataWriterPolicy gets setWriteOnceKey: a
write to a write-once key reverts once that token has a non-empty
value, through setMetadata and setMetadataBatch alike. A collection
that wants every record frozen locks the authority to 0x...dEaD.

From Prem's follow-up review.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

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.

🔵 Needs a closer look

Irreversible metadata-authority locks and reliance on deployed upgradeable adapters warrant human security review because the cited audit covered an earlier authority design.

No open issues 🎉

Review effort: Balanced

Rename collectionAuthority() to tokenMetadataAuthority() and emit
TokenMetadataAuthoritySet, answer 0x04f90b41 in supportsInterface, make a
set authority the only writer, add ownerCannotWrite, and allow locking a
blank authority so the settings hold for good.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

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.

🟡 Changes recommended

The registration upper-bound check ignores the virtual starting token ID, potentially rejecting mintable IDs or accepting unmintable IDs in derived collections.

1 open issue

Review effort: Balanced

Comment thread src/extensions/ERC721SeaDropAgents.sol Resolved
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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