ERC-1155 URI and Metadata: The {id} Substitution, the JSON Schema, and Total Supply Explained

How ERC-1155's uri() function, the {id} hex substitution rule, the metadata JSON schema, and totalSupply(uint256) actually work, with a worked example and the correct ABI source.

evmquery team··7 min read
Share
ERC-1155 uri() and metadata reference: the {id} substitution rule explained

Call uri(1) on an ERC-1155 contract and you might get back a fully resolved link, or you might get back a template containing the literal string {id} that your code is expected to replace. Both are correct behavior under the standard. That ambiguity, plus a total supply function that half of ERC-1155 contracts simply don’t have, is why “read an ERC-1155 token” trips up more developers than the equivalent ERC-721 call.

ERC-1155 metadata key facts

  • uri(uint256 id) is one function that serves every token ID on the contract. ERC-721’s tokenURI(uint256) returns a distinct value per call; ERC-1155 usually returns the same templated string for every ID and expects the client to substitute in the ID.
  • The {id} placeholder, when present, must be replaced with the token ID in lowercase hexadecimal, zero-padded to 64 characters, with no 0x prefix.
  • totalSupply(uint256) is not part of the ERC-1155 standard interface. It comes from OpenZeppelin’s optional ERC1155Supply extension, so calling it on a contract that doesn’t inherit that extension reverts, not returns zero.
  • The core ERC-1155 methods share the same function selectors on every conforming contract, so one generic ERC-1155 ABI decodes uri(), balanceOf(), and the rest anywhere. Extension methods like totalSupply() need their own ABI fragment.

TL;DR

ERC-1155’s uri() is a single function shared by every token ID; when its return value contains {id}, replace it with the lowercase hex token ID padded to 64 characters. totalSupply(uint256) is an optional OpenZeppelin extension, not part of the base standard, so plenty of contracts don’t have it.

Why one uri() function serves every token ID

ERC-721 gives every token its own tokenURI(uint256 tokenId) call, and the contract is expected to return an already-resolved, individual URL for that ID. ERC-1155 doesn’t work that way. The EIP-1155 spec defines exactly one function, uri(uint256 id), that has to answer for every token ID the contract will ever mint, which for a game-item or trading-card contract can be thousands or millions of distinct IDs.

Storing a separate, unique string per ID on-chain for that many tokens would be expensive to write and awkward to update. So the standard allows (and most implementations use) one of two shortcuts:

  • Return the exact same string for every ID, and let the metadata JSON itself carry per-ID differences, or
  • Return a URI template containing the literal substring {id}, and let the client fill in the specific token ID before fetching it.

Neither approach is “more correct” than the other; both are valid ERC-1155 contracts. A contract can also skip the template entirely and just concatenate the raw ID into the string server-side inside uri(), which is what plenty of real deployments do. As a concrete example, the demo collection on evmquery’s ERC-1155 Inspector returns ipfs://QmdEWNzkWQhvJp6AMs5iMkZ3xX3idxTp6Ai2mKwYCFWaSs/1 for token ID 1: the raw decimal ID appended directly, no {id} placeholder, no substitution needed. Your code has to handle both cases, because you can’t know which one a given contract chose without calling uri() and checking.

The {id} substitution rule, worked

When {id} does show up in the returned string, the spec is exact about the replacement: hexadecimal, lowercase, no 0x prefix, left-padded with zeroes to exactly 64 characters (32 bytes). This is the rule most implementations get subtly wrong, usually by forgetting the padding or leaving the 0x on.

Take token ID 1. Converted to hex that’s just 1, one character. Padded to 64 characters it becomes:

before: 1
after: 0000000000000000000000000000000000000000000000000000000000000001

So a template of https://token-cdn-domain/{id}.json resolves to https://token-cdn-domain/0000000000000000000000000000000000000000000000000000000000000001.json for token ID 1. The EIP text itself uses a less trivial example worth checking your own implementation against: token ID 314592 (0x4cce0 in hex) against the same template resolves to:

https://token-cdn-domain/000000000000000000000000000000000000000000000000000000000004cce0.json

Both examples are exactly 64 hex characters after the domain and before .json. If your substitution code produces a shorter string, or a string with 0x still attached, or mixed-case hex, the resulting URL is wrong and will most likely 404 against a gateway that expects the padded form.

Off-chain metadata (name, image, description) is never enforced by the contract itself. uri() only tells you where to look; nothing on-chain guarantees the JSON at that address actually matches the schema in the next section, or that it exists at all.

The ERC-1155 Metadata URI JSON Schema

Whatever the resolved URL points to is expected to conform to the schema the EIP defines, called the “ERC-1155 Metadata URI JSON Schema.” It’s a documentation-level contract, not something enforced on-chain, and every field in it is optional:

Field Type Meaning
name string Identifies the asset the token represents.
decimals integer Number of decimal places to display, for tokens meant to be shown as fractional amounts (a fungible in-game currency, for example). Defaults to 0, meaning the token displays as a whole number.
description string Human-readable description of the asset.
image string (URI) Points to a resource with MIME type image/*, ideally 1:1 aspect ratio, that represents the asset.
properties object Arbitrary key/value pairs. Values may be strings, numbers, objects, or arrays; the schema doesn’t constrain what goes in here.

That’s the full set. There’s no attributes array standardized the way marketplaces later converged on for ERC-721 collections; anything beyond name, decimals, description, image, and properties is a de facto convention some marketplace adopted, not part of EIP-1155 itself. If you’re building a reader that has to work across arbitrary ERC-1155 collections, treat every field as optionally absent and don’t assume a marketplace-specific extension will be there.

Reading total supply (and why plenty of contracts don’t have it)

This is the most common surprise for developers coming from ERC-721, where enumeration and supply tracking are common add-ons. ERC-1155’s mandatory interface is limited to safeTransferFrom, safeBatchTransferFrom, balanceOf, balanceOfBatch, setApprovalForAll, and isApprovedForAll, plus the uri() read covered above. There is no totalSupply in that list. It was never part of the standard.

totalSupply(uint256 id) exists because OpenZeppelin ships it as an optional extension, ERC1155Supply, that a project’s contract has to explicitly inherit. The extension adds totalSupply(uint256 id) (how many of that specific ID have been minted, net of burns) and exists(uint256 id) (whether that ID has ever been minted). A contract that doesn’t inherit ERC1155Supply simply doesn’t have this function at all: the call doesn’t revert with “zero supply,” it reverts because there’s no matching function selector on the deployed bytecode. If you’re scripting reads across a batch of collections, expect a meaningful fraction of them to fail on totalSupply specifically, independent of whether the contract or the token ID is otherwise valid.

Practically, that means: check whether a contract implements the extension before you build a query that depends on it, and don’t treat a revert on totalSupply(id) as evidence the token doesn’t exist. It might exist fine; the contract just never opted into supply tracking.

Where the ABI for these functions comes from

Because uri(), balanceOf(), safeTransferFrom(), and the rest of the core interface are defined by the standard itself, their function selectors are identical on every conforming ERC-1155 contract. That means a single, generic ERC-1155 ABI JSON, whether you copy it from the EIP text, pull it from OpenZeppelin’s compiled build artifacts, or import it from a package like viem’s built-in ABI helpers, decodes those core methods against any ERC-1155 contract regardless of who deployed it or what else the contract does.

The optional extension methods break that guarantee. totalSupply(uint256) isn’t part of the fixed interface, so it isn’t in a generic ERC-1155 ABI; you either need the ERC1155Supply extension’s own ABI fragment, or you need to confirm the specific contract’s verified source before assuming the function is there.

evmquery skips the manual part of this entirely. Point it at a contract address and it pulls the verified source for that exact deployment (resolving through an EIP-1967 proxy first if there is one), reconstructs the real interface, and exposes uri() and totalSupply() as callable methods only when the deployed contract actually has them. There’s no static ABI file to keep in sync and no guessing whether a given collection implements the supply extension.

{
"uri": "token.uri(id)",
"totalSupply": "string(formatUnits(token.totalSupply(id), 0))"
}

That expression, run through the ERC-1155 Inspector, returns both fields (or just the URI, if the contract skips supply tracking) in one Multicall3 round trip. The same query is available from any language your stack already uses through the REST API, no proxy resolution or ABI management required on your end.

Next steps

Share

Read uri() and totalSupply() in one request

evmquery resolves the ABI, batches the calls, and hands back typed JSON. No ABI file, no {id} substitution to write by hand.