Docs

TypeScript Client for Sui gRPC

Complete guide to building TypeScript applications with Sui gRPC API on Dwellir. Includes project setup, type safety, React integration, and modern async patterns.

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

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

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

Bash
# 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:

TypeScript
// 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)

Bash
# 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:

JSON
{
  "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:

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

Text
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:

Bash
# .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:

TypeScript
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:

TypeScript
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:

TypeScript
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:

TypeScript
// 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:

TypeScript
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:

TypeScript
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:

TypeScript
/**
 * 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:

TypeScript
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

TypeScript
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

TypeScript
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

TypeScript
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