Sui TypeScript SDK与前端集成
### 1. @mysten/sui SDK 概览
日期: 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 | 密钥对管理和签名 |
| bcs | Binary 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 中 Provider 和 Signer 是分开的抽象层;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 provider | Wallet 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 前端的独特优势总结
- PTB 消除了 approve 模式:不需要先授权再操作,减少交易次数和安全风险
- 即时终结性:交易一旦执行就是最终的,前端不需要轮询确认状态
- 不需要 ABI:Move 函数用 target 字符串调用,无需维护 ABI 文件
- 原生 Gas 代付:不需要 ERC-4337 等额外基础设施就能实现无 Gas 用户体验
- 对象直接传递:PTB 中前一步返回值直接作为后一步输入,无需临时存储
- 存储退费:删除对象可以回收 Gas,鼓励清理无用数据
关键要点总结
- SuiClient 是所有链上交互的统一入口:查询、提交交易、事件订阅都通过它完成,不像 EVM 需要区分 Provider/Signer
- PTB 是 Sui 前端开发的核心优势:一个交易组合多步操作,消除 approve 模式,用户只签名一次
- 对象模型需要思维转换:Coin 是对象不是数字,NFT 是 Owned Object,查询方式从
balanceOf变成getOwnedObjects - @mysten/dapp-kit 对标 wagmi:提供
useSuiClient、useSignAndExecuteTransaction、useCurrentAccount等 hooks - Dry Run 是前端必备:在交易提交前模拟执行,预估 Gas、检查是否会失败、预览对象变化
- 事件订阅双模式:实时用
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 两步模式,减少签名次数,提升原子性和用户体验。
详细回答:
- 减少交易次数:EVM 上 approve -> swap -> transfer 需要 3 笔交易,Sui 用一个 PTB 搞定
- 中间结果传递:splitCoins 返回的 Coin 可以直接传给 moveCall,不需要临时存储
- 原子性保证:所有操作要么全成功要么全回滚,不会出现 approve 成功但 swap 失败的中间状态
- 不需要辅助合约:EVM 上复杂操作需要 multicall/router 合约,Sui 在 SDK 层就能组合
- 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 npm | npm 包和更新日志 |
| @mysten/dapp-kit 文档 | React DApp 开发工具包 |
| Sui JSON-RPC API | 底层 RPC 接口参考 |
| PTB 详解 | 可编程交易块官方文档 |
| Sui Examples | 官方示例代码 |
| wagmi | EVM React hooks(对比参考) |
| viem | EVM TypeScript 客户端(对比参考) |