Metadata
ABX stores a token's metadata as a set of fields on-chain. Each field maps to one representation and a value. A field can be stored fully on-chain, located off-chain, or committed on-chain by a hash. The model is deterministic: for a field it can resolve, any resolver reading the same chain produces the same value.
Fields, representations, and values
Metadata is a key-value store at two scopes:
- Token scope:
tokenId -> field -> { representation, value }. - Collection scope:
field -> { representation, value }, contract-wide. This is what ERC-7572contractURIreads.
A field is the JSON key it produces, such as image, description, or attributes. A
representation says how the value is carried. A field has exactly one active representation; setting
it again replaces the previous one. That means a field cannot carry both a locator and a verification
hash: url is the location, sha256/keccak256 is the commitment. Putting the URL in image and the
hash in attributes is an integrator convention, not a protocol shape. Field resolution reads token
scope first and falls back to collection scope.
The contract stores the representation as an opaque value and does not act on it. The representation vocabulary is a protocol convention, defined in the SDK rather than in Solidity, so new representations are additive.
Reserved fields
A field's JSON key is either a reserved key, projected straight into the served document, or an
arbitrary creator key, which never appears at the top level — it surfaces only through the data
plane's artifacts manifest.
| Scope | Reserved keys |
|---|---|
Token (tokenURI) | name, description, image, animation_url, external_url, attributes, background_color, youtube_url |
Collection (contractURI) | name, description, banner_image, featured_image, external_link, creator, display_notes, creator_links, license |
Which plane projects which key
The field store is one; serving is two planes, and they are deliberately not identical. The rule:
a key both planes can emit must be emitted by both. Getting that wrong on a shared key is what
produced three separate remediations (a computed image wrapped into an undereferenceable data: URI,
a duplicated abx_params block, and the image duplicated into artifacts).
- Both planes — every reserved key in the table above, whenever its representation is
chain-reachable (
inline·reader·renderer·url·url-template·ipfs·arweave).nameandimageare required and always resolve to something; the rest are omitted when unset or unrenderable, andabx_provenancesays which happened.background_color,youtube_url,banner_imageandfeatured_imagejoined this list at renderer spec v10 — they were reserved from the start but projected on chain by nothing, which is a hole rather than a saving: you set the field, saw no effect, and couldn't tell whether the tool or the marketplace was at fault. - Resolver only, by design —
abx_provenance.statusandanchor(re-hashing gateway bytes is off-chain work), the operatordisplay.*overlay, effect and live-view seams, theartifactslisting, a collectionimagecourtesy taken from a representative token (contractURIhas no token id),image_data, and any value behind an off-chain load or decode (keccak256·sha256·*-gzip). Don't wait for the contract to emit these. (ipfsandarweaveleft this list at renderer spec v11 — see Preferred gateways below.) - No longer reserved —
collaborators. It was in the table, projected by neither plane, and because reserved keys are excluded from the resolver'sartifactslisting, setting it on chain did nothing observable anywhere. It is now an ordinary creator key, which means it does list inartifacts. Put a collaborator list in a named artifact or indescription.
That split is about which keys a plane emits. A key's value is specified too, and for the two
URI-valued keys — image and animation_url — it differs by plane on purpose:
| the field holds | on chain (tokenURI) | off chain (a resolver) |
|---|---|---|
content (inline, reader, ±gzip, computed) | the bytes, inlined as a data: URI | a URL to the resolver's route, which serves those bytes |
a locator (url, url-template, a computed text/uri-list) | the locator, verbatim | the locator, verbatim |
On chain there is no choice — a contract has no URL space, so the document must carry the bytes, and that
self-contained tokenURI is exactly the durability you were promised. Off chain there is a choice, and
the resolver serves a link: bytes should not ride inside a JSON document that marketplaces re-fetch on
every view, which is the same reason image has always been a link.
A link doesn't mean the content isn't on chain. abx_provenance still reports source: inline (or
reader, or renderer) and status: on-chain — the route changes where the bytes ride, not where they
live, and the chain's own tokenURI still carries them in full. For an on-chain SVG the resolver also
emits image_data with the raw bytes inline, per OpenSea's standard.
If you want a URI-valued field to carry a locator on both planes, the supported way is a field renderer
that returns text/uri-list — the project's choice, recorded on chain, rather than the host's.
The authoritative version of this split lives in
specs/protocol/onchain-metadata.md.
The collection-scope authorship-and-rights keys — creator, display_notes, creator_links, and
license (a rights identifier such as CC0-1.0 or CC-BY-NC-4.0) — are ordinary reserved fields,
not a separate extension. Set them at deploy (abx deploy … --creator "…" --license "…") or later
(abx set-field <addr> --collection --field creator --text "…"); both the resolver and the on-chain
renderer project them into contractURI when present.
Two more keys appear in served JSON but are never set with set-field — they are computed,
assembled at read time from state no single field holds:
artifacts: the data plane's complete listing of every named piece of content the token anchors, reserved-key duplicates included.abx_provenance: one entry per served field, saying where its value came from — see Provenance.
set-field and attach refuse artifacts and abx_provenance as field names, along with the
retired abx_params.
Parameters are not in tokenURI
Configured parameters used to be projected into the token JSON as an
abx_params block. They no longer are, on either surface. Parameters enumerate directly from the
contract — paramSchemaKeys, contractParamKeys, tokenParamKeys, then tokenParam /
contractParam — which is canonical, needs no indexer and no resolver, and is what every consumer that
actually read them was using. A second serialization of the same state inside a document marketplaces
must fetch cheaply bought nothing and nobody parsed it back. A code project's program still receives
every parameter as tokenData; traits meant for marketplace display belong in attributes.
Representations
| Representation | Value | Where the content lives |
|---|---|---|
inline | the content bytes | on-chain, in the field |
inline-gzip | gzip(content) | on-chain, decompressed off-chain |
reader | a reader contract and a storage pointer | on-chain, behind a reader contract |
reader-gzip | as reader, but the assembled bytes are gzip(content) | on-chain, decompressed off-chain |
renderer | a field-renderer contract address | computed on read, nothing stored |
arweave | an Arweave transaction id | off-chain, content-addressed |
ipfs | an IPFS CID | off-chain, content-addressed |
url | a URL | off-chain |
url-template | a URL with an {id} placeholder | off-chain; the resolver substitutes the token id |
keccak256 / sha256 | a hash of the content | off-chain, verified against the on-chain hash |
Three mechanisms sit behind these:
- On-chain storage (
reader) returns bytes that were written to chain storage. The field stores a reader contract and a pointer, andIAbxOnChainReader.read(pointer)returns the stored content. Storage details such as SSTORE2, compression, and chunk stitching live inside the reader — see On-chain storage for the reference implementation. The content is fixed. - On-chain compute (
renderer) produces the value at read time. The field stores only a field-renderer address, andIAbxFieldRenderer.render(token, tokenId, field), a view function, computes the value from current chain state. Nothing is stored for the value, so a renderer's output can depend on the token's state, such as its parameters, seed, or owner, while staying deterministic and reproducible. It is also the only representation that declares its own type:renderreturns a content type alongside the bytes, and one value of it is special —text/uri-listmeans the computed bytes are a locator, so they land verbatim as the field's value instead of being wrapped as adata:URI. That is how a computed field points somewhere rather than carrying content. The rule is the same for every field, including the URI-valuedimageandanimation_url: a computedipfs://…is emitted asipfs://…, never asdata:text/uri-list;base64,…, which no marketplace would dereference. - Off-chain compression is signaled by the
-gzipsuffix, because gzip cannot be reversed on-chain. A-gzipfield is not on-chain-renderable.
How tokenURI resolves
How the tokenURI and contractURI documents are produced is configurable per contract:
- Off-chain pointer (default).
tokenURIreturns a URL to a resolver, which assembles the JSON from the fields. - On-chain renderer.
tokenURIreturns adata:application/jsondocument assembled on-chain by a contract behindIAbxMetadataRenderer.
The toggle is the stored renderer address: non-zero resolves on-chain, zero uses the stored pointer.
Resolution precedence is the same for tokenURI and contractURI:
- If a renderer is set, resolve on-chain through it.
- Otherwise, if a per-token override URL is set, return it.
- Otherwise, if a base URL is set, return
{base}/{chainId}/{address}/{tokenId}. - Otherwise, return an empty string.
An on-chain renderer can serve only the representations it can produce on-chain: inline, reader,
renderer, url, url-template, and — since spec v11 — ipfs and arweave. A field committed by
hash alone (keccak256, sha256) is served by the off-chain resolver instead, because loading and
re-hashing off-chain bytes is not something a contract can do.
Preferred gateways
A CID is not a URL. An IPFS CID or an Arweave txid is the integrity hash of the bytes it names —
which is exactly why this protocol tells you to prefer ipfs/arweave over url, and exactly why
no browser or marketplace will dereference one. Something has to supply the https://.
The tempting answer is to bake it in at upload time: store https://<your-gateway>/ipfs/<cid> and be
done. That is what abx deploy --onchain-uri --backend ipfs used to do, and it costs you two things
quietly. Your gateway's hostname becomes part of a value you may later lock, so the day that gateway
goes away your token goes with it. And the chain now says source: url about bytes that live on
IPFS — it has stopped describing where your work actually is.
So the two facts live apart. The CID stays in the field — identity, under that field's own lock. The gateway is a project-wide preference, in two reserved collection-scope fields:
| Field | Example |
|---|---|
abx_gateway_ipfs | https://ipfs.io/ipfs/ · https://<you>.mypinata.cloud/ipfs/ |
abx_gateway_arweave | https://arweave.net/ |
The renderer and the resolver join them at read time, so a marketplace receives an https:// and a
dead gateway is a repoint — one transaction, every token moved, the CID untouched, and it works
even on fields you have already frozen:
abx set-gateway 0xYourContract --ipfs https://your-dedicated.mypinata.cloud/ipfs/Two fields rather than one, because a project can pay for a dedicated IPFS gateway and leave Arweave
on the public one. Set them at deploy with --ipfs-gateway / --arweave-gateway (or --gateway,
which seeds the one matching your storage backend). Leave them unset and you get the public defaults
— and stay on them as those defaults improve.
A {id} in the value is substituted with the token id, so one collection-scope ipfs field can
address a whole pinned directory, exactly as url-template does for a plain CDN. A value that
already names its own host is passed through untouched, never prefixed twice.
The two keys are a serving preference, not metadata: neither plane emits them into the JSON, and
they are absent from the artifacts listing. Provenance reports source: ipfs/arweave and says
the value is served through the collection's preferred gateway — never verified, because the
chain holds the locator and not the bytes, and nothing on chain can re-hash what a gateway returns.
A project that resolves fully on-chain can now use ipfs/arweave for its heavy assets and still
have tokenURI assemble from the chain alone.
Configured parameters are not fields and carry no such limit. The parameter store
enumerates its own keys on-chain, so a chain-only reader can see a token's complete configuration
with one contract per key and no server at all — a field with an arbitrary key needs a resolver to be
seen; a parameter never does. That read goes to the contract, not to tokenURI.
Locking
Two locks freeze metadata permanently: a per-field lock, on (scope, field), freezes a field's
representation and value; a URI-config lock freezes the pointer and renderer. With both set, the field
values and the way they resolve can never change again. A code project has two more,
lockScript and lockDependencies, for the program and its library set.
Locked metadata does not always mean a locked output
A lock freezes what this contract stores. It cannot freeze anything downstream of that, and two things routinely sit downstream:
- No metadata lock reaches a parameter. Parameters are not fields, so a field
lock does not touch them, and a program keeps reading the current value as
tokenData. An owner can keep writing an ungoverned parameter after every metadata lock is engaged. A parameter's lock is its own, per-key and part of its schema: oncelockAfterpasses, that key's token-scope value and schema are welded permanently, and the deadline can only ever move earlier — with one documented exception, a contract-scope default, described under configurable parameters. So freezing a parameter is a separate, deliberate act — not somethinglockField/lockTokenURIdo for you. - A
Registrydependency resolves live.lockDependenciespins which library a ref means and which registry it resolves through; the bytes still live in that registry, and its owner can change them. - Every lock freezes a pointer, not the code behind it. A locked renderer, reader or hook is an address. If that address is an upgradeable proxy, its behavior can be replaced afterwards while the lock still reads as engaged. On-chain proxy detection is complex and incomplete, so the protocol defines locks as pointer locks and states the limit instead. A permanence claim therefore requires immutable renderer and hook deployments; verification tooling is the right place to flag a settled project pointing at administered code.
So a locked pointer is a real guarantee — it is just a guarantee about the pointer. Say "locked metadata" to a buyer, not "immutable work", unless the whole graph is on-chain and frozen.
Read the other way, this is a capability: a token designed to adapt to live chain state — a collector-set palette, a read-time hook folding in current conditions — is a deliberate and interesting thing to build, and the same mechanism is what makes it possible. Whether a given project is frozen or living is a per-project choice, and what a project owner can do is where a buyer reads which one they are looking at.
Provenance
Served metadata includes an abx_provenance entry per field, stating where that field's bytes came
from. Every entry carries field, source (for example inline, reader, renderer, url,
ipfs, fallback, or effect:<key>) and a human-readable note describing the route — "stored on
chain", "computed on chain (field renderer)", "composed on chain from a stored template; a locator".
A value taken from the collection scope rather than the token's own is tagged [collection].
The off-chain resolver adds a status: on-chain, verified, mismatch, anchored, off-chain,
or n/a. That field is the resolver's alone, because it is the only surface that can do the work —
it can fetch off-chain bytes and re-hash them against the on-chain anchor. A contract cannot.
There is no onChain flag, deliberately
Provenance entries used to carry an onChain boolean and a verifiedAgainstChain field. Both are
gone. verifiedAgainstChain was hardcoded null on every on-chain entry, and onChain was wrong in
both directions: a url field reported false even though the URL string is stored on chain, while
an inline value of "https://example.com/x.png" reported true for a pure pointer. It was trying to
answer a question no on-chain code can see without interpreting the value, and interpreting a value is
off-chain work. What a contract can honestly report is the route the bytes took — which is what source
and note now say. Whether a given value is a self-contained data: URI or a pointer to something else
is plainly visible in the value, and that determination belongs to the reader, which can actually make
it.
Worked examples
Same reserved-field vocabulary, two different resolution paths.
On-chain rendered. An image field renderer computes the SVG at read time;
tokenURI is a data:application/json;base64 document, decoded here:
{
"name": "Drift #42",
"image": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0i...",
"attributes": [{ "trait_type": "Palette", "value": "#0e1a40" }],
"abx_provenance": [ // {field, source, note} per field
{ "field": "name", "source": "fallback", "note": "on-chain (ERC-721 name() + #id)" },
{ "field": "image", "source": "renderer", "note": "computed on chain (field renderer)" },
{ "field": "attributes", "source": "renderer", "note": "computed on chain (field renderer)" }
]
}No off-chain custody, no resolver: one eth_call to tokenURI reconstructs the whole document.
There is no artifacts key at all — the key is absent, not empty. Through spec v8 the renderer made
one exception and emitted a renderer-represented image there too, on the theory that the entry
declared a mimeType the projection lacked. It didn't: the value is a data: URI, which states its own
mediatype, so the entry's whole contribution was a second copy of the image inside a document that then
gets base64-encoded around both — roughly doubling the inner payload of the read this protocol most
wants to stay cheap. Since v9 the renderer takes the omission the data plane
always allowed it, for every reserved key. The off-chain resolver still emits the complete listing,
duplicates included; that asymmetry is the spec's, and the table below says which plane owns what.
The collector-set palette the renderer drew this SVG from is not in the document, and does not need
to be: it is read straight from the contract (tokenParamKeys(42) → tokenParam(42, "palette")), by
the same eth_call budget and with no list anyone maintains. Here it also reaches a marketplace the
way traits are meant to — through attributes, which this project computes with a field renderer of
its own. That is a choice, not automatic: a parameter becomes a displayed trait only if the project
puts it there.
Off-chain resolver. The same reserved keys, but image is a creator-set ipfs field, and a
render effect produced a still and a 3D model:
{
"name": "Drift #7",
"image": "https://ipfs.io/ipfs/QmStill/nft.png", // the CID on chain, this collection's gateway prefix
"artifacts": [
{ "key": "image", "mimeType": "image/png", "uri": "https://ipfs.io/ipfs/QmStill/nft.png" },
{ "key": "render/image", "mimeType": "image/png", "uri": "ipfs://QmRender" },
{ "key": "world.rebuild/model", "mimeType": "model/gltf-binary", "uri": "ar://TX" }
],
"abx_provenance": [ // {field, source, status, note} per field
{ "field": "image", "source": "ipfs", "status": "anchored" },
{ "field": "world.rebuild/model", "source": "effect:world.rebuild", "status": "off-chain" }
]
}Here artifacts lists the creator's image (deliberately duplicating the reserved key) plus every
current effect output — a resolver has no size or field-enumeration limit an on-chain renderer does.
The on-chain renderer does the opposite, on purpose. From spec v9 it omits every entry that would
duplicate a reserved key it already emits, so on a fully-on-chain project artifacts is absent rather
than empty. The two surfaces are not in conflict: the manifest's job is to be the complete listing,
and the resolver can afford that while a tokenURI view assembling the whole document in one call
cannot — a duplicated data: URI is the single largest thing it could carry twice. The spec grants the
omission explicitly ("an EVM-efficiency reduction, never a semantic one"), and nothing is lost, because
a data: URI already declares its own mediatype.