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 } } }
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 } } }
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 } }
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.
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.
costBasisis in pool currency (18dp),balancein 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.
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, zeroSYNC_DEPOSITorREDEEM_*. Sync deposits appear as the share leg only, withcurrencyAmountandtokenPriceboth0. 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.
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.
isManualseparates manager-side issuance from investor deposits — exactly the split we want when reporting organic growth. - Watch out
-
On
REVOKEtheaccountis the escrow, not the redeeming investor, so this cannot attribute redemptions per user.
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.
triggertells 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.
Deposit capacity without an RPC round trip
The two inputs the SDK uses to compute maxDeposit —
maxReserve 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
maxReserveisuint128max (effectively uncapped).
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.
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 → revokedAtis 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.approvedPercentageOfTotalPendingexposes 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%).
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
-
statusis untypedJSON, so it needs its own runtime schema rather than generated types.
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 anAsyncvault on the same USDC address as our linkedSyncDepositAsyncRedeemone. 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
-
statuscan beLinkInProgress/UnlinkInProgressmid-governance; treat anything butLinkedas a failed assertion, not as a reason to switch vaults.
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
InvalidPricerevert 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 fetchtokenPriceComputedAtand carry it in the payload — nothing consumes it. - Watch out
-
timestamp(when indexed) andtokenPriceComputedAt(the manager’s valuation date) differ by days on our test pool. The staleness that matters is the latter.
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
isFrozenis not. A frozen wallet getsTransferBlocked, 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 withvalidUntilatuint32max — i.e. permanent, which is what “no allowlist” looks like in this schema. - Watch out
-
Freezes propagate cross-chain via
UpdateRestrictionmessages, so the indexer may show a freeze that has not landed on the spoke yet (see #14).
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…3acdis both the escrow address inholdingEscrowsand a top position in this list, because pending redeem shares are custodied there. Filter escrow addresses out before publishing a count.
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.
yield30dComp365isnullfor 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.yieldSinceInceptionis 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.
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
AwaitingBatchDeliverysince deployment, includingSetRequestManagerandUpdateRestrictionto 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.
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
holdingSnapshotsgives it history for free. - Watch out
-
Confirmed empty on our test pool.
holdingsreturns no items whileholdingEscrowsreturns 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.