Zivoe · Protocol data

Centrifuge Indexer — queries & opportunities

The GraphQL queries running against the Centrifuge V3 indexer today, and fifteen high-value queries available against our pool that nothing is using yet.

Pool 281474976720680 Share class 0x0001…0001 SyncDeposit / AsyncRedeem Ethereum · USDC No KYC · no allowlist SDK 1.22.0

The three queries we run today

Three documents over three entities — tokenInstances, tokenSnapshots and investorTransactions. Together they select nine fields.

1 · CurrentShareMetrics

The live headline numbers in one round trip: the manager-published Share Price, the total share issuance, and the newest trailing-yield row. NAV is derived from the first two as price × issuance.

query CurrentShareMetrics($shareTokenAddress: String!, $tokenId: String!) {
  tokenInstances(where: { address: $shareTokenAddress }) {
    items { token { tokenPrice tokenPriceComputedAt totalIssuance decimals } }
  }
  tokenSnapshots(where: { id: $tokenId }, orderBy: "timestamp", orderDirection: "desc", limit: 1) {
    items { yield30dComp365 }
  }
}
Share Price NAV / TVL 30-day trailing APY price staleness timestamp

2 · DailyTokenSnapshots

The full historical close series from pool inception — the Share Price, NAV and APY chart. Rows are fetched newest-first and deduped down to the last priced row per UTC day.

query DailyTokenSnapshots($tokenId: String!, $limit: Int!) {
  tokenSnapshots(where: { id: $tokenId }, orderBy: "timestamp", orderDirection: "desc", limit: $limit) {
    items { timestamp tokenPrice totalIssuance yield30dComp365 }
  }
}
Historical price series Historical NAV Historical APY
Snapshot semantics NewPeriod rows are stamped at exactly UTC midnight and carry the previous day’s closing state, so day-bucketing has to key on the instant just before the snapshot. Intraday UpdatePricePoolPerShare events add extra same-day rows. The current day has no close row at all until it ends.

3 · AnyInvestorTransaction

A single boolean: has any of a given set of wallets ever transacted against our pool? Only totalCount is selected — every row type counts, so holders who received shares by transfer and users who deposited then fully redeemed both register as active.

query AnyInvestorTransaction($tokenId: String!, $poolId: BigInt!, $accounts: [String]) {
  investorTransactions(where: { tokenId: $tokenId, poolId: $poolId, account_in: $accounts }, limit: 1) {
    totalCount
  }
}
Has-activity check any lifecycle type multi-wallet

Fifteen queries we could be running against our own pool

Ranked within three groups by how much they change what a user or an operator can see. Every query below was executed against the live Sepolia indexer and returned real data unless noted.

Investor-facing product
1

Per-investor P&L, computed by the indexer

“How much has this wallet actually earned?” — cost basis, cumulative earnings and realised P&L, maintained by the indexer on every balance change.

query InvestorPosition($tokenId: String!, $centrifugeId: String!, $account: String!) {
  tokenInstancePosition(tokenId: $tokenId, centrifugeId: $centrifugeId, accountAddress: $account) {
    balance costBasis cumulativeEarnings cumulativeRealizedPnl tokenPriceAtLastChange isFrozen updatedAt
  }
}
Why it matters
The Portfolio tab currently has no earnings number, and deriving one client-side means replaying every deposit at its execution price. The indexer already does this. costBasis is in pool currency (18dp), balance in share base units.
Watch out
Position rows are per token instance (share class × chain). When we add a second chain a wallet will have one row per chain and the app must sum them.
Portfolio pageverified live
2

Full investor activity feed & statement export

Every lifecycle event for a wallet, with tx hash — the data behind a real transaction history and a CSV statement.

query InvestorActivity($tokenId: String!, $accounts: [String], $limit: Int!, $after: String) {
  investorTransactions(
    where: { tokenId: $tokenId, account_in: $accounts }
    orderBy: "createdAt", orderDirection: "desc", limit: $limit, after: $after
  ) {
    totalCount
    items { type tokenAmount currencyAmount tokenPrice createdAt createdAtTxHash fromAccount toAccount }
    pageInfo { hasNextPage endCursor }
  }
}
Why it matters
We already run this exact entity for a boolean. Selecting the items instead gives us a history view for free, including wallet-to-wallet transfers that no other source records.
Watch out
On our test pool every row is TRANSFER_IN / TRANSFER_OUT — 120 rows, zero SYNC_DEPOSIT or REDEEM_*. Sync deposits appear as the share leg only, with currencyAmount and tokenPrice both 0. So this entity is a share ledger, not a USDC ledger — pair it with #3 for economics, and re-verify the type distribution on mainnet.
Portfolio pageCSV export semantics caveatverified live
3

Mint/burn ledger with execution price

Every share issue and revoke with the pool-per-share price it executed at — the economic record of deposits and redemptions.

query Issuances($tokenId: String!, $since: String!) {
  tokenIssuances(
    where: { tokenId: $tokenId, createdAt_gt: $since }
    orderBy: "createdAt", orderDirection: "desc", limit: 1000
  ) {
    totalCount
    items { type shares pricePoolPerShare isManual account createdAt createdAtTxHash }
  }
}
Why it matters
Daily net inflow in USD without diffing issuance snapshots, and it is the only place the execution price is attached to the flow. isManual separates manager-side issuance from investor deposits — exactly the split we want when reporting organic growth.
Watch out
On REVOKE the account is the escrow, not the redeeming investor, so this cannot attribute redemptions per user.
Transparency pagegrowth reporting verified live
4

Earnings timeline per investor

A checkpoint every time a wallet’s balance changes: balance before/after, price, period earnings, realised P&L, and what triggered it.

query EarningsTimeline($tokenId: String!, $account: String!) {
  investorPositionCheckpoints(
    where: { tokenId: $tokenId, accountAddress: $account }
    orderBy: "createdAt", orderDirection: "asc", limit: 1000
  ) {
    items { balanceBefore balanceAfter tokenPrice periodEarnings cumulativeEarnings realizedPnl trigger createdAt }
  }
}
Why it matters
Turns #1 from a single number into a chart. trigger tells you why the checkpoint exists (tokenInstance:Transfer, price update, …), which makes it readable as a statement.
Watch out
Checkpoints fire on balance changes, not daily — a dormant holder has a flat line with no points.
Portfolio chartverified live
5

Deposit capacity without an RPC round trip

The two inputs the SDK uses to compute maxDepositmaxReserve and the escrow’s USDC balance — both live in the indexer.

query Capacity($tokenId: String!) {
  vaults(where: { tokenId: $tokenId, status: "Linked" }) {
    items { id kind status maxReserve assetAddress }
  }
  holdingEscrows(where: { tokenId: $tokenId }) {
    items { assetId assetAmount assetPrice escrowAddress updatedAt }
  }
}
Why it matters
Today the “instant liquidity available” number needs a connected client and an RPC multicall, so the landing page cannot show it and the server cannot cache it. This makes capacity a server-rendered, cacheable number.
Watch out
Indexer state lags the chain by a block or two — fine for a marketing figure, not for the pre-sign gate. Keep the RPC read as the authority at transaction time. On Sepolia maxReserve is uint128 max (effectively uncapped).
Landing pageserver-cacheable verified live
Operations & reliability
6

Redemption queue depth

How many shares are sitting unfulfilled across all investors right now, split into pending (this epoch) and queued (next).

query RedemptionQueue($tokenId: String!) {
  epochOutstandingRedeems(where: { tokenId: $tokenId }) {
    items { assetId pendingSharesAmount queuedSharesAmount updatedAt }
  }
  epochOutstandingInvests(where: { tokenId: $tokenId }) {
    items { assetId pendingAssetsAmount queuedAssetsAmount }
  }
}
Why it matters
This is the single number that tells the manager how much USDC must be raised to clear the queue, and it is the honest input to any user-facing “current wait time” copy. We have no visibility into it today — vault.investment() only shows the connected wallet’s own pending amount.
Watch out
Empty on our test pool right now (the queue is clear), so the shape is confirmed but not the population. Pair with #7 before publishing a wait-time estimate.
Ops dashboardredeem UX copy empty on test
7

Redemption fulfilment SLA and partial-fill ratio

Per epoch: when the manager approved, how much of total pending was approved, when it was revoked (paid out), and at what NAV per share.

query RedeemEpochs($tokenId: String!) {
  epochRedeemOrders(
    where: { tokenId: $tokenId }
    orderBy: "index", orderDirection: "desc", limit: 100
  ) {
    totalCount
    items {
      index approvedAt approvedSharesAmount approvedPercentageOfTotalPending
      revokedAt revokedSharesAmount revokedAssetsAmount revokedWithNavAssetPerShare
    }
  }
}
Why it matters
approvedAt → revokedAt is the measured processing time; the historical distribution is what should drive the “typically N days” copy in the redeem flow instead of a hardcoded guess. approvedPercentageOfTotalPending exposes partial fills — on our test pool epoch 16 was approved at 50%.
Watch out
Amounts are per asset id; with one asset (USDC) this is a clean series. Percentages are scaled 1e18 × 100 (1000000000000000000000 = 100%).
Ops dashboardredeem UX copy verified live
8

Indexer freshness guard

Per-chain indexed block number and timestamp — how stale is the data we just rendered?

{ _meta { status } }
# { "ethereum": { "id": 11155111, "block": { "number": 11363759, "timestamp": 1785181896 } }, ... }
Why it matters
We currently trust the indexer blindly: if it stalls, the landing hero and the chart silently show yesterday’s numbers with no alert. One cheap field turns that into a warning and, if we want, a “data may be delayed” badge.
Watch out
status is untyped JSON, so it needs its own runtime schema rather than generated types.
Alertingcheap verified live
9

Vault config drift guard

Which vaults exist on our share class, which one is Linked, and does it match the address we hardcode?

query VaultInventory($tokenId: String!) {
  vaults(where: { tokenId: $tokenId }) {
    items { id kind status isActive assetAddress manager factory centrifugeId updatedAt }
  }
}
Why it matters
Our test share class has three vaults, two of them Unlinked, and one is an Async vault on the same USDC address as our linked SyncDepositAsyncRedeem one. Picking the wrong one is a silent, total breakage. A CI or boot-time assertion against this query makes the configured vault and router addresses self-verifying — which matters most at mainnet cutover.
Watch out
status can be LinkInProgress / UnlinkInProgress mid-governance; treat anything but Linked as a failed assertion, not as a reason to switch vaults.
CI assertioncutover safety verified live
10

Price-publication staleness — catch InvalidPrice before the user does

When did the manager last publish a Share Price, and how often do they normally?

query PricePublications($tokenId: String!) {
  tokenSnapshots(
    where: { id: $tokenId, trigger_contains: "UpdatePricePoolPerShare" }
    orderBy: "timestamp", orderDirection: "desc", limit: 30
  ) {
    totalCount
    items { timestamp tokenPriceComputedAt tokenPrice }
  }
}
Why it matters
A stale price is exactly the precondition for the InvalidPrice revert that our deposit flow already has copy for (“The current deposit price is unavailable”). Watching the publication cadence turns a user-visible failure into an internal alert hours earlier. We already fetch tokenPriceComputedAt and carry it in the payload — nothing consumes it.
Watch out
timestamp (when indexed) and tokenPriceComputedAt (the manager’s valuation date) differ by days on our test pool. The staleness that matters is the latter.
Alertingdeposit availability verified live
11

Frozen-address detection — the one restriction that still applies to us

Which addresses the restriction hook has frozen, even though we run no KYC gate and no allowlist.

query Restrictions($tokenId: String!, $account: String!, $centrifugeId: String!) {
  whitelistedInvestor(tokenId: $tokenId, centrifugeId: $centrifugeId, accountAddress: $account) {
    isFrozen validUntil updatedAt
  }
  tokenInstancePositions(where: { tokenId: $tokenId, isFrozen: true }) {
    totalCount
    items { accountAddress balance updatedAt }
  }
}
Why it matters
We deliberately run without KYC or allowlisting, so the eligibility half of this entity is irrelevant — but isFrozen is not. A frozen wallet gets TransferBlocked, for which our deposit and redeem flows both show the vague “can’t be completed for this wallet right now”. Reading the flag lets us say something accurate, and lets ops see the freeze list. The test pool has 7 whitelist rows with validUntil at uint32 max — i.e. permanent, which is what “no allowlist” looks like in this schema.
Watch out
Freezes propagate cross-chain via UpdateRestriction messages, so the indexer may show a freeze that has not landed on the spoke yet (see #14).
Better error copyOps visibility verified live
Transparency & growth
12

Holder count and concentration

How many wallets hold zMCA, and how concentrated is the top of the book?

query Holders($tokenId: String!) {
  tokenInstancePositions(
    where: { tokenId: $tokenId, balance_gt: "0" }
    orderBy: "balance", orderDirection: "desc", limit: 100
  ) {
    totalCount
    items { accountAddress balance updatedAt }
  }
}
Why it matters
A “N holders” stat tile next to TVL, and a concentration figure for the transparency page. It is one query with no wallet connection required.
Watch out
The pool escrow appears as a holder. On our test pool 0x8fcd…3acd is both the escrow address in holdingEscrows and a top position in this list, because pending redeem shares are custodied there. Filter escrow addresses out before publishing a count.
Transparency pagefilter escrow verified live
13

The rest of the yield surface

Seven trailing windows plus YTD, TTM and since-inception — all already computed, all on the row we already fetch.

query YieldSurface($tokenId: String!) {
  tokenSnapshots(where: { id: $tokenId }, orderBy: "timestamp", orderDirection: "desc", limit: 1) {
    items {
      yield7d365 yield30d365 yield90d365 yield180d365
      yield30dComp365 yieldTtm yieldYtd yieldSinceInception
    }
  }
}
Why it matters
We read exactly one of these. yield30dComp365 is null for the first 30 days of a pool’s life — which is precisely the mainnet launch window where we most want a number on the page. yieldSinceInception is populated from day one and is the honest headline for a young pool.
Watch out
All Ray-scaled (1e27). Short windows are extremely noisy when annualised — our test pool shows a 7-day figure of ~341% after a few manual price bumps. Pick windows deliberately, not because they exist.
Marketing statslaunch-window APY verified live
14

Cross-chain message health — ahead of multi-chain

Every hub→spoke message for our pool, its status, and why it failed.

query PoolMessages($poolId: BigInt!) {
  crosschainMessages(
    where: { poolId: $poolId }
    orderBy: "createdAt", orderDirection: "desc", limit: 50
  ) {
    items { messageType status failReason fromCentrifugeId toCentrifugeId createdAt executedAt }
  }
  poolSpokeBlockchains(where: { poolId: $poolId }) { items { centrifugeId createdAt } }
}
Why it matters
The moment we add a second chain, “is the share class actually live over there?” becomes a real question, and this is the answer. It is already useful: our test pool has 5 messages, several stuck at AwaitingBatchDelivery since deployment, including SetRequestManager and UpdateRestriction to centrifugeId 2.
Watch out
A stuck message is normal during batching; only sustained non-delivery is a signal. Alert on age, not on status alone.
Multi-chain readinessOps dashboard verified live
15

On-chain asset backing for the transparency page

What the pool actually holds — hub-side accounting holdings and the spoke-side escrow balance, with valuations.

query Backing($tokenId: String!) {
  holdings(where: { tokenId: $tokenId }) {
    items { assetId assetQuantity totalValue isLiability valuation updatedAt }
  }
  holdingEscrows(where: { tokenId: $tokenId }) {
    items { assetId assetAmount assetPrice maxAssetPriceAge escrowAddress updatedAt }
  }
  holdingSnapshots(
    where: { tokenId: $tokenId }, orderBy: "timestamp", orderDirection: "desc", limit: 90
  ) {
    items { timestamp assetId assetQuantity totalValue }
  }
}
Why it matters
The Asset Allocation panel is currently sourced outside Centrifuge. As holdings move on-chain, this is the query that makes that panel verifiable — and holdingSnapshots gives it history for free.
Watch out
Confirmed empty on our test pool. holdings returns no items while holdingEscrows returns the 14.04 USDC reserve, i.e. hub-side holdings are not initialised on this deployment. Re-verify against mainnet before designing UI on it.
Transparency pageempty on test