dApp Docs/dApp 用户体验与钱包交互最佳实践
Development reference. Not independently verified for production.

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>
  );
}
// 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. 资源与参考