Appearance
Metadata
Token metadata is served off-chain and pointed at from the contract. This page is the exact schema, and — more importantly — the exact semantics of a missing value, which are not what most integrators assume.
Resolving a token URI
solidity
function tokenURI(uint256 tokenId) public view override returns (string memory) {
_requireOwned(tokenId);
return string.concat(baseURI, tokenId.toString());
}The URI is baseURI concatenated with the decimal token id. There is no separator, no .json suffix, and no zero padding. The base URI currently ends in /, which is what makes the concatenation produce a valid path — but the contract neither adds nor checks a separator, so that trailing slash is an operational convention, not a guarantee tokenURI enforces. Read baseURI() if you need to be certain.
baseURI https://example.invalid/metadata/data/
tokenId (decimal) 452312848583266388373324160190187140051835877600158453279131187530910662659
tokenURI https://example.invalid/metadata/data/452312848583266388373324160190187140051835877600158453279131187530910662659_requireOwned means tokenURI reverts for a token that does not exist or has been burned. Handle that revert; it is the normal signal that a token was withdrawn.
Read baseURI on chain — do not hardcode a host
solidity
function baseURI() external view returns (string memory);The owner can update it with updateBaseURI, which emits BaseURIUpdated. An integrator who hardcodes the host silently breaks when it changes; one who reads baseURI() (or just calls tokenURI) does not.
The JSON schema
jsonc
{
"name": "string", // market hash name, or "LootFi #<tokenId>"
"description": "string",
"image": "string | null", // absolute URL
"float": "number | null", // 0.0 – 1.0 wear value
"paintSeed": "number | null", // integer pattern index
"paintIndex": "number | null", // integer paint kit id
"fadePercentage": "number | null",
"dopplerPhase": "string | null", // e.g. "Phase 2", "Ruby"
"stickers": "array | null", // [] means "none applied"
"wear": "string | null", // e.g. "Field-Tested"
"rarity": "string | null",
"statTrak": "boolean", // never null
"souvenir": "boolean", // never null
"steamAssetId": "string | null",
"attributes": [
{ "trait_type": "string", "value": "string | number", "display_type": "number (optional)" }
]
}The top-level fields mirror the attribute values. They exist so a consumer can read float directly without depending on attribute-name casing or array position. Both are built from one source, so they cannot disagree.
Attributes actually emitted
trait_type | Value type | display_type | Emitted when |
|---|---|---|---|
Category | string | — | Always |
Rarity | string | — | Known |
Wear | string | — | Known |
Float | number | number | Known |
Paint Seed | number | number | Known — including 0 |
Paint Index | number | number | Known — including 0 |
Fade | number | number | Known |
Doppler Phase | string | — | Known |
StatTrak | "Yes" | — | Only when true |
Souvenir | "Yes" | — | Only when true |
Stickers | number (count) | number | Only when at least one sticker |
Steam Fingerprint | string | — | Always |
Two details that catch people out:
StatTrakandSouvenirappear only when true. There is no"StatTrak": "No"attribute. Absence means false. The top-level booleans are always present and are the reliable place to read them.- The
Stickersattribute is a count, not the stickers. The actual sticker data is the top-levelstickersarray. An attribute of4and an array of four entries describe the same thing at different fidelities.
Null-valued traits are omitted, not emitted empty. A trait with a null value renders as a blank row on most marketplace front-ends, which reads worse than the trait simply not being there. So absence from attributes carries no information beyond "we do not have this value" — which brings us to the part that matters most.
Critical semantics: null is not zero and not "none"
Get this wrong and you will display false information about somebody's item.
null means "not looked up yet", not "the item has none"
Stat enrichment is a separate step from minting. A token can exist with float: null simply because the inspection has not completed yet — that item absolutely has a float; nobody has read it yet.
ts
// ✗ WRONG — claims a vanilla knife has no float
if (!meta.float) return "No float data";
// ✓ correct
if (meta.float == null) return "Float not yet resolved";paintSeed: 0 is a real seed, and sometimes a valuable one
Seed 0 is an ordinary pattern index. Some of the most sought-after patterns in CS2 sit at low seed numbers. Every falsy check erases them:
ts
// ✗ WRONG — hides seed 0 entirely
if (meta.paintSeed) show(meta.paintSeed);
// ✓ correct
if (meta.paintSeed != null) show(meta.paintSeed);The same applies to paintIndex: 0 and to float values very close to zero — a 0.0000xx float is a factory-new extreme, not a missing value.
stickers: [] is not stickers: null
| Value | Meaning |
|---|---|
null | The item has not been inspected. Whether it has stickers is unknown. |
[] | The item has been inspected and has no stickers applied. |
[…] | Inspected; these stickers are applied. |
An empty array is a positive finding and is more informative than null. Any code that collapses them to "falsy" throws that away.
The rule
Never test truthiness on a metadata field. Use != null (or is not None, or an explicit undefined/null comparison in your language of choice). Every numeric field in this schema has a meaningful zero, and every nullable field has a meaningful empty.
This is the same trap as Category.Gun === 0 in Token IDs — the pattern recurs because zero is a real value everywhere in this data model.
Metadata is mutable in one direction
The blob can be rewritten after minting. It is worth being precise about what can and cannot change.
Can change — enrichment resolving:
Float, paint seed, paint index, fade percentage, Doppler phase, and stickers may be null at mint and get filled in later. Inspecting an item requires the item to be in an inspectable state, which is not always true at the moment a token is created. So a token is often born with partial stats and completes shortly after.
When inspection data arrives, it wins over the deposit-time snapshot: it is read from the item itself, whereas the snapshot is whatever Steam exposed in an inventory listing. Where inspection has nothing to say, the snapshot value survives rather than being nulled out. One exception, deliberately: an inspected stickers: [] does overwrite null, because "confirmed none" is real information.
Can change — steamAssetId:
Steam reassigns asset ids when a trade's protection window ends. The recorded asset id is updated when that happens. This is a correction of a fact about the world, not a change of which item is meant.
Cannot change — item identity:
The token id, its category, and which physical asset the token is a claim on do not change. The Steam Fingerprint attribute is that identity; the category is in the token id itself and no code path anywhere can alter it.
The distinction in one line: the protocol's knowledge about the item improves over time; the item does not change.
Fetching metadata
ts
const uri = await lootSkins.tokenURI(tokenId); // reverts if burned
const meta = await fetch(uri).then((r) => r.json());
// Correct reads
const float = meta.float ?? null; // null = not yet resolved
const seed = meta.paintSeed != null ? meta.paintSeed : null; // 0 is valid
const stickers = meta.stickers; // null ≠ []
const isStatTrak = meta.statTrak === true; // always a booleanpython
meta = requests.get(uri).json()
float_value = meta.get("float") # may be None — not yet resolved
seed = meta.get("paintSeed") # 0 is a real seed
if seed is not None:
show(seed)
stickers = meta.get("stickers") # None = unknown, [] = confirmed noneTrust note
Metadata is served over HTTP from a host the contract owner controls. It is not on chain and it is not signed. A consumer that needs a trust-minimised fact should read it from the contract instead:
- Who owns a token →
ownerOf(tokenId) - What category it is →
categoryOf(tokenId), a pure bit-shift - Whether it still exists →
ownerOfreverting, or theDetokenized/Transfer-to-zero log
Float, pattern and stickers have no on-chain representation. They are operator data, and they are listed as a trust assumption on Trust & security.
Related
- Metadata format — integration-focused reference.
- Token IDs — what the id in the URI encodes.
- LootSkins (ERC-721) —
tokenURI,baseURI.