Skip to content

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) | sequence

The 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 0 through 7. A token id whose top byte is 8 or higher is not a valid LootFi id and the contract will reject it.
  • Low 248 bits — sequence. A per-category counter. Category Knife sequence 5 and category Gun sequence 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.

OrdinalCategoryLabel
0GunGun
1KnifeKnife
2GloveGlove
3AgentAgent
4StickerSticker
5GraffitiGraffiti
6ContainerContainer
7MusicKitMusic 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:

  1. Every existing LootFi token id has a top byte in 0..7.
  2. A token id, once minted, is never reissued — _mint reverts 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) = 452312848583266388373324160190187140051835877600158453279131187530910662659

Decoding it: tokenId >> 248 == 1Knife; 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 steamAssetId as 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.

LootFi is not affiliated with, endorsed by, or sponsored by Valve Corporation. Counter-Strike and Steam are trademarks of Valve Corporation.