TypeScript Client for Sui gRPC
Build type-safe, modern TypeScript applications for Sui using gRPC. This guide covers project setup, React integration, and production patterns for both Node.js and browser environments.
Why TypeScript for gRPC?#
- Full type safety - Catch errors at compile-time with TypeScript
- Excellent IDE support - IntelliSense, autocomplete, refactoring
- Modern async/await - Clean, readable asynchronous code
- React/Next.js ready - Perfect for frontend blockchain applications
- npm ecosystem - Access to millions of packages
Prerequisites#
- Node.js 18+ and npm/yarn/pnpm
- TypeScript 5.0 or higher
- Basic understanding of Promises and async/await
- Dwellir API key (get one here)
Project Setup#
Initialize Project#
# Create project directory
mkdir sui-grpc-ts
cd sui-grpc-ts
# Initialize npm project
npm init -y
# Install TypeScript
npm install --save-dev typescript @types/node
npx tsc --init
Install Dependencies#
# Core gRPC dependencies
npm install @grpc/grpc-js @grpc/proto-loader
# Protocol Buffers
npm install google-protobuf
npm install --save-dev @types/google-protobuf
# Code generation (optional, for static typing)
npm install --save-dev grpc-tools grpc_tools_node_protoc_ts
# Additional recommended packages
npm install dotenv # Environment variables
npm install zod # Runtime validation
npm install p-retry # Retry logic
Get Proto Files from Sui APIs Repository#
# Clone the Sui APIs repository
git clone https://github.com/MystenLabs/sui-apis.git
cd sui-apis
# Copy proto files to your project
mkdir -p ../sui-grpc-ts/protos
cp -r protos/sui ../sui-grpc-ts/protos/
The proto files are located in protos/sui/:
ledger.proto- Ledger service (checkpoints, objects, transactions)state.proto- State service (balances, objects)move_package.proto- Package servicetransaction.proto- Transaction servicesignature_verification.proto- Signature verification
Generate TypeScript Definitions (Optional)#
You can use proto files dynamically with @grpc/proto-loader (runtime loading) or generate static TypeScript definitions for better type safety:
Option 1: Dynamic Loading (Recommended for Getting Started)#
// Uses @grpc/proto-loader - no code generation needed
import * as protoLoader from '@grpc/proto-loader';
const packageDefinition = await protoLoader.load(
'./protos/sui/ledger.proto',
{
keepCase: true,
longs: String,
enums: String,
defaults: true,
oneofs: true,
}
);
Option 2: Static Code Generation (Better Type Safety)#
# Navigate to your project
cd sui-grpc-ts
# Generate TypeScript definitions
./node_modules/.bin/grpc_tools_node_protoc \
--plugin=protoc-gen-ts=./node_modules/.bin/protoc-gen-ts \
--ts_out=grpc_js:./src/generated \
--js_out=import_style=commonjs,binary:./src/generated \
--grpc_out=grpc_js:./src/generated \
-I./protos \
./protos/sui/*.proto
# This creates in src/generated/:
# - *_pb.js files (JavaScript message definitions)
# - *_pb.d.ts files (TypeScript type definitions)
# - *_grpc_pb.js files (Service clients)
# - *_grpc_pb.d.ts files (Service client types)
Auto-Generation Script
Create a package.json script:
{
"scripts": {
"generate": "grpc_tools_node_protoc --plugin=protoc-gen-ts=./node_modules/.bin/protoc-gen-ts --ts_out=grpc_js:./src/generated --js_out=import_style=commonjs,binary:./src/generated --grpc_out=grpc_js:./src/generated -I./protos ./protos/sui/*.proto",
"dev": "npm run generate && ts-node src/index.ts",
"build": "npm run generate && tsc"
}
}
Run with: npm run generate
TypeScript Configuration#
Update tsconfig.json:
{
"compilerOptions": {
"target": "ES2022",
"module": "commonjs",
"lib": ["ES2022"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
Project Structure#
sui-grpc-ts/
├── .env # Environment variables (gitignored)
├── .gitignore
├── package.json
├── tsconfig.json
├── src/
│ ├── index.ts # Main exports
│ ├── client/
│ │ ├── SuiGrpcClient.ts # Main client class
│ │ ├── services.ts # Service wrappers
│ │ └── types.ts # TypeScript types
│ ├── config/
│ │ └── config.ts # Configuration
│ ├── utils/
│ │ ├── errors.ts # Error handling
│ │ ├── retry.ts # Retry logic
│ │ └── formatting.ts # Data formatting
│ └── examples/
│ ├── getCheckpoint.ts
│ ├── getBalance.ts
│ └── monitorEpochs.ts
└── protos/ # Proto definitions
└── *.proto
Configuration#
Environment Variables#
Create a .env file:
# .env
DWELLIR_ENDPOINT=api-sui-mainnet-full.n.dwellir.com:443
DWELLIR_API_KEY=your_api_key_here
NODE_ENV=development
Configuration Module#
Create src/config/config.ts:
import { config as dotenvConfig } from 'dotenv';
import { z } from 'zod';
dotenvConfig();
const configSchema = z.object({
endpoint: z.string().min(1, 'DWELLIR_ENDPOINT is required'),
apiKey: z.string().min(1, 'DWELLIR_API_KEY is required'),
timeout: z.number().default(30000),
isProduction: z.boolean(),
});
export type Config = z.infer<typeof configSchema>;
function loadConfig(): Config {
const raw = {
endpoint: process.env.DWELLIR_ENDPOINT,
apiKey: process.env.DWELLIR_API_KEY,
timeout: process.env.GRPC_TIMEOUT
? parseInt(process.env.GRPC_TIMEOUT, 10)
: 30000,
isProduction: process.env.NODE_ENV === 'production',
};
try {
return configSchema.parse(raw);
} catch (error) {
if (error instanceof z.ZodError) {
const messages = error.errors.map((e) => `${e.path.join('.')}: ${e.message}`);
throw new Error(`Configuration validation failed:\n${messages.join('\n')}`);
}
throw error;
}
}
export const config = loadConfig();
Client Implementation#
Main Client Class#
Create src/client/SuiGrpcClient.ts:
import * as grpc from '@grpc/grpc-js';
import * as protoLoader from '@grpc/proto-loader';
import { config } from '../config/config';
import { GrpcError, wrapGrpcError } from '../utils/errors';
interface ClientOptions {
endpoint?: string;
apiKey?: string;
timeout?: number;
}
export class SuiGrpcClient {
private channel: grpc.Channel;
private apiKey: string;
private timeout: number;
// Service clients (lazy-loaded)
private _ledgerService?: any;
private _stateService?: any;
private _packageService?: any;
private _transactionService?: any;
constructor(options: ClientOptions = {}) {
this.apiKey = options.apiKey || config.apiKey;
this.timeout = options.timeout || config.timeout;
const endpoint = options.endpoint || config.endpoint;
// Create SSL credentials
const credentials = grpc.credentials.createSsl();
// Create channel with options
this.channel = new grpc.Client(
endpoint,
credentials,
{
'grpc.max_receive_message_length': 50 * 1024 * 1024, // 50MB
'grpc.max_send_message_length': 50 * 1024 * 1024,
'grpc.keepalive_time_ms': 30000,
'grpc.keepalive_timeout_ms': 10000,
}
);
}
/**
* Load proto definitions and create service client
*/
private async loadService(protoPath: string, serviceName: string): Promise<any> {
const packageDefinition = await protoLoader.load(protoPath, {
keepCase: true,
longs: String,
enums: String,
defaults: true,
oneofs: true,
});
const protoDescriptor = grpc.loadPackageDefinition(packageDefinition);
const service = (protoDescriptor.sui.rpc.v2 as any)[serviceName];
return new service(this.channel);
}
/**
* Get metadata with authentication
*/
private getMetadata(): grpc.Metadata {
const metadata = new grpc.Metadata();
metadata.add('x-api-key', this.apiKey);
return metadata;
}
/**
* Make a unary gRPC call with error handling
*/
async call<TRequest, TResponse>(
service: any,
method: string,
request: TRequest
): Promise<TResponse> {
return new Promise((resolve, reject) => {
const deadline = new Date(Date.now() + this.timeout);
service[method](
request,
this.getMetadata(),
{ deadline },
(error: grpc.ServiceError | null, response: TResponse) => {
if (error) {
reject(wrapGrpcError(error));
} else {
resolve(response);
}
}
);
});
}
/**
* Get Ledger Service client
*/
get ledger() {
if (!this._ledgerService) {
throw new Error('Ledger service not loaded. Call connect() first.');
}
return this._ledgerService;
}
/**
* Get State Service client
*/
get state() {
if (!this._stateService) {
throw new Error('State service not loaded. Call connect() first.');
}
return this._stateService;
}
/**
* Initialize connection and load services
*/
async connect(): Promise<void> {
try {
// Load all services
this._ledgerService = await this.loadService(
'./protos/ledger.proto',
'LedgerService'
);
this._stateService = await this.loadService(
'./protos/state.proto',
'StateService'
);
this._packageService = await this.loadService(
'./protos/package.proto',
'MovePackageService'
);
this._transactionService = await this.loadService(
'./protos/transaction.proto',
'TransactionService'
);
} catch (error) {
throw new Error(`Failed to initialize client: ${error}`);
}
}
/**
* Close the gRPC connection
*/
close(): void {
this.channel.close();
}
}
Type-Safe Service Wrappers#
Create src/client/services.ts:
import { SuiGrpcClient } from './SuiGrpcClient';
import * as types from './types';
export class LedgerService {
constructor(private client: SuiGrpcClient) {}
async getCheckpoint(
request: types.GetCheckpointRequest = {}
): Promise<types.Checkpoint> {
const response = await this.client.call(
this.client.ledger,
'GetCheckpoint',
request
);
return response.checkpoint;
}
async getObject(objectId: string): Promise<types.Object> {
const request: types.GetObjectRequest = {
object_id: objectId,
};
const response = await this.client.call(
this.client.ledger,
'GetObject',
request
);
return response.object;
}
async getTransaction(digest: string): Promise<types.Transaction> {
const request: types.GetTransactionRequest = {
digest,
};
const response = await this.client.call(
this.client.ledger,
'GetTransaction',
request
);
return response.transaction;
}
async getEpoch(epochId?: number): Promise<types.Epoch> {
const request: types.GetEpochRequest = epochId
? { epoch_id: epochId }
: {};
const response = await this.client.call(
this.client.ledger,
'GetEpoch',
request
);
return response.epoch;
}
}
export class StateService {
constructor(private client: SuiGrpcClient) {}
async getBalance(
owner: string,
coinType: string
): Promise<bigint> {
const request: types.GetBalanceRequest = {
owner,
coin_type: coinType,
};
const response = await this.client.call(
this.client.state,
'GetBalance',
request
);
return BigInt(response.balance);
}
async listBalances(owner: string): Promise<types.Balance[]> {
const request: types.ListBalancesRequest = {
owner,
};
const response = await this.client.call(
this.client.state,
'ListBalances',
request
);
return response.balances;
}
async getCoinInfo(coinType: string): Promise<types.CoinMetadata> {
const request: types.GetCoinInfoRequest = {
coin_type: coinType,
};
const response = await this.client.call(
this.client.state,
'GetCoinInfo',
request
);
return response.metadata;
}
}
Type Definitions#
Create src/client/types.ts:
// Request types
export interface GetCheckpointRequest {
sequence_number?: number;
read_mask?: FieldMask;
}
export interface GetObjectRequest {
object_id: string;
read_mask?: FieldMask;
}
export interface GetBalanceRequest {
owner: string;
coin_type: string;
}
export interface ListBalancesRequest {
owner: string;
}
export interface GetCoinInfoRequest {
coin_type: string;
}
export interface GetTransactionRequest {
digest: string;
read_mask?: FieldMask;
}
export interface GetEpochRequest {
epoch_id?: number;
}
export interface FieldMask {
paths: string[];
}
// Response types
export interface Checkpoint {
sequence_number: number;
timestamp_ms: number;
contents?: CheckpointContents;
// ... other fields
}
export interface CheckpointContents {
transactions: TransactionDigest[];
}
export interface TransactionDigest {
transaction: string;
}
export interface Object {
object_id: string;
version: number;
digest: string;
owner?: ObjectOwner;
// ... other fields
}
export interface ObjectOwner {
address_owner?: string;
object_owner?: string;
shared?: SharedOwner;
}
export interface SharedOwner {
initial_shared_version: number;
}
export interface Balance {
coin_type: string;
balance: string;
locked_balance: number;
}
export interface CoinMetadata {
decimals: number;
name: string;
symbol: string;
description: string;
icon_url?: string;
}
export interface Transaction {
digest: string;
transaction?: TransactionData;
effects?: TransactionEffects;
// ... other fields
}
export interface Epoch {
epoch: number;
first_checkpoint: number;
last_checkpoint: number;
// ... other fields
}
// Utility types
export type SuiAddress = string;
export type ObjectID = string;
export type TransactionDigestType = string;
Utility Functions#
Error Handling#
Create src/utils/errors.ts:
import * as grpc from '@grpc/grpc-js';
export class GrpcError extends Error {
constructor(
message: string,
public code: grpc.status,
public details?: string
) {
super(message);
this.name = 'GrpcError';
}
get isNotFound(): boolean {
return this.code === grpc.status.NOT_FOUND;
}
get isUnauthenticated(): boolean {
return this.code === grpc.status.UNAUTHENTICATED;
}
get isInvalidArgument(): boolean {
return this.code === grpc.status.INVALID_ARGUMENT;
}
get isUnavailable(): boolean {
return this.code === grpc.status.UNAVAILABLE;
}
}
export function wrapGrpcError(error: grpc.ServiceError): GrpcError {
return new GrpcError(error.message, error.code, error.details);
}
Retry Logic#
Create src/utils/retry.ts:
import pRetry, { Options } from 'p-retry';
import { GrpcError } from './errors';
const defaultRetryOptions: Options = {
retries: 3,
factor: 2,
minTimeout: 1000,
maxTimeout: 5000,
};
export async function withRetry<T>(
fn: () => Promise<T>,
options: Options = {}
): Promise<T> {
return pRetry(fn, {
...defaultRetryOptions,
...options,
onFailedAttempt: (error) => {
// Only retry on specific errors
if (error instanceof GrpcError) {
if (!error.isUnavailable && !error.code === 14 /* UNAVAILABLE */) {
throw error; // Don't retry
}
}
console.log(
`Attempt ${error.attemptNumber} failed. ${error.retriesLeft} retries left.`
);
},
});
}
Data Formatting#
Create src/utils/formatting.ts:
/**
* Convert MIST to SUI
*/
export function mistToSui(mist: bigint): string {
const sui = Number(mist) / 1_000_000_000;
return sui.toFixed(9);
}
/**
* Convert SUI to MIST
*/
export function suiToMist(sui: number): bigint {
return BigInt(Math.floor(sui * 1_000_000_000));
}
/**
* Format address for display (truncate middle)
*/
export function formatAddress(address: string, length: number = 8): string {
if (address.length <= length * 2) {
return address;
}
return `${address.slice(0, length)}...${address.slice(-length)}`;
}
/**
* Format timestamp to readable date
*/
export function formatTimestamp(timestampMs: number): string {
return new Date(timestampMs).toISOString();
}
Usage Examples#
Get Current Checkpoint#
Create src/examples/getCheckpoint.ts:
import { SuiGrpcClient } from '../client/SuiGrpcClient';
import { LedgerService } from '../client/services';
import { formatTimestamp } from '../utils/formatting';
async function main() {
const client = new SuiGrpcClient();
await client.connect();
try {
const ledger = new LedgerService(client);
const checkpoint = await ledger.getCheckpoint();
console.log('Current Checkpoint:');
console.log(` Sequence Number: ${checkpoint.sequence_number}`);
console.log(` Timestamp: ${formatTimestamp(checkpoint.timestamp_ms)}`);
console.log(` Transactions: ${checkpoint.contents?.transactions.length || 0}`);
} finally {
client.close();
}
}
main().catch(console.error);
Portfolio Tracker#
import { SuiGrpcClient } from '../client/SuiGrpcClient';
import { StateService } from '../client/services';
import { mistToSui } from '../utils/formatting';
interface TokenBalance {
symbol: string;
balance: string;
coinType: string;
}
class PortfolioTracker {
private stateService: StateService;
constructor(private client: SuiGrpcClient) {
this.stateService = new StateService(client);
}
async getPortfolio(address: string): Promise<TokenBalance[]> {
// Get all balances
const balances = await this.stateService.listBalances(address);
// Enrich with coin metadata
const portfolio = await Promise.all(
balances.map(async (balance) => {
try {
const metadata = await this.stateService.getCoinInfo(balance.coin_type);
const balanceNum = BigInt(balance.balance);
const formattedBalance = (
Number(balanceNum) / Math.pow(10, metadata.decimals)
).toFixed(metadata.decimals);
return {
symbol: metadata.symbol,
balance: formattedBalance,
coinType: balance.coin_type,
};
} catch (error) {
console.warn(`Could not get metadata for ${balance.coin_type}`);
return {
symbol: 'UNKNOWN',
balance: balance.balance,
coinType: balance.coin_type,
};
}
})
);
return portfolio.filter((token) => parseFloat(token.balance) > 0);
}
printPortfolio(portfolio: TokenBalance[]): void {
console.log('\nPortfolio:');
console.log('━'.repeat(50));
portfolio.forEach((token) => {
console.log(`${token.symbol.padEnd(10)} ${token.balance.padStart(20)}`);
});
console.log('━'.repeat(50));
console.log(`Total tokens: ${portfolio.length}\n`);
}
}
async function main() {
const client = new SuiGrpcClient();
await client.connect();
try {
const tracker = new PortfolioTracker(client);
const portfolio = await tracker.getPortfolio(
'0x742d35cc6634c0532925a3b844bc9e7eb503c114a04bd3e02c7681a09e58b01d'
);
tracker.printPortfolio(portfolio);
} finally {
client.close();
}
}
main().catch(console.error);
React Integration#
Custom Hook#
import { useState, useEffect } from 'react';
import { SuiGrpcClient } from '../client/SuiGrpcClient';
import { StateService } from '../client/services';
export function useBalance(address: string, coinType: string) {
const [balance, setBalance] = useState<bigint | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
let client: SuiGrpcClient | null = null;
async function fetchBalance() {
try {
setLoading(true);
setError(null);
client = new SuiGrpcClient();
await client.connect();
const stateService = new StateService(client);
const bal = await stateService.getBalance(address, coinType);
setBalance(bal);
} catch (err) {
setError(err instanceof Error ? err : new Error('Unknown error'));
} finally {
setLoading(false);
}
}
fetchBalance();
return () => {
client?.close();
};
}, [address, coinType]);
return { balance, loading, error };
}
React Component#
import React from 'react';
import { useBalance } from '../hooks/useBalance';
import { mistToSui } from '../utils/formatting';
interface BalanceDisplayProps {
address: string;
}
export function BalanceDisplay({ address }: BalanceDisplayProps) {
const SUI_COIN_TYPE = '0x2::sui::SUI';
const { balance, loading, error } = useBalance(address, SUI_COIN_TYPE);
if (loading) {
return <div>Loading balance...</div>;
}
if (error) {
return <div>Error: {error.message}</div>;
}
if (balance === null) {
return <div>No balance data</div>;
}
return (
<div>
<h3>SUI Balance</h3>
<p>{mistToSui(balance)} SUI</p>
</div>
);
}
Production Best Practices#
- Always close connections - Use try/finally blocks
- Implement retry logic - Handle transient failures
- Use connection pooling - For high-throughput applications
- Cache coin metadata - It rarely changes
- Set appropriate timeouts - Prevent hanging requests
- Use TypeScript strict mode - Catch errors at compile time
- Implement proper error handling - Don't swallow errors
- Add logging - Use structured logging for debugging
Related Resources#
- gRPC Overview - Complete API introduction
- Python Setup - Python client
- Go Setup - Go client implementation
- grpcurl Setup - Command-line testing
Need help? Contact support@dwellir.com