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