Indexer
Slot deployments, ownership transitions, tax and module events are indexed by
Ponder (packages/ponder) and served as GraphQL.
SlotsClient itself reads the chain; the indexer is for lists and history.
Endpoint
| Hosted | https://0xslots-production.up.railway.app/graphql (development instance: https://0xslots-dev.up.railway.app/graphql) |
| Local | http://localhost:42069/graphql (pnpm dev:local) |
@0xslots/sdk exports them as DEFAULT_API_URL, LOCAL_API_URL, API_URLS
and apiUrlFor(env). The API is unauthenticated.
Shape
- Plural fields return
{ items, totalCount, pageInfo }, not a bare list. - Pagination is
limitwithoffset, or theafter/beforecursors frompageInfo. - Foreign keys are scalar columns; the joined row is the
*Refsibling —moduleis an address,moduleRefis the row. - There is no
block:argument. Ponder has no time-travel query.
Key entities
| Entity | Key fields |
|---|---|
slot | id, chainId, recipient, currency, manager, occupant, isOccupied, price, deposit, taxRateBps, minRunwaySeconds, module, settings, moduleFeeBps, moduleFeeRecipient, the scope* flags, collectedTax, taxPaidTotal, totalCollected, moduleFeesTotal, tenureId, lastSettled, createdAt |
module | id, chainId, declaredKnown and the declared* flags, slotCount, failedCallCount |
account | id, type (EOA/CONTRACT/DELEGATED/SPLIT), slotCount, occupiedCount, totalHoldTime, taxPaidTotal |
accountChain | account, chainId, slotCount, occupiedCount, occupiedAsRecipient |
currency | id, chainId, name, symbol, decimals |
A slot points at one module (or none). Scopes are stored on both
rows and they answer different questions: slot.scope* is the copy the slot
accepted — what it obeys — while module.declared* is what the module declared
when the indexer first saw it. Comparing them is how you find a module that
changed its scopes after slots had committed to it.
slot.scopeAfterCallbacksMustSucceed is the one worth surfacing: such a
module's after callbacks run uncapped and can fail the slot, eviction included.
module.failedCallCount counts after callbacks that reverted and were swallowed
(moduleCallFailedEvent). A module accumulating those is quietly broken.
slot also carries its queued terms: pendingMask and, per term,
pendingHasTaxRate / pendingTaxRateBps, pendingHasRecipient /
pendingRecipient, pendingHasMinRunway / pendingMinRunwaySeconds,
pendingHasModule / pendingModule / pendingModuleSettings,
pendingHasScopes / pendingScopes, and pendingProposedAt.
account vs accountChain
account has no chainId and holds protocol-wide totals. accountChain holds
the same counters per chain, and is what a per-chain view must read.
occupiedCount counts slots the account occupies; slotCount counts slots
where it is the recipient. The one that pairs with slotCount is
occupiedAsRecipient — the only honest numerator for an occupancy percentage.
Schema drift
The indexer serves exactly one schema, and GraphQL rejects a document containing an unknown field — so a client querying a renamed column gets an error or an empty list, not a missing field. Deploy the indexer and its consumers together.
Example queries
List slots
{
slots(
where: { chainId: 31337 }
orderBy: "createdAt"
orderDirection: "desc"
limit: 10
) {
items {
id
recipient
occupant
price
deposit
taxRateBps
module
currencyRef { symbol decimals }
moduleRef { slotCount failedCallCount }
}
totalCount
pageInfo { hasNextPage endCursor }
}
}Slot activity
query GetSlotActivity($slot: String!) {
boughtEvents(where: { slot: $slot }, orderBy: "timestamp", orderDirection: "desc") {
items { buyer from price deposit paid tenure timestamp }
}
releasedEvents(where: { slot: $slot }, orderBy: "timestamp", orderDirection: "desc") {
items { occupant refund timestamp }
}
liquidatedEvents(where: { slot: $slot }, orderBy: "timestamp", orderDirection: "desc") {
items { by occupant heldFor timestamp }
}
}Module money and failures have their own tables: moduleFeePaidEvents and
moduleCallFailedEvents, both keyed by slot and module.
Indexing status
{ _meta { status } }status is keyed by chain and carries the latest indexed block. An erroring
indexer stops rather than serving stale rows, so it shows up as a failed
request.
Creatives
One module is indexed: AdLand, whose creative is the entire content of a sponsor space and lives nowhere else.
| Entity | Key fields |
|---|---|
creative | slot, chainId, module, uri, tenureId, publisher, clearedAt, publishCount, firstPublishedAt |
publishedEvent | id, slot, module, uri, tenureId, publisher, timestamp, tx |
clearedEvent | id, slot, module, fromTenure, toTenure, timestamp, tx |
adKey | key, chainId, module, slot, pendingSlot, pendingReadyAt, setCount |
Two events, and both matter. Published is somebody putting a creative up.
Cleared is the module blanking one because the slot changed hands — emitted
from afterBuy, afterRelease and afterLiquidate. Reading only the first
leaves a creative in the index long after the chain has stopped serving it.
A clear is recorded, not applied: the row keeps its uri and gains a
clearedAt, mirroring the contract, which refuses to return a creative once
tenureId has moved on.