Skip to main content

Sui RWA Indexer & GraphQL API

Overview

A custom indexer for Upside Sui security tokens (SharedToken). Native Sui ownership indexes cannot list holders because every token object is shared. This indexer stores an event log, a live object set and the holder register, then exposes them over GraphQL for the TA API and client wallets.

Key Features

  • Checkpoint ingestion via sui-indexer-alt-framework
  • Event types: mint, burn, transfer, force_transfer
  • Live SharedToken balances and object IDs
  • Holder register: which wallets belong to which holder
  • GraphQL API with GraphiQL IDE
  • Chronological ordering for cap-table rebuild

Authentication

Use the X-API-Key header on POST /graphql.

curl -H "X-API-Key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"query": "{ __typename }"}' \
https://sui-rwa-indexer.upside.gg/graphql

Endpoints

Production (mainnet)

  • GraphQL: https://sui-rwa-indexer.upside.gg/graphql
  • GraphiQL: https://sui-rwa-indexer.upside.gg/graphiql
  • Health: https://sui-rwa-indexer.upside.gg/health

Staging (testnet)

  • GraphQL: https://sui-rwa-indexer-staging.upside.gg/graphql
  • GraphiQL: https://sui-rwa-indexer-staging.upside.gg/graphiql
  • Health: https://sui-rwa-indexer-staging.upside.gg/health

Token identity

Pass the fully-qualified coin type as coinType, for example:

0xPACKAGE::test_token::TEST_TOKEN

The short form of the address works too, and so does any letter case: every query pads the coin type and the owner address before the lookup.

tokenActivity

query TokenActivity($coinType: String!, $limit: Int, $offset: Int, $order: SortOrder) {
tokenActivity(coinType: $coinType, limit: $limit, offset: $offset, order: $order) {
activityType
txDigest
eventSeq
checkpoint
checkpointTimestamp
amount
coinType
decimals
fromAddress
toAddress
signer
tokenObjectId
}
}

checkpointTimestamp is unix seconds (string), matching Solana slotTimestamp.

Object-set queries

query HolderView($coinType: String!, $owner: String!) {
tokenBalance(coinType: $coinType, owner: $owner) {
owner
balance
objectCount
decimals
}
tokenObjects(coinType: $coinType, owner: $owner, limit: 50) {
objectId
owner
balance
version
checkpoint
}
}
query Holders($coinType: String!) {
tokenHolders(coinType: $coinType, limit: 100) {
owner
balance
objectCount
}
}

An owner whose live objects add up to zero is not in the holder list.

holderEvents

A balance names an address. It cannot say that two addresses belong to one person, which is what a share register has to report. The contract keeps that as a holder: a numbered set of wallets. This query returns the changes to that set, oldest first.

query HolderEvents($coinType: String!, $sinceTimestamp: String, $limit: Int) {
holderEvents(
coinType: $coinType
sinceTimestamp: $sinceTimestamp
limit: $limit
) {
eventType
eventId
position
holderId
address
groupId
isFrozen
checkpointTimestamp
}
}

eventType is one of:

EventMeaning
holder_address_addedA wallet now belongs to the holder
holder_address_removedA wallet no longer belongs to the holder
holder_addedA holder was created, naming every wallet it starts with
holder_removedA holder was removed
address_joined_groupA wallet moved into a transfer group
address_left_groupA wallet left a transfer group
address_frozenA wallet was frozen, or released. See isFrozen

Notes for a consumer:

  • Page from your cursor. sinceTimestamp is unix seconds and keeps the events from that second onward.
  • Apply each event once. eventId is {txDigest}-{eventSeq} and is stable across a replay.
  • Order by position. It is a decimal string that rises with the order of the chain, thus an event older than the newest one you applied can be refused.
  • holderId and groupId are decimal strings. A Move u64 is wider than a JSON number, and "0" is a real holder rather than a missing one.
  • holder_added arrives after its own holder_address_added events. The contract emits one per wallet first, thus treating the batch as a creation would place it after its own appends. Read the per-wallet events instead.
  • The group and freeze events say nothing about membership. They carry no holderId, because they describe the terms a wallet holds under.

indexerStatus

A balance describes the chain only up to the checkpoint that the indexer has reached, and an owner whose first mint is not indexed yet looks exactly like an owner who holds nothing. Read the watermark before you treat an answer as final.

query IndexerStatus {
indexerStatus {
pipeline
checkpoint
checkpointTimestamp
ageSeconds
}
}

token_activity serves tokenActivity, shared_token_objects serves the balance and holder queries, and holder_lifecycle serves holderEvents. Each keeps its own watermark, thus one can trail the others. In steady state ageSeconds stays near the checkpoint interval of the network. A large value means the data is stale, for example during a backfill.