Appearance
Token IDs
Every LootFi token id encodes what kind of item it is. The encoding is fixed, consensus-critical, and identical on-chain and off-chain.
tokenId = (uint256(category) << 248) | sequenceThe 256-bit layout
bit 255 bit 0
│ │
├─── 8 bits ───┬──────────────────── 248 bits ──────────────────────┤
│ │ │
│ CATEGORY │ SEQUENCE │
│ 0 .. 7 │ per-category counter, from 1 │
│ │ │
└──────────────┴─────────────────────────────────────────────────────┘
▲ ▲
│ │
tokenId >> 248 tokenId & ((1 << 248) - 1)- Top 8 bits — category. Must be
0through7. A token id whose top byte is8or higher is not a valid LootFi id and the contract will reject it. - Low 248 bits — sequence. A per-category counter. Category
Knifesequence 5 and categoryGunsequence 5 are two different, unrelated tokens.
Each category therefore gets 2^248 ids — an effectively unbounded subspace. There is no range arithmetic to get wrong, no category that can exhaust while another has room, and no off-by-one at a boundary, because there are no boundaries.
Category table
The ordinal is what is packed into the id. It matches between the Solidity enum and the TypeScript enum, and ordinals are never renumbered. Renumbering would silently change the meaning of every id already minted.
| Ordinal | Category | Label |
|---|---|---|
0 | Gun | Gun |
1 | Knife | Knife |
2 | Glove | Glove |
3 | Agent | Agent |
4 | Sticker | Sticker |
5 | Graffiti | Graffiti |
6 | Container | Container |
7 | MusicKit | Music Kit |
A new category, if one is ever added, takes ordinal 8 and up. Existing ordinals are frozen.
Category.Gun is ordinal 0 — never test truthiness
Gun is 0, which is falsy in JavaScript, Python, and most languages people will integrate from.
ts
if (category) { … } // ✗ silently drops every gun
if (category != null) { … } // ✓This is the single most likely integration bug on this page. Every guns token — the large majority of the collection — fails a truthiness test.
On-chain: categoryOf is a pure bit-shift
solidity
function categoryOf(uint256 tokenId) external pure returns (Category) {
uint256 category = tokenId >> 248;
if (category >= 8) revert InvalidCategory(category);
return Category(category);
}pure, so it costs nothing to call off-chain and cannot be affected by state.
A category byte of 8 or higher reverts with InvalidCategory(uint256). The same check runs inside minting, so a token with an out-of-range category byte can never be created in the first place:
solidity
function _mintChecked(address to, uint256 tokenId) private {
uint256 category = tokenId >> 248;
if (category >= 8) revert InvalidCategory(category);
_mint(to, tokenId); // also reverts on a duplicate id
}Two guarantees fall out of that, for free, for any integrator:
- Every existing LootFi token id has a top byte in
0..7. - A token id, once minted, is never reissued —
_mintreverts on duplicates.
Decoding
Solidity
solidity
uint256 constant CATEGORY_SHIFT = 248;
function decode(uint256 tokenId) pure returns (uint256 category, uint256 sequence) {
category = tokenId >> CATEGORY_SHIFT;
sequence = tokenId & ((uint256(1) << CATEGORY_SHIFT) - 1);
}Or just call categoryOf(tokenId) on the contract — it is pure and free.
TypeScript
ts
export enum Category {
Gun = 0,
Knife = 1,
Glove = 2,
Agent = 3,
Sticker = 4,
Graffiti = 5,
Container = 6,
MusicKit = 7,
}
const CATEGORY_SHIFT = 248n;
const CATEGORY_COUNT = 8;
export function encodeTokenId(category: Category, sequence: bigint): bigint {
if (!Number.isInteger(category) || category < 0 || category >= CATEGORY_COUNT) {
throw new Error(`invalid category: ${category}`);
}
if (sequence < 0n || sequence >> CATEGORY_SHIFT !== 0n) {
throw new Error(`sequence out of range: ${sequence}`);
}
return (BigInt(category) << CATEGORY_SHIFT) | sequence;
}
export function categoryOf(tokenId: bigint): Category {
const c = Number(tokenId >> CATEGORY_SHIFT);
if (!Number.isInteger(c) || c < 0 || c >= CATEGORY_COUNT) {
throw new Error(`tokenId has invalid category: ${tokenId}`);
}
return c;
}
export function sequenceOf(tokenId: bigint): bigint {
return tokenId & ((1n << CATEGORY_SHIFT) - 1n);
}Use bigint, not number. A token id does not fit in a JavaScript number; parsing one with Number() or parseInt corrupts it silently.
Python
python
from enum import IntEnum
CATEGORY_SHIFT = 248
CATEGORY_COUNT = 8
class Category(IntEnum):
GUN = 0
KNIFE = 1
GLOVE = 2
AGENT = 3
STICKER = 4
GRAFFITI = 5
CONTAINER = 6
MUSIC_KIT = 7
def encode_token_id(category: int, sequence: int) -> int:
if not 0 <= category < CATEGORY_COUNT:
raise ValueError(f"invalid category: {category}")
if not 0 <= sequence < (1 << CATEGORY_SHIFT):
raise ValueError(f"sequence out of range: {sequence}")
return (category << CATEGORY_SHIFT) | sequence
def category_of(token_id: int) -> Category:
c = token_id >> CATEGORY_SHIFT
if not 0 <= c < CATEGORY_COUNT:
raise ValueError(f"token id has invalid category: {token_id}")
return Category(c)
def sequence_of(token_id: int) -> int:
return token_id & ((1 << CATEGORY_SHIFT) - 1)Python integers are arbitrary precision, so no special handling is needed — but the same falsy-zero trap applies: if category_of(tid): drops every gun.
Worked example
A knife (category = 1) with sequence 3:
category = 1
category << 248 = 0x01 in the top byte, all other bits zero
sequence = 3
tokenId (hex) = 0x0100000000000000000000000000000000000000000000000000000000000003
tokenId (dec) = 452312848583266388373324160190187140051835877600158453279131187530910662659Decoding it: tokenId >> 248 == 1 → Knife; low bits → 3.
The decimal form is what tokenURI appends and what JSON APIs use. Always carry it as a string or a big integer.
The Steam asset id is metadata, never the token id
This deserves its own heading because the opposite design is the obvious one and it is wrong.
Steam asset ids change. When an item moves between accounts and its trade protection ends, Valve assigns it a new asset id. An id-scheme built on the asset id would produce a token whose identifier stops matching the item it represents, days after minting, with no way to fix it.
Token ids never change. A LootFi token id is allocated once from a category and a counter, and is meaningless to Steam. The asset id lives in metadata as steamAssetId, where it is allowed to be updated when Valve reassigns it. See Metadata.
Practical rules for integrators:
- Key your data on the token id. It is permanent.
- Treat
steamAssetIdas descriptive, current-as-of-now information. - Do not attempt to derive one from the other. There is no relationship.
Allocation
The sequence counter lives off-chain, but the chain is authoritative on which ids exist. Before every mint the minter checks whether the id is already occupied and, critically, whether it is occupied by the intended recipient. An id owned by anyone else is a collision, never a success — the id is released and the counter is fast-forwarded past the occupied range.
The counter only ever moves forward. Gaps are never reclaimed, so a gap in the sequence is normal and carries no meaning. See Minting for the full gate.
Related
- LootSkins (ERC-721) — the contract interface.
- Read token state — calling
categoryOf,ownerOf, andtokenURI. - Metadata — what the token id resolves to.