返回 SC 笔记
SC Day 68

Sui TypeScript SDK与前端集成

### 1. @mysten/sui SDK 概览

2026-06-17
第三阶段:安全审计
suitypescriptsdkfrontend

日期: 2026-06-17 方向: Move/Sui 阶段: 第三阶段:安全审计 标签: #sui #typescript #sdk #frontend


今日目标

类型内容
学习掌握 @mysten/sui TypeScript SDK 的核心 API:SuiClient、Transaction 构建、对象查询、事件订阅
实操用 TypeScript 完成与 Sui 链的完整交互:查询对象、构建交易、执行交易、监听事件
产出完整的前端集成代码示例 + Sui vs EVM 前端开发对比分析

核心概念

1. @mysten/sui SDK 概览

@mysten/sui 是 Mysten Labs 官方维护的 TypeScript SDK,用于与 Sui 区块链交互。它取代了早期的 @mysten/sui.js,提供了更现代化的 API 设计。

特性说明
SuiClient与 Sui RPC 节点通信的核心客户端
Transaction可编程交易块(PTB)的构建器
Ed25519Keypair密钥对管理和签名
bcsBinary Canonical Serialization 编解码
事件订阅实时监听链上事件

SDK 架构总览

@mysten/sui SDK 架构:
├── SuiClient              # RPC 客户端(所有链上交互的入口)
│   ├── getObject()        # 查询单个对象
│   ├── getOwnedObjects()  # 查询用户拥有的对象
│   ├── getCoins()         # 查询 Coin 余额
│   ├── getDynamicFields() # 查询动态字段
│   ├── queryEvents()      # 查询历史事件
│   ├── subscribeEvent()   # WebSocket 实时事件订阅
│   ├── executeTransactionBlock()  # 提交已签名交易
│   ├── dryRunTransactionBlock()   # 模拟执行(不上链)
│   └── waitForTransaction()       # 等待交易确认
├── Transaction            # 交易构建器(PTB)
│   ├── moveCall()         # 调用 Move 函数
│   ├── splitCoins()       # 拆分 Coin 对象
│   ├── mergeCoins()       # 合并 Coin 对象
│   ├── transferObjects()  # 转移对象所有权
│   ├── setSender()        # 设置发送者
│   ├── setGasOwner()      # 设置 Gas 代付者
│   └── build()            # 序列化为交易字节
├── Ed25519Keypair         # 密钥管理
│   ├── generate()         # 生成新密钥对
│   ├── deriveKeypair()    # 从助记词派生
│   ├── fromSecretKey()    # 从私钥恢复
│   └── signTransaction()  # 签名交易
└── Wallet Adapters        # 钱包连接层
    ├── @mysten/dapp-kit   # React 组件库(ConnectButton, hooks)
    └── @mysten/wallet-standard  # 钱包标准接口

2. SuiClient:连接 Sui 网络

SuiClient 是所有链上交互的基础。它封装了 Sui JSON-RPC API,提供类型安全的 TypeScript 接口。

import { SuiClient, getFullnodeUrl } from '@mysten/sui/client';

// 连接到不同网络
const mainnetClient = new SuiClient({ url: getFullnodeUrl('mainnet') });
const testnetClient = new SuiClient({ url: getFullnodeUrl('testnet') });
const devnetClient  = new SuiClient({ url: getFullnodeUrl('devnet') });
const localClient   = new SuiClient({ url: 'http://127.0.0.1:9000' });

// 也可以连接自定义 RPC(BlockVision, Shinami 等)
const customClient = new SuiClient({
  url: 'https://sui-mainnet.blockvision.org/v1/YOUR_API_KEY'
});

与 EVM 连接模型的对比

EVM(ethers.js / viem):
  Provider(只读) → 查询链上状态
  Signer(读写) → Provider + 签名能力
  Provider 和 Signer 是不同的抽象层

Sui(@mysten/sui):
  SuiClient → 所有 RPC 通信(查询 + 提交已签名交易)
  Keypair / Wallet → 独立的签名层
  签名和通信是完全解耦的

关键区别:EVM 中 ProviderSigner 是分开的抽象层;Sui 中 SuiClient 负责所有 RPC 通信,签名由 Keypair 或钱包适配器独立处理。这种解耦使得 sponsored transaction(Gas 代付)等高级模式更容易实现。

3. Sui 对象模型与查询

Sui 的核心数据单元是对象(Object),而非 EVM 的账户状态。前端开发者需要理解从"查合约 storage"到"查对象内容"的思维转换。

EVM 思维:                    Sui 思维:
"查询地址A的余额"           "查询地址A拥有的所有 Coin 对象"
"查询合约的 storage"         "查询特定对象的内容"
"读取 mapping 中的值"        "查询动态字段"
"监听 Event Log"            "订阅 Move Event"

对象类型与前端影响

对象类型说明前端影响
Owned Object属于某个地址,只有所有者可操作需要用户签名才能使用
Shared Object任何人都可以操作(需共识排序)类似 EVM 合约状态,可能有并发冲突
Immutable Object创建后不可修改可以安全缓存,不用刷新
Wrapped Object被包裹在另一个对象内部无法直接查询,需通过父对象访问

4. 可编程交易块(Programmable Transaction Block, PTB)

PTB 是 Sui 最革命性的特性。一个 PTB 可以包含多个操作(Move Call、对象转移、Coin 拆分/合并等),它们在一个原子交易中顺序执行,中间结果可以传递给后续操作。

EVM 交易模型:
  一笔交易 = 一次合约调用(或使用 multicall 合约)
  多步操作需要:
    方案1: 多笔独立交易(approve → swap,有中间失败风险)
    方案2: 部署 multicall/router 辅助合约
    方案3: 使用 flashbots bundle

Sui PTB 模型:
  一笔交易 = 多个操作的有序组合,原生支持:
    splitCoins → moveCall(swap) → transferObjects
    所有操作原子执行,要么全成功要么全回滚
    前一步返回值直接作为后一步输入
    不需要额外辅助合约,SDK 层直接组合

这意味着:很多在 EVM 上需要两笔交易或复杂合约才能实现的操作,在 Sui 上只需一个 PTB。用户只签名一次,UX 大幅改善。


代码实战

实战1:环境搭建与全局配置

// 安装依赖
// pnpm add @mysten/sui @mysten/dapp-kit @tanstack/react-query

// src/lib/sui-client.ts — 全局配置文件
import { SuiClient, getFullnodeUrl } from '@mysten/sui/client';

export const NETWORK = 'testnet'; // 开发阶段用 testnet
export const PACKAGE_ID = '0xYOUR_DEPLOYED_PACKAGE_ID';

// 全局客户端实例(整个应用共用)
export const suiClient = new SuiClient({
  url: getFullnodeUrl(NETWORK),
});

实战2:对象查询——账户信息、对象详情、Coin 余额

// src/lib/queries.ts
import { suiClient, PACKAGE_ID } from './sui-client';

// ============================================================
// 1. 查询账户的 SUI 余额和所有 Token 余额
// ============================================================
export async function getAccountBalances(address: string) {
  // 查询 SUI 余额(1 SUI = 1e9 MIST)
  const suiBalance = await suiClient.getBalance({
    owner: address,
    coinType: '0x2::sui::SUI',
  });
  console.log('SUI Balance:', suiBalance.totalBalance, 'MIST');
  console.log('Coin Object Count:', suiBalance.coinObjectCount);

  // 查询所有 Coin 类型的余额
  const allBalances = await suiClient.getAllBalances({ owner: address });
  allBalances.forEach(b => {
    console.log(`${b.coinType}: ${b.totalBalance}`);
  });

  return { suiBalance, allBalances };
}

// ============================================================
// 2. 查询单个对象的详细信息
// ============================================================
export async function getObjectDetails(objectId: string) {
  const object = await suiClient.getObject({
    id: objectId,
    options: {
      showContent: true,    // Move 结构体字段值
      showType: true,       // 完整类型路径
      showOwner: true,      // 所有者信息
      showDisplay: true,    // Display 标准元数据(NFT 图片等)
      showBcs: false,       // BCS 编码原始数据(通常不需要)
    },
  });

  if (object.data) {
    console.log('Type:', object.data.type);
    console.log('Owner:', object.data.owner);

    // 访问 Move 结构体的字段
    if (object.data.content?.dataType === 'moveObject') {
      const fields = object.data.content.fields as Record<string, any>;
      console.log('Fields:', fields);
    }
  }
  return object;
}

// ============================================================
// 3. 查询用户拥有的指定类型对象(如 NFT)
// ============================================================
export async function getUserNFTs(address: string) {
  const response = await suiClient.getOwnedObjects({
    owner: address,
    filter: {
      StructType: `${PACKAGE_ID}::nft::MomoNFT`,
    },
    options: {
      showContent: true,
      showDisplay: true,
      showType: true,
    },
  });

  return response.data.map(obj => {
    const fields = obj.data?.content?.dataType === 'moveObject'
      ? (obj.data.content.fields as Record<string, any>)
      : {};
    const display = obj.data?.display?.data || {};

    return {
      id: obj.data?.objectId,
      name: display.name || fields.name,
      description: display.description || fields.description,
      imageUrl: display.image_url || fields.url,
    };
  });
}

// ============================================================
// 4. 分页查询(对象数量多时必须使用分页)
// ============================================================
export async function getAllOwnedObjects(address: string) {
  let cursor: string | null = null;
  let allObjects: any[] = [];

  do {
    const page = await suiClient.getOwnedObjects({
      owner: address,
      cursor: cursor ?? undefined,
      limit: 50,  // 每页最多 50 个
      options: { showContent: true, showType: true },
    });
    allObjects.push(...page.data);
    cursor = page.hasNextPage ? (page.nextCursor ?? null) : null;
  } while (cursor);

  console.log(`Total objects: ${allObjects.length}`);
  return allObjects;
}

// ============================================================
// 5. 查询动态字段(类似 EVM 的 mapping)
// ============================================================
export async function getDynamicFieldsOf(parentObjectId: string) {
  const fields = await suiClient.getDynamicFields({
    parentId: parentObjectId,
  });

  // 获取每个动态字段的具体值
  for (const field of fields.data) {
    const fieldValue = await suiClient.getDynamicFieldObject({
      parentId: parentObjectId,
      name: field.name,
    });
    console.log('Field:', field.name, '→', fieldValue.data?.content);
  }

  return fields;
}

// ============================================================
// 6. 批量查询多个对象
// ============================================================
export async function getMultipleObjects(objectIds: string[]) {
  const objects = await suiClient.multiGetObjects({
    ids: objectIds,
    options: { showContent: true, showType: true },
  });
  return objects;
}

实战3:交易构建——从简单到复杂

// src/lib/transactions.ts
import { Transaction } from '@mysten/sui/transactions';
import { PACKAGE_ID } from './sui-client';

// ============================================================
// 1. 最简单的交易:调用 Counter 的 increment
// ============================================================
export function buildIncrementTx(counterId: string): Transaction {
  const tx = new Transaction();

  tx.moveCall({
    target: `${PACKAGE_ID}::counter::increment`,
    arguments: [
      tx.object(counterId),  // Shared Object 引用
    ],
  });

  return tx;
}

// ============================================================
// 2. SUI 转账:splitCoins + transferObjects
// ============================================================
export function buildTransferSuiTx(
  recipientAddress: string,
  amountInMist: bigint,
): Transaction {
  const tx = new Transaction();

  // tx.gas 是特殊的 Gas Coin 引用
  // splitCoins 从中拆出指定金额
  const [coin] = tx.splitCoins(tx.gas, [amountInMist]);

  // 将拆分出的 coin 转给接收者
  tx.transferObjects([coin], recipientAddress);

  return tx;
}

// ============================================================
// 3. NFT Mint:传递基本类型参数
// ============================================================
export function buildMintNFTTx(
  name: string,
  description: string,
  url: string,
): Transaction {
  const tx = new Transaction();

  tx.moveCall({
    target: `${PACKAGE_ID}::nft::mint`,
    arguments: [
      tx.pure.string(name),
      tx.pure.string(description),
      tx.pure.string(url),
    ],
  });

  return tx;
}

// ============================================================
// 4. 带泛型的 Move Call(typeArguments)
// ============================================================
export function buildSwapTx(
  poolId: string,
  coinAId: string,
  amountIn: bigint,
  minAmountOut: bigint,
): Transaction {
  const tx = new Transaction();

  // 从用户的 Coin 中拆出要 swap 的金额
  const [coinToSwap] = tx.splitCoins(tx.object(coinAId), [amountIn]);

  // 调用泛型 swap 函数 — 需要传 typeArguments
  const [coinOut] = tx.moveCall({
    target: `${PACKAGE_ID}::dex::swap_a_to_b`,
    arguments: [
      tx.object(poolId),          // &mut Pool<A, B>
      coinToSwap,                 // Coin<A>
      tx.pure.u64(minAmountOut),  // min_amount_out: u64
    ],
    typeArguments: [
      '0x2::sui::SUI',                   // CoinTypeA
      `${PACKAGE_ID}::token::USDC`,      // CoinTypeB
    ],
  });

  // coinOut 可以直接传给后续操作
  tx.transferObjects([coinOut], tx.pure.address('0xUSER'));

  return tx;
}

// ============================================================
// 5. 复杂 PTB:Swap + Transfer 组合(EVM 需要3笔交易)
// ============================================================
/**
 * 在 EVM 上,这个流程需要:
 *   TX1: token.approve(dexAddress, amount)  — 用户签名
 *   TX2: dex.swap(tokenA, tokenB, amount)   — 用户签名
 *   TX3: tokenB.transfer(friend, amount)     — 用户签名
 *
 * 在 Sui 上,一个 PTB 搞定:
 */
export function buildSwapAndTransferTx(
  poolId: string,
  coinAId: string,
  amountIn: bigint,
  minAmountOut: bigint,
  recipientAddress: string,
): Transaction {
  const tx = new Transaction();

  // Step 1: 拆分 Coin
  const [coinToSwap] = tx.splitCoins(tx.object(coinAId), [amountIn]);

  // Step 2: Swap — 前一步的 coinToSwap 直接传入
  const [coinOut] = tx.moveCall({
    target: `${PACKAGE_ID}::dex::swap_a_to_b`,
    arguments: [tx.object(poolId), coinToSwap, tx.pure.u64(minAmountOut)],
    typeArguments: ['0x2::sui::SUI', `${PACKAGE_ID}::token::USDC`],
  });

  // Step 3: 将 swap 结果转给接收者
  tx.transferObjects([coinOut], recipientAddress);

  // 用户只签名 1 次,3步原子完成
  return tx;
}

// ============================================================
// 6. Marketplace 操作:上架 / 购买 NFT
// ============================================================
export function buildListNFTTx(
  marketplaceId: string,
  nftId: string,
  price: bigint,
): Transaction {
  const tx = new Transaction();
  tx.moveCall({
    target: `${PACKAGE_ID}::marketplace::list`,
    arguments: [
      tx.object(marketplaceId),
      tx.object(nftId),
      tx.pure.u64(price),
    ],
    typeArguments: [`${PACKAGE_ID}::nft::MomoNFT`],
  });
  return tx;
}

export function buildBuyNFTTx(
  marketplaceId: string,
  listingId: string,
  price: bigint,
): Transaction {
  const tx = new Transaction();
  // 从 gas coin 拆出购买金额
  const [payment] = tx.splitCoins(tx.gas, [price]);
  tx.moveCall({
    target: `${PACKAGE_ID}::marketplace::buy`,
    arguments: [
      tx.object(marketplaceId),
      tx.pure.address(listingId),
      payment,
    ],
    typeArguments: [`${PACKAGE_ID}::nft::MomoNFT`],
  });
  return tx;
}

// ============================================================
// 7. Coin 合并(解决碎片化问题)
// ============================================================
export function buildMergeCoinsTx(
  primaryCoinId: string,
  coinIdsToMerge: string[],
): Transaction {
  const tx = new Transaction();
  tx.mergeCoins(
    tx.object(primaryCoinId),
    coinIdsToMerge.map(id => tx.object(id)),
  );
  return tx;
}

实战4:使用 dapp-kit 进行 React 前端集成

// src/providers/SuiProvider.tsx — Provider 配置
import { SuiClientProvider, WalletProvider } from '@mysten/dapp-kit';
import { getFullnodeUrl } from '@mysten/sui/client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import '@mysten/dapp-kit/dist/index.css';

const queryClient = new QueryClient();

const networks = {
  testnet: { url: getFullnodeUrl('testnet') },
  mainnet: { url: getFullnodeUrl('mainnet') },
  devnet:  { url: getFullnodeUrl('devnet') },
};

export function SuiProvider({ children }: { children: React.ReactNode }) {
  return (
    <QueryClientProvider client={queryClient}>
      <SuiClientProvider networks={networks} defaultNetwork="testnet">
        <WalletProvider autoConnect>
          {children}
        </WalletProvider>
      </SuiClientProvider>
    </QueryClientProvider>
  );
}
// src/components/CounterApp.tsx — 完整的 Counter DApp 组件
import {
  useCurrentAccount,
  useSuiClient,
  useSignAndExecuteTransaction,
} from '@mysten/dapp-kit';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { buildIncrementTx } from '../lib/transactions';

const COUNTER_ID = '0xCOUNTER_SHARED_OBJECT_ID';

export function CounterApp() {
  const client = useSuiClient();
  const account = useCurrentAccount();
  const queryClient = useQueryClient();
  const { mutateAsync: signAndExecute, isPending } = useSignAndExecuteTransaction();

  // 查询 Counter 当前值(自动缓存 + 定时刷新)
  const { data: counterValue, isLoading } = useQuery({
    queryKey: ['counter', COUNTER_ID],
    queryFn: async () => {
      const object = await client.getObject({
        id: COUNTER_ID,
        options: { showContent: true },
      });
      if (object.data?.content?.dataType === 'moveObject') {
        const fields = object.data.content.fields as Record<string, any>;
        return Number(fields.value);
      }
      return 0;
    },
    refetchInterval: 5000,
  });

  // 执行 increment 交易
  const handleIncrement = async () => {
    if (!account) return;

    const tx = buildIncrementTx(COUNTER_ID);

    try {
      const result = await signAndExecute({ transaction: tx });
      console.log('TX digest:', result.digest);

      // 等待交易上链后刷新查询缓存
      await client.waitForTransaction({ digest: result.digest });
      queryClient.invalidateQueries({ queryKey: ['counter'] });
    } catch (error) {
      console.error('Transaction failed:', error);
    }
  };

  return (
    <div>
      <h2>Counter DApp</h2>
      {isLoading ? <p>Loading...</p> : <p>Current Value: {counterValue}</p>}
      <button onClick={handleIncrement} disabled={!account || isPending}>
        {isPending ? 'Confirming...' : 'Increment'}
      </button>
    </div>
  );
}

实战5:事件订阅——实时 + 历史

// src/lib/events.ts
import { suiClient, PACKAGE_ID } from './sui-client';

// ============================================================
// 1. WebSocket 实时订阅
// ============================================================
export async function subscribeToMarketplaceEvents() {
  const unsubscribe = await suiClient.subscribeEvent({
    filter: {
      MoveModule: {
        package: PACKAGE_ID,
        module: 'marketplace',
      },
    },
    onMessage: (event) => {
      console.log('Event type:', event.type);
      console.log('Event data:', event.parsedJson);
      console.log('Timestamp:', event.timestampMs);
    },
  });

  return unsubscribe; // 调用 unsubscribe() 取消订阅
}

// ============================================================
// 2. HTTP 查询历史事件
// ============================================================
export async function queryHistoricalEvents(eventType: string, limit = 50) {
  const events = await suiClient.queryEvents({
    query: {
      MoveEventType: `${PACKAGE_ID}::marketplace::${eventType}`,
    },
    limit,
    order: 'descending',
  });

  return events.data.map(event => ({
    id: event.id,
    type: event.type,
    data: event.parsedJson,
    timestamp: event.timestampMs,
    txDigest: event.id.txDigest,
  }));
}

// ============================================================
// 3. React Hook:实时事件流
// ============================================================
import { useEffect, useState } from 'react';
import { useSuiClient } from '@mysten/dapp-kit';

interface MarketEvent {
  type: string;
  data: any;
  timestamp: string;
}

export function useMarketplaceEvents() {
  const client = useSuiClient();
  const [events, setEvents] = useState<MarketEvent[]>([]);

  useEffect(() => {
    let unsub: (() => void) | undefined;

    const setup = async () => {
      unsub = await client.subscribeEvent({
        filter: {
          MoveModule: { package: PACKAGE_ID, module: 'marketplace' },
        },
        onMessage: (event) => {
          setEvents(prev => [
            { type: event.type, data: event.parsedJson, timestamp: event.timestampMs || '' },
            ...prev,
          ].slice(0, 100)); // 只保留最近 100 条
        },
      });
    };

    setup();
    return () => { if (unsub) unsub(); };
  }, [client]);

  return events;
}

实战6:Dry Run 和 Gas 预估

// src/lib/gas-estimation.ts
import { suiClient } from './sui-client';
import { Transaction } from '@mysten/sui/transactions';

/**
 * Dry Run:在不上链的情况下模拟交易执行
 * 对应 EVM 的 eth_call / eth_estimateGas
 */
export async function dryRunTransaction(tx: Transaction, senderAddress: string) {
  tx.setSender(senderAddress);
  const txBytes = await tx.build({ client: suiClient });

  const result = await suiClient.dryRunTransactionBlock({
    transactionBlock: txBytes,
  });

  return {
    status: result.effects.status,           // success / failure
    gasUsed: result.effects.gasUsed,         // Gas 明细
    objectChanges: result.objectChanges,     // 对象创建/修改/删除
    events: result.events,                   // 触发的事件
    balanceChanges: result.balanceChanges,   // 余额变化
  };
}

/**
 * Gas 费用预估
 * Sui Gas = computationCost + storageCost - storageRebate
 */
export async function estimateGas(tx: Transaction, senderAddress: string) {
  const result = await dryRunTransaction(tx, senderAddress);
  const gas = result.gasUsed;

  const totalCost = (
    BigInt(gas.computationCost) +
    BigInt(gas.storageCost) -
    BigInt(gas.storageRebate)
  ).toString();

  return {
    computationCost: gas.computationCost,
    storageCost: gas.storageCost,
    storageRebate: gas.storageRebate,
    totalCost, // 单位 MIST
  };
}

实战7:Sponsored Transaction(Gas 代付)

// src/lib/sponsored.ts
import { Transaction } from '@mysten/sui/transactions';
import { Ed25519Keypair } from '@mysten/sui/keypairs/ed25519';
import { suiClient } from './sui-client';

/**
 * Sponsored Transaction:用户不需要持有 SUI 也能操作
 * 这是 Sui 原生支持的特性,EVM 需要 ERC-4337 才能实现
 */
export async function executeSponsoredTx(
  tx: Transaction,
  userKeypair: Ed25519Keypair,
  sponsorKeypair: Ed25519Keypair,
) {
  const userAddress = userKeypair.getPublicKey().toSuiAddress();
  const sponsorAddress = sponsorKeypair.getPublicKey().toSuiAddress();

  // 设置交易发起者和 Gas 支付者
  tx.setSender(userAddress);
  tx.setGasOwner(sponsorAddress);

  // 构建交易字节
  const txBytes = await tx.build({ client: suiClient });

  // 双方分别签名
  const userSig = await userKeypair.signTransaction(txBytes);
  const sponsorSig = await sponsorKeypair.signTransaction(txBytes);

  // 提交带双签名的交易
  const result = await suiClient.executeTransactionBlock({
    transactionBlock: txBytes,
    signature: [userSig.signature, sponsorSig.signature],
    options: { showEffects: true },
  });

  return result;
}

Sui vs EVM 前端开发全面对比

架构层面对比

维度EVM (ethers.js / viem)Sui (@mysten/sui)
数据模型Account State(余额是 mapping 中的 uint256)Object Model(每个资产是独立对象)
交易模型一笔交易 = 一次合约调用PTB = 多操作原子组合
Token 转账token.transfer(to, amount)splitCoins → transferObjects
合约调用需要 ABI JSON 文件只需 target 字符串(package::module::fn)
Approve 模式必须先 approve 再操作不需要,直接在 PTB 中传递对象
Gas 支付只能用原生 Token支持 sponsored transaction
终结性需要等多个区块确认即时终结(< 1 秒)
钱包连接WalletConnect / injected providerWallet Standard + dapp-kit
事件查询contract.queryFilter()client.queryEvents()

核心代码模式对比

// === 余额查询 ===
// EVM: 原生币和 ERC20 用不同方式
const ethBalance = await provider.getBalance(address);
const tokenBalance = await contract.balanceOf(address); // 需要 ABI

// Sui: 统一接口,只换 coinType
const suiBalance = await client.getBalance({ owner: address, coinType: '0x2::sui::SUI' });
const tokenBalance = await client.getBalance({ owner: address, coinType: `${pkg}::token::TOKEN` });

// === 合约交互 ===
// EVM: 需要维护 ABI 文件
const contract = new ethers.Contract(addr, abi, signer);
const tx = await contract.increment();
await tx.wait(); // 等确认

// Sui: 直接用 target 字符串
const tx = new Transaction();
tx.moveCall({ target: `${pkg}::counter::increment`, arguments: [tx.object(id)] });
const result = await signAndExecute({ transaction: tx });
// 即时终结,不需要等确认

// === 多步操作 ===
// EVM: 两笔独立交易
await token.approve(dex, amount);  // TX 1
await dex.swap(tokenA, amount);    // TX 2

// Sui: 一个 PTB
const tx = new Transaction();
const [coin] = tx.splitCoins(tx.object(coinId), [amount]);
const [out] = tx.moveCall({ target: `${pkg}::dex::swap`, arguments: [pool, coin] });
tx.transferObjects([out], user);   // 1 TX, 1 签名

Sui 前端的独特优势总结

  1. PTB 消除了 approve 模式:不需要先授权再操作,减少交易次数和安全风险
  2. 即时终结性:交易一旦执行就是最终的,前端不需要轮询确认状态
  3. 不需要 ABI:Move 函数用 target 字符串调用,无需维护 ABI 文件
  4. 原生 Gas 代付:不需要 ERC-4337 等额外基础设施就能实现无 Gas 用户体验
  5. 对象直接传递:PTB 中前一步返回值直接作为后一步输入,无需临时存储
  6. 存储退费:删除对象可以回收 Gas,鼓励清理无用数据

关键要点总结

  1. SuiClient 是所有链上交互的统一入口:查询、提交交易、事件订阅都通过它完成,不像 EVM 需要区分 Provider/Signer
  2. PTB 是 Sui 前端开发的核心优势:一个交易组合多步操作,消除 approve 模式,用户只签名一次
  3. 对象模型需要思维转换:Coin 是对象不是数字,NFT 是 Owned Object,查询方式从 balanceOf 变成 getOwnedObjects
  4. @mysten/dapp-kit 对标 wagmi:提供 useSuiClientuseSignAndExecuteTransactionuseCurrentAccount 等 hooks
  5. Dry Run 是前端必备:在交易提交前模拟执行,预估 Gas、检查是否会失败、预览对象变化
  6. 事件订阅双模式:实时用 subscribeEvent(WebSocket),历史用 queryEvents(HTTP),按 package/module 过滤

常见误区

误区1:"Sui 的前端开发和 EVM 差不多,换个 SDK 就行"

底层模型的差异导致前端架构有根本不同。最典型的是 Coin 处理:EVM 上 transfer(to, amount) 一行搞定,Sui 上需要考虑用户持有哪些 Coin 对象、是否需要合并、如何拆分。虽然 dapp-kit 屏蔽了部分复杂度,但开发者必须理解对象模型。

误区2:"Shared Object 和 EVM 合约状态一样"

表面上 Shared Object 任何人可操作,类似 EVM 合约存储。但关键区别是:Shared Object 需要共识排序,影响并发性能。同一 Shared Object 的多个交易不能并行执行。设计时应尽量使用 Owned Object 实现并行处理,只在必要时使用 Shared Object。

误区3:"PTB 可以无限组合操作"

PTB 有 gas budget 限制和交易大小限制。过多操作会导致交易字节过大或 gas 超限。实际开发中一个 PTB 通常包含 3-10 个操作。超过这个范围需要拆分为多个交易。

误区4:"不需要关心 Coin 对象碎片化"

用户频繁交易后可能持有大量小额 Coin 对象。这会占用链上存储(有存储费),还会让查询变慢。前端应提供 Coin 合并功能(tx.mergeCoins),定期帮用户整理碎片。这是 EVM 上不存在的 UX 问题。

误区5:"Sui 交易即时终结,不需要 loading 状态"

虽然终结性很快(< 1秒),但网络延迟、签名弹窗、钱包确认仍需要时间。前端仍然需要 pending 状态。区别在于不需要像 EVM 那样轮询区块来确认交易。


面试关联

Q1: "Sui 的 PTB 相比 EVM 的交易模型有什么优势?从前端角度说。"

简短回答:PTB 允许一笔交易组合多个操作并传递中间结果,消除了 EVM 的 approve + execute 两步模式,减少签名次数,提升原子性和用户体验。

详细回答

  1. 减少交易次数:EVM 上 approve -> swap -> transfer 需要 3 笔交易,Sui 用一个 PTB 搞定
  2. 中间结果传递:splitCoins 返回的 Coin 可以直接传给 moveCall,不需要临时存储
  3. 原子性保证:所有操作要么全成功要么全回滚,不会出现 approve 成功但 swap 失败的中间状态
  4. 不需要辅助合约:EVM 上复杂操作需要 multicall/router 合约,Sui 在 SDK 层就能组合
  5. Gas 更低:一笔交易的开销远低于多笔独立交易之和

Q2: "如何设计一个 Sui DApp 的前端架构?"

回答:围绕三层设计:(1) 连接层 -- SuiClientProvider + WalletProvider + 网络切换;(2) 状态层 -- React Query 管理链上数据缓存,queryKey 用对象 ID 区分,通过事件订阅实现实时更新;(3) 交易层 -- Transaction 构建逻辑抽到独立文件,组件只负责调用和展示。关键设计决策:利用 PTB 减少用户交互步骤;实现 sponsored transaction 降低新用户门槛;用 dry run 在提交前预览交易结果。

Q3: "Sui 前端开发最常踩的坑有哪些?"

回答:(1) Coin 碎片化 -- 用户可能持有几十个同类型 Coin 对象,需要在交易前合并;(2) Shared Object 并发冲突 -- 同一 Shared Object 被其他交易修改后版本变更,自己的交易可能因版本过期失败,需要重试机制;(3) typeArguments 遗漏 -- Move 泛型函数必须传 typeArguments,忘记传会报错但信息不直观;(4) Owned Object 独占 -- 同一 Owned Object 在同一时间只能被一笔交易使用,多个交易同时操作会冲突。


参考资源

资源说明
Sui TypeScript SDK 文档官方 SDK 完整文档
@mysten/sui npmnpm 包和更新日志
@mysten/dapp-kit 文档React DApp 开发工具包
Sui JSON-RPC API底层 RPC 接口参考
PTB 详解可编程交易块官方文档
Sui Examples官方示例代码
wagmiEVM React hooks(对比参考)
viemEVM TypeScript 客户端(对比参考)