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.
- One gRPC stream (
SubscribeCheckpoints) covers all tracked addresses with no additional bandwidth cost per address - Load proto files at runtime with
@grpc/proto-loaderusingkeepCase: trueandlongs: Stringto avoid silent field-mapping bugs read_maskis effectively required: omitting it returns an empty checkpoint shell with no transactions or balance databalance_changesin 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
SubscribeCheckpointswith the correctread_maskconfiguration - How to interpret
balance_changesin 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.
| Layer | Protocol | Best For | Auth |
|---|---|---|---|
| GraphQL | HTTP POST | Structured queries (balances, tx history, pagination) | Token in URL path |
| gRPC | HTTP/2 streaming | Real-time checkpoint subscription, archive access | x-token metadata header |
| JSON-RPC | HTTP POST | Legacy only, avoid for new apps | Token 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:
| Package | Purpose |
|---|---|
@grpc/grpc-js | gRPC client for Node.js |
@grpc/proto-loader | Loads .proto files at runtime |
chalk | Terminal color output |
cli-table3 | ASCII tables for balances and transactions |
dotenv | Loads .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
# 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:
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_casethroughout. This matches the proto definitions because we load the proto withkeepCase: true. If you usecamelCasefield names (balanceChangesinstead ofbalance_changes), you get silent mismatches where fields appear undefined at runtime. GrpcTimestamp.secondsisstring, notnumber. Checkpoint sequence numbers and timestamps on Sui exceedNumber.MAX_SAFE_INTEGER, so we instruct proto-loader to represent alluint64fields 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.
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 like0x2::sui::SUI. The coin symbol is the last segment after::.totalBalanceis a raw string in the smallest unit (MIST for SUI, where 1 SUI = 10^9 MIST). Never parse it withparseIntorparseFloatsince values exceedNumber.MAX_SAFE_INTEGER. UseBigIntinstead.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:
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:
const PROTO_PATH = path.join(
__dirname,
'../protos/proto/sui/rpc/v2/subscription_service.proto'
);
const INCLUDE_DIR = path.join(__dirname, '../protos/proto');
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 convertsbalance_changestobalanceChangesandsequence_numbertosequenceNumber. Your TypeScript types (which mirror the proto field names) would silently receiveundefinedfor every renamed field.longs: String: Sui checkpoint sequence numbers andgoogle.protobuf.Timestamp.secondsareuint64values that exceedNumber.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):
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.
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:
- Is the sender a tracked address? This catches outgoing transfers and contract calls.
- Does any
balance_changesentry 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
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:
- Phase 1 (GraphQL): Fetch balances and transactions for all tracked addresses, render the static terminal UI.
- Phase 2 (gRPC): Start the checkpoint stream. For each checkpoint, print a live feed line. If the checkpoint is relevant, call
scheduleRefetchto 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 singleconsole.logline 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:
// 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
| Symptom | Likely Cause | Fix |
|---|---|---|
no such file or directory on proto load | Wrong includeDirs path | includeDirs must point to protos/proto, not protos/ |
All gRPC fields are undefined | keepCase: false (default) | Add keepCase: true to proto-loader options |
| Sequence numbers are truncated or unexpectedly large | longs not set to String | Add longs: String to proto-loader options |
| Empty checkpoints, no transactions | Missing read_mask | Include the read_mask field in the request (see above) |
GraphQL error: Unknown field | Schema field name mismatch | Use transactions(...) not transactionBlocks(...), check with introspection |
| Missing incoming transfers | Only checking sender | Also check balance_changes[].address for tracked addresses |
Next Steps
- Add coin metadata lookup: Query
CoinMetadatafor 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
LedgerServiceexposesGetObject,GetTransaction, and other query methods alongside theSubscriptionServiceused 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.