Skip to main content

How to Build a Real-Time Sui Portfolio Tracker with GraphQL and gRPC

Updated on
May 07, 2026

16 min read

Overview

Tracking a Sui portfolio in real time means solving two distinct problems: querying structured data (current balances, transaction history) and reacting to onchain activity the moment it happens. This guide builds a Node.js terminal app that solves both by combining Quicknode's Sui GraphQL and gRPC endpoints. GraphQL handles the structured queries for balances and transaction history. gRPC provides a low-latency stream of every new checkpoint so your app reacts to onchain activity without polling.

TL;DR
  • One gRPC stream (SubscribeCheckpoints) covers all tracked addresses with no additional bandwidth cost per address
  • Load proto files at runtime with @grpc/proto-loader using keepCase: true and longs: String to avoid silent field-mapping bugs
  • read_mask is effectively required: omitting it returns an empty checkpoint shell with no transactions or balance data
  • balance_changes in each checkpoint tells you who was affected, what coin moved, and the direction, enabling real-time inflow/outflow detection
  • A 2-second debounce collapses the burst of related checkpoints from a single onchain action into one GraphQL re-fetch
  • Full source code available in qn-guide-examples

What You Will Learn

  • How Quicknode's GraphQL and gRPC endpoints for Sui differ in authentication, data shape, and intended use
  • How to load Sui proto files and call SubscribeCheckpoints with the correct read_mask configuration
  • How to interpret balance_changes in a checkpoint to determine what moved, in which direction, and for whom
  • How to connect a streaming gRPC subscription to a debounced GraphQL re-fetch for an event-driven portfolio view
  • How to support multiple tracked addresses efficiently with a single gRPC stream and parallel GraphQL queries

What You Will Need

  • A Quicknode account with a Sui mainnet endpoint
  • Node.js v20 or higher
  • Familiarity with TypeScript
  • Basic understanding of gRPC concepts (service, method, streaming RPC)

Understanding Quicknode's Sui API Stack

Sui exposes three API layers: JSON-RPC (deprecated, being deactivated July 2026), GraphQL (beta, mainnet), and gRPC (mainnet + testnet). Quicknode surfaces all three from a single endpoint URL.

LayerProtocolBest ForAuth
GraphQLHTTP POSTStructured queries (balances, tx history, pagination)Token in URL path
gRPCHTTP/2 streamingReal-time checkpoint subscription, archive accessx-token metadata header
JSON-RPCHTTP POSTLegacy only, avoid for new appsToken in URL path

The authentication mechanisms are different for each protocol, even though they share the same base endpoint hostname. This is the most common source of confusion when first wiring up both layers.

GraphQL: The token is part of the URL path itself.

POST https://<hostname>.sui-mainnet.quiknode.pro/<token>/graphql

No Authorization header is needed. The token segment in the path is the credential.

gRPC: The token goes in request metadata (the gRPC equivalent of an HTTP header).

Host: <hostname>.sui-mainnet.quiknode.pro:9000
Metadata: x-token: <token>

Your app derives both from the same two environment variables (QN_ENDPOINT_URL and QN_ENDPOINT_TOKEN):

graphqlUrl  = QN_ENDPOINT_URL + "/" + QN_ENDPOINT_TOKEN + "/graphql"
grpcHost = hostname(QN_ENDPOINT_URL) + ":9000"

Project Setup

Clone the Sample App

The complete source code is available on GitHub: quiknode-labs/qn-guide-examples/sui/sui-portfolio-tracker.

git clone https://github.com/quiknode-labs/qn-guide-examples.git
cd qn-guide-examples/sui/sui-portfolio-tracker

This guide walks through the key parts of the code, focusing on the decisions and gotchas that matter most. For the full implementation, refer to the GitHub repository.

Install Dependencies

pnpm install
# or: npm install / yarn install

The app uses five runtime packages:

PackagePurpose
@grpc/grpc-jsgRPC client for Node.js
@grpc/proto-loaderLoads .proto files at runtime
chalkTerminal color output
cli-table3ASCII tables for balances and transactions
dotenvLoads .env variables into process.env

And three dev dependencies: typescript, tsx (for running TS directly), and @types/node.

Clone the Sui Proto Files

The gRPC service definitions live in a separate repository maintained by Mysten Labs. Clone them into the project root:

git clone https://github.com/MystenLabs/sui-apis.git protos

This creates protos/proto/sui/rpc/v2/ containing subscription_service.proto and its dependencies. The protos/ directory is in .gitignore so it stays local.

Important: when you configure proto-loader later, includeDirs must point to protos/proto (the directory that contains the sui/ folder), not protos/ itself. Getting this wrong produces a no such file error at runtime.

Configure TypeScript

The full tsconfig.json is in the repository. The key setting is "module": "NodeNext", which means every local import must include a .js extension even when importing .ts files.

Configure Environment Variables

Copy the example file and fill in your values:

cp .env.example .env
.env
# Quicknode Sui endpoint URL (mainnet)
# Format: https://<your-endpoint>.sui-mainnet.quiknode.pro
QN_ENDPOINT_URL=https://your-endpoint.sui-mainnet.quiknode.pro

# Quicknode auth token (the path segment after the hostname)
QN_ENDPOINT_TOKEN=your-token-here

# Sui address(es) to track, comma-separated for multiple
SUI_ADDRESS=0xYourSuiAddressHere
# SUI_ADDRESS=0xabc...,0xdef... # example with multiple addresses

Find your endpoint URL and token on the Quicknode dashboard. The URL is https://<subdomain>.sui-mainnet.quiknode.pro and the token is the path segment that appears after the subdomain in your endpoint URL.

Defining Shared Types

All TypeScript interfaces live in src/types.ts. Here are the key types that shape how the app handles gRPC data:

src/types.ts (key gRPC types)
export interface GrpcBalanceChange {
address?: string;
coin_type?: string;
/** Signed string: negative means outflow, positive means inflow */
amount?: string;
}

export interface GrpcCheckpoint {
/** uint64 as string (longs: String prevents JS number overflow) */
sequence_number?: string;
digest?: string;
summary?: GrpcCheckpointSummary;
transactions?: GrpcTransaction[];
}

/** Wrapper returned by SubscribeCheckpoints stream */
export interface SubscribeCheckpointsResponse {
/** uint64 as string (the cursor IS the checkpoint sequence number) */
cursor?: string;
checkpoint?: GrpcCheckpoint;
}

Two details to note about these types:

  • gRPC field names use snake_case throughout. This matches the proto definitions because we load the proto with keepCase: true. If you use camelCase field names (balanceChanges instead of balance_changes), you get silent mismatches where fields appear undefined at runtime.
  • GrpcTimestamp.seconds is string, not number. Checkpoint sequence numbers and timestamps on Sui exceed Number.MAX_SAFE_INTEGER, so we instruct proto-loader to represent all uint64 fields as strings (longs: String).

Fetching Portfolio Data with GraphQL

src/graphql.ts contains the queries, a fetch helper, and a balance formatter. The queries are the most important part to understand since the Sui GraphQL schema has some non-obvious naming.

The Queries

The Sui GraphQL schema uses transactions(...) for querying transactions by address (not transactionBlocks, which appears in older documentation and does not work). Effects fields including status and timestamp are nested under effects, not at the top level of a transaction node.

src/graphql.ts (queries)
const BALANCES_QUERY = `
query GetBalances($address: SuiAddress!) {
address(address: $address) {
balances {
nodes {
coinType { repr }
totalBalance
}
}
}
}
`;

const TRANSACTIONS_QUERY = `
query GetTransactions($address: SuiAddress!) {
address(address: $address) {
transactions(last: 20) {
nodes {
digest
sender { address }
effects { status timestamp }
}
}
}
}
`;

A few things to note about these queries:

  • coinType { repr } returns the full Move type string like 0x2::sui::SUI. The coin symbol is the last segment after ::.
  • totalBalance is a raw string in the smallest unit (MIST for SUI, where 1 SUI = 10^9 MIST). Never parse it with parseInt or parseFloat since values exceed Number.MAX_SAFE_INTEGER. Use BigInt instead.
  • effects { status } returns an uppercase string ("SUCCESS" or "FAILURE"). Normalize it to lowercase in your app code.
  • effects { timestamp } returns an ISO 8601 string, not a Unix integer.

If you are unsure whether a field exists on your endpoint version, use GraphQL introspection to check:

{ __type(name: "Address") { fields { name } } }

Balance Formatting with BigInt

All Sui coin amounts arrive in the smallest unit. Most Sui tokens use nine decimal places (MIST for SUI, where 1 SUI = 10^9 MIST). Using BigInt arithmetic avoids floating-point rounding errors on large balances:

src/graphql.ts (formatBalance)
function formatBalance(rawBalance: string, coinType: string): string {
const symbol = coinType.split('::').pop() ?? coinType;
const DECIMALS = 9n;
const DIVISOR = 10n ** DECIMALS;

try {
const raw = BigInt(rawBalance);
const whole = raw / DIVISOR;
const fraction = raw % DIVISOR;
const fractionStr = fraction.toString().padStart(Number(DECIMALS), '0');
const wholeFormatted = whole.toLocaleString('en-US');
return `${wholeFormatted}.${fractionStr} ${symbol}`;
} catch {
return `${rawBalance} ${symbol}`;
}
}

A production app would query CoinMetadata per coin type to get exact decimals. For a portfolio tracker demo, nine is sufficient as a safe default.

Multi-Address Fetching

When tracking multiple addresses, the app fires all GraphQL requests concurrently with Promise.all, then merges and deduplicates transactions by digest. For N addresses, this means 2N parallel requests per refresh (one balances query + one transactions query per address). See fetchAllPortfolioData in the full source for the implementation.

Streaming Live Checkpoints with gRPC

src/grpc-stream.ts handles proto loading, the SubscribeCheckpoints call, address filtering, and reconnection with exponential backoff.

Loading the Proto at Runtime

Instead of compiling protos to TypeScript (which requires maintaining generated files), this app loads the .proto file at runtime with @grpc/proto-loader. The proto-loader options are where most gRPC gotchas hide:

src/grpc-stream.ts (constants)
const PROTO_PATH = path.join(
__dirname,
'../protos/proto/sui/rpc/v2/subscription_service.proto'
);
const INCLUDE_DIR = path.join(__dirname, '../protos/proto');
src/grpc-stream.ts (proto loading)
const packageDefinition = protoLoader.loadSync(PROTO_PATH, {
keepCase: true, // field names stay snake_case, required for correct mapping
longs: String, // uint64 becomes string, prevents JS number overflow
enums: String, // enum values as strings
defaults: true, // empty repeated fields return [] instead of undefined
oneofs: true, // adds _field indicators for optional fields
includeDirs: [INCLUDE_DIR], // must point to protos/proto, not protos/
});

Two of these options are critical:

  • keepCase: true: Without this, proto-loader converts balance_changes to balanceChanges and sequence_number to sequenceNumber. Your TypeScript types (which mirror the proto field names) would silently receive undefined for every renamed field.
  • longs: String: Sui checkpoint sequence numbers and google.protobuf.Timestamp.seconds are uint64 values that exceed Number.MAX_SAFE_INTEGER (2^53 - 1). Without this option, large values are silently truncated to a floating-point approximation.

The service is SubscriptionService under sui.rpc.v2 (not LedgerService, which appears in older Sui gRPC examples). SubscribeCheckpoints is the only streaming RPC (Remote Procedure Call) in the subscription service.

The read_mask Field Is Effectively Required

The proto marks read_mask as optional, but sending a request without it returns only the cursor and an empty checkpoint shell. No transactions, no sequence number, no timestamp. You must specify the fields you want relative to the Checkpoint message (not the SubscribeCheckpointsResponse wrapper):

src/grpc-stream.ts (request)
const request = {
read_mask: {
paths: [
'sequence_number',
'summary.timestamp',
'transactions.digest',
'transactions.transaction.sender',
'transactions.effects.status',
'transactions.balance_changes',
'transactions.timestamp',
],
},
};

These paths follow the FieldMask convention: dot-separated, relative to the target message. transactions.balance_changes requests the entire balance_changes repeated field for each transaction. You do not need to enumerate sub-fields of a repeated message to get its contents.

The Cursor Is the Sequence Number

Even with sequence_number in the mask, checkpoint.sequence_number is often not populated in the response. The proto comment explains why: the sequence number is guaranteed on the top-level cursor field, not the checkpoint object itself.

src/grpc-stream.ts (cursor fallback)
stream.on('data', (raw: unknown) => {
const response = raw as SubscribeCheckpointsResponse;
const checkpoint = response.checkpoint;
if (!checkpoint) return;

// The cursor IS the sequence number (per proto documentation).
// checkpoint.sequence_number is often empty even with it in the read_mask.
if (!checkpoint.sequence_number && response.cursor) {
checkpoint.sequence_number = response.cursor;
}

// ... continue processing
});

Read checkpoint.sequence_number when it is populated; fall back to response.cursor when it is not, since the cursor is guaranteed by the proto to carry the sequence number.

Filtering Checkpoints for Tracked Addresses

The gRPC stream delivers every checkpoint on the network, regardless of which addresses you care about. Filtering happens client-side. A single stream handles any number of tracked addresses with no additional bandwidth cost.

For each transaction in a checkpoint, the filter checks two things:

  1. Is the sender a tracked address? This catches outgoing transfers and contract calls.
  2. Does any balance_changes entry reference a tracked address? This catches incoming transfers, DEX (Decentralized Exchange) outputs, and airdrops where the tracked address is not the sender.

Checking only the sender would miss incoming transfers. Always check balance_changes as well.

Address comparison must be case-insensitive (lowercase both sides) since Sui addresses are hex strings. See the full filterCheckpoint function for the implementation.

Reconnection with Exponential Backoff

gRPC streams can disconnect due to network interruptions or server-side keepalive limits. The app reconnects automatically with exponential backoff (1s, 2s, 4s, ... up to 30s). After reconnection, the stream picks up from the latest checkpoint. There is no gap recovery, so missed checkpoints during the outage are not replayed. For a portfolio tracker this is acceptable since the next relevant checkpoint triggers a full GraphQL re-fetch. For an indexer that needs every checkpoint, you would track the last seen cursor and pass it as the starting point on reconnect.

Connecting GraphQL and gRPC in the Entry Point

src/index.ts orchestrates the app: it loads config from environment variables, runs the initial GraphQL fetch, starts the gRPC stream, and wires the two together. The most interesting part is the debounce pattern.

The Debounce Pattern

A single onchain action (for example, a DEX swap) can appear across multiple consecutive checkpoints: the transaction itself settles in one checkpoint, gas refunds may propagate in the next, and market maker responses arrive in the one after that. Sui checkpoints land every ~400ms, so a single user action can trigger three or four relevant checkpoints in under two seconds.

Without debouncing, each relevant checkpoint fires a GraphQL re-fetch immediately, creating a burst of redundant requests. The debounce timer collapses that burst into a single re-fetch that runs after the activity settles:

checkpoint ★  ->  start 2s timer
checkpoint ★ -> reset timer (still 2s from now)
checkpoint -> timer continues
[2 seconds pass]
-> timer fires -> GraphQL re-fetch
src/index.ts (scheduleRefetch)
let refetchTimer: ReturnType<typeof setTimeout> | null = null;

function scheduleRefetch(config: AppConfig, state: PortfolioState): void {
if (refetchTimer) clearTimeout(refetchTimer);
refetchTimer = setTimeout(async () => {
try {
// Snapshot old balances so we can compute and display deltas
const oldBalancesByAddress = new Map<string, CoinBalance[]>();
for (const [addr, portfolio] of state.portfolios) {
oldBalancesByAddress.set(addr, [...portfolio.balances]);
}

const { balancesByAddress, transactions } = await fetchAllPortfolioData(config);
updateBalances(state, balancesByAddress);
updateTransactions(state, transactions);
renderStatic(state);
printRefreshComplete(state, oldBalancesByAddress, balancesByAddress);
} catch (err) {
// Non-fatal: the gRPC stream continues even if a re-fetch fails.
// The next relevant checkpoint will trigger another attempt.
const msg = err instanceof Error ? err.message : String(err);
printStreamStatus('error', `Re-fetch failed: ${msg}`);
}
}, 2_000);
}

The Main Flow

The main function runs in two phases:

  1. Phase 1 (GraphQL): Fetch balances and transactions for all tracked addresses, render the static terminal UI.
  2. Phase 2 (gRPC): Start the checkpoint stream. For each checkpoint, print a live feed line. If the checkpoint is relevant, call scheduleRefetch to queue a debounced GraphQL refresh.

The gRPC stream's onCheckpoint callback is where the two layers connect: it checks relevance, prints the live output, and triggers the debounced re-fetch when a tracked address is involved. Graceful shutdown (Ctrl+C) cancels the refetch timer and destroys the gRPC stream.

Building the Terminal UI

src/portfolio.ts manages application state and renders the terminal output. The key architectural decision is splitting output into two render modes to avoid flicker:

  • Static render (renderStatic): Clears the screen and redraws the header, balances table, and recent transactions table. Only runs on startup and after a GraphQL refresh.
  • Live append (printCheckpointLine): Appends a single console.log line per checkpoint. Never clears the screen.

Because Sui checkpoints land every ~400ms, clearing the screen on every checkpoint would cause constant flickering. The split keeps stable data at the top and a smooth scrolling feed at the bottom.

Interpreting balance_changes

Each GrpcBalanceChange tells you three things: which address was affected (address), what coin moved (coin_type), and how much in which direction (amount as a signed string). A negative amount means the address lost that coin; a positive amount means it gained.

The coin_type field is a full Move type path like 0x2::sui::SUI or 0xabc...::usdc::USDC. Extract the symbol from the last :: segment:

const symbol = (bc.coin_type ?? '').split('::').pop() ?? '???';

For display, the same BigInt approach as the balance formatter handles the sign correctly:

src/portfolio.ts (balance change display)
// Inside printCheckpointLine, for each relevant balance change:
const symbol = (bc.coin_type ?? '').split('::').pop() ?? '???';
const amount = bc.amount ?? '0';
const isOut = amount.startsWith('-');
const display = formatChangeAmount(amount); // handles the leading '-'

const change = isOut
? chalk.red(`${display} ${symbol}`) // outflow
: chalk.green(`${display} ${symbol}`); // inflow

From balance_changes alone you can infer transaction type. If you see -SUI and +USDC on the same address in the same transaction, that address bought USDC with SUI. If you see +SUI arriving on an address that is not the sender, it is an incoming transfer. This is what makes balance_changes the most useful field in the checkpoint stream for a portfolio tracker.

Running and Testing

Start the app in development mode (no build step needed):

pnpm start

You should see output similar to this:

  ⟳ Loading portfolio data...

──────────────────────────────────────────────────────────────────────
SUI PORTFOLIO TRACKER
Address : 0xabc123...
Updated : 2026-04-10 12:00:00 UTC
──────────────────────────────────────────────────────────────────────

● COIN BALANCES
┌──────────────────────────────────────────┬────────────────────────────────┐
│ Coin Type │ Balance │
├──────────────────────────────────────────┼────────────────────────────────┤
│ 0x2::sui::SUI │ 142.500000000 SUI │
└──────────────────────────────────────────┴────────────────────────────────┘

● RECENT TRANSACTIONS
┌──────────────────────┬────────────────────────┬────────────┐
│ Digest │ Timestamp │ Status │
├──────────────────────┼────────────────────────┼────────────┤
│ 3xKpQ8...zT9mB2 │ 2026-04-10 11:58:03 UTC│ ✓ success │
└──────────────────────┴────────────────────────┴────────────┘

[◌ CONNECTING] establishing gRPC stream...
[● LIVE] stream connected
[12:00:01] #4,521,033 ██ 4 txns
[12:00:01] #4,521,034 ████ 8 txns
[12:00:02] #4,521,035 ██████ 12 txns ★ 1 relevant
↳ ▼ 5.000000000 SUI ▲ 14.203000000 USDC
⟳ refreshing balances via GraphQL...
[12:00:02] #4,521,036 ███ 6 txns
✓ balances refreshed: SUI: ▼ 5.000000000

Yellow-highlighted lines with the marker indicate checkpoints that contain transactions involving your tracked address. The / symbols show outflows and inflows from the balance_changes field in real time.

To track multiple addresses, set SUI_ADDRESS as a comma-separated list:

SUI_ADDRESS=0xabc...,0xdef... pnpm start

You do not open multiple gRPC streams. One stream covers all addresses. Each checkpoint is scanned once, and any matching address is reported. The balances panel expands to show each address separately.

Troubleshooting

SymptomLikely CauseFix
no such file or directory on proto loadWrong includeDirs pathincludeDirs must point to protos/proto, not protos/
All gRPC fields are undefinedkeepCase: false (default)Add keepCase: true to proto-loader options
Sequence numbers are truncated or unexpectedly largelongs not set to StringAdd longs: String to proto-loader options
Empty checkpoints, no transactionsMissing read_maskInclude the read_mask field in the request (see above)
GraphQL error: Unknown fieldSchema field name mismatchUse transactions(...) not transactionBlocks(...), check with introspection
Missing incoming transfersOnly checking senderAlso check balance_changes[].address for tracked addresses

Next Steps


  • Add coin metadata lookup: Query CoinMetadata for each coin type to get exact decimal places and display names for non-SUI tokens.
  • Persist checkpoint state: Track the last seen cursor and resume from that point after a restart to avoid missing checkpoints during downtime.
  • Export to JSON or a database: Write balance snapshots and transaction records to SQLite or Postgres to build historical charts.
  • Build a web frontend: Replace the terminal renderer with a WebSocket server that streams checkpoint events to a browser dashboard.
  • Add alerting: Trigger a notification (Slack, email, webhook) when a balance change exceeds a threshold.
  • Explore the full Sui gRPC surface: The LedgerService exposes GetObject, GetTransaction, and other query methods alongside the SubscriptionService used here. See the Quicknode Sui gRPC documentation for the full method list.

Conclusion

You have built a working real-time Sui portfolio tracker that demonstrates the key patterns for using Quicknode's Sui GraphQL and gRPC endpoints together. GraphQL gives you structured, queryable access to balances and transaction history. gRPC gives you a low-latency, push-based stream of every checkpoint. Connecting them through a client-side filter and a debounced re-fetch produces an event-driven app that reacts to onchain activity without polling and without hammering your API quota.

The balance_changes field is the core primitive that makes the live feed useful: a single field tells you who was affected, what moved, and in which direction, for every transaction in every checkpoint on the network.

Frequently Asked Questions

What is the difference between Sui GraphQL and gRPC, and when should I use each?

GraphQL is an HTTP-based query interface suited for structured, on-demand requests: fetch the current balances for an address, look up a specific transaction, paginate through history. gRPC is a streaming interface suited for push-based, real-time data: subscribe once and receive every new checkpoint as it finalizes. For a portfolio tracker you need both: GraphQL for accurate snapshot data, gRPC for the trigger that tells you when a snapshot is stale.

Why do I need to clone the proto files separately instead of installing them from npm?

Mysten Labs publishes the Sui gRPC proto definitions in a separate GitHub repository (https://github.com/MystenLabs/sui-apis) rather than as an npm package. The @grpc/proto-loader library loads them at runtime from disk, so you clone the repository locally. The protos directory is gitignored because the canonical source is the upstream repository.

Can this app run on Sui testnet?

Yes. gRPC is available on both mainnet and testnet via Quicknode. GraphQL is currently mainnet only (beta). To target testnet, create a Sui testnet endpoint on Quicknode, update your QN_ENDPOINT_URL and QN_ENDPOINT_TOKEN, and change the grpcHost derivation to target the testnet hostname. The gRPC port (9000) and authentication pattern remain the same.

How do I handle the case where balance_changes amounts overflow a JavaScript number?

Use BigInt for all arithmetic on raw balance values. The proto-loader option longs: String ensures that uint64 fields (like timestamp seconds and sequence numbers) arrive as strings. For balance amounts in balance_changes, the amount field is already a signed string in the proto definition, so BigInt parsing handles both positive and negative values correctly.

What happens if the gRPC stream disconnects?

The startCheckpointStream function reconnects automatically with exponential backoff: 1 second after the first failure, 2 seconds after the second, up to a maximum of 30 seconds. After reconnection the stream resumes from the current latest checkpoint, not from the point of disconnection. Checkpoints that arrived during the outage are not replayed. For a portfolio tracker this means a slightly delayed balance refresh after a reconnect, but no data corruption.

Why is the debounce timer set to 2 seconds?

Sui checkpoints land every ~400ms. A single onchain action (such as a DEX swap) can produce relevant balance changes across two or three consecutive checkpoints as the transaction, gas refund, and any triggered effects settle. Without debouncing, each of those checkpoints would fire a separate GraphQL re-fetch within under a second. The 2-second debounce collapses the burst into one re-fetch that runs after the related checkpoints have all arrived.

We ❤️ Feedback!

Let us know if you have any feedback or requests for new topics. We'd love to hear from you.

Share this guide