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
SharedTokenbalances and object IDs - Live vesting grants (
Timelock) and the tokens they still hold - 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
timelockBalance
timelockCount
timelocksIncluded
decimals
}
tokenObjects(coinType: $coinType, owner: $owner, limit: 50) {
objectId
owner
balance
version
checkpoint
}
}
query Holders($coinType: String!) {
tokenHolders(coinType: $coinType, limit: 100) {
owner
balance
timelockBalance
timelocksIncluded
}
}
balance is everything the owner holds: the SharedToken balances plus what
the vesting grants of the owner still hold. The holder list is ordered by it,
the largest first, and an owner whose balance adds up to zero is not in it.
timelockBalanceis the vesting part ofbalance: the locked tokens and the unlocked tokens that the owner has not claimed. They are in noSharedToken, thus a transfer cannot spend them before a claim.balanceminustimelockBalanceis what theSharedTokenobjects hold.objectCountcountsSharedTokenobjects, whichtokenObjectslists.timelockCountcounts the live grants, whichtokenTimelockslists.timelocksIncluded: falsemeans the grants are left out. This happens while the grant index is more than 1000 checkpoints behind theSharedTokenindex, for example during its first catch-up. A grant claimed in that window would otherwise count twice.balanceis then theSharedTokenpart alone, andtimelockBalanceandtimelockCountare zero.
tokenTimelocks
The live vesting grants of a coin type, or of one owner when owner is set. The
earliest commencement comes first.
query Timelocks($coinType: String!, $owner: String) {
tokenTimelocks(coinType: $coinType, owner: $owner, limit: 100) {
objectId
owner
funder
releaseScheduleId
releaseCount
delayUntilFirstReleaseMs
periodBetweenReleasesMs
initialReleasePortionBps
commencementTimestampMs
totalAmount
tokensTransferred
leftBalance
decimals
}
}
leftBalanceis what the grant still holds: the locked part and the unlocked part that the owner has not claimed. A claim moves tokens out of it into aSharedTokenof the owner. A cancel moves the locked part to the reclaimer. TheleftBalanceof all grants of an owner is thetimelockBalanceof that owner.- The times are milliseconds, as the contract keeps them. This is different
from
checkpointTimestamp, which is unix seconds. - The amounts and the
u64settings are decimal strings, because a Moveu64is wider than a JSON number. - A grant that
destroy_emptydeleted is not listed. - The query is an error while the grant index is far behind. In that
window the list can hold grants that are gone. The balance queries say the
same with
timelocksIncluded: false.
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:
| Event | Meaning |
|---|---|
holder_address_added | A wallet now belongs to the holder |
holder_address_removed | A wallet no longer belongs to the holder |
holder_added | A holder was created, naming every wallet it starts with |
holder_removed | A holder was removed |
address_joined_group | A wallet moved into a transfer group |
address_left_group | A wallet left a transfer group |
address_frozen | A wallet was frozen, or released. See isFrozen |
Notes for a consumer:
- Page from your cursor.
sinceTimestampis unix seconds and keeps the events from that second onward. - Apply each event once.
eventIdis{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. holderIdandgroupIdare decimal strings. A Moveu64is wider than a JSON number, and"0"is a real holder rather than a missing one.holder_addedarrives after its ownholder_address_addedevents. 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.
walletRoles
The addresses that may administer the token now, and the roles of each one.
The contract announces every grant and revoke as a
controlled_treasury::RoleChange, and the newest event for each owner and role
decides.
query WalletRoles($coinType: String!) {
walletRoles(coinType: $coinType) {
owner
roleBits
roles
}
}
roleBits uses the same bits as the TA API role mask:
| Role | Bit |
|---|---|
ContractAdmin | 1 |
ReserveAdmin | 2 |
WalletsAdmin | 4 |
TransferAdmin | 8 |
TimelockCanceler | 16 |
roles holds the canonical Move type name of each role, for example
0x…::roles::ContractAdmin. A type from another package that is also named
roles::ContractAdmin is in roles but adds no bit, because it gives no power
over the token.
The list is a full state. Read the role_changes age in indexerStatus before
you replace your own copy with it: during a backfill the list is partial.
roleEvents
The grants and revokes behind walletRoles, oldest first. The arguments and
the eventId, position and checkpointTimestamp fields work the same way as
in holderEvents.
query RoleEvents($coinType: String!, $sinceTimestamp: String, $limit: Int) {
roleEvents(
coinType: $coinType
sinceTimestamp: $sinceTimestamp
limit: $limit
) {
eventId
position
owner
role
roleBit
granted
signer
checkpointTimestamp
}
}
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, timelock_objects serves tokenTimelocks,
holder_lifecycle serves holderEvents, and
role_changes serves walletRoles and roleEvents. 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.