abx.
Protocol

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-7572 contractURI reads.

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.

ScopeReserved 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). name and image are required and always resolve to something; the rest are omitted when unset or unrenderable, and abx_provenance says which happened. background_color, youtube_url, banner_image and featured_image joined 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 designabx_provenance.status and anchor (re-hashing gateway bytes is off-chain work), the operator display.* overlay, effect and live-view seams, the artifacts listing, a collection image courtesy taken from a representative token (contractURI has 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. (ipfs and arweave left this list at renderer spec v11 — see Preferred gateways below.)
  • No longer reservedcollaborators. It was in the table, projected by neither plane, and because reserved keys are excluded from the resolver's artifacts listing, setting it on chain did nothing observable anywhere. It is now an ordinary creator key, which means it does list in artifacts. Put a collaborator list in a named artifact or in description.

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 holdson chain (tokenURI)off chain (a resolver)
content (inline, reader, ±gzip, computed)the bytes, inlined as a data: URIa URL to the resolver's route, which serves those bytes
a locator (url, url-template, a computed text/uri-list)the locator, verbatimthe 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 contractparamSchemaKeys, 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

RepresentationValueWhere the content lives
inlinethe content byteson-chain, in the field
inline-gzipgzip(content)on-chain, decompressed off-chain
readera reader contract and a storage pointeron-chain, behind a reader contract
reader-gzipas reader, but the assembled bytes are gzip(content)on-chain, decompressed off-chain
renderera field-renderer contract addresscomputed on read, nothing stored
arweavean Arweave transaction idoff-chain, content-addressed
ipfsan IPFS CIDoff-chain, content-addressed
urla URLoff-chain
url-templatea URL with an {id} placeholderoff-chain; the resolver substitutes the token id
keccak256 / sha256a hash of the contentoff-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, and IAbxOnChainReader.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, and IAbxFieldRenderer.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: render returns a content type alongside the bytes, and one value of it is special — text/uri-list means the computed bytes are a locator, so they land verbatim as the field's value instead of being wrapped as a data: URI. That is how a computed field points somewhere rather than carrying content. The rule is the same for every field, including the URI-valued image and animation_url: a computed ipfs://… is emitted as ipfs://…, never as data:text/uri-list;base64,…, which no marketplace would dereference.
  • Off-chain compression is signaled by the -gzip suffix, because gzip cannot be reversed on-chain. A -gzip field is not on-chain-renderable.

How tokenURI resolves

How the tokenURI and contractURI documents are produced is configurable per contract:

  • Off-chain pointer (default). tokenURI returns a URL to a resolver, which assembles the JSON from the fields.
  • On-chain renderer. tokenURI returns a data:application/json document assembled on-chain by a contract behind IAbxMetadataRenderer.

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:

  1. If a renderer is set, resolve on-chain through it.
  2. Otherwise, if a per-token override URL is set, return it.
  3. Otherwise, if a base URL is set, return {base}/{chainId}/{address}/{tokenId}.
  4. 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:

FieldExample
abx_gateway_ipfshttps://ipfs.io/ipfs/ · https://<you>.mypinata.cloud/ipfs/
abx_gateway_arweavehttps://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: once lockAfter passes, 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 something lockField/lockTokenURI do for you.
  • A Registry dependency resolves live. lockDependencies pins 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.

On-chain storage, Field renderers, Data plane, Effects

On this page