Skip to main content

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 service
  • transaction.proto - Transaction service
  • signature_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:

// 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#

  1. Always close connections - Use try/finally blocks
  2. Implement retry logic - Handle transient failures
  3. Use connection pooling - For high-throughput applications
  4. Cache coin metadata - It rarely changes
  5. Set appropriate timeouts - Prevent hanging requests
  6. Use TypeScript strict mode - Catch errors at compile time
  7. Implement proper error handling - Don't swallow errors
  8. Add logging - Use structured logging for debugging

Need help? Contact support@dwellir.com