dApp 用户体验与钱包交互最佳实践 — MSG Chain
数据来源:MSG Chain 代码库核实
主网状态: No-Go — 当前 MSGChain 主网裁决为 No-Go,以下内容反映代码实际状态,不代表生产可用。
适用链:
msg-chain-1| 地址前缀:msg| 生态钱包: Keplr / Leap / Cosmostation
1. 概述
1.1 为什么 dApp 用户体验至关重要
区块链 dApp 的用户体验与传统 Web 应用存在根本性差异。每笔交易都需要用户签名确认,每次钱包交互都可能产生 Gas 费用,每个操作都是不可逆的。这种复杂性使得 UX 设计直接决定了 dApp 的用户留存率和转化率。
MSG Chain 作为基于 Cosmos SDK 构建的应用链,继承了 IBC 互操作性,同时带来了独特的设计约束与机遇:
| 维度 | 传统 Web | MSG Chain dApp |
|---|---|---|
| 身份认证 | 邮箱/密码 | 钱包私钥签名 |
| 操作确认 | 点击即可 | 签名弹窗 + Gas 费 |
| 数据最终性 | 即时 | 等待共识确认(~6秒出块) |
| 错误恢复 | 可撤销 | 不可逆,需预检 |
| 网络状态 | 始终在线 | 链状态变更需轮询 |
良好的 UX 设计能够弥合这一鸿沟,让用户专注于产品价值而非底层技术。
1.2 MSG Chain 钱包生态
MSG Chain 支持 Cosmos 生态主流钱包,每种钱包有不同的 API 暴露方式:
| 钱包 | 检测方式 | 注入对象 | 特性 |
|---|---|---|---|
| Keplr | window.keplr |
keplr |
最广泛支持,功能完整 |
| Leap | window.leap |
leap |
移动端优化,社交恢复 |
| Cosmostation | window.cosmostation |
cosmostation |
多链支持,硬件钱包 |
建议的检测优先级:Keplr > Leap > Cosmostation。
1.3 用户旅程映射
┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
│ 发现场景 │ -> │ 连接钱包 │ -> │ 浏览交互 │ -> │ 签名交易 │ -> │ 确认完成 │
│ (Landing) │ │ (Connect) │ │ (Browse) │ │ (Sign) │ │ (Success)│
└──────────┘ └──────────┘ └──────────┘ └──────────┘ └──────────┘
│
v
┌──────────┐
│ 错误处理 │
│ (Error) │
└──────────┘
本文档将按照这一旅程,逐一深入每个环节的最佳实践。
2. 钱包连接 UX
2.1 钱包检测与安装提示
多钱包检测是连接流程的起点。以下是最佳实践实现:
// src/hooks/useWalletDetection.ts
interface WalletInfo {
id: 'keplr' | 'leap' | 'cosmostation';
name: string;
logo: string;
installed: boolean;
downloadUrl: string;
}
const SUPPORTED_WALLETS: WalletInfo[] = [
{
id: 'keplr',
name: 'Keplr',
logo: '/wallets/keplr.svg',
installed: typeof window !== 'undefined' && !!window.keplr,
downloadUrl: 'https://www.keplr.app/download',
},
{
id: 'leap',
name: 'Leap',
logo: '/wallets/leap.svg',
installed: typeof window !== 'undefined' && !!window.leap,
downloadUrl: 'https://www.leapwallet.io/download',
},
{
id: 'cosmostation',
name: 'Cosmostation',
logo: '/wallets/cosmostation.svg',
installed: typeof window !== 'undefined' && !!window.cosmostation,
downloadUrl: 'https://cosmostation.io/download',
},
];
function useWalletDetection() {
const [wallets, setWallets] = useState<WalletInfo[]>([]);
useEffect(() => {
const detect = () => {
const detected = SUPPORTED_WALLETS.map((w) => ({
...w,
installed:
w.id === 'keplr'
? typeof window.keplr !== 'undefined'
: w.id === 'leap'
? typeof window.leap !== 'undefined'
: typeof window.cosmostation !== 'undefined',
}));
setWallets(detected);
};
// 有些钱包在 DOMContentLoaded 后才注入
if (document.readyState === 'complete') {
detect();
} else {
window.addEventListener('load', detect);
return () => window.removeEventListener('load', detect);
}
}, []);
return wallets;
}
安装提示弹窗组件:
// src/components/WalletInstallPrompt.tsx
interface InstallPromptProps {
walletId: 'keplr' | 'leap' | 'cosmostation';
onClose: () => void;
}
const DOWNLOAD_LINKS: Record<string, string> = {
keplr: 'https://www.keplr.app/download',
leap: 'https://www.leapwallet.io/download',
cosmostation: 'https://cosmostation.io/download',
};
function WalletInstallPrompt({ walletId, onClose }: InstallPromptProps) {
const walletName = walletId.charAt(0).toUpperCase() + walletId.slice(1);
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="w-full max-w-md rounded-xl bg-white p-6 shadow-2xl dark:bg-gray-900">
<h2 className="mb-4 text-xl font-bold">未检测到 {walletName}</h2>
<p className="mb-6 text-gray-600 dark:text-gray-300">
请先安装 {walletName} 浏览器扩展,然后刷新页面重试。
</p>
<div className="space-y-3">
<a
href={DOWNLOAD_LINKS[walletId]}
target="_blank"
rel="noopener noreferrer"
className="block w-full rounded-lg bg-msg-primary px-4 py-3 text-center text-white transition hover:bg-msg-primary-dark"
>
下载 {walletName}
</a>
<button
onClick={() => window.location.reload()}
className="block w-full rounded-lg border border-gray-300 px-4 py-3 text-center transition hover:bg-gray-50 dark:border-gray-600 dark:hover:bg-gray-800"
>
已安装,刷新页面
</button>
<button
onClick={onClose}
className="block w-full py-2 text-center text-sm text-gray-500 hover:text-gray-700"
>
稍后再说
</button>
</div>
</div>
</div>
);
}
2.2 链配置注入
在连接前,确保 MSG Chain 已配置到钱包中。Keplr 的 suggestChain API 是行业标准:
// src/config/msgChainConfig.ts
export const MSG_CHAIN_CONFIG: ChainInfo = {
chainId: 'msg-chain-1',
chainName: 'MSG Chain',
rpc: 'https://rpc.msg-chain.com',
rest: 'https://rest.msg-chain.com',
bip44: {
coinType: 118,
},
bech32Config: {
bech32PrefixAccAddr: 'msg',
bech32PrefixAccPub: 'msgpub',
bech32PrefixValAddr: 'msgvaloper',
bech32PrefixValPub: 'msgvaloperpub',
bech32PrefixConsAddr: 'msgvalcons',
bech32PrefixConsPub: 'msgvalconspub',
},
currencies: [
{
coinDenom: 'MSG',
coinMinDenom: 'umsg',
coinDecimals: 6,
coinGeckoId: 'msg-chain',
},
],
feeCurrencies: [
{
coinDenom: 'MSG',
coinMinDenom: 'umsg',
coinDecimals: 6,
coinGeckoId: 'msg-chain',
gasPriceStep: {
low: 1000000000,
average: 1000000000,
high: 1000000000,
},
},
],
stakeCurrency: {
coinDenom: 'MSG',
coinMinDenom: 'umsg',
coinDecimals: 6,
coinGeckoId: 'msg-chain',
},
features: ['ibc-transfer', 'ibc-go'],
};
确保链已添加到钱包:
// src/lib/suggestChain.ts
async function ensureChainAdded(wallet: WalletClient): Promise<void> {
try {
await wallet.enable('msg-chain-1');
} catch (e) {
if (e.message?.includes('chain') || e.message?.includes('not found')) {
try {
await wallet.experimentalSuggestChain?.(MSG_CHAIN_CONFIG);
// 部分钱包使用 suggestChain
await wallet.suggestChain?.(MSG_CHAIN_CONFIG);
} catch (suggestError) {
throw new Error(
`请在钱包中手动添加 MSG Chain (msg-chain-1)。错误: ${suggestError.message}`,
);
}
}
throw e;
}
}
2.3 连接状态机
完整的连接流程应由有限状态机管理,避免竞态条件和无效状态:
// src/hooks/useWalletConnect.ts
type WalletConnectionState =
| { status: 'idle' }
| { status: 'detecting' }
| { status: 'prompting_install'; walletId: string }
| { status: 'connecting' }
| { status: 'switching_chain' }
| { status: 'connected'; address: string; balance: MsgBalance }
| { status: 'error'; error: WalletError };
interface WalletError {
code: ErrorCode;
title: string;
message: string;
recoverable: boolean;
recoveryAction?: () => void;
}
type ErrorCode =
| 'NO_WALLET'
| 'CHAIN_NOT_FOUND'
| 'USER_REJECTED'
| 'NETWORK_ERROR'
| 'TIMEOUT'
| 'UNKNOWN';
function useWalletConnect() {
const [state, setState] = useState<WalletConnectionState>({ status: 'idle' });
const [walletClient, setWalletClient] = useState<WalletClient | null>(null);
const connect = useCallback(async (preferredWallet?: 'keplr' | 'leap' | 'cosmostation') => {
setState({ status: 'connecting' });
try {
// 1. 选择钱包
const wallet = selectWalletClient(preferredWallet);
if (!wallet) {
setState({
status: 'error',
error: {
code: 'NO_WALLET',
title: '未检测到钱包',
message: '请安装 Keplr、Leap 或 Cosmostation 扩展',
recoverable: true,
},
});
return;
}
setWalletClient(wallet);
// 2. 启用 MSG Chain
setState({ status: 'switching_chain' });
await wallet.enable('msg-chain-1');
// 3. 获取用户地址
const key = await wallet.getKey('msg-chain-1');
const address = key.bech32Address;
// 4. 验证地址前缀
if (!address.startsWith('msg')) {
throw new Error(`地址前缀不正确,期望 msg 开头,实际: ${address.slice(0, 6)}`);
}
// 5. 获取余额
const balance = await queryMsgBalance(address);
setState({
status: 'connected',
address,
balance,
});
} catch (e) {
const error = normalizeWalletError(e);
setState({ status: 'error', error });
}
}, []);
const disconnect = useCallback(() => {
setWalletClient(null);
setState({ status: 'idle' });
// 清除本地缓存的连接信息
localStorage.removeItem('msg_wallet_preference');
}, []);
const retry = useCallback(() => {
if (state.status === 'error' && state.error.recoverable) {
connect();
}
}, [state, connect]);
return { state, connect, disconnect, retry, walletClient };
}
function normalizeWalletError(e: unknown): WalletError {
const msg = e?.message || String(e);
if (msg.includes('rejected') || msg.includes('Request rejected')) {
return {
code: 'USER_REJECTED',
title: '用户取消了操作',
message: '请在钱包弹窗中授权连接',
recoverable: true,
};
}
if (msg.includes('chain') && (msg.includes('not found') || msg.includes('enable'))) {
return {
code: 'CHAIN_NOT_FOUND',
title: '链未配置',
message: '请先在钱包中添加 MSG Chain (msg-chain-1)',
recoverable: true,
};
}
if (msg.includes('timeout') || msg.includes('timed out')) {
return {
code: 'TIMEOUT',
title: '连接超时',
message: '请检查网络连接后重试',
recoverable: true,
};
}
if (msg.includes('network') || msg.includes('fetch')) {
return {
code: 'NETWORK_ERROR',
title: '网络错误',
message: '无法连接到 RPC 节点,请检查网络',
recoverable: true,
};
}
return {
code: 'UNKNOWN',
title: '连接失败',
message: msg,
recoverable: false,
};
}
2.4 钱包选择器 UI
// src/components/WalletSelector.tsx
interface WalletOption {
id: 'keplr' | 'leap' | 'cosmostation';
name: string;
icon: string;
installed: boolean;
loading?: boolean;
}
function WalletSelector({ onSelect }: { onSelect: (id: string) => void }) {
const wallets = useWalletDetection();
const [selected, setSelected] = useState<string | null>(null);
return (
<div className="w-full max-w-sm rounded-2xl border border-gray-200 bg-white p-6 shadow-lg dark:border-gray-700 dark:bg-gray-800">
<h2 className="mb-2 text-center text-2xl font-bold">连接钱包</h2>
<p className="mb-6 text-center text-sm text-gray-500">
选择钱包以连接 MSG Chain
</p>
<div className="space-y-3">
{wallets.map((w) => (
<button
key={w.id}
onClick={() => {
setSelected(w.id);
onSelect(w.id);
}}
disabled={!w.installed}
className={`
flex w-full items-center gap-4 rounded-xl border-2 p-4 transition
${
selected === w.id
? 'border-msg-primary bg-msg-primary/5'
: 'border-transparent bg-gray-50 hover:bg-gray-100 dark:bg-gray-700/50'
}
${!w.installed ? 'cursor-not-allowed opacity-50' : 'cursor-pointer'}
`}
>
<img src={w.icon} alt={w.name} className="h-10 w-10 rounded-full" />
<div className="flex-1 text-left">
<div className="font-semibold">{w.name}</div>
<div className="text-xs text-gray-500">
{w.installed ? '已安装' : '未安装'}
</div>
</div>
{selected === w.id && (
<div className="h-4 w-4 rounded-full bg-msg-primary">
<div className="h-2 w-2 animate-ping rounded-full bg-msg-primary" />
</div>
)}
</button>
))}
</div>
<div className="mt-6 rounded-lg bg-yellow-50 p-3 text-xs text-yellow-800 dark:bg-yellow-900/20 dark:text-yellow-200">
<strong>提示:</strong>首次连接需要钱包授权签名。
该签名仅用于验证地址所有权,不会消耗 Gas。
</div>
</div>
);
}
2.5 已连接状态展示
// src/components/WalletConnected.tsx
function WalletConnected({ address, balance, onDisconnect, onSwitchWallet }) {
const [copied, setCopied] = useState(false);
const copyAddress = async () => {
await navigator.clipboard.writeText(address);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
const truncatedAddress = `${address.slice(0, 8)}...${address.slice(-6)}`;
return (
<div className="flex items-center gap-4 rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-700 dark:bg-gray-800">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-green-100 text-green-600">
<div className="h-3 w-3 rounded-full bg-green-500" />
</div>
<div className="flex-1">
<div className="flex items-center gap-2">
<span className="font-mono text-sm">{truncatedAddress}</span>
<button
onClick={copyAddress}
className="text-gray-400 hover:text-gray-600"
title="复制地址"
>
{copied ? '✓' : '📋'}
</button>
</div>
<div className="text-sm text-gray-500">
<BalanceDisplay balance={balance} denom="MSG" />
</div>
</div>
<div className="flex gap-1">
<button
onClick={onSwitchWallet}
className="rounded-lg px-3 py-1.5 text-xs text-gray-600 hover:bg-gray-100"
>
切换
</button>
<button
onClick={onDisconnect}
className="rounded-lg px-3 py-1.5 text-xs text-red-500 hover:bg-red-50"
>
断开
</button>
</div>
</div>
);
}
2.6 自动重连
// src/hooks/useAutoReconnect.ts
function useAutoReconnect() {
const { connect, state } = useWalletConnect();
useEffect(() => {
const preferredWallet = localStorage.getItem('msg_wallet_preference');
if (!preferredWallet) return;
// 延后自动重连,避免与页面加载资源竞争
const timer = setTimeout(() => {
connect(preferredWallet as any);
}, 1000);
return () => clearTimeout(timer);
}, []);
// 监听钱包账户切换
useEffect(() => {
if (state.status !== 'connected') return;
const handleKeyChange = (e: CustomEvent) => {
if (e.detail?.chainId === 'msg-chain-1') {
connect();
}
};
window.addEventListener('keplr_keystorechange', handleKeyChange);
window.addEventListener('leap_keystorechange', handleKeyChange);
return () => {
window.removeEventListener('keplr_keystorechange', handleKeyChange);
window.removeEventListener('leap_keystorechange', handleKeyChange);
};
}, [state.status, connect]);
return { isAutoReconnecting: state.status === 'connecting' };
}
2.7 网络切换 UX
// src/hooks/useNetworkSwitch.ts
type NetworkTarget = 'msg-chain-1' | 'msg-chain-testnet';
function useNetworkSwitch() {
const [switching, setSwitching] = useState(false);
const switchToChain = async (
wallet: WalletClient,
targetChainId: NetworkTarget,
) => {
setSwitching(true);
try {
await wallet.enable(targetChainId);
const key = await wallet.getKey(targetChainId);
return {
address: key.bech32Address,
chainId: targetChainId,
};
} catch (e) {
if (e.message?.includes('chain')) {
// 如果钱包中没有该链,尝试添加
const config = targetChainId === 'msg-chain-1' ? MSG_CHAIN_CONFIG : MSG_TESTNET_CONFIG;
await wallet.experimentalSuggestChain?.(config);
await wallet.enable(targetChainId);
const key = await wallet.getKey(targetChainId);
return { address: key.bech32Address, chainId: targetChainId };
}
throw e;
} finally {
setSwitching(false);
}
};
return { switchToChain, switching };
}
2.8 多地址管理
// src/hooks/useWalletAccounts.ts
function useWalletAccounts() {
const [accounts, setAccounts] = useState<AccountInfo[]>([]);
const [activeAccount, setActiveAccount] = useState<string | null>(null);
const listAccounts = async (wallet: WalletClient) => {
// Keplr 和 Leap 支持多条路径派生地址
const key = await wallet.getKey('msg-chain-1');
const accts = [{
address: key.bech32Address,
path: "m/44'/118'/0'/0/0",
isLedger: key.isLedger,
name: key.name,
}];
setAccounts(accts);
setActiveAccount(accts[0].address);
};
const switchAccount = async (wallet: WalletClient, address: string) => {
// 部分钱包支持切换账户
if (wallet.switchAccount) {
await wallet.switchAccount(address);
}
setActiveAccount(address);
};
return { accounts, activeAccount, listAccounts, switchAccount };
}
3. 交易流程 UX
3.1 交易生命周期
每笔交易经历以下阶段,每个阶段都应有对应的 UI 反馈:
// src/types/transaction.ts
type TxStage =
| 'idle' // 初始状态
| 'building' // 构造交易
| 'simulating' // 模拟执行
| 'signing' // 等待钱包签名
| 'broadcasting' // 广播到链
| 'pending' // 等待共识确认
| 'confirmed' // 交易已确认
| 'failed'; // 交易失败
interface TransactionState {
stage: TxStage;
txHash?: string;
error?: TxError;
gasUsed?: number;
gasWanted?: number;
height?: number;
events?: TxEvent[];
startTime?: number;
duration?: number;
}
interface TxError {
code: number;
message: string;
log?: string;
}
interface TxEvent {
type: string;
attributes: Record<string, string>;
}
3.2 交易执行 Hook
// src/hooks/useTransaction.ts
function useTransaction() {
const [txState, setTxState] = useState<TransactionState>({
stage: 'idle',
});
const { walletClient } = useWalletConnect();
const queryClient = useQueryClient();
const execute = useCallback(
async (messages: MsgEncodeObject[], options?: TxOptions) => {
if (!walletClient) {
throw new Error('钱包未连接');
}
const startTime = Date.now();
setTxState({ stage: 'building' });
try {
// 1. 构造交易
setTxState({ stage: 'building' });
const signer = walletClient.getOfflineSigner('msg-chain-1');
const accounts = await signer.getAccounts();
const sender = accounts[0].address;
// 2. 获取账户序列号
const account = await queryAccount(sender);
const pubKey = Buffer.from(accounts[0].pubkey).toString('base64');
// 3. Gas 估算
setTxState({ stage: 'simulating' });
const simulatedGas = await simulateTx(sender, messages);
const gasAdjustment = options?.gasAdjustment ?? 1.3;
const gasLimit = Math.ceil(simulatedGas * gasAdjustment);
// 4. 组装 StdFee
const fee: StdFee = {
amount: options?.fee ?? [
{
denom: 'umsg',
amount: calculateFee(gasLimit, options?.gasPrice ?? GasPrice.low),
},
],
gas: gasLimit.toString(),
};
// 5. 签名
setTxState({ stage: 'signing' });
const signDoc = createSignDoc(sender, account, messages, fee);
const signed = await signer.signDirect(sender, signDoc);
// 6. 广播
setTxState({ stage: 'broadcasting' });
const txBytes = encodeTx(signed);
const broadcastRes = await broadcastTx(txBytes);
// 7. 等待确认
setTxState({
stage: 'pending',
txHash: broadcastRes.txHash,
});
const receipt = await waitForTx(broadcastRes.txHash, {
timeout: options?.timeout ?? 60000,
interval: 2000,
});
if (receipt.code !== 0) {
throw new TxExecutionError(receipt.code, receipt.rawLog, receipt.log);
}
// 8. 完成
const elapsed = Date.now() - startTime;
setTxState({
stage: 'confirmed',
txHash: broadcastRes.txHash,
gasUsed: receipt.gasUsed,
gasWanted: Number(fee.gas),
height: receipt.height,
events: receipt.events,
duration: elapsed,
startTime,
});
// 刷新余额
queryClient.invalidateQueries(['balance', sender]);
return receipt;
} catch (e) {
const elapsed = Date.now() - startTime;
setTxState({
stage: 'failed',
error: normalizeTxError(e),
duration: elapsed,
startTime,
});
throw e;
}
},
[walletClient, queryClient],
);
const reset = useCallback(() => {
setTxState({ stage: 'idle' });
}, []);
return { txState, execute, reset };
}
3.3 交易流程 UI 组件
// src/components/TxFlow/TxProgress.tsx
function TxProgress({ stage }: { stage: TxStage }) {
const stages: { key: TxStage; label: string }[] = [
{ key: 'building', label: '构造交易' },
{ key: 'simulating', label: '模拟执行' },
{ key: 'signing', label: '钱包签名' },
{ key: 'broadcasting', label: '广播中' },
{ key: 'pending', label: '确认中' },
{ key: 'confirmed', label: '已完成' },
];
const currentIdx = stages.findIndex((s) => s.key === stage);
return (
<div className="space-y-2">
{stages.map((s, i) => {
const isActive = i === currentIdx;
const isDone = i < currentIdx;
const isError = stage === 'failed';
return (
<div key={s.key} className="flex items-center gap-3">
<div
className={`
flex h-8 w-8 items-center justify-center rounded-full text-sm font-bold
${isDone ? 'bg-green-500 text-white' : ''}
${isActive && !isError ? 'bg-msg-primary text-white' : ''}
${isActive && isError ? 'bg-red-500 text-white' : ''}
${!isActive && !isDone ? 'bg-gray-200 text-gray-400' : ''}
`}
>
{isDone ? '✓' : isError && isActive ? '✗' : i + 1}
</div>
<span
className={`
text-sm ${isActive ? 'font-semibold text-gray-900' : 'text-gray-500'}
`}
>
{s.label}
</span>
{isActive && stage === 'signing' && (
<div className="ml-auto">
<div className="h-4 w-4 animate-spin rounded-full border-2 border-msg-primary border-t-transparent" />
</div>
)}
</div>
);
})}
</div>
);
}
3.4 交易详情面板
// src/components/TxFlow/TxDetails.tsx
interface TxDetailsProps {
messages: MsgEncodeObject[];
fee: StdFee;
memo?: string;
onEditFee: () => void;
}
function TxDetails({ messages, fee, memo, onEditFee }: TxDetailsProps) {
const [expanded, setExpanded] = useState(false);
return (
<div className="rounded-xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-800/50">
<div className="mb-3 flex items-center justify-between">
<h3 className="font-semibold">交易详情</h3>
<button
onClick={() => setExpanded(!expanded)}
className="text-xs text-msg-primary hover:underline"
>
{expanded ? '收起' : '展开'}
</button>
</div>
<div className="space-y-2 text-sm">
{messages.map((msg, i) => (
<div key={i} className="rounded-lg bg-white p-3 dark:bg-gray-800">
<div className="font-mono text-xs text-gray-500">{msg.typeUrl}</div>
<DecodedMessage data={msg.value} expanded={expanded} />
</div>
))}
{memo && (
<div className="rounded-lg bg-white p-3 dark:bg-gray-800">
<div className="text-xs text-gray-500">备注</div>
<div className="font-mono text-sm">{memo}</div>
</div>
)}
<FeeDisplay fee={fee} onEdit={onEditFee} />
</div>
</div>
);
}
3.5 Gas 估算与费用显示
// src/components/TxFlow/FeeDisplay.tsx
const GAS_PRICES = {
low: 1000000000,
average: 1000000000,
high: 1000000000,
} as const;
type GasTier = keyof typeof GAS_PRICES;
function FeeDisplay({ fee, onEdit }: { fee: StdFee; onEdit?: () => void }) {
const [selectedTier, setSelectedTier] = useState<GasTier>('average');
const gasLimit = Number(fee.gas);
const feeAmount = fee.amount[0];
const coinAmount = Number(feeAmount.amount) / 1_000_000;
const tiers: GasTier[] = ['low', 'average', 'high'];
const tierInfo = {
low: { label: '慢', eta: '~30秒', color: 'text-green-500' },
average: { label: '中', eta: '~15秒', color: 'text-yellow-500' },
high: { label: '快', eta: '~6秒', color: 'text-red-500' },
};
return (
<div className="rounded-lg border border-gray-200 bg-white p-3 dark:border-gray-600 dark:bg-gray-800">
<div className="mb-3 flex items-center justify-between">
<span className="text-xs text-gray-500">Gas 费</span>
<button
onClick={onEdit}
className="text-xs text-msg-primary hover:underline"
>
编辑
</button>
</div>
<div className="mb-2 flex items-baseline gap-1">
<span className="text-lg font-bold">{coinAmount.toFixed(4)}</span>
<span className="text-sm text-gray-500">MSG</span>
</div>
<div className="mb-3 flex gap-1">
{tiers.map((tier) => (
<button
key={tier}
onClick={() => setSelectedTier(tier)}
className={`
flex-1 rounded-lg py-1.5 text-xs font-medium transition
${
selectedTier === tier
? 'bg-msg-primary/10 text-msg-primary ring-1 ring-msg-primary'
: 'bg-gray-100 text-gray-500 hover:bg-gray-200'
}
`}
>
<div>{tierInfo[tier].label}</div>
<div className={tierInfo[tier].color}>{tierInfo[tier].eta}</div>
</button>
))}
</div>
<div className="flex justify-between text-xs text-gray-400">
<span>Gas 限额: {gasLimit.toLocaleString()}</span>
<span>价格: {GAS_PRICES[selectedTier]} attoMSG</span>
</div>
</div>
);
}
3.6 Gas 估算的实现
// src/lib/gasEstimation.ts
interface GasEstimateResult {
gasEstimated: number;
gasAdjusted: number;
fee: StdFee;
}
async function estimateGas(
client: SigningStargateClient,
sender: string,
messages: MsgEncodeObject[],
memo?: string,
): Promise<GasEstimateResult> {
const DEFAULT_GAS = 200_000;
const GAS_ADJUSTMENT = 1.3;
try {
// 使用模拟执行估算 Gas
const simulated = await client.simulate(sender, messages, memo);
const adjusted = Math.ceil(simulated * GAS_ADJUSTMENT);
const gasPrice = GAS_PRICES.average;
const feeAmount = Math.ceil(adjusted * gasPrice * 1_000_000);
return {
gasEstimated: simulated,
gasAdjusted: adjusted,
fee: {
gas: adjusted.toString(),
amount: [
{
denom: 'umsg',
amount: feeAmount.toString(),
},
],
},
};
} catch (e) {
console.warn('Gas 估算失败,使用默认值', e);
const defaultFee = Math.ceil(DEFAULT_GAS * GAS_PRICES.average * 1_000_000);
return {
gasEstimated: DEFAULT_GAS,
gasAdjusted: DEFAULT_GAS,
fee: {
gas: DEFAULT_GAS.toString(),
amount: [
{
denom: 'umsg',
amount: defaultFee.toString(),
},
],
},
};
}
}
// 实时 Gas 价格查询
async function fetchGasPrice(): Promise<GasPrices> {
const response = await fetch('https://rest.msg-chain.com/cosmos/base/tx/v1beta1/gas_prices');
const data = await response.json();
return {
low: Number(data.prices.find((p) => p.denom === 'umsg')?.amount || 1000000000),
average: 1000000000,
high: 1000000000,
};
}
3.7 等待交易确认
// src/lib/txWatcher.ts
interface WaitForTxOptions {
timeout?: number;
interval?: number;
onPoll?: (height: number) => void;
}
async function waitForTx(
txHash: string,
options: WaitForTxOptions = {},
): Promise<TxResponse> {
const timeout = options.timeout ?? 60_000;
const interval = options.interval ?? 2_000;
const startTime = Date.now();
while (Date.now() - startTime < timeout) {
try {
const response = await fetchTx(txHash);
if (response) {
return response;
}
} catch (e) {
// 交易可能还未上链,继续轮询
}
await sleep(interval);
}
// 使用 WebSocket 进行实时监听(可选)
const wsResult = await watchTxViaWebSocket(txHash, timeout);
if (wsResult) {
return wsResult;
}
throw new Error('交易确认超时,请检查浏览器历史');
}
// WebSocket 交易监听
function watchTxViaWebSocket(txHash: string, timeout: number): Promise<TxResponse> {
return new Promise((resolve, reject) => {
const ws = new WebSocket('wss://rpc.msg-chain.com/websocket');
const timer = setTimeout(() => {
ws.close();
reject(new Error('WebSocket 监听超时'));
}, timeout);
ws.onopen = () => {
// 订阅交易事件
const query = `tm.event='Tx' AND tx.hash='${txHash.toUpperCase()}'`;
ws.send(
JSON.stringify({
jsonrpc: '2.0',
method: 'subscribe',
id: 1,
params: { query },
}),
);
};
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data.result?.events) {
clearTimeout(timer);
ws.close();
resolve(parseTxResponse(data.result));
}
};
ws.onerror = () => {
clearTimeout(timer);
reject(new Error('WebSocket 连接失败'));
};
});
}
3.8 交易等待 UI
// src/components/TxFlow/TxStatus.tsx
interface TxStatusProps {
txState: TransactionState;
onViewExplorer: (txHash: string) => void;
onRetry: () => void;
onDismiss: () => void;
}
function TxStatus({ txState, onViewExplorer, onRetry, onDismiss }: TxStatusProps) {
if (txState.stage === 'idle') return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
<div className="w-full max-w-md rounded-2xl bg-white p-6 shadow-2xl dark:bg-gray-900">
{/* 确认动画 */}
{txState.stage === 'confirmed' && (
<div className="mb-6 text-center">
<div className="mx-auto mb-3 flex h-16 w-16 items-center justify-center rounded-full bg-green-100">
<div className="text-3xl text-green-600">✓</div>
</div>
<h2 className="text-xl font-bold text-green-600">交易成功</h2>
<p className="mt-1 text-sm text-gray-500">
已确认于高度 #{txState.height}
</p>
<p className="text-xs text-gray-400">
耗时 {((txState.duration ?? 0) / 1000).toFixed(1)} 秒
{txState.gasUsed && ` | Gas 消耗: ${txState.gasUsed.toLocaleString()}`}
</p>
</div>
)}
{/* 失败状态 */}
{txState.stage === 'failed' && (
<div className="mb-6 text-center">
<div className="mx-auto mb-3 flex h-16 w-16 items-center justify-center rounded-full bg-red-100">
<div className="text-3xl text-red-600">✗</div>
</div>
<h2 className="text-xl font-bold text-red-600">交易失败</h2>
<p className="mt-1 text-sm text-red-500">
{txState.error?.message || '未知错误'}
</p>
{txState.error?.log && (
<details className="mt-2 text-left">
<summary className="cursor-pointer text-xs text-gray-500">
查看详情
</summary>
<pre className="mt-1 max-h-32 overflow-y-auto rounded bg-gray-100 p-2 text-xs dark:bg-gray-800">
{txState.error.log}
</pre>
</details>
)}
</div>
)}
{/* 等待中状态 */}
{(txState.stage === 'pending' || txState.stage === 'broadcasting') && (
<div className="mb-6 text-center">
<div className="mx-auto mb-4 flex items-center justify-center">
<div className="h-12 w-12 animate-spin rounded-full border-4 border-msg-primary border-t-transparent" />
</div>
<h2 className="text-xl font-bold">交易处理中</h2>
<p className="mt-1 text-sm text-gray-500">
{txState.stage === 'pending'
? '等待链上确认...'
: '广播到网络中...'}
</p>
{txState.txHash && (
<p className="mt-2 font-mono text-xs text-gray-400">
Hash: {txState.txHash.slice(0, 16)}...
</p>
)}
</div>
)}
{/* 操作按钮 */}
<div className="flex gap-2">
{txState.stage === 'confirmed' && txState.txHash && (
<button
onClick={() => onViewExplorer(txState.txHash!)}
className="flex-1 rounded-lg bg-msg-primary px-4 py-2.5 text-white transition hover:bg-msg-primary-dark"
>
在浏览器中查看
</button>
)}
{txState.stage === 'failed' && (
<button
onClick={onRetry}
className="flex-1 rounded-lg bg-msg-primary px-4 py-2.5 text-white transition hover:bg-msg-primary-dark"
>
重试
</button>
)}
<button
onClick={onDismiss}
className={`
rounded-lg px-4 py-2.5 transition
${
txState.stage === 'confirmed' || txState.stage === 'failed'
? 'flex-1 border border-gray-300 hover:bg-gray-50'
: 'text-sm text-gray-500 hover:text-gray-700'
}
`}
>
{txState.stage === 'confirmed' || txState.stage === 'failed'
? '关闭'
: '取消'}
</button>
</div>
</div>
</div>
);
}
3.9 交易历史展示
// src/components/TxHistory/TxHistoryList.tsx
interface TxRecord {
txHash: string;
type: string;
status: 'success' | 'pending' | 'failed';
msgCount: number;
fee: string;
height: number;
timestamp: number;
memo?: string;
}
// 交易类型的中文映射
const TX_TYPE_LABELS: Record<string, string> = {
'/cosmos.bank.v1beta1.MsgSend': '转账',
'/cosmos.staking.v1beta1.MsgDelegate': '委托质押',
'/cosmos.staking.v1beta1.MsgUndelegate': '取消委托',
'/cosmos.staking.v1beta1.MsgBeginRedelegate': '转委托',
'/cosmos.distribution.v1beta1.MsgWithdrawDelegatorReward': '提取奖励',
'/cosmos.gov.v1beta1.MsgVote': '治理投票',
'/ibc.applications.transfer.v1.MsgTransfer': 'IBC 跨链转账',
};
function TxHistoryList({ address }: { address: string }) {
const [txs, setTxs] = useState<TxRecord[]>([]);
const [loading, setLoading] = useState(true);
const [page, setPage] = useState(1);
const [hasMore, setHasMore] = useState(true);
const fetchTxs = useCallback(async (pageNum: number) => {
setLoading(true);
try {
const res = await fetch(
`https://rest.msg-chain.com/cosmos/tx/v1beta1/txs` +
`?events=message.sender=%27${address}%27` +
`&pagination.offset=${(pageNum - 1) * 20}` +
`&pagination.limit=20` +
`&order_by=2`,
);
const data = await res.json();
const records: TxRecord[] = data.tx_responses.map(mapTxResponse);
setTxs((prev) => (pageNum === 1 ? records : [...prev, ...records]));
setHasMore(records.length === 20);
} finally {
setLoading(false);
}
}, [address]);
useEffect(() => {
fetchTxs(1);
}, [fetchTxs]);
if (loading && txs.length === 0) {
return <div className="py-8 text-center text-gray-400">加载交易历史...</div>;
}
if (txs.length === 0) {
return (
<div className="rounded-xl border-2 border-dashed border-gray-200 py-12 text-center dark:border-gray-700">
<div className="mb-2 text-4xl">📭</div>
<p className="text-gray-500">暂无交易记录</p>
<p className="text-xs text-gray-400">
开始交互后,交易历史将显示在这里
</p>
</div>
);
}
return (
<div className="space-y-2">
<div className="flex items-center justify-between">
<h3 className="font-semibold">交易历史</h3>
<span className="text-xs text-gray-400">
共 {txs.length} 笔
</span>
</div>
<div className="space-y-1">
{txs.map((tx) => (
<TxHistoryItem key={tx.txHash} tx={tx} />
))}
</div>
{hasMore && (
<button
onClick={() => setPage((p) => p + 1)}
disabled={loading}
className="w-full py-3 text-sm text-msg-primary hover:underline"
>
{loading ? '加载中...' : '查看更多'}
</button>
)}
</div>
);
}
function TxHistoryItem({ tx }: { tx: TxRecord }) {
const timeAgo = formatTimeAgo(tx.timestamp);
const txHashShort = `${tx.txHash.slice(0, 6)}...${tx.txHash.slice(-4)}`;
const typeLabel = TX_TYPE_LABELS[tx.type] || tx.type.split('.').pop() || tx.type;
return (
<a
href={`https://explorer.msg-chain.com/tx/${tx.txHash}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-3 rounded-lg border border-gray-100 p-3 transition hover:bg-gray-50 dark:border-gray-800 dark:hover:bg-gray-800/50"
>
<div
className={`
flex h-8 w-8 items-center justify-center rounded-full
${tx.status === 'success' ? 'bg-green-100 text-green-600' : ''}
${tx.status === 'pending' ? 'bg-yellow-100 text-yellow-600' : ''}
${tx.status === 'failed' ? 'bg-red-100 text-red-600' : ''}
`}
>
{tx.status === 'success' ? '✓' : tx.status === 'pending' ? '⟳' : '✗'}
</div>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium truncate">{typeLabel}</div>
<div className="flex items-center gap-2 text-xs text-gray-400">
<span className="font-mono">{txHashShort}</span>
<span>#{tx.height}</span>
<span>{timeAgo}</span>
</div>
</div>
<div className="text-right">
<div className="text-sm font-medium">{tx.fee}</div>
{tx.memo && (
<div className="max-w-[120px] truncate text-xs text-gray-400" title={tx.memo}>
{tx.memo}
</div>
)}
</div>
</a>
);
}
3.10 批量发送
// src/hooks/useBatchSend.ts
function useBatchSend() {
const [progress, setProgress] = useState<BatchProgress>({
total: 0,
completed: 0,
failed: 0,
currentTx: null,
status: 'idle',
});
const executeBatch = useCallback(
async (txs: BatchTx[]) => {
setProgress({
total: txs.length,
completed: 0,
failed: 0,
currentTx: null,
status: 'processing',
});
for (let i = 0; i < txs.length; i++) {
const tx = txs[i];
setProgress((prev) => ({
...prev,
currentTx: { index: i, ...tx },
}));
try {
await executeTx(tx);
setProgress((prev) => ({
...prev,
completed: prev.completed + 1,
}));
} catch {
setProgress((prev) => ({
...prev,
failed: prev.failed + 1,
}));
}
}
setProgress((prev) => ({
...prev,
status: 'done',
currentTx: null,
}));
},
[],
);
return { progress, executeBatch };
}
4. 签名请求 UX
4.1 签名请求描述组件
用户在钱包中看到签名请求时,需要清楚地了解他们在签署什么:
// src/components/SignRequest/SignRequestPreview.tsx
interface SignRequestPreviewProps {
messages: MsgEncodeObject[];
fee: StdFee;
memo?: string;
accountNumber: number;
sequence: number;
chainId: string;
}
function SignRequestPreview({
messages,
fee,
memo,
accountNumber,
sequence,
chainId,
}: SignRequestPreviewProps) {
return (
<div className="rounded-xl border border-gray-200 bg-white p-5 dark:border-gray-700 dark:bg-gray-800">
<div className="mb-4 flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-yellow-100 text-yellow-600">
✍️
</div>
<div>
<h3 className="font-semibold">确认签名请求</h3>
<p className="text-xs text-gray-500">
请确认以下操作内容,然后在钱包中签名
</p>
</div>
</div>
{/* 消息列表 */}
<div className="mb-4 space-y-2">
{messages.map((msg, i) => (
<MessageCard key={i} message={msg} index={i} />
))}
</div>
{/* 费用明细 */}
<FeeBreakdown fee={fee} />
{/* 元信息 */}
<div className="mt-4 rounded-lg bg-gray-50 p-3 text-xs text-gray-500 dark:bg-gray-900">
<div className="flex justify-between">
<span>Chain ID</span>
<span className="font-mono">{chainId}</span>
</div>
<div className="flex justify-between">
<span>Account #</span>
<span className="font-mono">{accountNumber}</span>
</div>
<div className="flex justify-between">
<span>Sequence</span>
<span className="font-mono">{sequence}</span>
</div>
{memo && (
<div className="flex justify-between">
<span>备注</span>
<span className="max-w-[200px] truncate font-mono">{memo}</span>
</div>
)}
</div>
<div className="mt-4 rounded-lg bg-yellow-50 p-3 text-xs text-yellow-800 dark:bg-yellow-900/20 dark:text-yellow-200">
⚠️ 签名前请仔细核对交易详情。MSG Chain 交易不可撤销。
</div>
</div>
);
}
4.2 消息解码与展示
// src/components/SignRequest/MessageCard.tsx
interface MessageCardProps {
message: MsgEncodeObject;
index: number;
}
const MESSAGE_RENDERERS: Record<
string,
(value: any) => { title: string; fields: { label: string; value: string }[] }
> = {
'/cosmos.bank.v1beta1.MsgSend': (v) => ({
title: '转账',
fields: [
{ label: '发送地址', value: v.fromAddress },
{ label: '接收地址', value: v.toAddress },
{ label: '数量', value: formatCoins(v.amount) },
],
}),
'/cosmos.staking.v1beta1.MsgDelegate': (v) => ({
title: '委托质押',
fields: [
{ label: '委托地址', value: v.delegatorAddress },
{ label: '验证者', value: v.validatorAddress },
{ label: '数量', value: formatCoin(v.amount) },
],
}),
'/cosmos.staking.v1beta1.MsgUndelegate': (v) => ({
title: '取消委托',
fields: [
{ label: '委托地址', value: v.delegatorAddress },
{ label: '验证者', value: v.validatorAddress },
{ label: '数量', value: formatCoin(v.amount) },
],
}),
'/cosmos.distribution.v1beta1.MsgWithdrawDelegatorReward': (v) => ({
title: '提取质押奖励',
fields: [
{ label: '委托人', value: v.delegatorAddress },
{ label: '验证者', value: v.validatorAddress },
],
}),
'/cosmos.gov.v1beta1.MsgVote': (v) => ({
title: '治理投票',
fields: [
{ label: '提案', value: `#${v.proposalId}` },
{ label: '投票选项', value: formatVoteOption(v.option) },
{ label: '投票人', value: v.voter },
],
}),
'/ibc.applications.transfer.v1.MsgTransfer': (v) => ({
title: 'IBC 跨链转账',
fields: [
{ label: '发送地址', value: v.sender },
{ label: '接收地址', value: v.receiver },
{ label: '数量', value: formatCoin(v.token) },
{ label: '目标链', value: v.sourcePort + '/' + v.sourceChannel },
{ label: '超时高度', value: v.timeoutHeight?.revisionHeight || 'N/A' },
],
}),
};
function formatVoteOption(option: number): string {
const options = ['弃权', '赞成', '反对', '强烈反对'];
return options[option] || `未知(${option})`;
}
function MessageCard({ message, index }: MessageCardProps) {
const renderer = MESSAGE_RENDERERS[message.typeUrl];
const decoded = renderer?.(message.value);
return (
<div className="rounded-lg border border-gray-100 bg-gray-50 p-3 dark:border-gray-700 dark:bg-gray-800/50">
<div className="mb-2 flex items-center gap-2">
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-msg-primary/10 text-xs font-bold text-msg-primary">
{index + 1}
</span>
<span className="text-sm font-medium">
{decoded?.title || message.typeUrl.split('.').pop() || '未知操作'}
</span>
<span className="font-mono text-[10px] text-gray-400">{message.typeUrl}</span>
</div>
{decoded ? (
<div className="space-y-1 text-sm">
{decoded.fields.map((field, fi) => (
<div key={fi} className="flex justify-between">
<span className="text-gray-500">{field.label}</span>
<span className="max-w-[60%] truncate font-mono text-xs">{field.value}</span>
</div>
))}
</div>
) : (
<pre className="max-h-24 overflow-y-auto text-xs text-gray-400">
{JSON.stringify(message.value, null, 2)}
</pre>
)}
</div>
);
}
4.3 费用明细
// src/components/SignRequest/FeeBreakdown.tsx
function FeeBreakdown({ fee }: { fee: StdFee }) {
const gasLimit = Number(fee.gas);
const feeCoin = fee.amount[0];
const feeAmount = Number(feeCoin.amount) / 1_000_000;
const actualCost = feeAmount;
const gasPrice = (Number(feeCoin.amount) / gasLimit) * 1_000_000;
return (
<div className="rounded-lg bg-gradient-to-r from-purple-50 to-blue-50 p-4 dark:from-purple-900/20 dark:to-blue-900/20">
<h4 className="mb-3 text-xs font-semibold uppercase tracking-wide text-gray-500">
费用明细
</h4>
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-sm text-gray-600">Gas 限额</span>
<span className="font-mono text-sm">{gasLimit.toLocaleString()}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-gray-600">Gas 价格</span>
<span className="font-mono text-sm">{gasPrice.toFixed(0)} attoMSG</span>
</div>
<div className="border-t border-gray-200 pt-2 dark:border-gray-600">
<div className="flex items-center justify-between">
<span className="text-sm font-semibold">预计总费用</span>
<span className="font-mono text-lg font-bold text-msg-primary">
{actualCost.toFixed(6)} MSG
</span>
</div>
</div>
</div>
</div>
);
}
4.4 批量签名
// src/components/SignRequest/BatchSignPreview.tsx
interface BatchSignPreviewProps {
messages: MsgEncodeObject[][];
memo?: string;
}
function BatchSignPreview({ messages }: BatchSignPreviewProps) {
const [expandedBatch, setExpandedBatch] = useState(false);
const totalMsgs = messages.reduce((sum, batch) => sum + batch.length, 0);
return (
<div className="rounded-xl border border-gray-200 bg-white p-5 dark:border-gray-700 dark:bg-gray-800">
<div className="mb-4 flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-blue-100 text-blue-600">
📦
</div>
<div>
<h3 className="font-semibold">批量交易</h3>
<p className="text-xs text-gray-500">
此签名包含 {totalMsgs} 条消息({messages.length} 组)
</p>
</div>
</div>
<div className="mb-3 flex gap-2">
<div className="rounded-full bg-green-100 px-3 py-1 text-xs text-green-700">
{messages.filter((m) => m.length > 0).length} 组
</div>
<div className="rounded-full bg-blue-100 px-3 py-1 text-xs text-blue-700">
{totalMsgs} 条消息
</div>
</div>
<button
onClick={() => setExpandedBatch(!expandedBatch)}
className="mb-3 w-full rounded-lg bg-gray-50 py-2 text-sm text-gray-600 hover:bg-gray-100"
>
{expandedBatch ? '收起详情' : '查看所有消息'}
</button>
{expandedBatch && (
<div className="max-h-80 space-y-3 overflow-y-auto">
{messages.map((batch, i) => (
<div key={i}>
<div className="mb-1 text-xs font-medium text-gray-400">
批次 {i + 1} / {messages.length}({batch.length} 条)
</div>
{batch.map((msg, j) => (
<MessageCard key={j} message={msg} index={j} compact />
))}
</div>
))}
</div>
)}
<div className="mt-3 flex items-center gap-2 rounded-lg bg-yellow-50 p-3 text-xs text-yellow-800 dark:bg-yellow-900/20 dark:text-yellow-200">
<span>💡</span>
<span>
批量交易将一次性签名,最终费用为各消息费用之和
</span>
</div>
</div>
);
}
4.5 防钓鱼确认
// src/components/SignRequest/VerificationChecklist.tsx
function VerificationChecklist() {
const [checks, setChecks] = useState({
domain: false,
chainId: false,
message: false,
fee: false,
});
const allChecked = Object.values(checks).every(Boolean);
return (
<div className="space-y-2 rounded-lg border border-orange-200 bg-orange-50 p-4 dark:border-orange-800 dark:bg-orange-900/20">
<h4 className="text-sm font-semibold text-orange-700 dark:text-orange-300">
🔒 安全确认清单
</h4>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={checks.domain}
onChange={() => setChecks((c) => ({ ...c, domain: !c.domain }))}
className="rounded"
/>
<span>当前 dApp 域名正确</span>
</label>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={checks.chainId}
onChange={() => setChecks((c) => ({ ...c, chainId: !c.chainId }))}
className="rounded"
/>
<span>链 ID 为 <code className="rounded bg-orange-100 px-1 dark:bg-orange-800">msg-chain-1</code></span>
</label>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={checks.message}
onChange={() => setChecks((c) => ({ ...c, message: !c.message }))}
className="rounded"
/>
<span>我已阅读并理解所有消息内容</span>
</label>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={checks.fee}
onChange={() => setChecks((c) => ({ ...c, fee: !c.fee }))}
className="rounded"
/>
<span>费用在可接受范围内</span>
</label>
<button
disabled={!allChecked}
className="mt-2 w-full rounded-lg bg-orange-500 py-2 text-sm font-medium text-white transition hover:bg-orange-600 disabled:cursor-not-allowed disabled:opacity-50"
>
{allChecked ? '确认并签名' : '请先完成安全检查'}
</button>
</div>
);
}
5. 错误处理 UX
5.1 错误分类与映射
// src/lib/errors.ts
interface UserFriendlyError {
title: string;
message: string;
category: 'user' | 'network' | 'contract' | 'wallet' | 'unknown';
recoverable: boolean;
suggestion: string;
}
const ERROR_CATEGORIES = {
user: {
icon: '👤',
label: '用户操作',
},
network: {
icon: '🌐',
label: '网络错误',
},
contract: {
icon: '📄',
label: '合约错误',
},
wallet: {
icon: '💳',
label: '钱包错误',
},
unknown: {
icon: '❓',
label: '未知错误',
},
} as const;
const ERROR_MESSAGES: Record<string, UserFriendlyError> = {
// === 用户侧错误 ===
'insufficient funds': {
title: '余额不足',
message: '您的 MSG 余额不足以支付此交易',
category: 'user',
recoverable: true,
suggestion: '请通过交易所或跨链桥向您的 msg 地址充值 MSG',
},
'insufficient fee': {
title: 'Gas 费不足',
message: '账户中无足够的 MSG 支付 Gas 费用',
category: 'user',
recoverable: true,
suggestion: '请确保至少持有 0.01 MSG 用于支付 Gas',
},
'unauthorized': {
title: '无权限',
message: '当前账户没有执行此操作的权限',
category: 'user',
recoverable: false,
suggestion: '请确认您使用的是正确的钱包账户',
},
'signature rejected': {
title: '签名被拒绝',
message: '您在钱包中拒绝了签名请求',
category: 'user',
recoverable: true,
suggestion: '如需继续操作,请再次发起并在钱包中确认签名',
},
'tx already exist': {
title: '重复交易',
message: '此交易已存在,请勿重复提交',
category: 'user',
recoverable: false,
suggestion: '请等待前一笔交易确认后再尝试',
},
// === 网络侧错误 ===
'timeout': {
title: '请求超时',
message: '连接 RPC 节点超时,网络可能不稳定',
category: 'network',
recoverable: true,
suggestion: '请检查您的网络连接,稍后重试',
},
'connection refused': {
title: '连接被拒绝',
message: '无法连接到 MSG Chain RPC 节点',
category: 'network',
recoverable: true,
suggestion: 'RPC 节点可能维护中,请稍后重试',
},
'socket hang up': {
title: '连接断开',
message: '与 RPC 节点的连接意外断开',
category: 'network',
recoverable: true,
suggestion: '网页或已断开,请刷新页面重试',
},
'account sequence mismatch': {
title: '账户序列号不匹配',
message: '交易序列号冲突,通常由并发交易引起',
category: 'network',
recoverable: true,
suggestion: '等待前一笔交易确认后重试',
},
// === 合约侧错误 ===
'contract rejected': {
title: '合约拒绝',
message: '链上合约拒绝了此操作',
category: 'contract',
recoverable: false,
suggestion: '操作参数可能不合法,请检查输入后重试',
},
'out of gas': {
title: 'Gas 耗尽',
message: '交易消耗的 Gas 超过了预设的 Gas 限额',
category: 'contract',
recoverable: true,
suggestion: '请增加 Gas 限额后重试(可调整为估算值的 1.5 倍)',
},
'execution reverted': {
title: '执行回滚',
message: '交易执行过程中被回滚',
category: 'contract',
recoverable: false,
suggestion: '合约执行失败,请检查操作是否合法',
},
'code ID not found': {
title: '合约未找到',
message: '指定的合约地址或 Code ID 不存在',
category: 'contract',
recoverable: false,
suggestion: '请确认合约地址或 Code ID 是否正确',
},
// === 钱包侧错误 ===
'wallet not found': {
title: '未检测到钱包',
message: '请安装 Keplr、Leap 或 Cosmostation 钱包扩展',
category: 'wallet',
recoverable: true,
suggestion: '安装钱包扩展后刷新页面',
},
'chain not found': {
title: '链未配置',
message: '钱包中未找到 MSG Chain 配置',
category: 'wallet',
recoverable: true,
suggestion: '请通过 dApp 自动添加或手动在钱包中添加 msg-chain-1',
},
'key not found': {
title: '密钥未找到',
message: '钱包中未找到 MSG Chain 的账户密钥',
category: 'wallet',
recoverable: true,
suggestion: '请在钱包中添加 MSG Chain 账户',
},
};
function classifyError(e: unknown): UserFriendlyError {
const message = e?.message || String(e);
// 遍历已知错误模式
for (const [pattern, error] of Object.entries(ERROR_MESSAGES)) {
if (message.toLowerCase().includes(pattern)) {
return error;
}
}
// Cosmos SDK 错误码处理
const cosmosErrorCode = extractCosmosErrorCode(e);
if (cosmosErrorCode) {
const cosmosErrors: Record<number, Partial<UserFriendlyError>> = {
1: { title: '内部错误', message: '链内部发生未知错误' },
2: { title: '解析错误', message: '交易数据解析失败' },
3: { title: '编码错误', message: '交易数据编码无效' },
4: { title: '序列化错误', message: '交易数据序列化失败' },
5: {
title: '序列号错误',
message: '账户序列号不正确',
recoverable: true,
suggestion: '等待前一笔交易确认',
},
6: {
title: '签名无效',
message: '签名验证失败',
category: 'user',
},
7: {
title: '余额不足',
message: '账户余额不足以支付费用',
category: 'user',
recoverable: true,
suggestion: '请充值 MSG',
},
8: { title: '找不到模块', message: '交易引用了不存在的模块' },
9: {
title: '无权限',
message: '账户无权执行此操作',
category: 'user',
},
};
const ce = cosmosErrors[cosmosErrorCode];
if (ce) {
return {
title: ce.title ?? '交易错误',
message: ce.message ?? `Cosmos SDK 错误代码 ${cosmosErrorCode}`,
category: (ce.category as any) ?? 'unknown',
recoverable: ce.recoverable ?? false,
suggestion: ce.suggestion ?? '请重试或联系支持',
};
}
}
// Fallback
return {
title: '未知错误',
message: message.slice(0, 200),
category: 'unknown',
recoverable: false,
suggestion: '请截图此错误并联系 dApp 支持团队',
};
}
function extractCosmosErrorCode(e: unknown): number | null {
const msg = e?.message || '';
const match = msg.match(/code[:=]\s*(\d+)/i);
return match ? parseInt(match[1], 10) : null;
}
5.2 错误展示组件
// src/components/Error/ErrorDisplay.tsx
interface ErrorDisplayProps {
error: UserFriendlyError;
rawError?: string;
onRetry?: () => void;
onDismiss?: () => void;
}
function ErrorDisplay({ error, rawError, onRetry, onDismiss }: ErrorDisplayProps) {
const [showDetails, setShowDetails] = useState(false);
const category = ERROR_CATEGORIES[error.category];
return (
<div className="rounded-xl border border-red-200 bg-red-50 p-5 dark:border-red-800 dark:bg-red-900/20">
<div className="mb-3 flex items-start gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-red-100 text-lg dark:bg-red-800">
{category.icon}
</div>
<div className="flex-1">
<div className="flex items-start justify-between">
<div>
<span className="rounded bg-red-100 px-2 py-0.5 text-xs font-medium text-red-700 dark:bg-red-800 dark:text-red-200">
{category.label}
</span>
<h3 className="mt-1 text-lg font-bold text-red-800 dark:text-red-200">
{error.title}
</h3>
</div>
{onDismiss && (
<button onClick={onDismiss} className="text-red-400 hover:text-red-600">
✕
</button>
)}
</div>
<p className="mt-1 text-sm text-red-700 dark:text-red-300">
{error.message}
</p>
</div>
</div>
{error.recoverable && error.suggestion && (
<div className="mb-3 rounded-lg bg-white/60 p-3 text-sm dark:bg-black/20">
<span className="font-medium">💡 建议:</span>
{error.suggestion}
</div>
)}
{rawError && (
<details
open={showDetails}
onToggle={() => setShowDetails(!showDetails)}
className="mb-3"
>
<summary className="cursor-pointer text-xs text-red-500 hover:text-red-700">
{showDetails ? '隐藏技术详情' : '查看技术详情'}
</summary>
<pre className="mt-1 max-h-32 overflow-y-auto rounded bg-red-100/50 p-2 text-xs text-red-800 dark:bg-red-900/30 dark:text-red-200">
{rawError}
</pre>
</details>
)}
<div className="flex gap-2">
{error.recoverable && onRetry && (
<button
onClick={onRetry}
className="flex-1 rounded-lg bg-msg-primary px-4 py-2.5 text-sm font-medium text-white transition hover:bg-msg-primary-dark"
>
重试
</button>
)}
<button
onClick={onDismiss}
className="flex-1 rounded-lg border border-red-300 px-4 py-2.5 text-sm text-red-700 transition hover:bg-red-100"
>
关闭
</button>
</div>
</div>
);
}
5.3 交易模拟(预检)
// src/lib/txSimulation.ts
interface SimulationResult {
success: boolean;
gasUsed: number;
error?: string;
events?: TxEvent[];
}
async function simulateTransaction(
sender: string,
messages: MsgEncodeObject[],
memo?: string,
): Promise<SimulationResult> {
try {
const response = await fetch(
'https://rest.msg-chain.com/cosmos/tx/v1beta1/simulate',
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
tx: {
body: {
messages: messages.map((m) => ({
'@type': m.typeUrl,
...m.value,
})),
memo: memo || '',
timeout_height: '0',
extension_options: [],
non_critical_extension_options: [],
},
auth_info: {
signer_infos: [],
fee: {
amount: [],
gas_limit: '0',
payer: '',
granter: '',
},
},
signatures: [],
},
tx_config: {
has_extension_options: false,
has_non_critical_extension_options: false,
signers: [],
},
}),
},
);
const data = await response.json();
if (data.code) {
return {
success: false,
gasUsed: 0,
error: data.raw_log || `错误代码 ${data.code}`,
};
}
return {
success: true,
gasUsed: parseInt(data.gas_info?.gas_used || '0'),
events: data.result?.events,
};
} catch (e) {
return {
success: false,
gasUsed: 0,
error: `模拟执行失败: ${e.message}`,
};
}
}
// 预检按钮 Hook
function useTxSimulation(messages: MsgEncodeObject[]) {
const [result, setResult] = useState<SimulationResult | null>(null);
const [simulating, setSimulating] = useState(false);
const simulate = useCallback(async () => {
if (messages.length === 0) return;
setSimulating(true);
setResult(null);
try {
const res = await simulateTransaction(sender, messages);
setResult(res);
} finally {
setSimulating(false);
}
}, [messages]);
return { result, simulating, simulate };
}
5.4 错误恢复流程
// src/hooks/useErrorRecovery.ts
interface RecoveryStrategy {
type: 'retry' | 'increase_gas' | 'wait' | 'switch_account' | 'reconnect' | 'contact_support';
label: string;
action: () => Promise<void>;
}
function useErrorRecovery(error: UserFriendlyError) {
const { connect, state } = useWalletConnect();
const strategies: RecoveryStrategy[] = useMemo(() => {
const list: RecoveryStrategy[] = [];
if (error.recoverable) {
list.push({
type: 'retry',
label: '重试操作',
action: async () => { /* 重试逻辑 */ },
});
}
if (error.message.includes('Gas')) {
list.push({
type: 'increase_gas',
label: '增加 Gas 限额',
action: async () => { /* 调整 Gas 后重试 */ },
});
}
if (error.message.includes('序列号') || error.message.includes('sequence')) {
list.push({
type: 'wait',
label: '等待并重试',
action: async () => {
await sleep(5000);
/* 重试 */
},
});
}
if (error.category === 'network') {
list.push({
type: 'reconnect',
label: '重新连接钱包',
action: async () => {
await connect();
},
});
}
list.push({
type: 'switch_account',
label: '切换账户',
action: async () => {
/* 切换账户逻辑 */
},
});
list.push({
type: 'contact_support',
label: '联系支持',
action: async () => {
window.open('https://discord.gg/msg-chain', '_blank');
},
});
return list;
}, [error, connect]);
return { strategies };
}
5.5 全局错误边界
// src/components/Error/ErrorBoundary.tsx
interface ErrorBoundaryState {
hasError: boolean;
error: Error | null;
}
class ErrorBoundary extends React.Component<Props, ErrorBoundaryState> {
state: ErrorBoundaryState = { hasError: false, error: null };
static getDerivedStateFromError(error: Error) {
return { hasError: true, error };
}
componentDidCatch(error: Error, info: React.ErrorInfo) {
console.error('dApp 异常:', error, info);
// 上报错误监控服务
reportError(error, info);
}
render() {
if (this.state.hasError) {
const friendly = classifyError(this.state.error);
return (
<div className="flex min-h-screen items-center justify-center p-4">
<div className="w-full max-w-md">
<ErrorDisplay
error={friendly}
rawError={this.state.error.message}
onRetry={() => this.setState({ hasError: false, error: null })}
/>
</div>
</div>
);
}
return this.props.children;
}
}
6. Mobile UX
6.1 移动端钱包检测
移动端与桌面端的钱包交互方式不同 —— 通常通过 WalletConnect 或 Deep Link 实现:
// src/hooks/useMobileWallet.ts
function useMobileWallet() {
const [isMobile, setIsMobile] = useState(false);
const [walletConnect, setWalletConnect] = useState<WalletConnectClient | null>(null);
useEffect(() => {
const check = () => {
const ua = navigator.userAgent;
const mobile = /Android|iPhone|iPad|iPod|webOS/i.test(ua);
setIsMobile(mobile);
};
check();
}, []);
const connectViaWalletConnect = useCallback(async () => {
// 使用 WalletConnect v2 协议
const projectId = process.env.NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID;
const client = await WalletConnect.init({
projectId,
metadata: {
name: 'MSG dApp',
description: 'MSG Chain dApp',
url: window.location.origin,
icons: [`${window.location.origin}/icon.png`],
},
});
// 连接 Leap / Keplr 移动端
const session = await client.connect({
requiredNamespaces: {
cosmos: {
methods: [
'cosmos_signDirect',
'cosmos_signAmino',
],
chains: ['cosmos:msg-chain-1'],
events: ['chainChanged', 'accountsChanged'],
},
},
});
setWalletConnect(client);
return session;
}, []);
const deeplinkToWallet = useCallback((uri: string) => {
// 根据不同钱包构造 Deep Link
const wallets: Record<string, string> = {
keplr: `keplrwallet://wc?uri=${encodeURIComponent(uri)}`,
leap: `leap://wc?uri=${encodeURIComponent(uri)}`,
cosmostation: `cosmostation://wc?uri=${encodeURIComponent(uri)}`,
};
// 尝试打开已安装的钱包
const preferred = localStorage.getItem('msg_wallet_preference');
const link = wallets[preferred] || wallets.keplr;
window.location.href = link;
// Fallback: 如果用户未安装,跳转应用商店
setTimeout(() => {
if (document.hidden) return; // 钱包已打开
window.location.href = preferred === 'leap'
? 'https://play.google.com/store/apps/details?id=io.leapwallet.cosmos'
: 'https://apps.apple.com/app/keplr-wallet/id1567851089';
}, 3000);
}, []);
return { isMobile, walletConnect, connectViaWalletConnect, deeplinkToWallet };
}
6.2 响应式连接界面
// src/components/Mobile/MobileConnectSheet.tsx
function MobileConnectSheet({ onConnect }: { onConnect: (method: string) => void }) {
const [isOpen, setIsOpen] = useState(false);
return (
<>
<button
onClick={() => setIsOpen(true)}
className="w-full rounded-xl bg-msg-primary py-4 text-lg font-bold text-white"
>
连接钱包
</button>
{isOpen && (
<div className="fixed inset-0 z-50 flex items-end bg-black/40">
<div className="w-full rounded-t-2xl bg-white p-6 pb-10 dark:bg-gray-900">
<div className="mx-auto mb-6 h-1 w-10 rounded-full bg-gray-300" />
<h2 className="mb-2 text-center text-xl font-bold">连接钱包</h2>
<p className="mb-6 text-center text-sm text-gray-500">
选择连接方式
</p>
<div className="space-y-3">
<button
onClick={() => {
onConnect('walletconnect');
setIsOpen(false);
}}
className="flex w-full items-center gap-4 rounded-xl border border-gray-200 p-4"
>
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-blue-100">
📱
</div>
<div className="flex-1 text-left">
<div className="font-semibold">WalletConnect</div>
<div className="text-xs text-gray-500">
扫码或跳转钱包 App
</div>
</div>
<div className="text-gray-400">→</div>
</button>
<button
onClick={() => {
onConnect('deep-link');
setIsOpen(false);
}}
className="flex w-full items-center gap-4 rounded-xl border border-gray-200 p-4"
>
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-purple-100">
🔗
</div>
<div className="flex-1 text-left">
<div className="font-semibold">直接打开</div>
<div className="text-xs text-gray-500">
跳转到已安装的钱包 App
</div>
</div>
<div className="text-gray-400">→</div>
</button>
</div>
<button
onClick={() => setIsOpen(false)}
className="mt-6 w-full py-3 text-center text-sm text-gray-500"
>
取消
</button>
</div>
</div>
)}
</>
);
}
6.3 QR 码交互
// src/components/Mobile/QRConnect.tsx
function QRConnect() {
const [uri, setUri] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
useEffect(() => {
// 生成 WalletConnect URI
const generateWC = async () => {
const wcUri = await generateWalletConnectUri();
setUri(wcUri);
};
generateWC();
}, []);
const copyUri = async () => {
if (!uri) return;
await navigator.clipboard.writeText(uri);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
return (
<div className="flex flex-col items-center gap-4 p-6">
<h3 className="font-semibold">扫码连接钱包</h3>
<p className="text-center text-sm text-gray-500">
使用 Keplr / Leap 移动版扫描下方二维码
</p>
{uri && (
<div className="rounded-2xl bg-white p-4 shadow-lg">
<QRCode value={uri} size={240} level="M" />
</div>
)}
<button
onClick={copyUri}
className="rounded-lg border border-gray-200 px-4 py-2 text-sm"
>
{copied ? '已复制' : '复制连接地址'}
</button>
</div>
);
}
6.4 移动端交易确认
// src/components/Mobile/MobileTxConfirm.tsx
function MobileTxConfirm({ txState }: { txState: TransactionState }) {
const { isMobile } = useMobileWallet();
if (!isMobile) return null;
return (
<div className="fixed inset-x-0 bottom-0 z-50 rounded-t-2xl bg-white p-5 shadow-2xl dark:bg-gray-900">
{/* 进度指示器 */}
<div className="mb-4">
<div className="h-1.5 w-full overflow-hidden rounded-full bg-gray-200">
<div
className={`
h-full rounded-full transition-all duration-500
${txState.stage === 'confirmed' ? 'w-full bg-green-500' : ''}
${txState.stage === 'failed' ? 'w-full bg-red-500' : ''}
${txState.stage === 'pending' ? 'w-3/4 bg-msg-primary' : ''}
${txState.stage === 'signing' ? 'w-1/4 bg-yellow-500' : ''}
${txState.stage === 'idle' ? 'w-0' : ''}
`}
/>
</div>
</div>
<div className="text-center">
{txState.stage === 'signing' && (
<>
<div className="mb-2 text-3xl">✍️</div>
<p className="font-semibold">请在钱包中签名</p>
<p className="mt-1 text-xs text-gray-500">
已打开 {localStorage.getItem('msg_wallet_preference') === 'leap' ? 'Leap' : 'Keplr'} App
</p>
</>
)}
{txState.stage === 'pending' && (
<>
<div className="mb-2 text-3xl">⏳</div>
<p className="font-semibold">交易确认中</p>
<p className="mt-1 text-xs text-gray-500">
预计 6-15 秒完成
</p>
</>
)}
{txState.stage === 'confirmed' && (
<>
<div className="mb-2 text-3xl">✅</div>
<p className="font-semibold text-green-600">交易成功</p>
<p className="mt-1 text-xs text-gray-500">
高度 #{txState.height}
</p>
</>
)}
{txState.stage === 'failed' && (
<>
<div className="mb-2 text-3xl">❌</div>
<p className="font-semibold text-red-600">交易失败</p>
<p className="mt-1 text-xs text-red-500">
{txState.error?.message}
</p>
</>
)}
</div>
</div>
);
}
6.5 Deep Link 路由
// src/lib/deeplinks.ts
const DEEP_LINK_CONFIGS = {
keplr: {
scheme: 'keplrwallet://',
universal: 'https://wallet.keplr.app/',
playStore: 'https://play.google.com/store/apps/details?id=com.chainapsis.keplr',
appStore: 'https://apps.apple.com/app/keplr-wallet/id1567851089',
},
leap: {
scheme: 'leap://',
universal: 'https://leapwallet.io/',
playStore: 'https://play.google.com/store/apps/details?id=io.leapwallet.cosmos',
appStore: 'https://apps.apple.com/app/leap-wallet-cosmos-defi/id1642465549',
},
} as const;
function generateDeeplink(
walletId: 'keplr' | 'leap',
action: string,
params: Record<string, string>,
): string {
const config = DEEP_LINK_CONFIGS[walletId];
const query = new URLSearchParams(params).toString();
return `${config.universal}${action}?${query}`;
}
function redirectToWallet(uri: string) {
const walletId = localStorage.getItem('msg_wallet_preference') as 'keplr' | 'leap';
// 尝试通过 scheme 打开
const schemeUrl = DEEP_LINK_CONFIGS[walletId].scheme + uri;
window.location.href = schemeUrl;
// 如果未成功,尝试 Universal Link
const fallbackTimer = setTimeout(() => {
if (document.hidden) return;
window.location.href = generateDeeplink(walletId, 'wc', { uri });
}, 500);
const handleVisibility = () => {
if (document.hidden) {
clearTimeout(fallbackTimer);
}
};
document.addEventListener('visibilitychange', handleVisibility);
setTimeout(() => {
document.removeEventListener('visibilitychange', handleVisibility);
}, 5000);
}
6.6 移动端适配样式
// src/styles/mobile.ts
const MOBILE_STYLES = {
container: `
px-4 py-3
safe-area-bottom
`,
button: `
w-full py-4 rounded-xl
text-lg font-bold
active:scale-98
transition-transform
`,
sheet: `
fixed inset-x-0 bottom-0
rounded-t-2xl
bg-white dark:bg-gray-900
shadow-2xl
pb-safe
`,
input: `
w-full rounded-xl border-2
px-4 py-3
text-base
focus:border-msg-primary
transition-colors
`,
} as const;
// Safe area environment variable
function useSafeArea() {
const [safeArea, setSafeArea] = useState({ bottom: 0, top: 0 });
useEffect(() => {
const env = getComputedStyle(document.documentElement);
const bottom = parseInt(env.getPropertyValue('--safe-area-inset-bottom') || '0');
const top = parseInt(env.getPropertyValue('--safe-area-inset-top') || '0');
setSafeArea({ bottom, top });
}, []);
return safeArea;
}
7. AI Agent dApp UX
7.1 Agent 市场浏览
// src/components/AIAgent/AgentCard.tsx
interface Agent {
id: string;
name: string;
description: string;
price: Coin;
category: string;
rating: number;
totalUsers: number;
owner: string;
capabilities: string[];
}
function AgentCard({ agent, onSelect }: { agent: Agent; onSelect: (id: string) => void }) {
return (
<div className="group cursor-pointer rounded-xl border border-gray-200 bg-white p-5 transition hover:shadow-lg hover:border-msg-primary/30 dark:border-gray-700 dark:bg-gray-800">
<div className="mb-3 flex items-start justify-between">
<div>
<h3 className="text-lg font-bold">{agent.name}</h3>
<div className="flex items-center gap-2 text-sm text-gray-500">
<span>⭐ {agent.rating.toFixed(1)}</span>
<span>·</span>
<span>{agent.totalUsers.toLocaleString()} 用户</span>
<span>·</span>
<span className="rounded bg-msg-primary/10 px-2 py-0.5 text-xs text-msg-primary">
{agent.category}
</span>
</div>
</div>
<div className="text-right">
<div className="font-mono text-lg font-bold text-msg-primary">
{formatCoin(agent.price)}
</div>
<div className="text-xs text-gray-400">按次计费</div>
</div>
</div>
<p className="mb-3 line-clamp-2 text-sm text-gray-600 dark:text-gray-300">
{agent.description}
</p>
<div className="mb-4 flex flex-wrap gap-1.5">
{agent.capabilities.slice(0, 4).map((cap) => (
<span
key={cap}
className="rounded-full bg-gray-100 px-2.5 py-0.5 text-xs text-gray-600 dark:bg-gray-700 dark:text-gray-300"
>
{cap}
</span>
))}
{agent.capabilities.length > 4 && (
<span className="text-xs text-gray-400">
+{agent.capabilities.length - 4}
</span>
)}
</div>
<div className="flex items-center gap-2 text-xs text-gray-400">
<span>创建者:</span>
<span className="font-mono">{shortenAddress(agent.owner)}</span>
</div>
<button
onClick={() => onSelect(agent.id)}
className="mt-4 w-full rounded-lg bg-msg-primary py-2.5 text-sm font-medium text-white opacity-0 transition group-hover:opacity-100"
>
开始对话
</button>
</div>
);
}
7.2 Agent 聊天界面
// src/components/AIAgent/AgentChat.tsx
interface Message {
id: string;
role: 'user' | 'agent';
content: string;
timestamp: number;
status?: 'sending' | 'sent' | 'confirmed';
txHash?: string;
action?: {
type: string;
fee: string;
description: string;
};
}
function AgentChat({ agentId }: { agentId: string }) {
const [messages, setMessages] = useState<Message[]>([]);
const [input, setInput] = useState('');
const [agentThinking, setAgentThinking] = useState(false);
const { txState, execute } = useTransaction();
const messagesEndRef = useRef<HTMLDivElement>(null);
const sendMessage = async () => {
if (!input.trim()) return;
const userMsg: Message = {
id: nanoid(),
role: 'user',
content: input,
timestamp: Date.now(),
};
setMessages((prev) => [...prev, userMsg]);
setInput('');
setAgentThinking(true);
try {
// 发送到 Agent 服务
const response = await fetch(`/api/agents/${agentId}/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
message: input,
address: currentAddress,
}),
});
const data = await response.json();
// Agent 可能需要执行链上操作
if (data.action) {
const actionMsg: Message = {
id: nanoid(),
role: 'agent',
content: data.response,
timestamp: Date.now(),
action: data.action,
};
setMessages((prev) => [...prev, actionMsg]);
} else {
const agentMsg: Message = {
id: nanoid(),
role: 'agent',
content: data.response,
timestamp: Date.now(),
};
setMessages((prev) => [...prev, agentMsg]);
}
} finally {
setAgentThinking(false);
}
};
const confirmAction = async (action: NonNullable<Message['action']>) => {
// 用户确认 Agent 发起的交易
try {
await execute(actionToMessages(action));
} catch (e) {
// 错误处理
}
};
return (
<div className="flex h-[600px] flex-col rounded-xl border border-gray-200 dark:border-gray-700">
{/* 聊天头部 */}
<div className="flex items-center gap-3 border-b border-gray-100 p-4 dark:border-gray-700">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-gradient-to-br from-purple-400 to-blue-400 text-white">
🤖
</div>
<div>
<div className="font-semibold">{agentId}</div>
<div className="flex items-center gap-1 text-xs text-green-500">
<div className="h-2 w-2 rounded-full bg-green-500" />
在线
</div>
</div>
</div>
{/* 消息列表 */}
<div className="flex-1 space-y-4 overflow-y-auto p-4">
{messages.map((msg) => (
<div
key={msg.id}
className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}
>
<div
className={`
max-w-[80%] rounded-2xl px-4 py-3
${msg.role === 'user'
? 'bg-msg-primary text-white'
: 'bg-gray-100 dark:bg-gray-800'
}
`}
>
<p className="text-sm whitespace-pre-wrap">{msg.content}</p>
{msg.action && (
<div className="mt-3 rounded-lg border border-yellow-200 bg-yellow-50 p-3 dark:border-yellow-800 dark:bg-yellow-900/20">
<p className="mb-1 text-xs font-medium">Agent 请求执行操作</p>
<p className="text-xs text-gray-600">{msg.action.description}</p>
<p className="text-xs text-gray-500">费用: {msg.action.fee}</p>
<button
onClick={() => confirmAction(msg.action!)}
className="mt-2 w-full rounded-lg bg-yellow-500 py-1.5 text-xs font-medium text-white"
>
确认执行
</button>
</div>
)}
</div>
</div>
))}
{agentThinking && (
<div className="flex items-start gap-2">
<div className="rounded-2xl bg-gray-100 px-4 py-3 dark:bg-gray-800">
<div className="flex gap-1">
<div className="h-2 w-2 animate-bounce rounded-full bg-gray-400" />
<div className="h-2 w-2 animate-bounce rounded-full bg-gray-400 [animation-delay:0.1s]" />
<div className="h-2 w-2 animate-bounce rounded-full bg-gray-400 [animation-delay:0.2s]" />
</div>
</div>
</div>
)}
<div ref={messagesEndRef} />
</div>
{/* 输入区 */}
<div className="border-t border-gray-100 p-4 dark:border-gray-700">
<div className="flex gap-2">
<input
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && sendMessage()}
placeholder="输入消息..."
className="flex-1 rounded-xl border-2 border-gray-200 px-4 py-2.5 text-sm focus:border-msg-primary focus:outline-none dark:border-gray-600 dark:bg-gray-800"
/>
<button
onClick={sendMessage}
disabled={!input.trim() || agentThinking}
className="rounded-xl bg-msg-primary px-5 py-2.5 text-white transition hover:bg-msg-primary-dark disabled:cursor-not-allowed disabled:opacity-50"
>
发送
</button>
</div>
<p className="mt-1.5 text-xs text-gray-400">
Agent 操作需链上签名确认,消耗 MSG Gas 费
</p>
</div>
</div>
);
}
7.3 Agent 交易确认
// src/components/AIAgent/AgentTxConfirmation.tsx
interface AgentTxConfirmationProps {
action: {
description: string;
messages: MsgEncodeObject[];
fee: string;
agentName: string;
};
onConfirm: () => void;
onReject: () => void;
}
function AgentTxConfirmation({ action, onConfirm, onReject }: AgentTxConfirmationProps) {
const [step, setStep] = useState<'review' | 'confirming' | 'done'>('review');
return (
<div className="rounded-xl border-2 border-purple-200 bg-white p-5 dark:border-purple-800 dark:bg-gray-800">
<div className="mb-4 flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-purple-100 text-purple-600">
🤖
</div>
<div>
<h3 className="font-semibold">Agent 请求执行操作</h3>
<p className="text-xs text-gray-500">
{action.agentName} 请求执行以下链上操作
</p>
</div>
</div>
<div className="mb-4 rounded-lg bg-purple-50 p-3 dark:bg-purple-900/20">
<p className="text-sm">{action.description}</p>
</div>
<div className="mb-4 space-y-2">
{action.messages.map((msg, i) => (
<MessageCard key={i} message={msg} index={i} />
))}
</div>
<div className="mb-4 flex items-center justify-between rounded-lg bg-gray-50 p-3 dark:bg-gray-900">
<span className="text-sm text-gray-600">预计费用</span>
<span className="font-mono text-sm font-bold">{action.fee}</span>
</div>
{step === 'review' && (
<div className="flex gap-2">
<button
onClick={onReject}
className="flex-1 rounded-lg border border-gray-300 px-4 py-2.5 text-sm transition hover:bg-gray-50"
>
拒绝
</button>
<button
onClick={() => {
setStep('confirming');
onConfirm();
}}
className="flex-1 rounded-lg bg-purple-600 px-4 py-2.5 text-sm font-medium text-white transition hover:bg-purple-700"
>
授权执行
</button>
</div>
)}
{step === 'confirming' && (
<div className="flex items-center justify-center gap-2 py-3">
<div className="h-4 w-4 animate-spin rounded-full border-2 border-purple-600 border-t-transparent" />
<span className="text-sm text-gray-600">请在钱包中签名确认...</span>
</div>
)}
</div>
);
}
7.4 Agent 付款流程
// src/hooks/useAgentPayment.ts
function useAgentPayment(agentId: string) {
const [paymentState, setPaymentState] = useState<PaymentState>({
status: 'idle',
});
const payForSession = useCallback(async (sessionCount: number) => {
setPaymentState({ status: 'preparing' });
try {
// 1. 查询 Agent 价格
const agentInfo = await queryAgentInfo(agentId);
const totalPrice = {
denom: 'umsg',
amount: (Number(agentInfo.price.amount) * sessionCount).toString(),
};
// 2. 构造支付消息
const messages: MsgEncodeObject[] = [
{
typeUrl: '/cosmos.bank.v1beta1.MsgSend',
value: {
fromAddress: userAddress,
toAddress: agentInfo.owner,
amount: [totalPrice],
},
},
];
// 3. 添加 Agent 特定的使用消息
messages.push({
typeUrl: '/msg.agent.v1.MsgUseAgent',
value: {
user: userAddress,
agentId: agentId,
sessions: sessionCount.toString(),
payment: totalPrice,
},
});
setPaymentState({ status: 'signing' });
// 4. 执行交易
const receipt = await executeTx(messages);
setPaymentState({
status: 'success',
txHash: receipt.txHash,
});
} catch (e) {
setPaymentState({
status: 'error',
error: classifyError(e),
});
}
}, [agentId, userAddress]);
return { paymentState, payForSession };
}
// 订阅管理
function useAgentSubscription(agentId: string) {
const [subscription, setSubscription] = useState<Subscription | null>(null);
const subscribe = useCallback(async (plan: 'weekly' | 'monthly') => {
const prices = {
weekly: { denom: 'umsg', amount: '50000000' }, // 50 MSG
monthly: { denom: 'umsg', amount: '150000000' }, // 150 MSG
};
const messages: MsgEncodeObject[] = [
{
typeUrl: '/msg.agent.v1.MsgSubscribeAgent',
value: {
user: userAddress,
agentId,
plan,
amount: [prices[plan]],
},
},
];
return executeTx(messages);
}, [agentId, userAddress]);
return { subscription, subscribe };
}
8. 可访问性
8.1 键盘导航
// src/hooks/useKeyboardNavigation.ts
function useKeyboardNavigation() {
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
// Esc 关闭所有弹窗
if (e.key === 'Escape') {
const modals = document.querySelectorAll('[data-modal="true"]');
const topModal = modals[modals.length - 1];
if (topModal) {
(topModal as HTMLElement).dataset.close?.();
}
}
// Enter 确认操作
if (e.key === 'Enter' && e.ctrlKey) {
const confirmBtn = document.querySelector('[data-auto-confirm]');
if (confirmBtn) {
(confirmBtn as HTMLButtonElement).click();
}
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, []);
}
8.2 屏幕阅读器支持
// src/components/Accessibility/TxAnnouncer.tsx
// 使用 aria-live 区域广播交易状态变更
function TxAnnouncer({ txState }: { txState: TransactionState }) {
const announcements: Record<TxStage, string> = {
idle: '',
building: '正在构造交易',
simulating: '正在模拟交易执行',
signing: '请在钱包中签名确认此交易',
broadcasting: '交易已签名,正在广播到网络',
pending: '交易已广播,等待链上确认',
confirmed: '交易已确认成功',
failed: '交易执行失败',
};
return (
<div
aria-live="polite"
aria-atomic="true"
className="sr-only"
>
{announcements[txState.stage] && (
<p>{announcements[txState.stage]}</p>
)}
{txState.stage === 'confirmed' && txState.txHash && (
<p>交易哈希: {txState.txHash}</p>
)}
{txState.stage === 'failed' && txState.error && (
<p>错误信息: {txState.error.message}</p>
)}
</div>
);
}
// 加载状态指示器
function LoadingSpinner({ label = '加载中' }: { label?: string }) {
return (
<div
role="status"
aria-label={label}
className="flex items-center justify-center"
>
<div className="h-8 w-8 animate-spin rounded-full border-4 border-msg-primary/30 border-t-msg-primary" />
<span className="sr-only">{label}</span>
</div>
);
}
8.3 高对比度与字体
// src/styles/accessibility.css (摘录)
/*
可访问性 CSS
- 符合 WCAG 2.1 AA 标准
- 对比度 ≥ 4.5:1(正常文本)
- 可缩放至 200%
*/
:root {
/* 高对比度色彩 */
--msg-text-primary: #1a1a2e;
--msg-text-secondary: #4a4a6a;
--msg-bg-primary: #ffffff;
--msg-accent: #6366f1;
--msg-accent-hover: #4f46e5;
--msg-error: #dc2626;
--msg-success: #16a34a;
--msg-warning: #d97706;
/* 字号 */
--msg-font-base: 16px;
--msg-font-small: 14px;
--msg-font-large: 20px;
--msg-font-xl: 24px;
/* 间距 */
--msg-spacing-xs: 4px;
--msg-spacing-sm: 8px;
--msg-spacing-md: 16px;
--msg-spacing-lg: 24px;
--msg-spacing-xl: 32px;
/* 目标触摸区域 ≥ 44px */
--msg-touch-target: 44px;
}
@media (prefers-contrast: high) {
:root {
--msg-text-primary: #000000;
--msg-text-secondary: #1a1a1a;
--msg-accent: #4338ca;
--msg-border: #000000;
}
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
/* 焦点指示器 */
*:focus-visible {
outline: 2px solid var(--msg-accent);
outline-offset: 2px;
}
/* 跳过导航 */
.skip-to-content {
position: absolute;
top: -100%;
left: var(--msg-spacing-md);
padding: var(--msg-spacing-sm) var(--msg-spacing-md);
background: var(--msg-accent);
color: white;
z-index: 9999;
}
.skip-to-content:focus {
top: var(--msg-spacing-md);
}
8.4 多语言支持
// src/i18n/messages.ts
const MESSAGES = {
'zh-CN': {
wallet: {
connect: '连接钱包',
disconnect: '断开连接',
connecting: '连接中...',
switching: '切换网络中...',
sign: '签名中...',
install_prompt: '请安装 {{wallet}} 钱包',
no_wallet: '未检测到钱包',
},
tx: {
confirm: '确认交易',
pending: '等待确认',
success: '交易成功',
failed: '交易失败',
retry: '重试',
view_explorer: '在浏览器中查看',
gas: 'Gas 费',
fee: '费用',
estimated_fee: '预计费用',
},
error: {
title: '出错了',
retry: '重试',
dismiss: '关闭',
details: '技术详情',
},
accessibility: {
loading: '加载中',
tx_announcement: '交易状态更新',
skip_nav: '跳过导航',
},
},
'en': {
wallet: {
connect: 'Connect Wallet',
disconnect: 'Disconnect',
connecting: 'Connecting...',
switching: 'Switching network...',
sign: 'Signing...',
install_prompt: 'Please install {{wallet}}',
no_wallet: 'No wallet detected',
},
tx: {
confirm: 'Confirm Transaction',
pending: 'Awaiting Confirmation',
success: 'Transaction Successful',
failed: 'Transaction Failed',
retry: 'Retry',
view_explorer: 'View in Explorer',
gas: 'Gas Fee',
fee: 'Fee',
estimated_fee: 'Estimated Fee',
},
error: {
title: 'Something Went Wrong',
retry: 'Retry',
dismiss: 'Dismiss',
details: 'Technical Details',
},
accessibility: {
loading: 'Loading',
tx_announcement: 'Transaction status update',
skip_nav: 'Skip to content',
},
},
} as const;
8.5 ARIA 模式与焦点管理
// src/components/Accessibility/Modal.tsx
function Modal({
isOpen,
onClose,
title,
children,
}: {
isOpen: boolean;
onClose: () => void;
title: string;
children: React.ReactNode;
}) {
const modalRef = useRef<HTMLDivElement>(null);
const previousFocusRef = useRef<HTMLElement | null>(null);
useEffect(() => {
if (isOpen) {
previousFocusRef.current = document.activeElement as HTMLElement;
// 聚焦到弹窗
setTimeout(() => modalRef.current?.focus(), 100);
} else {
previousFocusRef.current?.focus();
}
}, [isOpen]);
// 焦点陷阱
useEffect(() => {
if (!isOpen) return;
const handleTab = (e: KeyboardEvent) => {
if (e.key !== 'Tab') return;
const focusable = modalRef.current?.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
);
if (!focusable || focusable.length === 0) return;
const first = focusable[0] as HTMLElement;
const last = focusable[focusable.length - 1] as HTMLElement;
if (e.shiftKey) {
if (document.activeElement === first) {
last.focus();
e.preventDefault();
}
} else {
if (document.activeElement === last) {
first.focus();
e.preventDefault();
}
}
};
document.addEventListener('keydown', handleTab);
return () => document.removeEventListener('keydown', handleTab);
}, [isOpen]);
if (!isOpen) return null;
return (
<div
role="dialog"
aria-modal="true"
aria-label={title}
aria-labelledby="modal-title"
ref={modalRef}
tabIndex={-1}
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40"
>
<div className="w-full max-w-lg rounded-2xl bg-white p-6 shadow-2xl dark:bg-gray-900">
<div className="mb-4 flex items-center justify-between">
<h2 id="modal-title" className="text-xl font-bold">{title}</h2>
<button
onClick={onClose}
aria-label="关闭弹窗"
className="rounded-lg p-2 hover:bg-gray-100"
>
✕
</button>
</div>
{children}
</div>
</div>
);
}
附录
A. 完整钱包连接示例
// pages/index.tsx — 完整集成示例
function HomePage() {
const { state, connect, disconnect, retry } = useWalletConnect();
const { txState, execute, reset } = useTransaction();
const { isMobile } = useMobileWallet();
return (
<div className="mx-auto max-w-4xl p-4">
<header className="mb-8 flex items-center justify-between">
<h1 className="text-2xl font-bold">MSG dApp</h1>
{state.status === 'connected' ? (
<WalletConnected
address={state.address}
balance={state.balance}
onDisconnect={disconnect}
onSwitchWallet={() => connect()}
/>
) : (
<WalletSelector onSelect={connect} />
)}
</header>
{state.status === 'error' && (
<ErrorDisplay
error={state.error}
onRetry={retry}
/>
)}
{/* 交易区域 */}
<div className="mt-8">
<button
onClick={() => execute(sampleMessages)}
className="rounded-xl bg-msg-primary px-6 py-3 text-white"
>
发送交易
</button>
<TxStatus
txState={txState}
onViewExplorer={(hash) => window.open(`https://explorer.msg-chain.com/tx/${hash}`)}
onRetry={() => execute(sampleMessages)}
onDismiss={reset}
/>
</div>
<TxAnnouncer txState={txState} />
</div>
);
}
B. 常用 MSG Chain 端点
| 类型 | URL |
|---|---|
| RPC | https://rpc.msg-chain.com |
| REST/LCD | https://rest.msg-chain.com |
| WebSocket | wss://rpc.msg-chain.com/websocket |
| 浏览器 | https://explorer.msg-chain.com |
| 水龙头 | https://faucet.msg-chain.com |
C. 资源与参考
- Cosmos SDK 文档: https://docs.cosmos.network
- Keplr 钱包文档: https://docs.keplr.app
- WalletConnect v2: https://docs.walletconnect.com
- WCAG 2.1 指南: https://www.w3.org/TR/WCAG21
- MSG Chain 官方: https://msg-chain.com
