Sui Move Integration Guide
This guide covers integrating Sui Move smart contracts with TypeScript frontends, including contract interaction patterns, event handling, and testing strategies for 2025.
Prerequisites
- Sui CLI installed for Move development
- Understanding of Move language basics
- TypeScript/React knowledge for frontend integration
- @mysten/sui SDK for blockchain interactions
Move Contract Structure
Basic Move Module Example
// sources/counter.move
module my_package::counter {
    use sui::object::{Self, UID};
    use sui::transfer;
    use sui::tx_context::{Self, TxContext};
    public struct Counter has key, store {
        id: UID,
        value: u64,
    }
    public struct CounterCreated has copy, drop {
        counter_id: ID,
        initial_value: u64,
    }
    public fun create_counter(ctx: &mut TxContext): Counter {
        let counter = Counter {
            id: object::new(ctx),
            value: 0,
        };
        
        sui::event::emit(CounterCreated {
            counter_id: object::id(&counter),
            initial_value: 0,
        });
        
        counter
    }
    public entry fun increment(counter: &mut Counter) {
        counter.value = counter.value + 1;
    }
    public fun get_value(counter: &Counter): u64 {
        counter.value
    }
    public entry fun set_value(counter: &mut Counter, new_value: u64) {
        counter.value = new_value;
    }
}
Move.toml Configuration
[package]
name = "my_package"
version = "1.0.0"
[dependencies]
Sui = { git = "https://github.com/MystenLabs/sui.git", subdir = "crates/sui-framework/packages/sui-framework", rev = "framework/mainnet" }
[addresses]
my_package = "0x0"
TypeScript Integration Patterns
Contract Interaction Utilities
// utils/contract.ts
import { Transaction } from '@mysten/sui/transactions';
import { SuiClient } from '@mysten/sui/client';
export class CounterContract {
  constructor(
    private client: SuiClient,
    private packageId: string
  ) {}
  // Create a new counter
  createCounter(): Transaction {
    const tx = new Transaction();
    
    const [counter] = tx.moveCall({
      target: `${this.packageId}::counter::create_counter`,
    });
    
    // Transfer the counter to the user
    tx.transferObjects([counter], tx.pure.address('{{user_address}}'));
    
    return tx;
  }
  // Increment counter value
  incrementCounter(counterId: string): Transaction {
    const tx = new Transaction();
    
    tx.moveCall({
      target: `${this.packageId}::counter::increment`,
      arguments: [tx.object(counterId)],
    });
    
    return tx;
  }
  // Set counter to specific value
  setCounterValue(counterId: string, value: number): Transaction {
    const tx = new Transaction();
    
    tx.moveCall({
      target: `${this.packageId}::counter::set_value`,
      arguments: [
        tx.object(counterId),
        tx.pure.u64(value),
      ],
    });
    
    return tx;
  }
  // Read counter value (view function)
  async getCounterValue(counterId: string): Promise<number> {
    try {
      const result = await this.client.getObject({
        id: counterId,
        options: { showContent: true },
      });
      if (result.data?.content?.dataType === 'moveObject') {
        const fields = result.data.content.fields as any;
        return Number(fields.value);
      }
      
      throw new Error('Counter object not found or invalid');
    } catch (error) {
      console.error('Error reading counter value:', error);
      throw error;
    }
  }
}
React Hook for Contract Interaction
// hooks/useCounter.ts
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useSignAndExecuteTransaction, useSuiClient, useCurrentAccount } from '@mysten/dapp-kit';
import { CounterContract } from '../utils/contract';
const PACKAGE_ID = '0x...'; // Your deployed package ID
export function useCounter(counterId?: string) {
  const client = useSuiClient();
  const account = useCurrentAccount();
  const queryClient = useQueryClient();
  const { mutate: signAndExecute } = useSignAndExecuteTransaction();
  const contract = new CounterContract(client, PACKAGE_ID);
  // Query counter value
  const {
    data: counterValue,
    isLoading,
    error
  } = useQuery({
    queryKey: ['counter', counterId],
    queryFn: () => contract.getCounterValue(counterId!),
    enabled: !!counterId,
    refetchInterval: 5000, // Refresh every 5 seconds
  });
  // Create counter mutation
  const createCounter = useMutation({
    mutationFn: async () => {
      if (!account) throw new Error('Wallet not connected');
      
      const tx = contract.createCounter();
      
      return new Promise((resolve, reject) => {
        signAndExecute(
          { 
            transaction: tx.replaceTemplateValue('{{user_address}}', account.address) 
          },
          {
            onSuccess: (result) => {
              queryClient.invalidateQueries({ queryKey: ['counters'] });
              resolve(result);
            },
            onError: reject,
          }
        );
      });
    },
  });
  // Increment mutation
  const incrementCounter = useMutation({
    mutationFn: async () => {
      if (!counterId) throw new Error('No counter ID provided');
      
      const tx = contract.incrementCounter(counterId);
      
      return new Promise((resolve, reject) => {
        signAndExecute(
          { transaction: tx },
          {
            onSuccess: (result) => {
              queryClient.invalidateQueries({ queryKey: ['counter', counterId] });
              resolve(result);
            },
            onError: reject,
          }
        );
      });
    },
  });
  // Set value mutation
  const setCounterValue = useMutation({
    mutationFn: async (value: number) => {
      if (!counterId) throw new Error('No counter ID provided');
      
      const tx = contract.setCounterValue(counterId, value);
      
      return new Promise((resolve, reject) => {
        signAndExecute(
          { transaction: tx },
          {
            onSuccess: (result) => {
              queryClient.invalidateQueries({ queryKey: ['counter', counterId] });
              resolve(result);
            },
            onError: reject,
          }
        );
      });
    },
  });
  return {
    counterValue,
    isLoading,
    error,
    createCounter,
    incrementCounter,
    setCounterValue,
  };
}
Counter Component Implementation
// components/Counter.tsx
import React, { useState } from 'react';
import { useCounter } from '../hooks/useCounter';
interface CounterProps {
  counterId?: string;
}
export function Counter({ counterId }: CounterProps) {
  const [newValue, setNewValue] = useState<number>(0);
  const {
    counterValue,
    isLoading,
    error,
    createCounter,
    incrementCounter,
    setCounterValue,
  } = useCounter(counterId);
  if (!counterId) {
    return (
      <div className="counter-container">
        <h3>Create a Counter</h3>
        <button
          onClick={() => createCounter.mutate()}
          disabled={createCounter.isPending}
        >
          {createCounter.isPending ? 'Creating...' : 'Create Counter'}
        </button>
      </div>
    );
  }
  if (isLoading) return <div>Loading counter...</div>;
  if (error) return <div>Error: {error.message}</div>;
  return (
    <div className="counter-container">
      <h3>Counter: {counterId.slice(0, 8)}...</h3>
      <div className="counter-display">
        <span className="counter-value">{counterValue}</span>
      </div>
      
      <div className="counter-actions">
        <button
          onClick={() => incrementCounter.mutate()}
          disabled={incrementCounter.isPending}
        >
          {incrementCounter.isPending ? 'Incrementing...' : '+1'}
        </button>
        
        <div className="set-value-section">
          <input
            type="number"
            value={newValue}
            onChange={(e) => setNewValue(Number(e.target.value))}
            placeholder="New value"
          />
          <button
            onClick={() => setCounterValue.mutate(newValue)}
            disabled={setCounterValue.isPending}
          >
            {setCounterValue.isPending ? 'Setting...' : 'Set Value'}
          </button>
        </div>
      </div>
    </div>
  );
}
Advanced Patterns
Complex Move Calls with Multiple Arguments
// sources/marketplace.move
module my_package::marketplace {
    use sui::coin::{Self, Coin};
    use sui::sui::SUI;
    use sui::transfer;
    public struct Listing has key, store {
        id: UID,
        item: ID,
        price: u64,
        seller: address,
    }
    public entry fun create_listing(
        item_id: ID,
        price: u64,
        ctx: &mut TxContext
    ) {
        let listing = Listing {
            id: object::new(ctx),
            item: item_id,
            price,
            seller: tx_context::sender(ctx),
        };
        
        transfer::share_object(listing);
    }
    public entry fun purchase_item(
        listing: &mut Listing,
        payment: Coin<SUI>,
        ctx: &mut TxContext
    ) {
        assert!(coin::value(&payment) >= listing.price, 0);
        
        // Transfer payment to seller
        transfer::public_transfer(payment, listing.seller);
        
        // Additional marketplace logic...
    }
}
// TypeScript integration for marketplace
export class MarketplaceContract {
  constructor(private client: SuiClient, private packageId: string) {}
  createListing(itemId: string, priceInSui: number): Transaction {
    const tx = new Transaction();
    
    tx.moveCall({
      target: `${this.packageId}::marketplace::create_listing`,
      arguments: [
        tx.pure.id(itemId),
        tx.pure.u64(priceInSui * 1_000_000_000), // Convert SUI to MIST
      ],
    });
    
    return tx;
  }
  purchaseItem(listingId: string, priceInSui: number): Transaction {
    const tx = new Transaction();
    
    // Split SUI for payment
    const [payment] = tx.splitCoins(tx.gas, [priceInSui * 1_000_000_000]);
    
    tx.moveCall({
      target: `${this.packageId}::marketplace::purchase_item`,
      arguments: [
        tx.object(listingId),
        payment,
      ],
    });
    
    return tx;
  }
}
Event Listening and Processing
// utils/eventListener.ts
import { SuiClient } from '@mysten/sui/client';
import { SuiEvent } from '@mysten/sui/client';
export class ContractEventListener {
  constructor(
    private client: SuiClient,
    private packageId: string
  ) {}
  async subscribeToCounterEvents(callback: (event: SuiEvent) => void) {
    try {
      // Query recent events
      const events = await this.client.queryEvents({
        query: {
          MoveModule: {
            package: this.packageId,
            module: 'counter',
          },
        },
        limit: 50,
        order: 'descending',
      });
      // Process existing events
      events.data.forEach(callback);
      // Set up polling for new events
      return setInterval(async () => {
        const newEvents = await this.client.queryEvents({
          query: {
            MoveModule: {
              package: this.packageId,
              module: 'counter',
            },
          },
          limit: 10,
          order: 'descending',
        });
        newEvents.data.forEach(callback);
      }, 5000);
    } catch (error) {
      console.error('Error setting up event subscription:', error);
      throw error;
    }
  }
  async getCounterCreatedEvents(): Promise<SuiEvent[]> {
    const events = await this.client.queryEvents({
      query: {
        MoveEventType: `${this.packageId}::counter::CounterCreated`,
      },
      limit: 100,
      order: 'descending',
    });
    return events.data;
  }
}
React Hook for Event Listening
// hooks/useContractEvents.ts
import { useEffect, useState } from 'react';
import { useSuiClient } from '@mysten/dapp-kit';
import { SuiEvent } from '@mysten/sui/client';
import { ContractEventListener } from '../utils/eventListener';
export function useContractEvents(packageId: string) {
  const client = useSuiClient();
  const [events, setEvents] = useState<SuiEvent[]>([]);
  const [isListening, setIsListening] = useState(false);
  useEffect(() => {
    let intervalId: NodeJS.Timeout;
    
    const startListening = async () => {
      setIsListening(true);
      const eventListener = new ContractEventListener(client, packageId);
      
      try {
        intervalId = await eventListener.subscribeToCounterEvents((event) => {
          setEvents(prev => {
            // Avoid duplicates
            const exists = prev.some(e => e.id.txDigest === event.id.txDigest);
            if (!exists) {
              return [event, ...prev].slice(0, 100); // Keep latest 100 events
            }
            return prev;
          });
        });
      } catch (error) {
        console.error('Failed to start event listening:', error);
        setIsListening(false);
      }
    };
    startListening();
    return () => {
      if (intervalId) {
        clearInterval(intervalId);
      }
      setIsListening(false);
    };
  }, [client, packageId]);
  return { events, isListening };
}
Testing Strategies
Unit Tests for Contract Utilities
// __tests__/contract.test.ts
import { describe, it, expect, beforeEach } from '@jest/globals';
import { SuiClient } from '@mysten/sui/client';
import { CounterContract } from '../utils/contract';
describe('CounterContract', () => {
  let client: SuiClient;
  let contract: CounterContract;
  beforeEach(() => {
    client = new SuiClient({ url: 'http://localhost:9000' });
    contract = new CounterContract(client, '0x123');
  });
  it('should create counter transaction', () => {
    const tx = contract.createCounter();
    expect(tx).toBeDefined();
    // Add more specific transaction validation
  });
  it('should create increment transaction', () => {
    const counterId = '0xabc123';
    const tx = contract.incrementCounter(counterId);
    expect(tx).toBeDefined();
  });
});
Integration Tests with Local Network
// __tests__/integration.test.ts
import { describe, it, expect, beforeAll } from '@jest/globals';
import { SuiClient, getFullnodeUrl } from '@mysten/sui/client';
import { Ed25519Keypair } from '@mysten/sui/keypairs/ed25519';
import { CounterContract } from '../utils/contract';
describe('Counter Integration Tests', () => {
  let client: SuiClient;
  let keypair: Ed25519Keypair;
  let contract: CounterContract;
  let packageId: string;
  beforeAll(async () => {
    // Setup local network client
    client = new SuiClient({ url: getFullnodeUrl('localnet') });
    keypair = new Ed25519Keypair();
    
    // Deploy contract and get package ID
    packageId = await deployContract();
    contract = new CounterContract(client, packageId);
  });
  it('should create and increment counter', async () => {
    // Create counter
    const createTx = contract.createCounter();
    const createResult = await client.signAndExecuteTransaction({
      transaction: createTx,
      signer: keypair,
    });
    expect(createResult.effects?.status?.status).toBe('success');
    // Extract counter ID from created objects
    const counterId = createResult.effects?.created?.[0]?.reference?.objectId;
    expect(counterId).toBeDefined();
    // Increment counter
    const incrementTx = contract.incrementCounter(counterId!);
    const incrementResult = await client.signAndExecuteTransaction({
      transaction: incrementTx,
      signer: keypair,
    });
    expect(incrementResult.effects?.status?.status).toBe('success');
    // Verify counter value
    const value = await contract.getCounterValue(counterId!);
    expect(value).toBe(1);
  });
});
async function deployContract(): Promise<string> {
  // Implementation for deploying contract in test environment
  // This would involve publishing the Move package
  return '0x...'; // Return actual package ID
}
Mock Testing with React Testing Library
// __tests__/Counter.test.tsx
import React from 'react';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { Counter } from '../components/Counter';
import { useCounter } from '../hooks/useCounter';
// Mock the useCounter hook
jest.mock('../hooks/useCounter');
const mockUseCounter = useCounter as jest.MockedFunction<typeof useCounter>;
describe('Counter Component', () => {
  let queryClient: QueryClient;
  beforeEach(() => {
    queryClient = new QueryClient({
      defaultOptions: {
        queries: { retry: false },
        mutations: { retry: false },
      },
    });
  });
  it('should display counter value', async () => {
    mockUseCounter.mockReturnValue({
      counterValue: 42,
      isLoading: false,
      error: null,
      createCounter: { mutate: jest.fn(), isPending: false },
      incrementCounter: { mutate: jest.fn(), isPending: false },
      setCounterValue: { mutate: jest.fn(), isPending: false },
    });
    render(
      <QueryClientProvider client={queryClient}>
        <Counter counterId="0x123" />
      </QueryClientProvider>
    );
    await waitFor(() => {
      expect(screen.getByText('42')).toBeInTheDocument();
    });
  });
  it('should call increment when button is clicked', async () => {
    const mockIncrement = jest.fn();
    mockUseCounter.mockReturnValue({
      counterValue: 0,
      isLoading: false,
      error: null,
      createCounter: { mutate: jest.fn(), isPending: false },
      incrementCounter: { mutate: mockIncrement, isPending: false },
      setCounterValue: { mutate: jest.fn(), isPending: false },
    });
    render(
      <QueryClientProvider client={queryClient}>
        <Counter counterId="0x123" />
      </QueryClientProvider>
    );
    fireEvent.click(screen.getByText('+1'));
    expect(mockIncrement).toHaveBeenCalled();
  });
});
Deployment and Production Considerations
Environment-Specific Configuration
// config/contracts.ts
export const getContractConfig = () => {
  const environment = process.env.NODE_ENV;
  
  switch (environment) {
    case 'production':
      return {
        packageId: process.env.NEXT_PUBLIC_COUNTER_PACKAGE_MAINNET!,
        network: 'mainnet',
      };
    case 'staging':
      return {
        packageId: process.env.NEXT_PUBLIC_COUNTER_PACKAGE_TESTNET!,
        network: 'testnet',
      };
    default:
      return {
        packageId: process.env.NEXT_PUBLIC_COUNTER_PACKAGE_DEVNET!,
        network: 'devnet',
      };
  }
};
Error Handling and Monitoring
// utils/errorHandling.ts
import { SuiTransactionBlockResponse } from '@mysten/sui/client';
export function handleTransactionResult(result: SuiTransactionBlockResponse) {
  if (result.effects?.status?.status === 'success') {
    console.log('Transaction successful:', result.digest);
    return result;
  } else {
    const error = result.effects?.status?.error;
    console.error('Transaction failed:', error);
    
    // Log to monitoring service
    if (process.env.NODE_ENV === 'production') {
      // logToMonitoringService('transaction_error', { error, digest: result.digest });
    }
    
    throw new Error(`Transaction failed: ${error}`);
  }
}
Best Practices Summary
- Type Safety: Use TypeScript for all contract interactions
- Error Handling: Implement comprehensive error handling for all Move calls
- Testing: Write both unit and integration tests for contract logic
- Event Monitoring: Set up proper event listening for real-time updates
- Performance: Use React Query for efficient data fetching and caching
- Security: Validate all inputs before sending to smart contracts
- Documentation: Document all contract interfaces and expected behaviors
Next Steps
- Implement performance optimization techniques for advanced patterns
- Learn about Multi-Chain dApp Architecture for cross-chain applications
- Follow security best practices for production deployment