Saltar al contenido principal

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

  1. Type Safety: Use TypeScript for all contract interactions
  2. Error Handling: Implement comprehensive error handling for all Move calls
  3. Testing: Write both unit and integration tests for contract logic
  4. Event Monitoring: Set up proper event listening for real-time updates
  5. Performance: Use React Query for efficient data fetching and caching
  6. Security: Validate all inputs before sending to smart contracts
  7. 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

Resources