MSG Chain 移动端 SDK 集成指南
数据来源:MSG Chain 代码库核实
主网状态: No-Go — 当前 MSGChain 主网裁决为 No-Go,以下内容反映代码实际状态,不代表生产可用。
版本: 1.0.0 | 链 ID: msg-chain-1 | 地址前缀: msg
目录
1. 概述
1.1 MSG Chain 移动端支持
MSG Chain 是基于 Cosmos SDK 构建的区块链网络,采用 Tendermint BFT 共识引擎,支持 CosmWasm 智能合约。移动端应用可以通过 RPC 端点与 MSG Chain 节点交互,实现钱包管理、交易签名、合约查询等核心功能。
链信息
| 参数 | 值 |
|---|---|
| 链 ID | msg-chain-1 |
| 地址前缀 | msg (bech32) |
| 共识引擎 | Tendermint BFT |
| 智能合约 | CosmWasm (wasmd v0.45+) |
| 原生代币 | MSG |
| 最小单位 | umsg (10⁻⁶ MSG) |
| coinType | 118 (Cosmos) |
| RPC 端口 | 26657 |
| REST API | 1317 |
移动端集成的典型架构如下:
┌──────────────────────┐ ┌───────────────────────┐
│ Mobile DApp │ │ Keplr Mobile │
│ (React Native / │◄────►│ (WalletConnect) │
│ Flutter) │ │ (私钥管理 / 签名) │
└──────────┬───────────┘ └───────────────────────┘
│ │
│ RPC │ RPC
▼ ▼
┌────────────────────────────────────────────────────────┐
│ MSG Chain Full Node │
│ (msg-chain-1) │
└────────────────────────────────────────────────────────┘
1.2 React Native vs Flutter 技术选型
| 对比维度 | React Native | Flutter |
|---|---|---|
| 开发语言 | TypeScript / JavaScript | Dart |
| 核心 SDK | CosmJS (@cosmjs/*) 官方维护 | cosmwasm_flutter 社区驱动 |
| 钱包连接 | WalletConnect v2 (官方 SDK) | WalletConnect v2 (walletconnect_dart) |
| 状态管理 | Zustand / Redux / Jotai | Riverpod / Bloc / Provider |
| 跨端一致性 | 中 (依赖原生组件) | 高 (自绘引擎 Skia) |
| 热重载 | Fast Refresh | Hot Reload + Hot Restart |
| 性能瓶颈 | JS Bridge 通信开销 | Dart FFI 直接调用 |
| 原生模块 | Turbo Modules / JSI | Method Channel / FFI |
| Cosmos 生态 | 成熟 (CosmJS 官方包) | 发展初期 |
| 学习曲线 | 需 React 基础 | 需 Dart 基础 |
| 包体积 | ~15MB (基础) | ~20MB (基础) |
| 社区资源 | 丰富 | 增长中 |
| 适合场景 | 轻量 DApp / DeFi | NFT 市场 / 重交互应用 |
选型建议:
- 团队熟悉 React 生态 => 选择 React Native,需先核验后使用 CosmJS
- 需要高性能图形渲染 => 选择 Flutter (Skia 引擎)
- 快速 MVP 验证 => React Native (社区资源丰富)
- 长期跨平台一致性要求高 => Flutter
1.3 移动端钱包方案
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| WalletConnect v2 + Keplr Mobile | 标准协议,无需管理私钥 | 依赖 Keplr 安装 | 通用场景 |
| 内嵌 HD Wallet | 完全自主控制 | 安全风险高 | 专用钱包 App |
| Social Recovery (MPC) | 用户友好 | 实现复杂 | 新手友好钱包 |
| Ledger 硬件钱包 | 最高安全性 | 移动端不便 | 大额资产管理 |
1.4 环境要求
| 组件 | 最低版本 | 推荐版本 |
|---|---|---|
| Node.js | 18.x | 20.x LTS |
| React Native | 0.73 | 0.76+ |
| Flutter | 3.16 | 3.24+ |
| Dart | 3.2 | 3.5+ |
| Xcode | 15.0 | 16.0+ (iOS 18) |
| Android Studio | Hedgehog | Ladybug+ |
| Keplr Mobile | 1.8.0 | 2.0+ |
| WalletConnect | v2.0 | v2.16+ |
| CosmJS | 0.32 | 0.33+ |
1.5 项目初始化
# React Native (TypeScript)
npx @react-native-community/cli@latest init MSGDApp --template react-native-template-typescript
cd MSGDApp
# Flutter
flutter create --org com.msgchain msg_dapp --platforms ios,android
cd msg_dapp
1.6 目录结构建议
MobileDApp/
├── src/
│ ├── lib/ # 核心库 (客户端、签名、查询)
│ ├── hooks/ # React Hooks / Dart providers
│ ├── store/ # 状态管理
│ ├── components/ # UI 组件
│ ├── screens/ # 页面
│ ├── models/ # 数据模型
│ └── utils/ # 工具函数
├── ios/ # iOS 原生配置
├── android/ # Android 原生配置
├── assets/ # 静态资源
└── test/ # 测试
2. React Native 集成
2.1 依赖安装
npm install @cosmjs/cosmwasm-stargate @cosmjs/stargate @cosmjs/proto-signing @cosmjs/encoding @cosmjs/crypto @cosmjs/math @walletconnect/web3wallet @walletconnect/core @walletconnect/types @walletconnect/utils @react-native-async-storage/async-storage react-native-get-random-values react-native-biometrics react-native-keychain react-native-safe-area-context react-native-svg zustand
cd ios && pod install && cd ..
2.2 链配置
// src/config/chain.ts
export const MSG_CHAIN = {
chainId: 'msg-chain-1',
chainName: 'MSG Chain',
rpcUrl: 'https://rpc.msg-chain.com',
restUrl: 'https://api.msg-chain.com',
prefix: 'msg',
denom: 'umsg',
decimals: 6,
gasPrice: '1000000000',
defaultGas: 200000,
contractGas: 300000,
coinType: 118,
bip44: { coinType: 118 },
gasPriceSteps: { low: 1000000000, average: 1000000000, high: 1000000000 },
} as const;
export function toAtomic(amount: string): string {
const [int, dec = ''] = amount.split('.');
const padded = dec.padEnd(MSG_CHAIN.decimals, '0');
return int + padded.slice(0, MSG_CHAIN.decimals);
}
export function fromAtomic(amount: string): string {
const padded = amount.padStart(MSG_CHAIN.decimals + 1, '0');
const int = padded.slice(0, -MSG_CHAIN.decimals) || '0';
const dec = padded.slice(-MSG_CHAIN.decimals);
return `${int}.${dec}`;
}
2.3 CosmJS 只读客户端
// src/lib/CosmWasmClient.ts
import { CosmWasmClient } from '@cosmjs/cosmwasm-stargate';
import { StargateClient } from '@cosmjs/stargate';
import { MSG_CHAIN } from '../config/chain';
export class MSGQueryClient {
private client: CosmWasmClient | null = null;
private stargate: StargateClient | null = null;
async connect(): Promise<void> {
this.client = await CosmWasmClient.connect(MSG_CHAIN.rpcUrl);
this.stargate = await StargateClient.connect(MSG_CHAIN.rpcUrl);
}
async getBalance(address: string): Promise<string> {
if (!this.client) await this.connect();
const coin = await this.client!.getBalance(address, MSG_CHAIN.denom);
return fromAtomic(coin.amount);
}
async queryContractSmart<T>(
contract: string, msg: Record<string, unknown>
): Promise<T> {
if (!this.client) await this.connect();
return this.client!.queryContractSmart(contract, msg);
}
async getBlockHeight(): Promise<number> {
if (!this.stargate) await this.connect();
const block = await this.stargate!.getBlock();
return block.header.height;
}
async getTx(hash: string) {
if (!this.stargate) await this.connect();
const tx = await this.stargate!.getTx(hash);
if (!tx) return null;
return { hash: tx.hash, height: tx.height, code: tx.code, rawLog: tx.rawLog };
}
async getAccount(address: string) {
if (!this.stargate) await this.connect();
return this.stargate!.getAccount(address);
}
async simulate(signer: string, msgs: any[]) {
if (!this.client) await this.connect();
return this.client!.simulate(signer, msgs);
}
disconnect() { this.client?.disconnect(); this.stargate?.disconnect(); }
}
2.4 签名客户端
// src/lib/SigningClient.ts
import { SigningCosmWasmClient } from '@cosmjs/cosmwasm-stargate';
import { OfflineSigner, AccountData } from '@cosmjs/proto-signing';
import { GasPrice, calculateFee, coins } from '@cosmjs/stargate';
import { MSG_CHAIN, toAtomic } from '../config/chain';
import { WalletConnectManager } from './WalletConnectManager';
export interface TxResult {
txHash: string; code: number; height: number;
gasUsed: number; rawLog: string;
}
export class WCSigner implements OfflineSigner {
private wc: WalletConnectManager;
private _address: string = '';
constructor(wc: WalletConnectManager) { this.wc = wc; }
setAddress(addr: string) { this._address = addr; }
async getAccounts(): Promise<readonly AccountData[]> {
if (!this._address) throw new Error('Address not set');
return [{
address: this._address,
algo: 'secp256k1' as const,
pubkey: new Uint8Array(0),
}];
}
async signDirect(signerAddress: string, signDoc: any) {
const sig = await this.wc.signDirect(signerAddress, signDoc);
return { signed: signDoc, signature: sig };
}
async signAmino(signerAddress: string, signDoc: any) {
const sig = await this.wc.signAmino(signerAddress, signDoc);
return { signed: signDoc, signature: sig };
}
}
export class MSGSigningClient {
private wcManager: WalletConnectManager;
private signer: WCSigner;
private client: SigningCosmWasmClient | null = null;
constructor(wcManager: WalletConnectManager) {
this.wcManager = wcManager;
this.signer = new WCSigner(wcManager);
}
async connect(address: string): Promise<void> {
this.signer.setAddress(address);
this.client = await SigningCosmWasmClient.connectWithSigner(
MSG_CHAIN.rpcUrl, this.signer,
{ gasPrice: GasPrice.fromString("1000000000attoMSG") },
);
}
async sendTokens(recipient: string, amount: string): Promise<TxResult> {
if (!this.client) throw new Error('Not connected');
const fee = calculateFee(MSG_CHAIN.defaultGas, GasPrice.fromString("1000000000attoMSG"));
const result = await this.client.sendTokens(
this.signer['_address'], recipient,
coins(toAtomic(amount), MSG_CHAIN.denom), fee,
);
return {
txHash: result.transactionHash, code: result.code ?? 0,
height: result.height, gasUsed: result.gasUsed ?? 0, rawLog: result.rawLog ?? '',
};
}
async executeContract(contract: string, msg: Record<string, unknown>, funds?: string): Promise<TxResult> {
if (!this.client) throw new Error('Not connected');
const fee = calculateFee(MSG_CHAIN.contractGas, GasPrice.fromString("1000000000attoMSG"));
const sendFunds = funds ? coins(toAtomic(funds), MSG_CHAIN.denom) : undefined;
const result = await this.client.execute(this.signer['_address'], contract, msg, fee, undefined, sendFunds);
return {
txHash: result.transactionHash, code: result.code ?? 0,
height: result.height, gasUsed: result.gasUsed ?? 0, rawLog: result.rawLog ?? '',
};
}
disconnect() { this.client?.disconnect(); }
}
2.5 WalletConnect v2 管理器
// src/lib/WalletConnectManager.ts
import Web3Wallet, { IWeb3Wallet } from '@walletconnect/web3wallet';
import { Core } from '@walletconnect/core';
import AsyncStorage from '@react-native-async-storage/async-storage';
const PROJECT_ID = 'YOUR_PROJECT_ID';
const RELAY_URL = 'wss://relay.walletconnect.com';
export class WalletConnectManager {
private web3wallet: IWeb3Wallet | null = null;
private sessions: Map<string, { address: string; topic: string }> = new Map();
async init(): Promise<void> {
const core = new Core({ projectId: PROJECT_ID, relayUrl: RELAY_URL });
this.web3wallet = await Web3Wallet.init({
core,
metadata: {
name: 'MSG Chain DApp',
description: 'MSG Chain Mobile App',
url: 'https://msg-chain.com',
icons: ['https://msg-chain.com/icon.png'],
},
});
this.web3wallet.on('session_proposal', async (proposal) => {
const approved = await this.web3wallet!.approveSession({
id: proposal.id,
namespaces: {
cosmos: {
methods: ['cosmos_signDirect', 'cosmos_signAmino'],
chains: ['cosmos:msg-chain-1'],
events: ['chainChanged', 'accountsChanged'],
},
},
});
const addr = approved.namespaces.cosmos?.accounts[0]?.split(':')[2];
if (addr) {
this.sessions.set(approved.topic, { address: addr, topic: approved.topic });
}
});
this.web3wallet.on('session_delete', ({ topic }) => { this.sessions.delete(topic); });
await this.restoreSessions();
}
async connect(): Promise<string> {
if (!this.web3wallet) throw new Error('Not initialized');
const { uri, approval } = await this.web3wallet.connect({
requiredNamespaces: {
cosmos: {
methods: ['cosmos_signDirect', 'cosmos_signAmino'],
chains: ['cosmos:msg-chain-1'],
events: ['chainChanged', 'accountsChanged'],
},
},
});
if (!uri) throw new Error('No URI generated');
return uri;
}
async signDirect(signerAddress: string, signDoc: any): Promise<Uint8Array> {
if (!this.web3wallet) throw new Error('Not initialized');
const session = this.findSession(signerAddress);
if (!session) throw new Error('No session');
const result = await this.web3wallet.request({
topic: session.topic, chainId: 'cosmos:msg-chain-1',
request: { method: 'cosmos_signDirect', params: { signerAddress, signDoc } },
});
return new Uint8Array(result.signature);
}
async signAmino(signerAddress: string, signDoc: any): Promise<Uint8Array> {
if (!this.web3wallet) throw new Error('Not initialized');
const session = this.findSession(signerAddress);
if (!session) throw new Error('No session');
const result = await this.web3wallet.request({
topic: session.topic, chainId: 'cosmos:msg-chain-1',
request: { method: 'cosmos_signAmino', params: { signerAddress, signDoc } },
});
return new Uint8Array(result.signature);
}
async disconnect(topic?: string): Promise<void> {
if (!this.web3wallet) return;
const topics = topic ? [topic] : [...this.sessions.keys()];
for (const t of topics) {
await this.web3wallet.disconnectSession({ topic: t, reason: { code: 6000, message: 'User disconnect' } });
this.sessions.delete(t);
}
}
isConnected(): boolean { return this.sessions.size > 0; }
getActiveAddress(): string | null { return this.sessions.values().next().value?.address ?? null; }
private findSession(address: string) {
for (const s of this.sessions.values()) {
if (s.address === address) return s;
}
return null;
}
private async restoreSessions() {
try {
const data = await AsyncStorage.getItem('@wc_sessions');
if (data) this.sessions = new Map(Object.entries(JSON.parse(data)));
} catch {}
}
}
2.6 生物识别认证
// src/lib/BiometricAuth.ts
import ReactNativeBiometrics from 'react-native-biometrics';
import * as Keychain from 'react-native-keychain';
const rnBiometrics = new ReactNativeBiometrics({ allowDeviceCredentials: true });
export async function requireBiometric(reason: string = '确认操作'): Promise<boolean> {
const { success } = await rnBiometrics.simplePrompt({
promptMessage: reason,
cancelButtonText: '取消',
});
return success;
}
export async function storeSecurely(key: string, value: string): Promise<void> {
await Keychain.setGenericPassword(key, value, {
service: `msg_chain_${key}`,
accessControl: Keychain.ACCESS_CONTROL.BIOMETRY_CURRENT_SET_OR_DEVICE_PASSCODE,
accessible: Keychain.ACCESSIBILITY.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
authenticationType: Keychain.AUTHENTICATION_TYPE.BIOMETRICS,
});
}
export async function retrieveSecurely(key: string): Promise<string | null> {
try {
const creds = await Keychain.getGenericPassword({
service: `msg_chain_${key}`,
authenticationPrompt: { title: '验证身份', subtitle: '使用生物识别解锁', cancel: '取消' },
});
return creds ? creds.password : null;
} catch { return null; }
}
2.7 交易历史管理
// src/lib/TxHistory.ts
import AsyncStorage from '@react-native-async-storage/async-storage';
export type TxStatus = 'pending' | 'success' | 'failed';
export interface TxRecord {
txHash: string; type: string; from: string; to: string;
amount: string; denom: string; fee: string; status: TxStatus;
height: number; timestamp: number; memo: string;
contractAddress?: string; rawLog?: string;
}
const STORAGE_KEY = '@msg_tx_history';
export class TxHistoryManager {
private cache: TxRecord[] | null = null;
async getAll(): Promise<TxRecord[]> {
if (this.cache) return this.cache;
try {
const data = await AsyncStorage.getItem(STORAGE_KEY);
this.cache = data ? JSON.parse(data) : [];
} catch { this.cache = []; }
return this.cache;
}
async add(tx: TxRecord): Promise<void> {
const list = await this.getAll();
list.unshift(tx);
if (list.length > 500) list.length = 500;
this.cache = list;
await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(list));
}
async update(hash: string, status: TxStatus, height?: number, rawLog?: string): Promise<void> {
const list = await this.getAll();
const idx = list.findIndex(r => r.txHash === hash);
if (idx !== -1) {
list[idx].status = status;
if (height) list[idx].height = height;
if (rawLog) list[idx].rawLog = rawLog;
this.cache = list;
await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(list));
}
}
async getPending(): Promise<TxRecord[]> {
const list = await this.getAll();
return list.filter(r => r.status === 'pending');
}
}
2.8 状态管理 (Zustand)
// src/store/walletStore.ts
import { create } from 'zustand';
interface WalletState {
address: string; balance: string; isConnected: boolean;
isConnecting: boolean; error: string | null;
setAddress: (addr: string) => void;
setBalance: (bal: string) => void;
setConnected: (v: boolean) => void;
setConnecting: (v: boolean) => void;
setError: (err: string | null) => void;
reset: () => void;
}
export const useWalletStore = create<WalletState>((set) => ({
address: '', balance: '0', isConnected: false, isConnecting: false, error: null,
setAddress: (address) => set({ address }),
setBalance: (balance) => set({ balance }),
setConnected: (isConnected) => set({ isConnected }),
setConnecting: (isConnecting) => set({ isConnecting }),
setError: (error) => set({ error }),
reset: () => set({ address: '', balance: '0', isConnected: false, isConnecting: false, error: null }),
}));
2.9 钱包 Hook
// src/hooks/useWallet.ts
import { useEffect, useCallback } from 'react';
import { useWalletStore } from '../store/walletStore';
import { WalletConnectManager } from '../lib/WalletConnectManager';
import { MSGQueryClient } from '../lib/CosmWasmClient';
import { requireBiometric } from '../lib/BiometricAuth';
import { MSGSigningClient } from '../lib/SigningClient';
import { TxHistoryManager, TxRecord } from '../lib/TxHistory';
let wcManager: WalletConnectManager | null = null;
function getWC(): WalletConnectManager {
if (!wcManager) wcManager = new WalletConnectManager();
return wcManager;
}
export function useWallet() {
const { address, balance, isConnected, isConnecting, error,
setAddress, setBalance, setConnected, setConnecting, setError, reset } = useWalletStore();
useEffect(() => {
const wc = getWC();
wc.init();
}, []);
const connect = useCallback(async () => {
setConnecting(true); setError(null);
try {
const uri = await getWC().connect();
return uri;
} catch (e: any) {
setError(e.message); setConnecting(false);
throw e;
}
}, []);
const disconnect = useCallback(async () => {
await getWC().disconnect();
reset();
}, []);
const send = useCallback(async (recipient: string, amount: string) => {
const addr = getWC().getActiveAddress();
if (!addr) throw new Error('Not connected');
const confirmed = await requireBiometric(`确认发送 ${amount} MSG`);
if (!confirmed) throw new Error('Cancelled');
const signing = new MSGSigningClient(getWC());
await signing.connect(addr);
const result = await signing.sendTokens(recipient, amount);
const record: TxRecord = {
txHash: result.txHash, type: 'send', from: addr, to: recipient,
amount, denom: 'umsg', fee: '0.005', status: 'success' as TxStatus,
height: result.height, timestamp: Date.now(), memo: '',
};
const hist = new TxHistoryManager();
await hist.add(record);
try {
const qc = new MSGQueryClient();
const bal = await qc.getBalance(addr);
setBalance(bal);
} catch {}
return result;
}, []);
return { address, balance, isConnected, isConnecting, error, connect, disconnect, send };
}
2.10 查询 Hook
// src/hooks/useQuery.ts
import { useState, useCallback } from 'react';
import { MSGQueryClient } from '../lib/CosmWasmClient';
export function useQuery() {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const query = useCallback(async <T>(contract: string, msg: Record<string, unknown>): Promise<T | null> => {
setLoading(true); setError(null);
try {
const qc = new MSGQueryClient();
return await qc.queryContractSmart<T>(contract, msg);
} catch (e: any) { setError(e.message); return null; }
finally { setLoading(false); }
}, []);
return { query, loading, error };
}
2.11 交易轮询
// src/lib/TxPoller.ts
import { MSGQueryClient } from './CosmWasmClient';
import { TxHistoryManager } from './TxHistory';
export class TxPoller {
private timer: ReturnType<typeof setInterval> | null = null;
start(): void { this.timer = setInterval(() => this.check(), 5000); }
stop(): void { if (this.timer) { clearInterval(this.timer); this.timer = null; } }
private async check(): Promise<void> {
try {
const hist = new TxHistoryManager();
const pending = await hist.getPending();
if (pending.length === 0) return;
const qc = new MSGQueryClient();
await qc.connect();
for (const tx of pending) {
if (!tx.txHash || tx.txHash.startsWith('pending_')) continue;
const result = await qc.getTx(tx.txHash);
if (result && result.code !== undefined) {
const status = result.code === 0 ? 'success' as const : 'failed' as const;
await hist.update(tx.txHash, status, result.height, result.rawLog);
}
}
} catch (e) { console.error('TxPoller error:', e); }
}
}
2.12 工具函数
// src/utils/format.ts
export function shortenAddress(addr: string, start = 8, end = 6): string {
if (addr.length <= start + end) return addr;
return `${addr.slice(0, start)}...${addr.slice(-end)}`;
}
export function isValidMsgAddress(addr: string): boolean {
return /^msg1[a-z0-9]{38}$/.test(addr);
}
export function extractWcUri(data: string): string | null {
if (data.startsWith('wc:')) return data;
const m = data.match(/wc:[^\s&]+/);
return m ? m[0] : null;
}
export function convertToMsgAddr(cosmosAddr: string): string {
try {
const { fromBech32, toBech32 } = require('@cosmjs/encoding');
const decoded = fromBech32(cosmosAddr);
return toBech32('msg', decoded.data);
} catch { return cosmosAddr; }
}
2.13 iOS 原生配置
<!-- ios/MSGDApp/Info.plist -->
<key>NSFaceIDUsageDescription</key>
<string>使用 Face ID 确认交易签名</string>
<key>NSCameraUsageDescription</key>
<string>扫描 WalletConnect 二维码</string>
<key>LSApplicationQueriesSchemes</key>
<array>
<string>keplrwallet</string>
<string>walletconnect</string>
</array>
2.14 Android 原生配置
<!-- android/app/src/main/AndroidManifest.xml -->
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
<uses-permission android:name="android.permission.INTERNET" />
<queries>
<package android:name="com.keplr.wallet" />
</queries>
2.15 完整示例:React Native DApp
// App.tsx
import React, { useEffect, useState } from 'react';
import { SafeAreaView, StyleSheet, Text, View, TouchableOpacity,
TextInput, ScrollView, ActivityIndicator, Alert, StatusBar } from 'react-native';
import { useWallet } from './src/hooks/useWallet';
import { useQuery } from './src/hooks/useQuery';
import { shortenAddress, isValidMsgAddress } from './src/utils/format';
import { TxPoller } from './src/lib/TxPoller';
import { TxHistoryManager, TxRecord } from './src/lib/TxHistory';
import { useWalletStore } from './src/store/walletStore';
type Tab = 'wallet' | 'send' | 'contract' | 'history';
export default function App() {
const { address, balance, isConnected, isConnecting, error, connect, disconnect, send } = useWallet();
const { query, loading: queryLoading, error: queryError } = useQuery();
const [tab, setTab] = useState<Tab>('wallet');
const [recipient, setRecipient] = useState('');
const [amount, setAmount] = useState('');
const [contractAddr, setContractAddr] = useState('');
const [queryMsg, setQueryMsg] = useState('{}');
const [queryResult, setQueryResult] = useState('');
const [history, setHistory] = useState<TxRecord[]>([]);
useEffect(() => {
const poller = new TxPoller();
poller.start();
new TxHistoryManager().getAll().then(setHistory);
return () => poller.stop();
}, []);
const handleSend = async () => {
if (!isValidMsgAddress(recipient)) { Alert.alert('无效地址'); return; }
if (isNaN(parseFloat(amount)) || parseFloat(amount) <= 0) { Alert.alert('无效金额'); return; }
try {
await send(recipient, amount);
Alert.alert('成功', '交易已提交');
setRecipient(''); setAmount('');
} catch (e: any) { Alert.alert('失败', e.message); }
};
const handleQuery = async () => {
if (!contractAddr) { Alert.alert('请输入合约地址'); return; }
try {
const msg = JSON.parse(queryMsg);
const result = await query(contractAddr, msg);
setQueryResult(JSON.stringify(result, null, 2));
} catch (e: any) { setQueryResult(`错误: ${e.message}`); }
};
return (
<SafeAreaView style={s.container}>
<StatusBar barStyle="light-content" />
<View style={s.header}>
<Text style={{color:'#fff',fontSize:20,fontWeight:'700'}}>MSG Chain</Text>
{isConnected && <View style={{width:10,height:10,borderRadius:5,backgroundColor:'#3FB950'}} />}
</View>
<View style={s.tabBar}>
{(['wallet','send','contract','history'] as const).map(t => (
<TouchableOpacity key={t} style={[s.tab, tab===t && s.activeTab]} onPress={() => setTab(t)}>
<Text style={[s.tabText, tab===t && s.activeTabText]}>
{t==='wallet'?'钱包':t==='send'?'发送':t==='contract'?'合约':'历史'}
</Text>
</TouchableOpacity>
))}
</View>
<ScrollView style={{flex:1,padding:16}}>
{tab==='wallet' && <View>
<Text style={s.title}>钱包</Text>
{isConnected ? <>
<View style={s.row}><Text style={s.label}>地址</Text><Text style={s.value}>{shortenAddress(address)}</Text></View>
<View style={s.row}><Text style={s.label}>余额</Text><Text style={s.value}>{balance} MSG</Text></View>
<TouchableOpacity style={s.btn} onPress={disconnect}><Text style={s.btnText}>断开连接</Text></TouchableOpacity>
</> : <>
<TouchableOpacity style={[s.btn, isConnecting && {opacity:0.5}]} onPress={connect} disabled={isConnecting}>
{isConnecting ? <ActivityIndicator color="#fff" /> : <Text style={s.btnText}>连接钱包</Text>}
</TouchableOpacity>
{error && <Text style={s.error}>{error}</Text>}
</>}
</View>}
{tab==='send' && <View>
<Text style={s.title}>发送 MSG</Text>
<TextInput style={s.input} placeholder="接收地址" value={recipient} onChangeText={setRecipient} autoCapitalize="none" />
<TextInput style={s.input} placeholder="数量" value={amount} onChangeText={setAmount} keyboardType="decimal-pad" />
<TouchableOpacity style={[s.btn, !isConnected && {opacity:0.5}]} onPress={handleSend} disabled={!isConnected}>
<Text style={s.btnText}>发送</Text>
</TouchableOpacity>
</View>}
{tab==='contract' && <View>
<Text style={s.title}>合约交互</Text>
<TextInput style={s.input} placeholder="合约地址" value={contractAddr} onChangeText={setContractAddr} autoCapitalize="none" />
<TextInput style={[s.input,{height:80}]} placeholder='{"key":"value"}' value={queryMsg} onChangeText={setQueryMsg} multiline />
<TouchableOpacity style={s.btn} onPress={handleQuery}><Text style={s.btnText}>查询</Text></TouchableOpacity>
{queryResult ? <View style={s.resultBox}><Text style={s.resultText}>{queryResult}</Text></View> : null}
{queryError && <Text style={s.error}>{queryError}</Text>}
</View>}
{tab==='history' && <View>
<Text style={s.title}>交易历史</Text>
{history.slice(0,20).map((tx,i) => (
<View key={i} style={s.txItem}>
<Text style={s.txHash}>{shortenAddress(tx.txHash,6,6)}</Text>
<Text style={{color:'#C9D1D9'}}>{tx.amount} MSG</Text>
<Text style={{color: tx.status==='success' ? '#3FB950' : '#F85149'}}>
{tx.status==='success' ? '✓ 成功' : '✗ 失败'}
</Text>
</View>
))}
</View>}
</ScrollView>
</SafeAreaView>
);
}
const s = StyleSheet.create({
container: { flex: 1, backgroundColor: '#0D1117' },
header: { flexDirection:'row', alignItems:'center', padding:16, backgroundColor:'#161B22', borderBottomWidth:1, borderBottomColor:'#30363D' },
tabBar: { flexDirection:'row', backgroundColor:'#161B22', borderBottomWidth:1, borderBottomColor:'#30363D' },
tab: { flex:1, paddingVertical:12, alignItems:'center' },
activeTab: { borderBottomWidth:2, borderBottomColor:'#58A6FF' },
tabText: { color:'#8B949E', fontSize:14 },
activeTabText: { color:'#58A6FF', fontWeight:'600' },
title: { color:'#fff', fontSize:18, fontWeight:'600', marginBottom:16 },
row: { flexDirection:'row', justifyContent:'space-between', paddingVertical:12, borderBottomWidth:1, borderBottomColor:'#21262D' },
label: { color:'#8B949E', fontSize:14 }, value: { color:'#fff', fontSize:14 },
input: { backgroundColor:'#21262D', borderWidth:1, borderColor:'#30363D', borderRadius:8, paddingHorizontal:16, paddingVertical:12, color:'#fff', fontSize:14, marginBottom:12 },
btn: { backgroundColor:'#238636', paddingVertical:14, borderRadius:8, alignItems:'center', marginBottom:8 },
btnText: { color:'#fff', fontSize:16, fontWeight:'600' },
error: { color:'#F85149', fontSize:14, marginTop:8, textAlign:'center' },
resultBox: { backgroundColor:'#161B22', borderWidth:1, borderColor:'#30363D', borderRadius:8, padding:12, marginTop:12 },
resultText: { color:'#C9D1D9', fontSize:12 },
txItem: { backgroundColor:'#21262D', borderRadius:6, padding:12, marginBottom:8 },
txHash: { color:'#58A6FF', fontSize:12 },
});
3. Flutter 集成
3.1 依赖配置
# pubspec.yaml
dependencies:
flutter:
sdk: flutter
flutter_riverpod: ^2.5.1
http: ^1.2.1
web_socket_channel: ^3.0.0
flutter_secure_storage: ^9.2.2
local_auth: ^2.3.0
mobile_scanner: ^6.0.0
qr_flutter: ^4.1.0
bech32: ^0.2.2
intl: ^0.19.0
equatable: ^2.0.5
url_launcher: ^6.3.0
fluttertoast: ^8.2.5
3.2 链配置
// lib/core/chain_config.dart
class ChainConfig {
static const String chainId = 'msg-chain-1';
static const String prefix = 'msg';
static const String rpcUrl = 'https://rpc.msg-chain.com';
static const String restUrl = 'https://api.msg-chain.com';
static const String denom = 'umsg';
static const int decimals = 6;
static const String gasPrice = '1000000000';
static const int defaultGas = 200000;
static const int contractGas = 300000;
static int toAtomic(double amount) => (amount * 1000000).toInt();
static double fromAtomic(int amount) => amount / 1000000.0;
static String formatBalance(int amount) => fromAtomic(amount).toStringAsFixed(decimals);
}
3.3 只读客户端
// lib/core/query_client.dart
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'chain_config.dart';
class MsgQueryClient {
final http.Client _client = http.Client();
Future<int> getBalance(String address) async {
final res = await _client.get(
Uri.parse('${ChainConfig.restUrl}/cosmos/bank/v1beta1/balances/$address'));
if (res.statusCode != 200) return 0;
final data = json.decode(res.body) as Map;
final balances = data['balances'] as List? ?? [];
for (final coin in balances) {
if (coin['denom'] == ChainConfig.denom) {
return int.tryParse(coin['amount']?.toString() ?? '0') ?? 0;
}
}
return 0;
}
Future<Map<String, dynamic>> querySmart(
String contract, Map<String, dynamic> msg) async {
final encoded = json.encode(msg);
final res = await _client.get(Uri.parse(
'${ChainConfig.restUrl}/cosmwasm/wasm/v1/contract/$contract/smart/$encoded'));
if (res.statusCode != 200) throw Exception('Query failed: ${res.statusCode}');
final data = json.decode(res.body) as Map;
return json.decode(data['data'] ?? '{}') as Map<String, dynamic>;
}
Future<int> getBlockHeight() async {
final res = await _client.get(Uri.parse('${ChainConfig.rpcUrl}/status'));
final data = json.decode(res.body) as Map;
return int.parse(data['result']['sync_info']['latest_block_height']);
}
Future<Map<String, dynamic>> getTx(String hash) async {
final res = await _client.get(
Uri.parse('${ChainConfig.restUrl}/cosmos/tx/v1beta1/txs/$hash'));
if (res.statusCode != 200) return {'status': 'pending'};
return json.decode(res.body)['tx_response'] ?? {'status': 'pending'};
}
Future<Map<String, dynamic>> getAccount(String address) async {
final res = await _client.get(
Uri.parse('${ChainConfig.restUrl}/cosmos/auth/v1beta1/accounts/$address'));
if (res.statusCode != 200) return {'account_number': 0, 'sequence': 0};
final acc = json.decode(res.body)['account'] ?? {};
return {
'account_number': int.tryParse(acc['account_number']?.toString() ?? '0') ?? 0,
'sequence': int.tryParse(acc['sequence']?.toString() ?? '0') ?? 0,
};
}
Future<Map<String, dynamic>> broadcast(Uint8List txBytes) async {
final res = await _client.post(
Uri.parse('${ChainConfig.restUrl}/cosmos/tx/v1beta1/txs'),
headers: {'Content-Type': 'application/json'},
body: json.encode({
'tx_bytes': base64Encode(txBytes),
'mode': 'BROADCAST_MODE_SYNC',
}),
);
if (res.statusCode != 200) throw Exception('Broadcast failed: ${res.statusCode}');
return json.decode(res.body)['tx_response'] ?? {};
}
void dispose() { _client.close(); }
}
3.4 签名服务
// lib/core/signing_service.dart
import 'dart:convert';
import 'dart:typed_data';
import 'query_client.dart';
import 'chain_config.dart';
typedef SignCallback = Future<Uint8List> Function(Map<String, dynamic> signDoc);
class MsgSigningService {
final MsgQueryClient _query;
final String _address;
MsgSigningService(this._query, this._address);
Future<Map<String, dynamic>> sendTokens({
required String recipient,
required int amount,
required SignCallback onSign,
String memo = '',
}) async {
final acc = await _query.getAccount(_address);
final seq = acc['sequence'] as int;
final fee = (ChainConfig.defaultGas * double.parse(ChainConfig.gasPrice)).round();
final signDoc = {
'body': {'messages': [{
'@type': '/cosmos.bank.v1beta1.MsgSend',
'from_address': _address, 'to_address': recipient,
'amount': [{'denom': ChainConfig.denom, 'amount': amount.toString()}],
}], 'memo': memo},
'auth_info': {'signer_infos': [{
'public_key': {'@type': '/cosmos.crypto.secp256k1.PubKey', 'key': ''},
'mode_info': {'single': {'mode': 'SIGN_MODE_DIRECT'}},
'sequence': seq.toString(),
}], 'fee': {
'amount': [{'denom': ChainConfig.denom, 'amount': fee.toString()}],
'gas_limit': ChainConfig.defaultGas.toString(),
}},
'signatures': [''],
'chain_id': ChainConfig.chainId,
'account_number': acc['account_number'].toString(),
};
final sig = await onSign(signDoc);
final bodyBytes = utf8.encode(json.encode(signDoc['body']));
final authBytes = utf8.encode(json.encode(signDoc['auth_info']));
final txRaw = utf8.encode(json.encode({
'body_bytes': base64Encode(bodyBytes),
'auth_info_bytes': base64Encode(authBytes),
'signatures': [base64Encode(sig)],
}));
return await _query.broadcast(Uint8List.fromList(txRaw));
}
Future<Map<String, dynamic>> executeContract({
required String contract,
required Map<String, dynamic> msg,
required SignCallback onSign,
String memo = '',
}) async {
final acc = await _query.getAccount(_address);
final seq = acc['sequence'] as int;
final fee = (ChainConfig.contractGas * double.parse(ChainConfig.gasPrice)).round();
final signDoc = {
'body': {'messages': [{
'@type': '/cosmwasm.wasm.v1.MsgExecuteContract',
'sender': _address, 'contract': contract,
'msg': base64Encode(utf8.encode(json.encode(msg))),
'funds': [],
}], 'memo': memo},
'auth_info': {'signer_infos': [{
'public_key': {'@type': '/cosmos.crypto.secp256k1.PubKey', 'key': ''},
'mode_info': {'single': {'mode': 'SIGN_MODE_DIRECT'}},
'sequence': seq.toString(),
}], 'fee': {
'amount': [{'denom': ChainConfig.denom, 'amount': fee.toString()}],
'gas_limit': ChainConfig.contractGas.toString(),
}},
'signatures': [''],
'chain_id': ChainConfig.chainId,
'account_number': acc['account_number'].toString(),
};
final sig = await onSign(signDoc);
final bodyBytes = utf8.encode(json.encode(signDoc['body']));
final authBytes = utf8.encode(json.encode(signDoc['auth_info']));
final txRaw = utf8.encode(json.encode({
'body_bytes': base64Encode(bodyBytes),
'auth_info_bytes': base64Encode(authBytes),
'signatures': [base64Encode(sig)],
}));
return await _query.broadcast(Uint8List.fromList(txRaw));
}
}
3.5 WalletConnect 服务
// lib/core/wallet_connect_service.dart
import 'dart:convert';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
class WalletConnectService {
final FlutterSecureStorage _storage = const FlutterSecureStorage();
String? _address;
bool _connected = false;
bool get isConnected => _connected;
String? get address => _address;
Future<void> restoreSession() async {
final saved = await _storage.read(key: 'wc_session');
if (saved != null) {
final data = json.decode(saved);
_address = data['address'];
_connected = true;
}
}
Future<String> connect() async {
return 'wc:YOUR_PROJECT_ID@2?relay-protocol=irn';
}
void setAddress(String addr) { _address = addr; _connected = true; _saveSession(); }
Future<void> disconnect() async {
_address = null; _connected = false;
await _storage.delete(key: 'wc_session');
}
Future<void> _saveSession() async {
if (_address != null) {
await _storage.write(key: 'wc_session', value: json.encode({'address': _address}));
}
}
}
3.6 安全存储
// lib/core/secure_storage.dart
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:local_auth/local_auth.dart';
class SecureStorage {
final FlutterSecureStorage _storage = const FlutterSecureStorage(
aOptions: AndroidOptions(encryptedSharedPreferences: true),
);
final LocalAuthentication _auth = LocalAuthentication();
Future<bool> isBiometricAvailable() async {
return await _auth.canCheckBiometrics || await _auth.isDeviceSupported();
}
Future<bool> authenticate({String reason = '验证身份'}) async {
try {
return await _auth.authenticate(
localizedReason: reason,
options: const AuthenticationOptions(biometricOnly: true, stickyAuth: true),
);
} catch { return false; }
}
Future<void> storeMnemonic(String mnemonic) async {
await _storage.write(key: 'msg_mnemonic', value: mnemonic);
}
Future<String?> getMnemonic() async {
final ok = await authenticate(reason: '使用生物识别解锁钱包');
if (!ok) return null;
return await _storage.read(key: 'msg_mnemonic');
}
Future<bool> hasMnemonic() async {
final m = await _storage.read(key: 'msg_mnemonic');
return m != null && m.isNotEmpty;
}
Future<void> clearAll() async { await _storage.deleteAll(); }
}
3.7 交易历史
// lib/core/tx_history.dart
import 'dart:convert';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
enum TxStatus { pending, success, failed }
class TxRecord {
final String txHash; final String type;
final String from; final String to;
final int amount; final String denom;
TxStatus status; final int height;
final DateTime timestamp;
TxRecord({
required this.txHash, this.type = 'send',
required this.from, required this.to, required this.amount,
this.denom = 'umsg', this.status = TxStatus.pending,
this.height = 0, DateTime? timestamp,
}) : timestamp = timestamp ?? DateTime.now();
String get formattedAmount => (amount / 1000000).toStringAsFixed(6);
bool get isPending => status == TxStatus.pending;
Map<String, dynamic> toJson() => {
'txHash': txHash, 'type': type, 'from': from, 'to': to,
'amount': amount, 'denom': denom, 'status': status.name,
'height': height, 'timestamp': timestamp.toIso8601String(),
};
factory TxRecord.fromJson(Map<String, dynamic> j) => TxRecord(
txHash: j['txHash'] ?? '', type: j['type'] ?? 'send',
from: j['from'] ?? '', to: j['to'] ?? '',
amount: j['amount'] ?? 0, denom: j['denom'] ?? 'umsg',
status: TxStatus.values.firstWhere((e) => e.name == j['status'],
orElse: () => TxStatus.pending),
height: j['height'] ?? 0,
timestamp: DateTime.tryParse(j['timestamp'] ?? '') ?? DateTime.now(),
);
}
class TxHistory {
final FlutterSecureStorage _storage = const FlutterSecureStorage();
static const _key = 'msg_tx_history';
Future<List<TxRecord>> getAll() async {
final d = await _storage.read(key: _key);
if (d == null) return [];
final list = json.decode(d) as List;
return list.map((e) => TxRecord.fromJson(e as Map<String, dynamic>)).toList();
}
Future<void> add(TxRecord tx) async {
final list = await getAll();
list.insert(0, tx);
if (list.length > 500) list.removeRange(500, list.length);
await _storage.write(key: _key, value: json.encode(list.map((e) => e.toJson()).toList()));
}
Future<void> update(String hash, TxStatus status) async {
final list = await getAll();
for (int i = 0; i < list.length; i++) {
if (list[i].txHash == hash) {
list[i].status = status;
await _storage.write(key: _key, value: json.encode(list.map((e) => e.toJson()).toList()));
break;
}
}
}
Future<List<TxRecord>> getPending() async {
final all = await getAll();
return all.where((t) => t.isPending).toList();
}
}
3.8 Riverpod 状态管理
// lib/providers/app_providers.dart
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../core/query_client.dart';
import '../core/wallet_connect_service.dart';
import '../core/secure_storage.dart';
import '../core/tx_history.dart';
final wcServiceProvider = Provider<WalletConnectService>((ref) {
final svc = WalletConnectService();
svc.restoreSession();
ref.onDispose(() => svc.disconnect());
return svc;
});
final queryClientProvider = Provider<MsgQueryClient>((ref) {
final client = MsgQueryClient();
ref.onDispose(() => client.dispose());
return client;
});
final secureStorageProvider = Provider<SecureStorage>((ref) => SecureStorage());
final txHistoryProvider = Provider<TxHistory>((ref) => TxHistory());
final walletStateProvider = StateNotifierProvider<WalletStateNotifier, WalletState>((ref) {
return WalletStateNotifier(ref);
});
class WalletState {
final String? address; final int balance;
final bool connected; final bool connecting;
final String? error; final String? wcUri;
const WalletState({
this.address, this.balance = 0, this.connected = false,
this.connecting = false, this.error, this.wcUri,
});
WalletState copyWith({
String? address, int? balance, bool? connected,
bool? connecting, String? error, String? wcUri,
}) => WalletState(
address: address ?? this.address, balance: balance ?? this.balance,
connected: connected ?? this.connected, connecting: connecting ?? this.connecting,
error: error, wcUri: wcUri ?? this.wcUri,
);
}
class WalletStateNotifier extends StateNotifier<WalletState> {
final Ref _ref;
WalletStateNotifier(this._ref) : super(const WalletState());
Future<void> connect() async {
state = state.copyWith(connecting: true, error: null);
try {
final wc = _ref.read(wcServiceProvider);
final uri = await wc.connect();
state = state.copyWith(wcUri: uri, connecting: false);
} catch (e) {
state = state.copyWith(connecting: false, error: e.toString());
}
}
void onConnected(String address) {
_ref.read(wcServiceProvider).setAddress(address);
state = state.copyWith(address: address, connected: true, wcUri: null);
refreshBalance(address);
}
Future<void> refreshBalance([String? addr]) async {
final a = addr ?? state.address;
if (a == null) return;
try {
final client = _ref.read(queryClientProvider);
final bal = await client.getBalance(a);
state = state.copyWith(balance: bal);
} catch (_) {}
}
}
3.9 工具函数
// lib/core/utils.dart
import 'package:bech32/bech32.dart';
import 'chain_config.dart';
class MsgUtils {
static String shortenAddress(String addr, {int start = 8, int end = 6}) {
if (addr.length <= start + end) return addr;
return '${addr.substring(0, start)}...${addr.substring(addr.length - end)}';
}
static bool isValidAddress(String addr) {
return RegExp(r'^msg1[a-z0-9]{38}$').hasMatch(addr);
}
static String convertToMsg(String cosmosAddr) {
try {
final decoded = Bech32.decode(cosmosAddr);
return Bech32.encode(ChainConfig.prefix, decoded.data);
} catch (_) { return cosmosAddr; }
}
static String parseError(String rawLog) {
if (rawLog.contains('insufficient funds')) return '余额不足';
if (rawLog.contains('out of gas')) return 'Gas 不足';
final m = RegExp(r'wasm error: ([^\n]+)').firstMatch(rawLog);
return m?.group(1) ?? rawLog;
}
static String timeAgo(DateTime dt) {
final diff = DateTime.now().difference(dt);
if (diff.inSeconds < 60) return '刚刚';
if (diff.inMinutes < 60) return '${diff.inMinutes}分钟前';
if (diff.inHours < 24) return '${diff.inHours}小时前';
if (diff.inDays < 7) return '${diff.inDays}天前';
return '${dt.month}/${dt.day}';
}
}
3.10 完整示例:Flutter DApp
// lib/main.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'providers/app_providers.dart';
import 'core/query_client.dart';
import 'core/signing_service.dart';
import 'core/tx_history.dart';
import 'core/utils.dart';
void main() {
WidgetsFlutterBinding.ensureInitialized();
runApp(const ProviderScope(child: MsgDApp()));
}
class MsgDApp extends StatelessWidget {
const MsgDApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'MSG Chain',
debugShowCheckedModeBanner: false,
theme: ThemeData.dark().copyWith(
scaffoldBackgroundColor: const Color(0xFF0D1117),
colorScheme: const ColorScheme.dark(
primary: Color(0xFF58A6FF), secondary: Color(0xFF3FB950),
error: Color(0xFFF85149),
),
),
home: const HomePage(),
);
}
}
class HomePage extends ConsumerStatefulWidget {
const HomePage({super.key});
@override
ConsumerState<HomePage> createState() => _HomePageState();
}
class _HomePageState extends ConsumerState<HomePage> {
int _tab = 0;
final _toCtrl = TextEditingController();
final _amtCtrl = TextEditingController();
final _contractCtrl = TextEditingController();
final _queryCtrl = TextEditingController(text: '{}');
String _queryResult = '';
@override
void dispose() {
_toCtrl.dispose(); _amtCtrl.dispose();
_contractCtrl.dispose(); _queryCtrl.dispose();
super.dispose();
}
Future<void> _send() async {
final amt = double.tryParse(_amtCtrl.text);
if (amt == null || amt <= 0) { _err('无效金额'); return; }
if (!MsgUtils.isValidAddress(_toCtrl.text)) { _err('无效地址'); return; }
final secure = ref.read(secureStorageProvider);
final ok = await secure.authenticate(reason: '确认发送 $amt MSG');
if (!ok) { _err('用户取消'); return; }
final ws = ref.read(walletStateProvider);
if (ws.address == null) { _err('钱包未连接'); return; }
try {
final qc = ref.read(queryClientProvider);
final svc = MsgSigningService(qc, ws.address!);
final wc = ref.read(wcServiceProvider);
final result = await svc.sendTokens(
recipient: _toCtrl.text, amount: ChainConfig.toAtomic(amt),
onSign: (doc) async => Uint8List(0),
);
final tx = TxRecord(
txHash: result['txhash'] ?? '',
from: ws.address!, to: _toCtrl.text, amount: ChainConfig.toAtomic(amt),
);
await ref.read(txHistoryProvider).add(tx);
_msg('交易已提交');
_toCtrl.clear(); _amtCtrl.clear();
} catch (e) { _err('$e'); }
}
Future<void> _query() async {
try {
final msg = json.decode(_queryCtrl.text);
final qc = ref.read(queryClientProvider);
final result = await qc.querySmart(_contractCtrl.text, msg);
setState(() => _queryResult = const JsonEncoder.withIndent(' ').convert(result));
} catch (e) { setState(() => _queryResult = '错误: $e'); }
}
void _err(String m) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(m), backgroundColor: Colors.red));
}
void _msg(String m) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(m), backgroundColor: const Color(0xFF3FB950)));
}
@override
Widget build(BuildContext context) {
final ws = ref.watch(walletStateProvider);
return Scaffold(
body: [
_walletPage(ws), _sendPage(ws), _contractPage(), _historyPage(),
][_tab],
bottomNavigationBar: BottomNavigationBar(
currentIndex: _tab, onTap: (i) => setState(() => _tab = i),
backgroundColor: const Color(0xFF161B22),
selectedItemColor: const Color(0xFF58A6FF),
unselectedItemColor: const Color(0xFF8B949E),
items: const [
BottomNavigationBarItem(icon: Icon(Icons.account_balance_wallet), label: '钱包'),
BottomNavigationBarItem(icon: Icon(Icons.send), label: '发送'),
BottomNavigationBarItem(icon: Icon(Icons.code), label: '合约'),
BottomNavigationBarItem(icon: Icon(Icons.history), label: '历史'),
],
),
);
}
Widget _walletPage(WalletState ws) {
return SafeArea(child: Padding(
padding: const EdgeInsets.all(20),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
const SizedBox(height: 20),
const Text('MSG Chain', style: TextStyle(fontSize: 28, fontWeight: FontWeight.w700, color: Colors.white)),
const SizedBox(height: 32),
Card(child: Padding(
padding: const EdgeInsets.all(20),
child: ws.connected && ws.address != null
? Column(children: [
const Icon(Icons.account_balance_wallet, size: 48, color: Color(0xFF58A6FF)),
const SizedBox(height: 12),
Text('${ChainConfig.formatBalance(ws.balance)} MSG', style: const TextStyle(fontSize: 32, fontWeight: FontWeight.w700, color: Colors.white)),
const SizedBox(height: 8),
Container(padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(color: const Color(0xFF21262D), borderRadius: BorderRadius.circular(6)),
child: Text(MsgUtils.shortenAddress(ws.address!), style: const TextStyle(color: Color(0xFF58A6FF), fontFamily: 'monospace', fontSize: 13))),
])
: Column(children: [
const Icon(Icons.wallet, size: 64, color: Color(0xFF484F58)),
const SizedBox(height: 16),
const Text('连接 Keplr Mobile', style: TextStyle(color: Color(0xFF8B949E))),
const SizedBox(height: 24),
ElevatedButton(
onPressed: ws.connecting ? null : () => ref.read(walletStateProvider.notifier).connect(),
child: ws.connecting
? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2))
: const Text('连接钱包'),
),
if (ws.error != null) Padding(padding: const EdgeInsets.only(top: 12), child: Text(ws.error!, style: const TextStyle(color: Color(0xFFF85149)))),
]),
)),
]),
));
}
Widget _sendPage(WalletState ws) {
return SafeArea(child: SingleChildScrollView(
padding: const EdgeInsets.all(20),
child: Column(children: [
const Text('发送 MSG', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: Colors.white)),
const SizedBox(height: 16),
TextField(controller: _toCtrl, decoration: const InputDecoration(labelText: '接收地址 (msg1...)'), style: const TextStyle(color: Colors.white)),
const SizedBox(height: 12),
TextField(controller: _amtCtrl, decoration: const InputDecoration(labelText: '数量'), keyboardType: TextInputType.number, style: const TextStyle(color: Colors.white)),
const SizedBox(height: 24),
ElevatedButton(onPressed: ws.connected ? _send : null, child: const Text('发送')),
]),
));
}
Widget _contractPage() {
return SafeArea(child: SingleChildScrollView(
padding: const EdgeInsets.all(20),
child: Column(children: [
const Text('合约交互', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: Colors.white)),
const SizedBox(height: 16),
TextField(controller: _contractCtrl, decoration: const InputDecoration(labelText: '合约地址'), style: const TextStyle(color: Colors.white)),
const SizedBox(height: 12),
TextField(controller: _queryCtrl, decoration: const InputDecoration(labelText: '查询消息 (JSON)'), maxLines: 3, style: const TextStyle(color: Colors.white)),
const SizedBox(height: 16),
ElevatedButton(onPressed: _query, child: const Text('查询')),
if (_queryResult.isNotEmpty)
Container(width: double.infinity, margin: const EdgeInsets.only(top: 16), padding: const EdgeInsets.all(12),
decoration: BoxDecoration(color: const Color(0xFF161B22), borderRadius: BorderRadius.circular(8), border: Border.all(color: const Color(0xFF30363D))),
child: Text(_queryResult, style: const TextStyle(fontSize: 12, fontFamily: 'monospace', color: Color(0xFFC9D1D9)))),
]),
));
}
Widget _historyPage() {
return SafeArea(child: Padding(
padding: const EdgeInsets.all(20),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
const Text('交易历史', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: Colors.white)),
const SizedBox(height: 16),
Expanded(child: FutureBuilder<List<TxRecord>>(
future: ref.read(txHistoryProvider).getAll(),
builder: (ctx, snap) {
if (!snap.hasData) return const Center(child: CircularProgressIndicator());
return ListView.builder(
itemCount: snap.data!.length,
itemBuilder: (ctx, i) {
final tx = snap.data![i];
return Card(child: ListTile(
title: Text(MsgUtils.shortenAddress(tx.txHash, start: 6, end: 6), style: const TextStyle(color: Color(0xFF58A6FF), fontFamily: 'monospace', fontSize: 12)),
subtitle: Text('${tx.formattedAmount} MSG ${MsgUtils.timeAgo(tx.timestamp)}', style: const TextStyle(color: Color(0xFF8B949E))),
trailing: Text(tx.isPending ? '⏳' : tx.status == TxStatus.success ? '✓' : '✗', style: TextStyle(color: tx.isPending ? Colors.orange : tx.status == TxStatus.success ? const Color(0xFF3FB950) : const Color(0xFFF85149))),
));
},
);
},
)),
]),
));
}
}
4. WalletConnect 协议详解
4.1 协议概述
WalletConnect v2 是一个开放协议,用于在移动端 DApp 和钱包之间建立安全通信。它使用中继服务器(Relay Server)传递加密消息,不依赖中心化基础设施。
核心流程:
- DApp 请求连接,生成 WC URI
- 用户将 URI 通过 QR 码或 Deep Link 发送给 Keplr Mobile
- Keplr 解析 URI,建立与中继服务器的 WebSocket 连接
- 双方交换对称密钥,建立加密通道
- DApp 发送签名请求,中继转发,Keplr 提示用户确认
- 签名结果加密返回,DApp 广播交易到 MSG Chain
4.2 URI 结构
wc:PROJECT_ID@2?relay-protocol=irn&symKey=BASE64URL_KEY&expiry=TIMESTAMP&relayUrl=wss://relay.walletconnect.com
| 参数 | 说明 |
|---|---|
| PROJECT_ID | WalletConnect Cloud 项目 ID |
| relay-protocol | 中继协议(默认 irn) |
| symKey | 32 字节对称密钥的 base64url 编码 |
| expiry | 连接过期时间戳 |
| relayUrl | 中继服务器 WebSocket 地址 |
4.3 命名空间
const requiredNamespaces = {
cosmos: {
methods: ['cosmos_signDirect', 'cosmos_signAmino'],
chains: ['cosmos:msg-chain-1'],
events: ['chainChanged', 'accountsChanged'],
},
};
方法说明:
cosmos_signDirect— 签名 Protobuf 编码的交易 (SIGN_MODE_DIRECT)cosmos_signAmino— 签名 Amino JSON 编码的交易 (兼容旧版)chainChanged— 钱包切换链时通知 DAppaccountsChanged— 钱包切换账户时通知 DApp
4.4 连接流程
// 初始化
const core = new Core({ projectId: 'YOUR_ID', relayUrl: 'wss://relay.walletconnect.com' });
const wallet = await Web3Wallet.init({
core,
metadata: { name: 'DApp', description: '...', url: '...', icons: [...] },
});
// 发起连接
const { uri, approval } = await wallet.connect({
requiredNamespaces: {
cosmos: { methods: ['cosmos_signDirect'], chains: ['cosmos:msg-chain-1'], events: [] },
},
});
// 等待用户批准
const session = await approval();
const address = session.namespaces.cosmos.accounts[0].split(':')[2];
4.5 签名请求
// Sign Direct (推荐)
const result = await wallet.request({
topic: session.topic,
chainId: 'cosmos:msg-chain-1',
request: {
method: 'cosmos_signDirect',
params: {
signerAddress: 'msg1...',
signDoc: { bodyBytes, authInfoBytes, chainId: 'msg-chain-1', accountNumber: '123' },
},
},
});
// Sign Amino (兼容)
const result = await wallet.request({
topic: session.topic, chainId: 'cosmos:msg-chain-1',
request: {
method: 'cosmos_signAmino',
params: { signerAddress: 'msg1...', signDoc: { chain_id: 'msg-chain-1', ... } },
},
});
4.6 会话管理
// 持久化会话
await AsyncStorage.setItem('@wc_topic', session.topic);
// 恢复会话
const topic = await AsyncStorage.getItem('@wc_topic');
if (topic) {
const session = wallet.getActiveSessions()[topic];
if (session) { /* 会话有效 */ }
}
// 断开连接
await wallet.disconnectSession({
topic: session.topic,
reason: { code: 6000, message: 'User disconnected' },
});
// 监听事件
wallet.on('session_proposal', (p) => { /* 自动批准或提示用户 */ });
wallet.on('session_delete', ({ topic }) => { /* 清理会话 */ });
wallet.on('session_expire', ({ topic }) => { /* 处理过期 */ });
4.7 错误码
| 错误码 | 含义 |
|---|---|
| 0 | 成功 |
| 1000 | 用户拒绝 |
| 2000 | 方法不支持 |
| 3000 | 链不支持 |
| 4000 | 会话过期 |
| 5000 | 签名失败 |
| 6000 | 用户断开连接 |
| 7000 | 钱包内部错误 |
4.8 Keplr Mobile 特定方法
Keplr Mobile 支持 WalletConnect v2,以下是其支持的链和方法:
{
"supported_chains": ["cosmos:msg-chain-1"],
"supported_methods": [
"cosmos_signDirect",
"cosmos_signAmino",
"keplr_getKey",
"keplr_enableWallet",
"keplr_signDirect",
"keplr_signAmino"
]
}
4.9 Deep Link 与 Universal Link
keplrwallet://wc?uri=WC_URI_ENCODED (Android Deep Link)
https://keplr.app/wc?uri=WC_URI_ENCODED (iOS Universal Link)
import { Linking } from 'react-native';
function openKeplr(wcUri: string) {
const encoded = encodeURIComponent(wcUri);
Linking.openURL(`keplrwallet://wc?uri=${encoded}`).catch(() => {
Linking.openURL(`https://keplr.app/wc?uri=${encoded}`);
});
}
5. 安全考虑
5.1 私钥安全管理
私钥和助记词绝不能存储在纯文本中。必须使用平台提供的安全硬件存储:
| 平台 | 安全存储技术 | 说明 |
|---|---|---|
| iOS | Keychain Services | 硬件加密,支持 Face ID/Touch ID |
| Android | Android Keystore | TEE 隔离,支持指纹/生物识别 |
| iOS | Secure Enclave | 256 位 AES 硬件加密 |
| Android | StrongBox | 独立安全芯片(需硬件支持) |
React Native:使用 react-native-keychain
import * as Keychain from 'react-native-keychain';
// 存储(自动加密)
await Keychain.setGenericPassword('mnemonic', MNEMONIC, {
service: 'msg_chain_key',
accessControl: Keychain.ACCESS_CONTROL.BIOMETRY_CURRENT_SET_OR_DEVICE_PASSCODE,
accessible: Keychain.ACCESSIBILITY.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
});
// 读取(需要生物识别)
const creds = await Keychain.getGenericPassword({
service: 'msg_chain_key',
authenticationPrompt: {
title: '验证身份',
subtitle: '解锁钱包需要生物识别',
cancel: '取消',
},
});
Flutter:使用 flutter_secure_storage
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:local_auth/local_auth.dart';
const storage = FlutterSecureStorage();
await storage.write(key: 'msg_mnemonic', value: mnemonic);
final localAuth = LocalAuthentication();
final authenticated = await localAuth.authenticate(
localizedReason: '使用生物识别解锁钱包',
options: AuthenticationOptions(biometricOnly: true),
);
5.2 生物识别确认
所有交易签名操作在发送到 WalletConnect 之前,应在 DApp 侧要求生物识别确认:
import ReactNativeBiometrics from 'react-native-biometrics';
const rnBiometrics = new ReactNativeBiometrics();
async function confirmTransaction(reason: string): Promise<boolean> {
const { success } = await rnBiometrics.simplePrompt({
promptMessage: reason,
cancelButtonText: '取消',
});
return success;
}
最佳实践:
- 显示完整的交易详情(发送地址、接收地址、金额、Gas 费用)
- 使用生物识别硬件级确认(不可绕过)
- 设置签名超时(建议 60 秒)
- 失败重试限制(3 次后锁定)
5.3 会话超时管理
const SESSION_DURATION = 24 * 60 * 60 * 1000; // 24 小时
function isSessionExpired(timestamp: number): boolean {
return Date.now() - timestamp > SESSION_DURATION;
}
// 定期检查(每分钟)
setInterval(() => {
// 检查并清理过期会话
}, 60000);
超时配置建议:
- 小额操作 (≤ 100 MSG): 无额外超时
- 中等金额 (100-10000 MSG): 生物识别 + 会话检查
- 大额操作 (> 10000 MSG): 生物识别 + 硬件密钥 + 2FA
5.4 钓鱼防护
// 验证接收地址
function isValidMsgAddress(addr: string): boolean {
return /^msg1[a-z0-9]{38}$/.test(addr);
}
// 域名白名单
const ALLOWED_DOMAINS = ['https://app.msg-chain.com', 'https://dapp.msg-chain.com'];
function verifyOrigin(url: string): boolean {
return ALLOWED_DOMAINS.some(d => url.startsWith(d));
}
安全清单:
- 始终验证 WalletConnect URI 来自可信源
- 地址栏输入自动校验 bech32 校验和
- 发送前显示完整的交易预览
- 不支持网页剪贴板自动粘贴(防篡改)
- 大额交易强制冷却期(10 秒)
5.5 网络通信安全
// 使用 HTTPS/WSS 连接
const RPC_URL = 'https://rpc.msg-chain.com';
const RELAY_URL = 'wss://relay.walletconnect.com';
// Android: android:usesCleartextTraffic="false" 禁用明文
// iOS: App Transport Security (ATS) 默认启用 HTTPS
5.6 其他安全建议
| 项目 | 建议措施 |
|---|---|
| 日志 | 不记录私钥、助记词、签名原文 |
| 截图 | Android 设置 FLAG_SECURE,iOS 使用 UITextField 保护 |
| 剪贴板 | 敏感信息在 30 秒后自动清除剪贴板 |
| 后台运行 | 切到后台时清空钱包界面,锁定应用 |
| 版本更新 | 强制检查签名,防止包篡改 |
| 第三方库 | 定期更新依赖,审计未维护的包 |
| 测试网 | 主网和测试网使用不同的配置文件和存储空间 |
6. 部署指南
6.1 WalletConnect 项目注册
- 访问 https://cloud.walletconnect.com
- 注册账号并创建新项目
- 获取 Project ID
- 配置支持的链:添加
cosmos:msg-chain-1 - 填入 DApp 名称、URL 和图标
- 在生产环境前验证连接
6.2 环境配置管理
// src/config/env.ts
const ENV = {
development: {
rpcUrl: 'https://testnet-rpc.msg-chain.com',
restUrl: 'https://testnet-api.msg-chain.com',
chainId: 'msg-chain-test-1',
wcProjectId: 'TEST_PROJECT_ID',
},
production: {
rpcUrl: 'https://rpc.msg-chain.com',
restUrl: 'https://api.msg-chain.com',
chainId: 'msg-chain-1',
wcProjectId: 'PROD_PROJECT_ID',
},
}[__DEV__ ? 'development' : 'production'];
6.3 iOS 发布配置
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<false/>
</dict>
App Store 审核注意事项:
- 确保 WalletConnect 功能正常工作
- 提供测试账号或测试说明
- 生物识别权限说明清晰
6.4 Android 发布配置
// android/app/build.gradle
android {
compileSdk 34
defaultConfig {
minSdk 24
targetSdk 34
}
}
6.5 CI/CD 集成
# .github/workflows/ci.yml
name: MSG DApp CI
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '20' }
- run: npm ci
- run: npm run lint
- run: npm run typecheck
- run: npm test
build-android:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with: { distribution: 'zulu', java-version: '17' }
- run: npm ci
- run: npx react-native build-android --mode release
6.6 安全检查清单
- [ ] WalletConnect Project ID 已替换为生产环境
- [ ] RPC/REST 端点为生产环境 URL
- [ ] 链 ID 为
msg-chain-1 - [ ] 生物识别权限已声明
- [ ] 相机权限已声明
- [ ] Keychain/Keystore 配置正确
- [ ] 无硬编码私钥或助记词
- [ ] 代码混淆已启用 (ProGuard/R8)
- [ ] 深度链接 (Deep Link) 已注册
- [ ] Universal Link 已配置 (iOS)
- [ ] 网络请求使用 HTTPS/WSS
- [ ] 依赖库版本无已知漏洞
6.7 性能优化建议
| 优化项 | 说明 |
|---|---|
| 连接池 | 复用 CosmWasmClient 实例,避免频繁创建 |
| 缓存 | 缓存账户序列号和合约查询结果 |
| Gas 估算 | 先 simulate 再发送,避免 Gas 不足回滚 |
| 批量查询 | 合并多个查询请求,减少 RPC 调用 |
| 懒加载 | 合约查询结果懒加载,分页显示历史 |
| WebSocket | 使用 ws 连接替代轮询获取最新区块 |
6.8 常见问题排查
1. WalletConnect 连接失败
- 确认 Keplr Mobile 已安装且版本 >= 1.8.0
- 检查 WalletConnect Project ID 是否有效
- 验证中继服务器地址是否可访问
2. 交易广播后无响应
- 检查 RPC 节点是否同步中
- 确认 Gas 费用是否充足
- 查看链上浏览器确认交易状态
3. 生物识别无法使用
- iOS: 检查 Face ID 权限是否开启
- Android: 确认设备已录入指纹
- 确保 Keychain/Keystore 服务正常
4. 合约查询返回空
- 确认合约地址格式正确 (msg1...)
- 验证查询消息 JSON 格式
- 检查合约是否在 msg-chain-1 上部署
6.9 相关资源
| 资源 | 地址 |
|---|---|
| MSG Chain 官方 | https://msg-chain.com |
| 区块浏览器 | https://msg-scan.com |
| RPC 端点 | https://rpc.msg-chain.com |
| REST API | https://api.msg-chain.com |
| CosmJS 文档 | https://cosmjs.tech |
| WalletConnect 文档 | https://docs.walletconnect.com |
| Keplr 文档 | https://docs.keplr.app |
| CosmWasm 文档 | https://docs.cosmwasm.com |
| React Native 文档 | https://reactnative.dev |
| Flutter 文档 | https://flutter.dev |
