Appearance
Indexing
LootFi's off-chain view of the world is built by replaying contract logs. This page describes how, so that a third party can build an equivalent view and get the same answers.
The model: durable block ranges, not subscriptions
persisted cursor chain head
│ │
▼ ▼
──────┼──────────────────────┬──────────────────────┼────────► blocks
│ bounded batch │ confirmations gap │
│◄────────────────────►│◄────────────────────►│
│ │ │
from = cursor+1 safeHead = latest − N latestEach pass:
- Read the cursor from durable storage.
- Compute
safeHead = latest − confirmations. eth_getLogsover both contract addresses for a bounded block range ending no later thansafeHead.- Process every log.
- Only then advance the cursor.
There is no eth_subscribe and no volatile in-memory listener anywhere in the path. A subscription drops events on a disconnect and has no way to know it did; a persisted cursor replays from exactly where it stopped. On restart the indexer resumes from the stored block, not from head.
Confirmations default to 3. Logs newer than that are simply not read yet. Robinhood Chain is an Arbitrum Nitro L2 — see Connect to the chain for its properties.
Idempotency and reorgs
Every log is identified by:
`${txHash}-${logIndex}`That id is recorded in a durable ledger, and the ledger insert commits in the same database transaction as the handler's side effects. Either both happen or neither does.
The consequences:
- Restarts are safe. A replayed range re-encounters ids already in the ledger and skips them.
- Reorgs are safe in both directions. A reorg that drops a log: the log was never processed, because the cursor never advanced past unconfirmed blocks. A reorg that re-mines a log: the same
txHash-logIndexis already in the ledger, so the handler does not run twice. - Handlers can be written as if they run once, because they do.
If a handler throws, the batch aborts without advancing the cursor and the whole range is retried on the next pass. The indexer never skips a log in order to make progress. A burn cannot lose its return job because the indexer was having a bad minute.
Events indexed
| Event | Contract | Drives |
|---|---|---|
Transfer | LootSkins | Ownership updates; invalidates stale orders |
Approval | LootSkins | Recognised; drives no state change |
ApprovalForAll | LootSkins | Revocation invalidates the maker's orders |
Detokenized | LootSkins | Burn confirmation → withdrawal |
OrderFulfilled | LootMarket | Order → FILLED, with taker and settled amounts |
OrderCancelled | LootMarket | Order → CANCELLED |
MinimumNonceUpdated | LootMarket | Bulk invalidation of the maker's stale orders |
Transfer
to == 0x0 → token marked burned
otherwise → token owner updatedAdditionally, any active order on that token whose maker is no longer the owner is marked invalid. A listing does not survive a transfer — the contract would reject the fill at MakerNotOwner anyway, so advertising it would only waste a buyer's gas.
Note this also fires on burns (to == 0x0), which is a second, independent route to marking a listing dead.
ApprovalForAll
Only revocations matter, and only revocations naming the market as operator. When a maker revokes the market's approval, every active order of theirs becomes unfillable (the contract rejects at MarketNotApproved), so they are all marked invalid at once.
Granting approval drives nothing.
Detokenized
The burn signal. For each token id in the event:
- Mark the token burned.
- Find the pending withdrawal reservation for that token whose reservation owner matches the event's
owner, and confirm it. A reservation belonging to a different address is never confirmed by someone else's burn. - Queue the return offer.
A burn with no matching owner-bound reservation creates a recovery record instead — see Withdrawal lifecycle.
OrderFulfilled
Marks the order filled and records the taker, the settled royalty, the settled platform fee, and the fill transaction. The settled amounts come from the event rather than being re-derived, so the record matches what actually moved.
An order previously marked invalid or expired can still fill on chain — off-chain status is an advisory verdict, not a contract state. So the fill handler accepts those statuses as well as active ones; anything else would silently discard the settlement record for a sale that really happened.
OrderCancelled and MinimumNonceUpdated
OrderCancelled marks that one maker/nonce pair cancelled. MinimumNonceUpdated marks every active order of that maker with a nonce below the new minimum as invalid, in one operation. Both mirror the contract's own checks; see Marketplace.
Order status, and what each value means
Off-chain order status is a display verdict. Only the contract decides whether a fill succeeds.
| Status | Meaning | Set by |
|---|---|---|
ACTIVE | Believed fillable | Created at signing |
FILLED | Settled on chain | OrderFulfilled |
CANCELLED | Maker cancelled that nonce | OrderCancelled |
INVALID | Cannot fill in its current state — maker no longer owns the token, approval revoked, or nonce below the maker's minimum | Transfer, ApprovalForAll, MinimumNonceUpdated |
EXPIRED | Past its expiry | Time |
INVALID is a soft verdict, not a terminal state: a condition that invalidated an order can be reversed (the token transferred back, approval re-granted), and the order becomes fillable again on chain regardless of what the off-chain status says.
Finality expectations
- Nothing is acted on until it is at least 3 confirmations deep.
- Ordering within a block follows log index; across blocks, block number.
- A newly mined event will not appear in LootFi's view instantly. If you are comparing your own indexer against LootFi's UI, expect a small lag by design, not a discrepancy.
- If you need zero-lag truth, read the contract directly (
ownerOf,usedOrCancelled,minimumNonce). That is what the withdrawal preflight does for ownership, precisely because an indexed value is a mirror.
Building your own indexer
Everything you need:
Start block: 19419826. That is the deployment block of the current contract set. Starting later misses history; starting from latest − N misses it silently, which is worse.
Addresses: on Contract deployments. Watch both LootSkins and LootMarket.
Chain: Robinhood Chain, chain id 4663, mainnet only.
ts
const CONFIRMATIONS = 3;
const BATCH = 2_000;
let cursor = await loadCursor(); // 19419826n on a cold start
for (;;) {
const latest = BigInt(await provider.getBlockNumber());
const safeHead = latest - BigInt(CONFIRMATIONS);
const from = cursor + 1n;
if (from > safeHead) { await sleep(8_000); continue; }
const to = from + BigInt(BATCH) - 1n < safeHead ? from + BigInt(BATCH) - 1n : safeHead;
const logs = await provider.getLogs({
address: [LOOT_SKINS, LOOT_MARKET],
fromBlock: Number(from),
toBlock: Number(to),
});
for (const log of logs) {
const id = `${log.transactionHash}-${log.index}`;
if (await alreadyProcessed(id)) continue;
await handleAndRecord(id, log); // one transaction: effect + ledger
}
await saveCursor(to); // only after the whole range succeeded
}Detokenized.tokenIds is in the DATA field
solidity
event Detokenized(address indexed owner, uint256[] tokenIds);owner is indexed. tokenIds is not, and that is deliberate.
Solidity cannot index a dynamic array as a value — it indexes the keccak hash of the encoded array. A hash is one-way: an indexer reading such a topic can never recover which ids were burned, only confirm a guess it already had.
So tokenIds lives in the log's data field where it is ABI-decodable. Decode it with the event ABI; do not look for it in topics.
ts
const parsed = lootSkinsInterface.parseLog({ topics: log.topics, data: log.data });
if (parsed?.name === "Detokenized") {
const owner: string = parsed.args.owner;
const tokenIds: bigint[] = parsed.args.tokenIds; // from data, fully recoverable
}This is a real mistake with a real cost — burns become unattributable and the underlying items unroutable — which is why the contract carries a comment about it and why it is called out here.
Related
- Events & indexing — full event signatures and ABIs.
- Read token state — direct contract reads.
- Contract deployments — addresses and deploy block.