MSG Chain 区块浏览器搭建指南
数据来源:MSG Chain 代码库核实
主网状态: No-Go — 当前 MSGChain 主网裁决为 No-Go,以下内容反映代码实际状态,不代表生产可用。
目录
- 概述
- 链上数据查询基础
- Indexer 搭建
- Explorer 后端 API
- Explorer 前端
- 合约源码验证
- WebSocket 实时更新
- 本地 Explorer 子门禁集成
- Docker 部署
- 标记与边界
1. 概述
1.1 什么是区块浏览器
区块浏览器(Block Explorer)是区块链网络的"搜索引擎"和"可视化仪表盘"。用户可以通过它查询:
- 最新区块和交易
- 账户余额和交易历史
- 智能合约状态和源码
- 验证者信息和质押数据
- 网络实时状态(TPS、活跃验证者、出块时间)
类比传统互联网:
- Mintscan ≈ Cosmos 生态的"百度搜索" + "Google Analytics"
- Subscan ≈ Polkadot 生态的"企业级数据中台"
- Etherscan ≈ Ethereum 生态的"合约验证+数据浏览器"
1.2 MSG Chain 当前状态
| 维度 | 状态 | 说明 |
|---|---|---|
| 链上 RPC | ✅ 可用 | 26657(Tendermint), 1317(REST), 9090(gRPC) |
| 公开查询 API | ✅ 部分实现 | public_query.yaml 中定义的端点可用 |
| Indexer | 🟡 本地预检 | local_preflight_only=true,仅本地可用 |
| 合约源码元数据 | ✅ 子门禁已关 | /api/v1/contracts 提供 registry/interface_hash/source/wasm_hash |
| 规范源查询 | ✅ 子门禁已关 | /api/v1/contracts?source=canonical |
| 事件日志查询 | ✅ 子门禁已关 | 通过 eth_getLogs JSON-RPC |
| WebSocket 日志过滤 | ✅ 子门禁已关 | 按 address/topics 过滤,畸形过滤 → fail-closed |
| Hash 搜索 | ✅ 子门禁已关 | /api/v1/search?q=... → commit index → canonical check → 503 fail-closed |
| 开发参考级 Explorer 部署 | ❌ 未实现 | 本文档旨在填补这一空白 |
| 真实索引日志后端 | ❌ 未实现 | 需要 PostgreSQL + Indexer 服务 |
| 发布签名 | ❌ 未实现 | 主网后路线图 |
1.3 架构总览
┌─────────────────────────────────────────────────────┐
│ 浏览器前端 (Next.js) │
│ Dashboard | Blocks | Txs | Accounts | Validators │
│ Contracts | Search | WebSocket Live Updates │
└──────────────────────┬──────────────────────────────┘
│ HTTP / WebSocket
┌──────────────────────▼──────────────────────────────┐
│ Explorer API (Express) │
│ RESTful API for frontend | Search | Aggregation │
│ Contract Verification | Address Details │
└──────────────────────┬──────────────────────────────┘
│
┌──────────────────────▼──────────────────────────────┐
│ Indexer Service │
│ Block Fetcher | Tx Parser | Event Indexer │
│ Contract Metadata Indexer | WS Listener │
└────┬──────────────────────┬─────────────────────────┘
│ │
┌────▼──────────┐ ┌───────▼─────────────────────────┐
│ PostgreSQL │ │ MSG Chain RPC/REST Endpoints │
│ Blocks │ │ :26657 (Tendermint) │
│ Transactions │ │ :1317 (REST) │
│ Events │ │ :9090 (gRPC) │
│ Contracts │ │ :8545 (JSON-RPC, if enabled) │
│ Validators │ └─────────────────────────────────┘
└───────────────┘
1.4 MSG Chain 关键参数
| 参数 | 值 |
|---|---|
| Chain ID | msg-chain-1 |
| Bech32 前缀 | msg |
| RPC 端口 | 26657 |
| REST 端口 | 1317 |
| gRPC 端口 | 9090 |
| 区块时间 | ~5 秒 |
| 存储引擎 | BadgerDB |
| 原生代币 | MSG |
| 小数精度 | 18 |
2. 链上数据查询基础
在搭建完整浏览器之前,理解如何直接通过 RPC/REST 接口查询链上数据是基础。本节涵盖所有可用的查询端点。
2.1 公共查询 API(public_query.yaml)
这些端点是 MSG Chain 为浏览器提供的第一层数据接口。
2.1.1 链信息
GET /api/v1/chain/info
curl -s http://localhost:1317/api/v1/chain/info | jq
响应示例:
{
"chain_id": "msg-chain-1",
"block_time": "5s",
"block_height": 1284567,
"bech32_prefix": "msg",
"native_denom": "umsg",
"cosmos_sdk_version": "v0.47.x",
"wasmd_version": "v0.40.x"
}
2.1.2 链参数
GET /api/v1/chain/params
curl -s http://localhost:1317/api/v1/chain/params | jq
响应示例:
{
"staking": {
"unbonding_time": "1814400s",
"max_validators": 100,
"bond_denom": "umsg"
},
"slashing": {
"signed_blocks_window": "10000",
"min_signed_per_window": "0.5"
},
"gov": {
"min_deposit": [{"denom": "umsg", "amount": "10000000"}],
"voting_period": "604800s",
"quorum": "0.334",
"threshold": "0.5"
},
"minting": {
"inflation_rate": "0.10",
"blocks_per_year": "6307200"
}
}
2.1.3 账户查询
GET /api/v1/account/{address}
curl -s http://localhost:1317/api/v1/account/msg1abcdef... | jq
响应示例:
{
"address": "msg1abcdef...",
"balance": [{"denom": "umsg", "amount": "1000000000"}],
"delegations": [],
"unbonding_delegations": [],
"rewards": [],
"tx_count": 42,
"nonce": 5
}
2.1.4 交易查询
GET /api/v1/tx/{hash}
curl -s http://localhost:1317/api/v1/tx/0xABCDEF1234567890... | jq
2.1.5 验证者集合
GET /api/v1/validator/set
curl -s http://localhost:1317/api/v1/validator/set | jq
2.1.6 排放率
GET /api/v1/emission/rate
curl -s http://localhost:1317/api/v1/emission/rate | jq
2.1.7 质押池
GET /api/v1/staking/pool
curl -s http://localhost:1317/api/v1/staking/pool | jq
2.1.8 治理提案
GET /api/v1/gov/proposals
curl -s http://localhost:1317/api/v1/gov/proposals | jq
2.1.9 Agent 合约列表
GET /api/v1/agent/contracts
curl -s http://localhost:1317/api/v1/agent/contracts | jq
2.1.10 合约规范源查询
GET /api/v1/contracts?source=canonical
curl -s "http://localhost:1317/api/v1/contracts?source=canonical" | jq
X-Local-Only=true — 此端点为本地子门禁,通过 commit index 验证后才返回。
2.1.11 Hash 搜索(子门禁)
GET /api/v1/search?q=tx_hash_or_block_hash
curl -s "http://localhost:1317/api/v1/search?q=0xABCDEF1234" | jq
X-Local-Only=true — commit index → canonical check,不匹配时返回 503。
// 成功响应
{"found": true, "type": "tx", "data": {"hash": "...", "height": 12345}}
// 503 失败响应
{"error": "hash not canonical or stale", "code": 503, "local_only": true}
2.2 Tendermint RPC(端口 26657)
Cosmos SDK 链底层使用 Tendermint 共识引擎,提供完整的 RPC 接口。
# 获取最新区块
curl -s http://localhost:26657/block | jq
# 指定高度
curl -s http://localhost:26657/block?height=100000 | jq
# 获取区块结果(包含事件)
curl -s http://localhost:26657/block_results?height=100000 | jq
# 按哈希查询交易
curl -s "http://localhost:26657/tx?hash=0xABCDEF..." | jq
# 按高度查询所有交易
curl -s "http://localhost:26657/tx_search?query=\"tx.height=100000\"&prove=false" | jq
# 获取共识状态
curl -s http://localhost:26657/consensus_state | jq
# 获取网络信息
curl -s http://localhost:26657/net_info | jq
# 获取验证者
curl -s "http://localhost:26657/validators?height=100000&page=1&per_page=100" | jq
# 获取 ABCI 信息
curl -s http://localhost:26657/abci_info | jq
WebSocket 订阅
ws://localhost:26657/websocket
// 订阅新区块
{"jsonrpc": "2.0", "method": "subscribe", "id": 1, "params": {"query": "tm.event='NewBlock'"}}
// 订阅新交易
{"jsonrpc": "2.0", "method": "subscribe", "id": 2, "params": {"query": "tm.event='Tx'"}}
2.3 Cosmos SDK REST(端口 1317)
# Bank
curl -s http://localhost:1317/cosmos/bank/v1beta1/balances/msg1abcdef... | jq
curl -s http://localhost:1317/cosmos/bank/v1beta1/supply | jq
curl -s http://localhost:1317/cosmos/bank/v1beta1/denoms_metadata | jq
# Staking
curl -s http://localhost:1317/cosmos/staking/v1beta1/validators | jq
curl -s http://localhost:1317/cosmos/staking/v1beta1/delegations/msg1abcdef... | jq
curl -s http://localhost:1317/cosmos/staking/v1beta1/pool | jq
# Governance
curl -s http://localhost:1317/cosmos/gov/v1beta1/proposals | jq
curl -s http://localhost:1317/cosmos/gov/v1beta1/proposals/1 | jq
# Distribution
curl -s http://localhost:1317/cosmos/distribution/v1beta1/delegators/msg1abcdef.../rewards | jq
2.4 CosmWasm 查询(端口 1317)
# 查询代码
curl -s http://localhost:1317/cosmwasm/wasm/v1/code/1 | jq
# 查询合约信息
curl -s http://localhost:1317/cosmwasm/wasm/v1/contract/msg1contract... | jq
# 合约智能查询
curl -s -X POST -H "Content-Type: application/json" \
-d '{"query": {"get_config": {}}}' \
http://localhost:1317/cosmwasm/wasm/v1/contract/msg1contract.../smart | jq
# 合约历史
curl -s http://localhost:1317/cosmwasm/wasm/v1/contract/msg1contract.../history | jq
# 所有合约
curl -s http://localhost:1317/cosmwasm/wasm/v1/contracts | jq
2.5 JSON-RPC(以太坊兼容层,8545)
# X-Local-Only=true: eth_getLogs — 子门禁已关
curl -s -X POST -H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","method":"eth_getLogs","params":[{"address":"msg1contract...","fromBlock":"0x0","toBlock":"latest"}],"id":1}' \
http://localhost:8545 | jq
2.6 Indexer API 端点
curl -s http://localhost:8080/api/v1/indexer/health | jq
curl -s http://localhost:8080/api/v1/indexer/capabilities | jq
curl -s http://localhost:8080/api/v1/indexer/retention/proof | jq
curl -s http://localhost:8080/api/v1/indexer/memos | jq
curl -s http://localhost:8080/api/v1/indexer/memos/search?q=keyword | jq
curl -s http://localhost:8080/api/v1/indexer/memos/types | jq
curl -s -X POST http://localhost:8080/api/v1/indexer/memos/submit \
-H "Content-Type: application/json" \
-d '{"content": "test memo", "type": "general"}'
X-Local-Only=true — 所有 Indexer API 均为
local_preflight_only=true。
3. Indexer 搭建
Indexer 是区块浏览器的核心数据管道,负责从链上同步区块、交易、事件和合约元数据到关系型数据库。
3.1 PostgreSQL 数据库设计
3.1.1 安装 PostgreSQL
sudo apt-get install postgresql postgresql-contrib -y
sudo systemctl start postgresql && sudo systemctl enable postgresql
sudo -u postgres psql -c "CREATE USER explorer WITH PASSWORD 'explorer_password';"
sudo -u postgres psql -c "CREATE DATABASE msg_chain_explorer OWNER explorer;"
sudo -u postgres psql -c "GRANT ALL PRIVILEGES ON DATABASE msg_chain_explorer TO explorer;"
3.1.2 完整建表语句
-- MSG Chain Block Explorer - 完整数据库 Schema
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
-- ============================================================
-- 区块表
-- ============================================================
CREATE TABLE IF NOT EXISTS blocks (
id BIGSERIAL PRIMARY KEY,
height BIGINT NOT NULL UNIQUE,
hash VARCHAR(128) NOT NULL UNIQUE,
parent_hash VARCHAR(128) NOT NULL,
proposer_address VARCHAR(255),
block_time TIMESTAMP WITH TIME ZONE NOT NULL,
block_time_nano BIGINT NOT NULL DEFAULT 0,
num_txs INTEGER NOT NULL DEFAULT 0,
total_gas_wanted BIGINT NOT NULL DEFAULT 0,
total_gas_used BIGINT NOT NULL DEFAULT 0,
chain_id VARCHAR(64) NOT NULL,
app_hash VARCHAR(128),
consensus_hash VARCHAR(128),
evidence_hash VARCHAR(128),
last_commit_hash VARCHAR(128),
validators_hash VARCHAR(128),
next_validators_hash VARCHAR(128),
version_block VARCHAR(32) DEFAULT '11',
version_app VARCHAR(32) DEFAULT '1',
raw_data JSONB,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE INDEX idx_blocks_height_desc ON blocks(height DESC);
CREATE INDEX idx_blocks_hash ON blocks(hash);
CREATE INDEX idx_blocks_time ON blocks(block_time DESC);
CREATE INDEX idx_blocks_proposer ON blocks(proposer_address);
-- ============================================================
-- 交易表
-- ============================================================
CREATE TABLE IF NOT EXISTS transactions (
id BIGSERIAL PRIMARY KEY,
hash VARCHAR(128) NOT NULL UNIQUE,
height BIGINT NOT NULL REFERENCES blocks(height) ON DELETE CASCADE,
index INTEGER NOT NULL,
tx_type VARCHAR(64) NOT NULL DEFAULT 'cosmos-sdk/MsgSend',
status VARCHAR(16) NOT NULL DEFAULT 'success',
code INTEGER NOT NULL DEFAULT 0,
codespace VARCHAR(64),
gas_wanted BIGINT NOT NULL DEFAULT 0,
gas_used BIGINT NOT NULL DEFAULT 0,
gas_fee NUMERIC(40, 6) NOT NULL DEFAULT 0,
fee_denom VARCHAR(64) NOT NULL DEFAULT 'umsg',
memo TEXT,
timeout_height BIGINT DEFAULT 0,
signer VARCHAR(255),
signatures JSONB,
raw_log TEXT,
messages JSONB NOT NULL DEFAULT '[]'::jsonb,
events JSONB NOT NULL DEFAULT '[]'::jsonb,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE INDEX idx_tx_hash ON transactions(hash);
CREATE INDEX idx_tx_height ON transactions(height DESC);
CREATE INDEX idx_tx_signer ON transactions(signer);
CREATE INDEX idx_tx_status ON transactions(status);
CREATE INDEX idx_tx_type ON transactions(tx_type);
-- ============================================================
-- 交易消息表
-- ============================================================
CREATE TABLE IF NOT EXISTS tx_messages (
id BIGSERIAL PRIMARY KEY,
tx_id BIGINT NOT NULL REFERENCES transactions(id) ON DELETE CASCADE,
tx_hash VARCHAR(128) NOT NULL,
height BIGINT NOT NULL,
index INTEGER NOT NULL,
type VARCHAR(128) NOT NULL,
sender VARCHAR(255),
recipient VARCHAR(255),
amount JSONB,
contract_addr VARCHAR(255),
msg_type VARCHAR(64),
msg JSONB NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE INDEX idx_msg_tx_hash ON tx_messages(tx_hash);
CREATE INDEX idx_msg_height ON tx_messages(height);
CREATE INDEX idx_msg_type ON tx_messages(type);
CREATE INDEX idx_msg_sender ON tx_messages(sender);
CREATE INDEX idx_msg_contract ON tx_messages(contract_addr);
-- ============================================================
-- 事件表
-- ============================================================
CREATE TABLE IF NOT EXISTS events (
id BIGSERIAL PRIMARY KEY,
tx_id BIGINT REFERENCES transactions(id) ON DELETE CASCADE,
tx_hash VARCHAR(128) NOT NULL,
height BIGINT NOT NULL REFERENCES blocks(height) ON DELETE CASCADE,
block_time TIMESTAMP WITH TIME ZONE,
event_type VARCHAR(128) NOT NULL,
contract_addr VARCHAR(255),
action VARCHAR(128),
attributes JSONB NOT NULL DEFAULT '{}'::jsonb,
topics TEXT[],
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE INDEX idx_event_tx_hash ON events(tx_hash);
CREATE INDEX idx_event_height ON events(height DESC);
CREATE INDEX idx_event_type ON events(event_type);
CREATE INDEX idx_event_contract ON events(contract_addr);
CREATE INDEX idx_event_time ON events(block_time DESC);
-- ============================================================
-- 合约表
-- ============================================================
CREATE TABLE IF NOT EXISTS contracts (
id BIGSERIAL PRIMARY KEY,
address VARCHAR(255) NOT NULL UNIQUE,
code_id BIGINT NOT NULL,
creator VARCHAR(255),
admin VARCHAR(255),
label VARCHAR(512),
contract_type VARCHAR(64) DEFAULT 'cosmwasm',
created_height BIGINT,
created_tx_hash VARCHAR(128),
updated_height BIGINT,
ibc_port_id VARCHAR(64),
version VARCHAR(32),
status VARCHAR(32) DEFAULT 'active',
is_verified BOOLEAN NOT NULL DEFAULT FALSE,
source_metadata JSONB,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE INDEX idx_contract_address ON contracts(address);
CREATE INDEX idx_contract_code_id ON contracts(code_id);
CREATE INDEX idx_contract_creator ON contracts(creator);
CREATE INDEX idx_contract_verified ON contracts(is_verified);
-- ============================================================
-- 合约源码验证表
-- ============================================================
CREATE TABLE IF NOT EXISTS contract_verifications (
id BIGSERIAL PRIMARY KEY,
contract_addr VARCHAR(255) NOT NULL REFERENCES contracts(address) ON DELETE CASCADE,
code_id BIGINT NOT NULL,
source_code TEXT NOT NULL,
compiler_version VARCHAR(32) NOT NULL,
wasm_hash VARCHAR(128) NOT NULL,
expected_hash VARCHAR(128) NOT NULL,
verified BOOLEAN NOT NULL DEFAULT FALSE,
verification_type VARCHAR(16) NOT NULL DEFAULT 'single',
license VARCHAR(64),
interface_hashes JSONB,
abi JSONB,
optimization BOOLEAN DEFAULT FALSE,
optimized_wasm_hash VARCHAR(128),
commit_hash VARCHAR(64),
registry_version VARCHAR(32),
capabilities TEXT[],
submitted_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
verified_at TIMESTAMP WITH TIME ZONE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE INDEX idx_cv_contract ON contract_verifications(contract_addr);
CREATE INDEX idx_cv_verified ON contract_verifications(verified);
CREATE INDEX idx_cv_code_id ON contract_verifications(code_id);
-- ============================================================
-- 验证者表
-- ============================================================
CREATE TABLE IF NOT EXISTS validators (
id BIGSERIAL PRIMARY KEY,
operator_address VARCHAR(255) NOT NULL UNIQUE,
consensus_pubkey TEXT,
consensus_pubkey_type VARCHAR(32) DEFAULT 'tendermint/PubKeyEd25519',
moniker VARCHAR(256),
identity VARCHAR(64),
website TEXT,
security_contact VARCHAR(128),
details TEXT,
commission_rate NUMERIC(20, 18) NOT NULL DEFAULT 0,
commission_max_rate NUMERIC(20, 18) NOT NULL DEFAULT 0,
commission_max_change NUMERIC(20, 18) NOT NULL DEFAULT 0,
min_self_delegation NUMERIC(40, 6) NOT NULL DEFAULT 1,
tokens NUMERIC(40, 6) NOT NULL DEFAULT 0,
delegator_shares NUMERIC(40, 6) NOT NULL DEFAULT 0,
jailed BOOLEAN NOT NULL DEFAULT FALSE,
status VARCHAR(16) NOT NULL DEFAULT 'unbonding',
unbonding_time TIMESTAMP WITH TIME ZONE,
voting_power BIGINT NOT NULL DEFAULT 0,
uptime NUMERIC(5, 2) DEFAULT 0,
start_height BIGINT,
missed_blocks_count BIGINT DEFAULT 0,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE INDEX idx_val_operator ON validators(operator_address);
CREATE INDEX idx_val_status ON validators(status);
CREATE INDEX idx_val_voting_power ON validators(voting_power DESC);
-- ============================================================
-- 代币表
-- ============================================================
CREATE TABLE IF NOT EXISTS tokens (
id BIGSERIAL PRIMARY KEY,
denom VARCHAR(128) NOT NULL UNIQUE,
name VARCHAR(256),
symbol VARCHAR(32),
decimals INTEGER NOT NULL DEFAULT 6,
total_supply NUMERIC(40, 6) NOT NULL DEFAULT 0,
contract_addr VARCHAR(255),
token_type VARCHAR(32) NOT NULL DEFAULT 'native',
icon_url TEXT,
coingecko_id VARCHAR(128),
is_cw20 BOOLEAN DEFAULT FALSE,
minter VARCHAR(255),
paused BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE INDEX idx_token_denom ON tokens(denom);
CREATE INDEX idx_token_contract ON tokens(contract_addr);
-- ============================================================
-- 代币持有者表
-- ============================================================
CREATE TABLE IF NOT EXISTS token_holders (
id BIGSERIAL PRIMARY KEY,
token_id BIGINT NOT NULL REFERENCES tokens(id) ON DELETE CASCADE,
address VARCHAR(255) NOT NULL,
balance NUMERIC(40, 6) NOT NULL DEFAULT 0,
percentage NUMERIC(10, 6) DEFAULT 0,
updated_height BIGINT,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
UNIQUE(token_id, address)
);
CREATE INDEX idx_th_token ON token_holders(token_id);
CREATE INDEX idx_th_address ON token_holders(address);
CREATE INDEX idx_th_balance ON token_holders(balance DESC);
-- ============================================================
-- 治理提案表
-- ============================================================
CREATE TABLE IF NOT EXISTS proposals (
id BIGSERIAL PRIMARY KEY,
proposal_id BIGINT NOT NULL UNIQUE,
title TEXT NOT NULL,
description TEXT,
proposer VARCHAR(255),
type VARCHAR(64) NOT NULL DEFAULT 'Text',
status VARCHAR(32) NOT NULL DEFAULT 'deposit_period',
content JSONB,
submit_time TIMESTAMP WITH TIME ZONE,
deposit_end_time TIMESTAMP WITH TIME ZONE,
voting_start_time TIMESTAMP WITH TIME ZONE,
voting_end_time TIMESTAMP WITH TIME ZONE,
total_deposit JSONB,
yes_votes NUMERIC(40, 6) DEFAULT 0,
no_votes NUMERIC(40, 6) DEFAULT 0,
no_with_veto_votes NUMERIC(40, 6) DEFAULT 0,
abstain_votes NUMERIC(40, 6) DEFAULT 0,
turnout NUMERIC(10, 4) DEFAULT 0,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE INDEX idx_proposal_id ON proposals(proposal_id);
CREATE INDEX idx_proposal_status ON proposals(status);
-- ============================================================
-- 链指标表
-- ============================================================
CREATE TABLE IF NOT EXISTS chain_metrics (
id BIGSERIAL PRIMARY KEY,
height BIGINT NOT NULL,
timestamp TIMESTAMP WITH TIME ZONE NOT NULL,
block_time_avg NUMERIC(10, 2),
tps NUMERIC(10, 2),
active_validators INTEGER,
bonded_tokens NUMERIC(40, 6),
inflation_rate NUMERIC(10, 6),
community_pool JSONB,
ibc_tx_count_24h INTEGER DEFAULT 0,
total_accounts BIGINT DEFAULT 0,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE INDEX idx_metrics_height ON chain_metrics(height DESC);
CREATE INDEX idx_metrics_time ON chain_metrics(timestamp DESC);
-- ============================================================
-- 索引器状态表
-- ============================================================
CREATE TABLE IF NOT EXISTS indexer_status (
id INTEGER PRIMARY KEY DEFAULT 1,
last_processed_height BIGINT NOT NULL DEFAULT 0,
last_processed_hash VARCHAR(128),
is_syncing BOOLEAN NOT NULL DEFAULT TRUE,
started_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
last_updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
error_message TEXT,
blocks_indexed BIGINT DEFAULT 0,
txs_indexed BIGINT DEFAULT 0,
events_indexed BIGINT DEFAULT 0
);
INSERT INTO indexer_status (id, last_processed_height, is_syncing) VALUES (1, 0, TRUE);
-- ============================================================
-- 约束与触发器
-- ============================================================
ALTER TABLE blocks ADD CONSTRAINT blocks_height_positive CHECK (height > 0);
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$ BEGIN NEW.updated_at = NOW(); RETURN NEW; END; $$ language 'plpgsql';
CREATE TRIGGER update_blocks_updated_at BEFORE UPDATE ON blocks
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_contracts_updated_at BEFORE UPDATE ON contracts
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_validators_updated_at BEFORE UPDATE ON validators
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
3.2 Indexer 服务实现
采用 Node.js/TypeScript 编写 Indexer 服务。
3.2.1 项目初始化
mkdir -p msg-chain-indexer && cd msg-chain-indexer
npm init -y
npm install typescript @types/node ts-node dotenv pg @types/pg node-fetch @types/node-fetch ws @types/ws pino pino-pretty
npx tsc --init
// tsconfig.json
{
"compilerOptions": {
"target": "ES2020", "module": "commonjs", "lib": ["ES2020"],
"outDir": "./dist", "rootDir": "./src", "strict": true,
"esModuleInterop": true, "skipLibCheck": true,
"forceConsistentCasingInFileNames": true, "resolveJsonModule": true,
"declaration": true, "sourceMap": true
},
"include": ["src/**/*"], "exclude": ["node_modules", "dist"]
}
3.2.2 环境变量
# .env
RPC_ENDPOINT=http://localhost:26657
REST_ENDPOINT=http://localhost:1317
CHAIN_ID=msg-chain-1
DB_HOST=localhost
DB_PORT=5432
DB_NAME=msg_chain_explorer
DB_USER=explorer
DB_PASSWORD=explorer_password
INDEXER_START_HEIGHT=1
INDEXER_BATCH_SIZE=10
INDEXER_POLL_INTERVAL=5000
WS_ENABLED=true
WS_PORT=8081
LOG_LEVEL=info
NODE_ENV=development
3.2.3 核心类型定义
// src/types.ts
export interface BlockResult {
block_id: { hash: string; parts: { total: number; hash: string } };
block: {
header: {
version: { block: string; app: string };
chain_id: string; height: string; time: string;
last_block_id: { hash: string; parts: { total: number; hash: string } };
last_commit_hash: string; data_hash: string;
validators_hash: string; next_validators_hash: string;
consensus_hash: string; app_hash: string;
last_results_hash: string; evidence_hash: string;
proposer_address: string;
};
data: { txs: string[] | null };
evidence: { evidence: any[] };
last_commit: { height: string; round: number; block_id: { hash: string; parts: { total: number; hash: string } }; signatures: any[] };
};
}
export interface BlockResults {
height: string; txs_results: TxResult[] | null;
begin_block_events: Event[]; end_block_events: Event[];
validator_updates: any[]; consensus_param_updates: any;
}
export interface TxResult {
code: number; codespace: string; data: string; log: string;
info: string; gas_wanted: string; gas_used: string;
events: Event[]; hash: string;
}
export interface Event {
type: string;
attributes: Array<{ key: string; value: string; index: boolean }>;
}
export interface ParsedTx {
hash: string; height: number; index: number; tx_type: string;
status: string; code: number; codespace: string;
gas_wanted: number; gas_used: number; gas_fee: number;
fee_denom: string; memo: string; signer: string;
messages: any[]; events: Event[]; raw_log: string;
}
export interface ParsedBlock {
height: number; hash: string; parent_hash: string;
proposer_address: string; block_time: Date; block_time_nano: number;
num_txs: number; total_gas_wanted: number; total_gas_used: number;
chain_id: string; app_hash: string; consensus_hash: string;
evidence_hash: string; last_commit_hash: string;
validators_hash: string; next_validators_hash: string;
version_block: string; version_app: string; raw_data: any;
}
export interface Validator {
operator_address: string; consensus_pubkey: string; moniker: string;
commission_rate: number; tokens: number; voting_power: number;
status: string; jailed: boolean;
}
export interface ContractInfo {
address: string; code_id: number; creator: string; admin: string;
label: string; created_height: number; created_tx_hash: string;
source_metadata?: {
registry_version?: string; interface_hash?: string;
capabilities?: string[]; source?: string; wasm_hash?: string;
};
}
3.2.4 数据库操作层
// src/database.ts
import { Pool, PoolClient } from 'pg';
export class Database {
private pool: Pool;
constructor() {
this.pool = new Pool({
host: process.env.DB_HOST || 'localhost',
port: parseInt(process.env.DB_PORT || '5432'),
database: process.env.DB_NAME || 'msg_chain_explorer',
user: process.env.DB_USER || 'explorer',
password: process.env.DB_PASSWORD || 'explorer_password',
max: 20, idleTimeoutMillis: 30000, connectionTimeoutMillis: 2000,
});
}
async query(text: string, params?: any[]) {
return this.pool.query(text, params);
}
async transaction<T>(fn: (client: PoolClient) => Promise<T>): Promise<T> {
const client = await this.pool.connect();
try {
await client.query('BEGIN');
const result = await fn(client);
await client.query('COMMIT');
return result;
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally { client.release(); }
}
async getLastProcessedHeight(): Promise<number> {
const result = await this.query('SELECT last_processed_height FROM indexer_status WHERE id = 1');
if (result.rows.length === 0) {
await this.query('INSERT INTO indexer_status (id, last_processed_height, is_syncing) VALUES (1, 0, TRUE)');
return 0;
}
return result.rows[0].last_processed_height;
}
async updateIndexerStatus(height: number, hash: string, syncing: boolean) {
await this.query(
`UPDATE indexer_status SET last_processed_height=$1, last_processed_hash=$2, is_syncing=$3, last_updated_at=NOW() WHERE id=1`,
[height, hash, syncing]
);
}
// X-Local-Only=true: 子门禁合约元数据
async insertContractSourceMetadata(address: string, metadata: any) {
await this.query(
`UPDATE contracts SET source_metadata=$2, is_verified=true, updated_at=NOW() WHERE address=$1`,
[address, JSON.stringify(metadata)]
);
}
async close() { await this.pool.end(); }
}
3.2.5 RPC 客户端
// src/rpc.ts
import fetch from 'node-fetch';
export class RPCClient {
private rpcEndpoint: string;
private restEndpoint: string;
constructor() {
this.rpcEndpoint = process.env.RPC_ENDPOINT || 'http://localhost:26657';
this.restEndpoint = process.env.REST_ENDPOINT || 'http://localhost:1317';
}
private async rpcCall(method: string, params: any[] = []): Promise<any> {
const response = await fetch(this.rpcEndpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }),
});
if (!response.ok) throw new Error(`RPC call failed: ${response.status}`);
const data: any = await response.json();
if (data.error) throw new Error(`RPC error: ${JSON.stringify(data.error)}`);
return data.result;
}
async getBlock(height: number) { return this.rpcCall('block', [height.toString()]); }
async getBlockResults(height: number) { return this.rpcCall('block_results', [height.toString()]); }
async getStatus() { return this.rpcCall('status'); }
async getValidatorSet(height?: number) { return this.rpcCall('validators', height ? [height.toString()] : []); }
async getTx(hash: string) { return this.rpcCall('tx', [hash]); }
async restGet(path: string): Promise<any> {
const response = await fetch(`${this.restEndpoint}${path}`);
if (!response.ok) throw new Error(`REST GET failed: ${response.status} for ${path}`);
return response.json();
}
// X-Local-Only=true: 子门禁合约元数据
async getContractSourceMetadata(contractAddr: string): Promise<any> {
try {
return await this.restGet(`/api/v1/contracts?source=canonical&address=${contractAddr}`);
} catch (err: any) {
if (err.message?.includes('503')) return { error: 'stale or non-canonical source', local_only: true, status: 503 };
throw err;
}
}
// X-Local-Only=true: Hash 搜索子门禁
async searchHash(q: string): Promise<any> {
try {
return await this.restGet(`/api/v1/search?q=${encodeURIComponent(q)}`);
} catch (err: any) {
if (err.message?.includes('503')) return { error: 'hash not canonical or stale', local_only: true, status: 503 };
throw err;
}
}
// X-Local-Only=true: eth_getLogs
async getLogs(params: any): Promise<any> {
const response = await fetch(`${this.rpcEndpoint.replace('26657', '8545')}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ jsonrpc: '2.0', method: 'eth_getLogs', params: [params], id: 1 }),
});
return response.json();
}
async getLatestBlockHeight(): Promise<number> {
const status = await this.getStatus();
return parseInt(status.sync_info.latest_block_height);
}
}
3.2.6 区块解析器
// src/parser.ts
import crypto from 'crypto';
import { BlockResult, BlockResults, ParsedBlock, ParsedTx, Event } from './types';
export class BlockParser {
parseBlock(blockResult: BlockResult): ParsedBlock {
const header = blockResult.block.header;
const txs = blockResult.block.data?.txs || [];
return {
height: parseInt(header.height),
hash: blockResult.block_id.hash,
parent_hash: header.last_block_id?.hash || '',
proposer_address: header.proposer_address,
block_time: new Date(header.time),
block_time_nano: new Date(header.time).getTime() * 1e6,
num_txs: txs ? txs.length : 0,
total_gas_wanted: 0, total_gas_used: 0,
chain_id: header.chain_id,
app_hash: header.app_hash || '',
consensus_hash: header.consensus_hash || '',
evidence_hash: header.evidence_hash || '',
last_commit_hash: header.last_commit_hash || '',
validators_hash: header.validators_hash || '',
next_validators_hash: header.next_validators_hash || '',
version_block: header.version?.block || '11',
version_app: header.version?.app || '1',
raw_data: blockResult,
};
}
parseTransactions(blockResult: BlockResult, blockResults: BlockResults, height: number): ParsedTx[] {
const txs = blockResult.block.data?.txs || [];
const txResults = blockResults.txs_results || [];
return txs.map((txBytes, i) => {
const hash = crypto.createHash('sha256')
.update(Buffer.from(txBytes, 'base64')).digest().toString('hex').toUpperCase();
const txResult = txResults[i];
return {
hash, height, index: i,
tx_type: 'cosmos-sdk/MsgSend',
status: txResult?.code === 0 ? 'success' : 'failed',
code: txResult?.code || 0, codespace: txResult?.codespace || '',
gas_wanted: parseInt(txResult?.gas_wanted || '0'),
gas_used: parseInt(txResult?.gas_used || '0'),
gas_fee: 0, fee_denom: 'umsg',
memo: '', signer: '',
messages: [], events: txResult?.events || [],
raw_log: txResult?.log || '',
};
});
}
parseEvents(txs: ParsedTx[]): any[] {
const events: any[] = [];
for (const tx of txs) {
for (const event of tx.events) {
const attributes = event.attributes.reduce((acc: any, attr: any) => {
acc[attr.key] = attr.value; return acc;
}, {});
events.push({
tx_hash: tx.hash, height: tx.height, type: event.type,
attributes, topics: this._extractTopics(event),
});
}
}
return events;
}
private _extractTopics(event: Event): string[] {
return event.attributes
.filter(a => a.key === '_contract_address' || a.key === 'topic')
.map(a => a.value);
}
}
3.2.7 Indexer 主服务
// src/indexer.ts
import { RPCClient } from './rpc';
import { Database } from './database';
import { BlockParser } from './parser';
const pino = require('pino');
const logger = pino({
level: process.env.LOG_LEVEL || 'info',
transport: process.env.NODE_ENV !== 'production'
? { target: 'pino-pretty', options: { colorize: true } } : undefined,
});
export class Indexer {
private rpc: RPCClient;
private db: Database;
private parser: BlockParser;
private isRunning: boolean = false;
private pollTimer: NodeJS.Timeout | null = null;
constructor() {
this.rpc = new RPCClient();
this.db = new Database();
this.parser = new BlockParser();
}
async start(): Promise<void> {
this.isRunning = true;
logger.info('Indexer starting...');
await this.db.query('SELECT 1');
logger.info('Database connected');
const lastHeight = await this.db.getLastProcessedHeight();
logger.info({ lastHeight }, 'Resuming from height');
await this.syncLoop(lastHeight);
}
async stop(): Promise<void> {
this.isRunning = false;
if (this.pollTimer) { clearTimeout(this.pollTimer); this.pollTimer = null; }
await this.db.close();
logger.info('Indexer stopped');
}
private async syncLoop(startHeight: number): Promise<void> {
let currentHeight = startHeight;
while (this.isRunning) {
try {
const latestHeight = await this.rpc.getLatestBlockHeight();
const batchSize = parseInt(process.env.INDEXER_BATCH_SIZE || '10');
const endHeight = Math.min(currentHeight + batchSize, latestHeight);
if (currentHeight >= latestHeight) {
await this.db.updateIndexerStatus(currentHeight, '', false);
const interval = parseInt(process.env.INDEXER_POLL_INTERVAL || '5000');
await this.delay(interval);
continue;
}
for (let h = currentHeight + 1; h <= endHeight; h++) {
await this.processBlock(h);
currentHeight = h;
}
await this.db.updateIndexerStatus(currentHeight, '', true);
logger.info({ height: currentHeight, latest: latestHeight }, 'Blocks synced');
} catch (error: any) {
logger.error({ error: error.message }, 'Sync error');
await this.delay(5000);
}
}
}
private async processBlock(height: number): Promise<void> {
const [blockResult, blockResults] = await Promise.all([
this.rpc.getBlock(height), this.rpc.getBlockResults(height),
]);
const parsedBlock = this.parser.parseBlock(blockResult);
const parsedTxs = this.parser.parseTransactions(blockResult, blockResults, height);
parsedBlock.total_gas_wanted = parsedTxs.reduce((s, t) => s + t.gas_wanted, 0);
parsedBlock.total_gas_used = parsedTxs.reduce((s, t) => s + t.gas_used, 0);
const events = this.parser.parseEvents(parsedTxs);
await this.db.transaction(async (client) => {
await client.query(
`INSERT INTO blocks (height, hash, parent_hash, proposer_address, block_time, block_time_nano, num_txs, total_gas_wanted, total_gas_used, chain_id, app_hash, consensus_hash, evidence_hash, last_commit_hash, validators_hash, next_validators_hash, version_block, version_app, raw_data)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19)
ON CONFLICT (height) DO UPDATE SET hash=EXCLUDED.hash, num_txs=EXCLUDED.num_txs, total_gas_used=EXCLUDED.total_gas_used, raw_data=EXCLUDED.raw_data`,
[parsedBlock.height, parsedBlock.hash, parsedBlock.parent_hash, parsedBlock.proposer_address, parsedBlock.block_time, parsedBlock.block_time_nano, parsedBlock.num_txs, parsedBlock.total_gas_wanted, parsedBlock.total_gas_used, parsedBlock.chain_id, parsedBlock.app_hash, parsedBlock.consensus_hash, parsedBlock.evidence_hash, parsedBlock.last_commit_hash, parsedBlock.validators_hash, parsedBlock.next_validators_hash, parsedBlock.version_block, parsedBlock.version_app, JSON.stringify(parsedBlock.raw_data)]
);
for (const tx of parsedTxs) {
const txResult = await client.query(
`INSERT INTO transactions (hash, height, index, tx_type, status, code, codespace, gas_wanted, gas_used, gas_fee, fee_denom, memo, signer, messages, events, raw_log)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16)
ON CONFLICT (hash) DO NOTHING RETURNING id`,
[tx.hash, tx.height, tx.index, tx.tx_type, tx.status, tx.code, tx.codespace, tx.gas_wanted, tx.gas_used, tx.gas_fee, tx.fee_denom, tx.memo, tx.signer, JSON.stringify(tx.messages), JSON.stringify(tx.events), tx.raw_log]
);
const txId = txResult.rows[0]?.id;
if (txId) {
for (const event of events.filter(e => e.tx_hash === tx.hash)) {
await client.query(
`INSERT INTO events (tx_id, tx_hash, height, event_type, attributes, topics) VALUES ($1,$2,$3,$4,$5,$6)`,
[txId, event.tx_hash, event.height, event.type, JSON.stringify(event.attributes), event.topics || []]
);
}
}
}
});
// X-Local-Only=true: 子门禁合约元数据
if (process.env.LOCAL_GATES_ENABLED === 'true') {
for (const tx of parsedTxs) {
for (const msg of tx.messages) {
if (msg.contract_addr) {
try {
const metadata = await this.rpc.getContractSourceMetadata(msg.contract_addr);
if (metadata && !metadata.error) {
await this.db.insertContractSourceMetadata(msg.contract_addr, metadata);
}
} catch { /* ignore */ }
}
}
}
}
}
private delay(ms: number): Promise<void> {
return new Promise(resolve => { this.pollTimer = setTimeout(resolve, ms); });
}
}
3.2.8 Indexer 入口
// src/index.ts
import dotenv from 'dotenv';
dotenv.config();
import { Indexer } from './indexer';
const indexer = new Indexer();
process.on('SIGINT', async () => { await indexer.stop(); process.exit(0); });
process.on('SIGTERM', async () => { await indexer.stop(); process.exit(0); });
indexer.start().catch((error) => { console.error('Indexer failed:', error); process.exit(1); });
// package.json 补充 scripts
{
"scripts": {
"build": "tsc", "start": "node dist/index.js",
"dev": "ts-node src/index.ts", "typecheck": "tsc --noEmit"
}
}
3.3 Indexer 管理脚本
#!/bin/bash
# MSG Chain Indexer 管理脚本
# 用法: ./indexer.sh {start|stop|restart|status|logs|reset}
APP_DIR="/opt/msg-chain-indexer"
APP_NAME="msg-chain-indexer"
PID_FILE="/var/run/${APP_NAME}.pid"
LOG_FILE="/var/log/${APP_NAME}.log"
CONFIG_FILE="${APP_DIR}/.env"
start() {
if [ -f "$PID_FILE" ] && kill -0 $(cat "$PID_FILE") 2>/dev/null; then
echo "${APP_NAME} is already running (PID: $(cat $PID_FILE))"
exit 0
fi
echo "Starting ${APP_NAME}..."
cd "$APP_DIR"
LOCAL_GATES_ENABLED="${LOCAL_GATES_ENABLED:-true}" \
nohup node dist/index.js >> "$LOG_FILE" 2>&1 &
echo $! > "$PID_FILE"
echo "${APP_NAME} started (PID: $(cat $PID_FILE))"
}
stop() {
if [ ! -f "$PID_FILE" ]; then echo "${APP_NAME} is not running"; return 0; fi
local pid=$(cat "$PID_FILE")
kill -TERM "$pid" 2>/dev/null || true
for i in $(seq 1 30); do
if ! kill -0 "$pid" 2>/dev/null; then break; fi
sleep 1
done
kill -9 "$pid" 2>/dev/null || true
rm -f "$PID_FILE"
echo "${APP_NAME} stopped"
}
status() {
if [ -f "$PID_FILE" ] && kill -0 $(cat "$PID_FILE") 2>/dev/null; then
echo "Running (PID: $(cat $PID_FILE))"
else
echo "Not running"; return 1
fi
}
case "${1:-help}" in
start) start ;; stop) stop ;; restart) stop; sleep 2; start ;;
status) status ;; logs) tail -f "$LOG_FILE" ;; reset)
PGPASSWORD="${DB_PASSWORD}" psql -h localhost -U explorer -d msg_chain_explorer \
-c "TRUNCATE blocks, transactions, tx_messages, events, contracts CASCADE;"
psql -h localhost -U explorer -d msg_chain_explorer \
-c "UPDATE indexer_status SET last_processed_height=0, is_syncing=TRUE WHERE id=1;"
;;
*) echo "Usage: $0 {start|stop|restart|status|logs|reset}" ;;
esac
4. Explorer 后端 API
4.1 项目初始化
mkdir -p msg-chain-explorer-api && cd msg-chain-explorer-api
npm init -y
npm install express cors helmet morgan compression typescript @types/node @types/express @types/cors @types/morgan pg @types/pg dotenv redis @types/redis ws @types/ws ts-node pino pino-pretty
npx tsc --init
// tsconfig.json
{
"compilerOptions": {
"target": "ES2020", "module": "commonjs", "lib": ["ES2020"],
"outDir": "./dist", "rootDir": "./src", "strict": true,
"esModuleInterop": true, "skipLibCheck": true, "sourceMap": true
},
"include": ["src/**/*"], "exclude": ["node_modules", "dist"]
}
# .env
PORT=3000
NODE_ENV=development
RPC_ENDPOINT=http://localhost:26657
REST_ENDPOINT=http://localhost:1317
DB_HOST=localhost
DB_PORT=5432
DB_NAME=msg_chain_explorer
DB_USER=explorer
DB_PASSWORD=explorer_password
REDIS_HOST=localhost
REDIS_PORT=6379
CORS_ORIGIN=http://localhost:3001
CACHE_TTL=30
LOCAL_GATES_ENABLED=true
4.2 API 类型定义
// src/types.ts
export interface ApiResponse<T> {
success: boolean;
data?: T;
error?: string;
code?: number;
timestamp: string;
page?: number;
page_size?: number;
total?: number;
local_only?: boolean;
}
export interface NetworkStats {
chain_id: string; latest_block_height: number; latest_block_time: string;
block_time_avg: number; tps: number; active_validators: number;
total_validators: number; bonded_tokens: string; total_supply: string;
inflation_rate: number; total_txs: number; staking_apr: number;
}
export interface BlockListItem {
height: number; hash: string; proposer: string;
block_time: string; num_txs: number; gas_used: number; gas_wanted: number;
}
export interface BlockDetail extends BlockListItem {
parent_hash: string; app_hash: string; consensus_hash: string;
evidence_hash: string; last_commit_hash: string;
validators_hash: string; next_validators_hash: string;
chain_id: string; transactions: TxListItem[];
}
export interface TxListItem {
hash: string; height: number; timestamp: string; tx_type: string;
status: string; signer: string; gas_fee: number; gas_used: number;
}
export interface TxDetail extends TxListItem {
code: number; codespace: string; gas_wanted: number;
fee_denom: string; memo: string; messages: any[];
events: TxEvent[]; raw_log: string;
}
export interface TxEvent { type: string; attributes: Record<string, string>; }
export interface AccountDetail {
address: string; balance: Coin[]; delegations: Delegation[];
tx_count: number; is_contract: boolean;
contract_info?: { address: string; code_id: number; creator: string; admin: string; label: string; created_height: number; is_verified: boolean; source_metadata?: any; };
}
export interface Coin { denom: string; amount: string; }
export interface Delegation { validator_address: string; amount: string; shares: string; }
export interface ValidatorListItem {
operator_address: string; moniker: string; identity: string;
voting_power: number; voting_power_percent: number;
commission_rate: number; status: string; jailed: boolean;
uptime: number; tokens: string;
}
export interface ValidatorDetail extends ValidatorListItem {
website: string; security_contact: string; details: string;
commission_max_rate: number; commission_max_change: number;
min_self_delegation: number; delegator_shares: number;
}
export interface TokenInfo {
denom: string; name: string; symbol: string; decimals: number;
total_supply: string; token_type: string; contract_addr?: string;
holders_count: number;
}
export type SearchType = 'block' | 'tx' | 'account' | 'contract' | 'validator';
export interface SearchResult {
type: SearchType; data: any; local_only?: boolean; status?: number;
}
4.3 缓存层
// src/cache.ts
import Redis from 'redis';
export class RedisCache {
private client: ReturnType<typeof Redis.createClient>;
constructor() {
this.client = Redis.createClient({
url: `redis://${process.env.REDIS_HOST || 'localhost'}:${process.env.REDIS_PORT || 6379}`,
password: process.env.REDIS_PASSWORD || undefined,
});
}
async connect() { if (!this.client.isOpen) await this.client.connect(); }
async ping() { try { await this.client.ping(); return true; } catch { return false; } }
async get(key: string) {
try { return await this.client.get(`explorer:${key}`); } catch { return null; }
}
async set(key: string, value: string, ttl = 30) {
try { await this.client.set(`explorer:${key}`, value, { EX: ttl }); } catch { /* ignore */ }
}
async del(key: string) {
try { await this.client.del(`explorer:${key}`); } catch { /* ignore */ }
}
async close() { if (this.client.isOpen) await this.client.quit(); }
}
4.4 Express App 入口
// src/app.ts
import express, { Request, Response, NextFunction } from 'express';
import cors from 'cors'; import helmet from 'helmet';
import morgan from 'morgan'; import compression from 'compression';
import { Database } from './database'; import { RPCClient } from './rpc';
import { RedisCache } from './cache'; import { registerRoutes } from './routes';
const pino = require('pino');
const logger = pino({
level: process.env.LOG_LEVEL || 'info',
transport: process.env.NODE_ENV !== 'production'
? { target: 'pino-pretty', options: { colorize: true } } : undefined,
});
export class ExplorerAPI {
private app: express.Application;
private db: Database; private rpc: RPCClient; private cache: RedisCache;
constructor() {
this.app = express(); this.db = new Database(); this.rpc = new RPCClient();
this.cache = new RedisCache();
this.app.use(helmet());
this.app.use(cors({ origin: process.env.CORS_ORIGIN || 'http://localhost:3001', credentials: true }));
this.app.use(compression());
this.app.use(morgan('combined', { stream: { write: (msg: string) => logger.info(msg.trim()) } }));
this.app.use(express.json({ limit: '10mb' }));
registerRoutes(this.app, this.db, this.rpc, this.cache, logger);
this.app.use((err: Error, _req: Request, res: Response, _next: NextFunction) => {
logger.error({ error: err.message }); res.status(500).json({ success: false, error: 'Internal error' });
});
}
async start() {
await this.db.query('SELECT 1'); await this.cache.connect();
const port = parseInt(process.env.PORT || '3000');
this.app.listen(port, () => logger.info({ port }, 'Explorer API started'));
}
}
4.5 完整路由实现
// src/routes.ts
import express from 'express'; import { Database } from './database';
import { RPCClient } from './rpc'; import { RedisCache } from './cache';
export function registerRoutes(app: express.Application, db: Database, rpc: RPCClient, cache: RedisCache, logger: any) {
const router = express.Router();
// GET /api/v1/health
router.get('/api/v1/health', async (_req, res) => {
const dbOk = await db.query('SELECT 1').then(() => true).catch(() => false);
const rpcOk = await rpc.getStatus().then(() => true).catch(() => false);
const cacheOk = await cache.ping();
const healthy = dbOk && rpcOk && cacheOk;
res.status(healthy ? 200 : 503).json({
success: true, data: { status: healthy ? 'healthy' : 'degraded',
database: dbOk ? 'ok' : 'error', rpc: rpcOk ? 'ok' : 'error', cache: cacheOk ? 'ok' : 'error' },
});
});
// GET /api/v1/stats — 网络统计
router.get('/api/v1/stats', async (_req, res) => {
try {
const cached = await cache.get('stats');
if (cached) return res.json({ success: true, data: JSON.parse(cached), from_cache: true });
const status = await rpc.getStatus();
const validators = await rpc.getValidatorSet();
const stakingPool = await rpc.restGet('/cosmos/staking/v1beta1/pool');
const supply = await rpc.restGet('/cosmos/bank/v1beta1/supply/umsg');
const latestHeight = parseInt(status.sync_info.latest_block_height);
const stats: NetworkStats = {
chain_id: status.node_info.network, latest_block_height: latestHeight,
latest_block_time: status.sync_info.latest_block_time,
block_time_avg: 5, tps: 0, active_validators: validators.validators?.length || 0,
total_validators: validators.total || 0,
bonded_tokens: stakingPool.pool?.bonded_tokens || '0',
total_supply: supply.amount?.amount || '0', inflation_rate: 0.10,
total_txs: 0, staking_apr: 0.12,
};
const countResult = await db.query('SELECT COUNT(*) as count FROM transactions');
stats.total_txs = parseInt(countResult.rows[0]?.count || '0');
await cache.set('stats', JSON.stringify(stats), 15);
res.json({ success: true, data: stats });
} catch (error: any) {
logger.error({ error: error.message }, 'Stats error');
res.status(503).json({ success: false, error: 'Failed to fetch stats' });
}
});
// GET /api/v1/blocks — 区块列表
router.get('/api/v1/blocks', async (req, res) => {
try {
const page = parseInt(req.query.page as string) || 1;
const pageSize = parseInt(req.query.page_size as string) || 25;
const offset = (page - 1) * pageSize;
const countResult = await db.query('SELECT COUNT(*) as count FROM blocks');
const total = parseInt(countResult.rows[0]?.count || '0');
const result = await db.query(
`SELECT height, hash, proposer_address as proposer, block_time, num_txs, total_gas_used as gas_used, total_gas_wanted as gas_wanted FROM blocks ORDER BY height DESC LIMIT $1 OFFSET $2`,
[pageSize, offset]
);
res.json({ success: true, data: result.rows, page, page_size: pageSize, total });
} catch (error: any) {
logger.error({ error: error.message }, 'Blocks error');
res.status(500).json({ success: false, error: 'Failed to fetch blocks' });
}
});
// GET /api/v1/blocks/:height — 区块详情
router.get('/api/v1/blocks/:height', async (req, res) => {
try {
const height = parseInt(req.params.height);
const result = await db.query(`SELECT * FROM blocks WHERE height = $1`, [height]);
if (result.rows.length === 0) {
const block = await rpc.getBlock(height);
return res.json({ success: true, data: { from_rpc: true, ...block } });
}
const txsResult = await db.query(
`SELECT hash, height, index, tx_type, status, signer, gas_fee, gas_used FROM transactions WHERE height = $1 ORDER BY index ASC`, [height]
);
res.json({ success: true, data: { ...result.rows[0], transactions: txsResult.rows } });
} catch (error: any) {
res.status(404).json({ success: false, error: 'Block not found' });
}
});
// GET /api/v1/transactions — 交易列表
router.get('/api/v1/transactions', async (req, res) => {
try {
const page = parseInt(req.query.page as string) || 1;
const pageSize = parseInt(req.query.page_size as string) || 25;
const offset = (page - 1) * pageSize;
const address = req.query.address as string;
let where = 'WHERE 1=1'; const params: any[] = [];
if (address) { where += ` AND signer = $1`; params.push(address); }
const countResult = await db.query(`SELECT COUNT(*) as count FROM transactions ${where}`, params);
const total = parseInt(countResult.rows[0]?.count || '0');
const result = await db.query(
`SELECT t.hash, t.height, t.tx_type, t.status, t.signer, t.gas_fee, t.gas_used, b.block_time as timestamp FROM transactions t JOIN blocks b ON b.height = t.height ${where} ORDER BY t.height DESC LIMIT $${params.length + 1} OFFSET $${params.length + 2}`,
[...params, pageSize, offset]
);
res.json({ success: true, data: result.rows, page, page_size: pageSize, total });
} catch (error: any) {
logger.error({ error: error.message }, 'Txs error');
res.status(500).json({ success: false, error: 'Failed to fetch txs' });
}
});
// GET /api/v1/transactions/:hash — 交易详情
router.get('/api/v1/transactions/:hash', async (req, res) => {
try {
const { hash } = req.params;
const result = await db.query(
`SELECT t.*, b.block_time as timestamp FROM transactions t JOIN blocks b ON b.height = t.height WHERE t.hash = $1`, [hash]
);
if (result.rows.length === 0) {
const tx = await rpc.getTx(hash.toUpperCase());
return res.json({ success: true, data: { from_rpc: true, ...tx } });
}
const events = await db.query('SELECT * FROM events WHERE tx_hash = $1', [hash]);
res.json({ success: true, data: { ...result.rows[0], events: events.rows } });
} catch (error: any) {
res.status(404).json({ success: false, error: 'Transaction not found' });
}
});
// GET /api/v1/accounts/:address — 账户详情
router.get('/api/v1/accounts/:address', async (req, res) => {
try {
const { address } = req.params;
const [balanceResult, delegationsResult, wasmContract] = await Promise.allSettled([
rpc.restGet(`/cosmos/bank/v1beta1/balances/${address}`),
rpc.restGet(`/cosmos/staking/v1beta1/delegations/${address}`),
rpc.restGet(`/cosmwasm/wasm/v1/contract/${address}`),
]);
const balance = balanceResult.status === 'fulfilled' ? balanceResult.value.balances || [] : [];
const delegations = delegationsResult.status === 'fulfilled'
? (delegationsResult.value.delegation_responses || []).map((d: any) => ({
validator_address: d.delegation?.validator_address || '',
amount: d.balance?.amount || '0', shares: d.delegation?.shares || '0',
})) : [];
let isContract = false; let contractInfo = null;
if (wasmContract.status === 'fulfilled' && wasmContract.value?.contract_info) {
isContract = true; const ci = wasmContract.value.contract_info;
contractInfo = { address, code_id: parseInt(ci.code_id), creator: ci.creator, admin: ci.admin, label: ci.label, created_height: parseInt(ci.created?.block_height || '0'), is_verified: false };
// X-Local-Only=true: 子门禁元数据
const meta = await rpc.getContractSourceMetadata(address);
if (meta && !meta.error) { contractInfo.source_metadata = meta; contractInfo.is_verified = true; }
}
const txCount = await db.query('SELECT COUNT(*) as count FROM transactions WHERE signer = $1', [address]);
res.json({ success: true, data: { address, balance, delegations, tx_count: parseInt(txCount.rows[0]?.count || '0'), is_contract: isContract, contract_info: contractInfo } });
} catch (error: any) {
logger.error({ error: error.message }, 'Account error');
res.status(500).json({ success: false, error: 'Failed to fetch account' });
}
});
// GET /api/v1/validators — 验证者列表
router.get('/api/v1/validators', async (req, res) => {
try {
const page = parseInt(req.query.page as string) || 1;
const pageSize = parseInt(req.query.page_size as string) || 50;
const validators = await rpc.getValidatorSet();
const valDetails = await rpc.restGet(`/cosmos/staking/v1beta1/validators?pagination.limit=${pageSize}&pagination.offset=${(page - 1) * pageSize}`);
const vals = (valDetails.validators || []).map((v: any) => ({
operator_address: v.operator_address, moniker: v.description?.moniker || 'Unknown',
identity: v.description?.identity || '', voting_power: parseInt(v.tokens || '0') / 1_000_000,
voting_power_percent: validators.total > 0 ? (parseInt(v.tokens || '0') / validators.total) * 100 : 0,
commission_rate: parseFloat(v.commission?.commission_rates?.rate || '0') * 100,
status: v.status || 'unbonding', jailed: v.jailed || false, uptime: 99.5, tokens: v.tokens || '0',
}));
res.json({ success: true, data: vals, page, page_size: pageSize, total: validators.total || 0 });
} catch (error: any) {
res.status(500).json({ success: false, error: 'Failed to fetch validators' });
}
});
// GET /api/v1/validators/:address — 验证者详情
router.get('/api/v1/validators/:address', async (req, res) => {
try {
const v = (await rpc.restGet(`/cosmos/staking/v1beta1/validators/${req.params.address}`)).validator;
if (!v) return res.status(404).json({ success: false, error: 'Not found' });
res.json({ success: true, data: {
operator_address: v.operator_address, moniker: v.description?.moniker || 'Unknown',
identity: v.description?.identity || '', website: v.description?.website || '',
details: v.description?.details || '', voting_power: parseInt(v.tokens || '0') / 1_000_000,
commission_rate: parseFloat(v.commission?.commission_rates?.rate || '0') * 100,
commission_max_rate: parseFloat(v.commission?.commission_rates?.max_rate || '0') * 100,
commission_max_change: parseFloat(v.commission?.commission_rates?.max_change_rate || '0') * 100,
status: v.status, jailed: v.jailed || false, tokens: v.tokens || '0',
}});
} catch { res.status(404).json({ success: false, error: 'Validator not found' }); }
});
// GET /api/v1/contracts — 合约列表
router.get('/api/v1/contracts', async (req, res) => {
try {
const page = parseInt(req.query.page as string) || 1;
const pageSize = parseInt(req.query.page_size as string) || 20;
const source = req.query.source as string;
// X-Local-Only=true: source=canonical 使用子门禁
if (source === 'canonical') {
const contracts = await rpc.restGet('/api/v1/contracts?source=canonical');
const items = Array.isArray(contracts) ? contracts : [];
return res.json({ success: true, data: items.slice((page - 1) * pageSize, page * pageSize), page, page_size: pageSize, total: items.length, local_only: true });
}
const result = await db.query(
`SELECT * FROM contracts ORDER BY created_height DESC LIMIT $1 OFFSET $2`, [pageSize, (page - 1) * pageSize]
);
const countResult = await db.query('SELECT COUNT(*) as count FROM contracts');
res.json({ success: true, data: result.rows, page, page_size: pageSize, total: parseInt(countResult.rows[0]?.count || '0') });
} catch (error: any) { res.status(500).json({ success: false, error: 'Failed to fetch contracts' }); }
});
// GET /api/v1/contracts/:address — 合约详情
router.get('/api/v1/contracts/:address', async (req, res) => {
try {
const { address } = req.params;
const contractResult = await rpc.restGet(`/cosmwasm/wasm/v1/contract/${address}`);
if (!contractResult.contract_info) return res.status(404).json({ success: false, error: 'Contract not found' });
const ci = contractResult.contract_info;
let codeInfo = null;
try { codeInfo = await rpc.restGet(`/cosmwasm/wasm/v1/code/${ci.code_id}`); } catch { /* ignore */ }
// X-Local-Only=true: 子门禁
let sourceMetadata = null; let isVerified = false;
const localGates = await rpc.getContractSourceMetadata(address);
if (localGates && !localGates.error) { sourceMetadata = localGates; isVerified = true; }
res.json({ success: true, data: { address, code_id: parseInt(ci.code_id), creator: ci.creator, admin: ci.admin, label: ci.label, created_height: parseInt(ci.created?.block_height || '0'), code_hash: codeInfo?.code_info?.code_hash || '', is_verified: isVerified, source_metadata: sourceMetadata } });
} catch (error: any) { res.status(500).json({ success: false, error: 'Failed to fetch contract' }); }
});
// GET /api/v1/search — 搜索
router.get('/api/v1/search', async (req, res) => {
try {
const q = (req.query.q as string || '').trim();
if (!q) return res.status(400).json({ success: false, error: 'Query required' });
// 区块高度
const heightNum = parseInt(q);
if (!isNaN(heightNum) && heightNum > 0) {
const block = await db.query('SELECT height, hash FROM blocks WHERE height = $1', [heightNum]);
if (block.rows.length > 0) return res.json({ success: true, data: { type: 'block', data: block.rows[0] } });
}
// 交易哈希
const tx = await db.query('SELECT hash, height FROM transactions WHERE hash ILIKE $1', [`%${q}%`]);
if (tx.rows.length > 0) return res.json({ success: true, data: { type: 'tx', data: tx.rows[0] } });
// 地址
if (q.startsWith('msg1') || q.startsWith('msgvaloper1')) {
// X-Local-Only=true: 子门禁搜索
const searchResult = await rpc.searchHash(q);
if (searchResult && searchResult.status === 503) {
return res.json({ success: true, data: { type: 'account', data: { address: q }, local_only: true, status: 503, message: 'Hash search not canonical or stale' } });
}
return res.json({ success: true, data: { type: 'account', data: { address: q } } });
}
res.json({ success: true, data: null, message: 'No results found' });
} catch (error: any) { res.status(500).json({ success: false, error: 'Search failed' }); }
});
// GET /api/v1/tokens — 代币列表
router.get('/api/v1/tokens', async (req, res) => {
try {
const page = parseInt(req.query.page as string) || 1;
const pageSize = parseInt(req.query.page_size as string) || 20;
const supply = await rpc.restGet('/cosmos/bank/v1beta1/supply');
const denoms = (supply.supply || []).slice((page - 1) * pageSize, page * pageSize);
const tokens = await Promise.all(denoms.map(async (s: any) => {
try {
const meta = await rpc.restGet(`/cosmos/bank/v1beta1/denoms_metadata/${s.denom}`);
return { denom: s.denom, name: meta?.metadata?.name || s.denom, symbol: meta?.metadata?.symbol || s.denom.toUpperCase(), decimals: 6, total_supply: s.amount || '0', token_type: 'native', holders_count: 0 };
} catch { return { denom: s.denom, name: s.denom, symbol: s.denom.toUpperCase(), decimals: 6, total_supply: s.amount || '0', token_type: 'native', holders_count: 0 }; }
}));
res.json({ success: true, data: tokens, page, page_size: pageSize, total: (supply.supply || []).length });
} catch (error: any) { res.status(500).json({ success: false, error: 'Failed to fetch tokens' }); }
});
// GET /api/v1/proposals — 治理提案
router.get('/api/v1/proposals', async (req, res) => {
try {
const proposals = await rpc.restGet('/cosmos/gov/v1beta1/proposals?pagination.limit=100');
const items = (proposals.proposals || []).map((p: any) => ({
proposal_id: parseInt(p.proposal_id), title: p.content?.title || p.title || '',
type: p.content?.['@type'] || 'Text', status: p.status, proposer: p.proposer || '',
submit_time: p.submit_time || '', voting_end_time: p.voting_end_time || '',
yes_votes: p.final_tally_result?.yes || '0', no_votes: p.final_tally_result?.no || '0',
no_with_veto_votes: p.final_tally_result?.no_with_veto || '0', abstain_votes: p.final_tally_result?.abstain || '0',
}));
res.json({ success: true, data: items, total: items.length, page: 1, page_size: 100 });
} catch (error: any) { res.status(500).json({ success: false, error: 'Failed to fetch proposals' }); }
});
// POST /api/v1/contracts/verify — 合约验证
router.post('/api/v1/contracts/verify', async (req, res) => {
try {
// X-Stub=true: 合约验证尚未完全实现开发参考级
const { address, source_code, compiler_version, wasm_hash, optimization } = req.body;
if (!address || !source_code || !compiler_version || !wasm_hash)
return res.status(400).json({ success: false, error: 'Missing required fields' });
const contractInfo = await rpc.restGet(`/cosmwasm/wasm/v1/contract/${address}`);
if (!contractInfo.contract_info) return res.status(404).json({ success: false, error: 'Contract not found' });
const codeInfo = await rpc.restGet(`/cosmwasm/wasm/v1/code/${contractInfo.contract_info.code_id}`);
await db.query(
`INSERT INTO contract_verifications (contract_addr, code_id, source_code, compiler_version, wasm_hash, expected_hash, verified, optimization)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8) ON CONFLICT DO NOTHING`,
[address, parseInt(contractInfo.contract_info.code_id), source_code, compiler_version, wasm_hash, codeInfo?.code_info?.code_hash || wasm_hash, true, optimization || false]
);
await db.query('UPDATE contracts SET is_verified=true, updated_at=NOW() WHERE address=$1', [address]);
res.json({ success: true, data: { contract_addr: address, verified: true, local_only: process.env.LOCAL_GATES_ENABLED === 'true' } });
} catch (error: any) { res.status(500).json({ success: false, error: 'Verification failed' }); }
});
// GET /api/v1/logs — 子门禁事件日志
router.get('/api/v1/logs', async (req, res) => {
try {
// X-Local-Only=true: eth_getLogs — 畸形过滤 → fail-closed
const contractAddr = req.query.address as string;
const fromBlock = req.query.from_block as string || '0x0';
const toBlock = req.query.to_block as string || 'latest';
const topics = req.query.topics as string;
if (topics) {
try { JSON.parse(topics); } catch {
return res.status(503).json({ success: false, error: 'Malformed filter: topics must be valid JSON array', local_only: true, code: 503 });
}
}
const params: any = { address: contractAddr, fromBlock, toBlock };
if (topics) params.topics = JSON.parse(topics);
const logs = await rpc.getLogs(params);
if (logs.error) return res.status(503).json({ success: false, error: logs.error.message || 'Event log query failed', local_only: true, code: 503 });
res.json({ success: true, data: logs.result || [], local_only: true });
} catch { res.status(503).json({ success: false, error: 'Failed to fetch logs', local_only: true, code: 503 }); }
});
// GET /api/v1/indexer/status
router.get('/api/v1/indexer/status', async (_req, res) => {
const result = await db.query('SELECT * FROM indexer_status WHERE id = 1');
res.json({ success: true, data: result.rows[0] || { synced: false } });
});
app.use(router);
}
4.6 API 入口
// src/index.ts
import dotenv from 'dotenv'; dotenv.config();
import { ExplorerAPI } from './app';
const api = new ExplorerAPI();
api.start().catch((error) => { console.error('Failed to start:', error); process.exit(1); });
5. Explorer 前端
使用 Next.js 14 + TypeScript + Tailwind CSS 构建前端界面。
5.1 项目初始化
npx create-next-app@latest msg-chain-explorer-frontend --typescript --tailwind --src-dir --app --import-alias "@/*"
cd msg-chain-explorer-frontend
npm install @tanstack/react-query date-fns recharts clsx lucide-react react-hot-toast
5.2 目录结构
src/
├── app/
│ ├── layout.tsx # 根布局 (Header + 主题)
│ ├── page.tsx # Dashboard 首页
│ ├── globals.css # Tailwind + 自定义样式
│ ├── blocks/
│ │ ├── page.tsx # 区块列表
│ │ └── [height]/page.tsx # 区块详情
│ ├── txs/
│ │ ├── page.tsx # 交易列表
│ │ └── [hash]/page.tsx # 交易详情
│ ├── accounts/[address]/page.tsx # 账户详情
│ ├── validators/
│ │ ├── page.tsx # 验证者列表
│ │ └── [address]/page.tsx # 验证者详情
│ ├── contracts/
│ │ ├── page.tsx # 合约列表
│ │ └── [address]/page.tsx # 合约详情 + 验证
│ ├── proposals/page.tsx # 治理提案
│ └── search/page.tsx # 搜索结果
├── components/
│ ├── layout/Header.tsx
│ ├── search/SearchBar.tsx
│ ├── dashboard/NetworkStats.tsx
│ ├── dashboard/LatestBlocks.tsx
│ ├── dashboard/LatestTxs.tsx
│ ├── blocks/BlockList.tsx
│ ├── txs/TxList.tsx
│ ├── contracts/VerifiedBadge.tsx
│ ├── contracts/ContractVerification.tsx
│ └── common/{Pagination,CopyButton,StatusBadge,Loading}.tsx
├── hooks/useWebSocket.ts
├── lib/api.ts
└── types/index.ts
5.3 前端类型定义
// src/types/index.ts
export interface NetworkStats {
chain_id: string; latest_block_height: number; latest_block_time: string;
block_time_avg: number; tps: number; active_validators: number;
total_validators: number; bonded_tokens: string; total_supply: string;
inflation_rate: number; total_txs: number; staking_apr: number;
}
export interface Block {
height: number; hash: string; proposer: string;
block_time: string; num_txs: number; gas_used: number; gas_wanted: number;
}
export interface Tx {
hash: string; height: number; timestamp: string; tx_type: string;
status: string; signer: string; gas_fee: number; gas_used: number;
}
export interface AccountDetail {
address: string; balance: Coin[]; tx_count: number;
is_contract: boolean; contract_info?: any;
}
export interface Coin { denom: string; amount: string; }
export interface Validator {
operator_address: string; moniker: string; voting_power: number;
voting_power_percent: number; commission_rate: number;
status: string; jailed: boolean; uptime: number;
}
export interface Token {
denom: string; name: string; symbol: string; decimals: number;
total_supply: string; token_type: string; holders_count: number;
}
export interface Proposal {
proposal_id: number; title: string; type: string; status: string;
proposer: string; submit_time: string; voting_end_time: string;
yes_votes: string; no_votes: string; abstain_votes: string;
}
export interface SearchResult {
type: 'block' | 'tx' | 'account' | 'contract' | 'validator';
data: any; local_only?: boolean; status?: number; message?: string;
}
5.4 API 客户端
// src/lib/api.ts
const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000';
export class ApiClient {
private async fetch<T>(path: string, options?: RequestInit): Promise<T> {
const res = await fetch(`${API_BASE}${path}`, {
...options,
headers: { 'Content-Type': 'application/json', ...options?.headers },
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
return data;
}
async getStats() { return this.fetch('/api/v1/stats'); }
async getBlocks(page = 1, pageSize = 25) { return this.fetch(`/api/v1/blocks?page=${page}&page_size=${pageSize}`); }
async getBlock(height: number) { return this.fetch(`/api/v1/blocks/${height}`); }
async getTransactions(page = 1, pageSize = 25, addr?: string) {
let path = `/api/v1/transactions?page=${page}&page_size=${pageSize}`;
if (addr) path += `&address=${addr}`;
return this.fetch(path);
}
async getTransaction(hash: string) { return this.fetch(`/api/v1/transactions/${hash}`); }
async getAccount(address: string) { return this.fetch(`/api/v1/accounts/${address}`); }
async getValidators(page = 1, pageSize = 50) { return this.fetch(`/api/v1/validators?page=${page}&page_size=${pageSize}`); }
async getValidator(address: string) { return this.fetch(`/api/v1/validators/${address}`); }
async getContracts(page = 1, pageSize = 20) { return this.fetch(`/api/v1/contracts?page=${page}&page_size=${pageSize}`); }
async getContract(address: string) { return this.fetch(`/api/v1/contracts/${address}`); }
async getProposals() { return this.fetch('/api/v1/proposals'); }
async getTokens() { return this.fetch('/api/v1/tokens'); }
async search(q: string) { return this.fetch(`/api/v1/search?q=${encodeURIComponent(q)}`); }
async verifyContract(data: any) {
return this.fetch('/api/v1/contracts/verify', { method: 'POST', body: JSON.stringify(data) });
}
// X-Local-Only=true: 子门禁
async getLogs(params: { address: string; fromBlock?: string; toBlock?: string; topics?: string[] }) {
let path = `/api/v1/logs?address=${params.address}`;
if (params.fromBlock) path += `&from_block=${params.fromBlock}`;
if (params.toBlock) path += `&to_block=${params.toBlock}`;
if (params.topics) path += `&topics=${JSON.stringify(params.topics)}`;
return this.fetch(path);
}
}
export const api = new ApiClient();
5.5 根布局
// src/app/layout.tsx
'use client';
import './globals.css';
import { Header } from '@/components/layout/Header';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { Toaster } from 'react-hot-toast';
const queryClient = new QueryClient({
defaultOptions: { queries: { refetchOnWindowFocus: false, retry: 2, staleTime: 10000 } },
});
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="zh-CN" suppressHydrationWarning>
<body className="bg-gray-50 dark:bg-gray-950 text-gray-900 dark:text-gray-100 min-h-screen">
<QueryClientProvider client={queryClient}>
<Header />
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">{children}</main>
<Toaster position="top-right" />
</QueryClientProvider>
</body>
</html>
);
}
5.6 Header 组件
// src/components/layout/Header.tsx
'use client';
import { useState } from 'react';
import Link from 'next/link';
import { Search, Menu, X, Moon, Sun } from 'lucide-react';
import { SearchBar } from '@/components/search/SearchBar';
export function Header() {
const [isMenuOpen, setIsMenuOpen] = useState(false);
const [isDark, setIsDark] = useState(false);
const toggleTheme = () => {
setIsDark(!isDark);
document.documentElement.classList.toggle('dark');
};
const navLinks = [
{ href: '/blocks', label: '区块' }, { href: '/txs', label: '交易' },
{ href: '/validators', label: '验证者' }, { href: '/contracts', label: '合约' },
{ href: '/proposals', label: '治理' },
];
return (
<header className="sticky top-0 z-50 bg-white dark:bg-gray-900 border-b border-gray-200 dark:border-gray-700 shadow-sm">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex items-center justify-between h-16">
<Link href="/" className="flex items-center space-x-2">
<div className="w-8 h-8 bg-blue-600 rounded-lg flex items-center justify-center">
<span className="text-white font-bold text-sm">MSG</span>
</div>
<span className="text-xl font-bold text-gray-900 dark:text-white">MSG Scan</span>
</Link>
<div className="hidden md:flex flex-1 max-w-lg mx-4"><SearchBar /></div>
<nav className="hidden lg:flex items-center space-x-6">
{navLinks.map((link) => (
<Link key={link.href} href={link.href} className="text-sm font-medium text-gray-600 dark:text-gray-300 hover:text-blue-600 transition-colors">{link.label}</Link>
))}
</nav>
<div className="flex items-center space-x-3">
<button onClick={toggleTheme} className="p-2 rounded-lg text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-800">
{isDark ? <Sun size={20} /> : <Moon size={20} />}
</button>
<button onClick={() => setIsMenuOpen(!isMenuOpen)} className="lg:hidden p-2 rounded-lg text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-800">
{isMenuOpen ? <X size={20} /> : <Menu size={20} />}
</button>
</div>
</div>
</div>
{isMenuOpen && (
<div className="lg:hidden border-t border-gray-200 dark:border-gray-700 px-4 py-3 space-y-2">
<SearchBar />
{navLinks.map((link) => (
<Link key={link.href} href={link.href} onClick={() => setIsMenuOpen(false)} className="block px-3 py-2 rounded-lg text-sm hover:bg-gray-100 dark:hover:bg-gray-800">{link.label}</Link>
))}
</div>
)}
</header>
);
}
5.7 SearchBar 组件
// src/components/search/SearchBar.tsx
'use client';
import { useState, useCallback, useEffect, useRef } from 'react';
import { useRouter } from 'next/navigation';
import { Search } from 'lucide-react';
import { debounce } from 'lodash';
export function SearchBar() {
const [query, setQuery] = useState('');
const [results, setResults] = useState<any[]>([]);
const [show, setShow] = useState(false);
const ref = useRef<HTMLDivElement>(null);
const router = useRouter();
const doSearch = useCallback(
debounce(async (q: string) => {
if (q.length < 2) { setResults([]); return; }
try {
const res = await fetch(`http://localhost:3000/api/v1/search?q=${encodeURIComponent(q)}`);
const data = await res.json();
if (data.success && data.data) setResults([data.data]); else setResults([]);
} catch { setResults([]); }
}, 300), []
);
useEffect(() => { doSearch(query); }, [query]);
useEffect(() => {
const handleClick = (e: MouseEvent) => { if (ref.current && !ref.current.contains(e.target as Node)) setShow(false); };
document.addEventListener('mousedown', handleClick);
return () => document.removeEventListener('mousedown', handleClick);
}, []);
const navigate = (type: string, id: string) => {
const paths: Record<string, string> = {
block: `/blocks/${id}`, tx: `/txs/${id}`,
account: `/accounts/${id}`, contract: `/accounts/${id}`,
validator: `/validators/${id}`,
};
router.push(paths[type] || `/search?q=${encodeURIComponent(query)}`);
setShow(false);
};
return (
<div className="relative w-full" ref={ref}>
<form onSubmit={(e) => { e.preventDefault(); if (query.trim()) { router.push(`/search?q=${encodeURIComponent(query.trim())}`); setShow(false); } }}>
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<input value={query} onChange={(e) => { setQuery(e.target.value); setShow(true); }} onFocus={() => setShow(true)}
placeholder="搜索区块、交易、账户、合约..."
className="w-full pl-10 pr-4 py-2 bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-600 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" />
</div>
</form>
{show && results.length > 0 && (
<div className="absolute top-full left-0 right-0 mt-1 bg-white dark:bg-gray-800 border rounded-lg shadow-lg z-50">
{results.map((r, i) => (
<button key={i} onClick={() => navigate(r.type, r.data?.height?.toString() || r.data?.hash || r.data?.address || query)}
className="w-full px-4 py-3 flex items-center space-x-3 hover:bg-gray-50 dark:hover:bg-gray-700 text-left">
<span className={`px-2 py-0.5 rounded text-xs font-medium ${r.type === 'block' ? 'bg-purple-100 text-purple-700' : r.type === 'tx' ? 'bg-green-100 text-green-700' : 'bg-blue-100 text-blue-700'}`}>
{r.type === 'block' ? '区块' : r.type === 'tx' ? '交易' : '地址'}
</span>
<span className="text-sm font-mono truncate">{r.data?.height || r.data?.hash || r.data?.address}</span>
{/* X-Local-Only=true: 子门禁 503 标记 */}
{r.local_only && <span className="text-xs text-yellow-600 bg-yellow-50 px-1 rounded">本地</span>}
</button>
))}
</div>
)}
</div>
);
}
5.8 Dashboard 页面
// src/app/page.tsx
'use client';
import { useQuery } from '@tanstack/react-query';
import { api } from '@/lib/api';
import { NetworkStats } from '@/components/dashboard/NetworkStats';
import { LatestBlocks } from '@/components/dashboard/LatestBlocks';
import { LatestTxs } from '@/components/dashboard/LatestTxs';
export default function DashboardPage() {
const { data: statsData, isLoading: statsLoading } = useQuery({ queryKey: ['stats'], queryFn: () => api.getStats(), refetchInterval: 5000 });
const { data: blocksData } = useQuery({ queryKey: ['blocks', 1], queryFn: () => api.getBlocks(1, 10) });
const { data: txsData } = useQuery({ queryKey: ['txs', 1], queryFn: () => api.getTransactions(1, 10) });
return (
<div className="space-y-6">
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">MSG Chain Explorer</h1>
<NetworkStats stats={statsData?.data} loading={statsLoading} />
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<LatestBlocks blocks={blocksData?.data || []} />
<LatestTxs txs={txsData?.data || []} />
</div>
</div>
);
}
5.9 NetworkStats 组件
// src/components/dashboard/NetworkStats.tsx
'use client';
import { NetworkStats as Stats } from '@/types';
import { Activity, Blocks, Users, Coins, TrendingUp, Database } from 'lucide-react';
interface Props { stats: Stats; loading: boolean; }
export function NetworkStats({ stats, loading }: Props) {
if (loading) return <div className="grid grid-cols-2 md:grid-cols-4 gap-4"><StatCardSkeleton /><StatCardSkeleton /><StatCardSkeleton /><StatCardSkeleton /></div>;
if (!stats) return null;
const cards = [
{ icon: <Blocks className="w-5 h-5" />, label: '最新区块', value: `#${stats.latest_block_height.toLocaleString()}`, sub: new Date(stats.latest_block_time).toLocaleString('zh-CN') },
{ icon: <Activity className="w-5 h-5" />, label: 'TPS', value: stats.tps.toFixed(2), sub: `${stats.block_time_avg}s 出块时间` },
{ icon: <Users className="w-5 h-5" />, label: '验证者', value: stats.active_validators.toString(), sub: `共 ${stats.total_validators} 个` },
{ icon: <Database className="w-5 h-5" />, label: '总交易数', value: stats.total_txs.toLocaleString(), sub: `${stats.chain_id}` },
];
return (
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{cards.map((card, i) => (
<div key={i} className="bg-white dark:bg-gray-800 rounded-xl p-4 shadow-sm border border-gray-200 dark:border-gray-700">
<div className="flex items-center space-x-2 text-blue-600 dark:text-blue-400 mb-2">{card.icon} <span className="text-xs font-medium">{card.label}</span></div>
<div className="text-2xl font-bold text-gray-900 dark:text-white">{card.value}</div>
<div className="text-xs text-gray-500 dark:text-gray-400 mt-1">{card.sub}</div>
</div>
))}
</div>
);
}
function StatCardSkeleton() {
return <div className="bg-white dark:bg-gray-800 rounded-xl p-4 shadow-sm animate-pulse"><div className="h-4 w-20 bg-gray-200 dark:bg-gray-700 rounded mb-2" /><div className="h-8 w-24 bg-gray-200 dark:bg-gray-700 rounded" /></div>;
}
5.10 LatestBlocks 组件
// src/components/dashboard/LatestBlocks.tsx
'use client';
import Link from 'next/link';
import { Block } from '@/types';
import { formatDistanceToNow } from 'date-fns';
import { zhCN } from 'date-fns/locale';
export function LatestBlocks({ blocks }: { blocks: Block[] }) {
return (
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700">
<div className="px-4 py-3 border-b border-gray-200 dark:border-gray-700">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">最新区块</h2>
</div>
<div className="divide-y divide-gray-200 dark:divide-gray-700">
{blocks.map((block) => (
<div key={block.height} className="px-4 py-3 flex items-center justify-between hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors">
<div className="flex items-center space-x-3">
<div className="w-8 h-8 bg-blue-100 dark:bg-blue-900 rounded-lg flex items-center justify-center text-blue-600 dark:text-blue-300 text-xs font-bold">#</div>
<div>
<Link href={`/blocks/${block.height}`} className="text-sm font-mono text-blue-600 dark:text-blue-400 hover:underline">{block.height.toLocaleString()}</Link>
<div className="text-xs text-gray-500 dark:text-gray-400">
{formatDistanceToNow(new Date(block.block_time), { addSuffix: true, locale: zhCN })}
</div>
</div>
</div>
<div className="text-right">
<div className="text-sm text-gray-900 dark:text-white">{block.num_txs} 笔交易</div>
<div className="text-xs text-gray-500 dark:text-gray-400">Gas: {block.gas_used.toLocaleString()}</div>
</div>
</div>
))}
</div>
<div className="px-4 py-2 border-t border-gray-200 dark:border-gray-700">
<Link href="/blocks" className="text-sm text-blue-600 dark:text-blue-400 hover:underline">查看全部区块 →</Link>
</div>
</div>
);
}
5.11 LatestTxs 组件
// src/components/dashboard/LatestTxs.tsx
'use client';
import Link from 'next/link';
import { Tx } from '@/types';
import { formatDistanceToNow } from 'date-fns';
import { zhCN } from 'date-fns/locale';
export function LatestTxs({ txs }: { txs: Tx[] }) {
return (
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700">
<div className="px-4 py-3 border-b border-gray-200 dark:border-gray-700">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">最新交易</h2>
</div>
<div className="divide-y divide-gray-200 dark:divide-gray-700">
{txs.map((tx) => (
<div key={tx.hash} className="px-4 py-3 flex items-center justify-between hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors">
<div className="flex items-center space-x-3 min-w-0">
<div className={`w-2 h-2 rounded-full flex-shrink-0 ${tx.status === 'success' ? 'bg-green-500' : 'bg-red-500'}`} />
<div>
<Link href={`/txs/${tx.hash}`} className="text-sm font-mono text-blue-600 dark:text-blue-400 hover:underline truncate block max-w-[200px]">{tx.hash.substring(0, 20)}...</Link>
<div className="text-xs text-gray-500">{formatDistanceToNow(new Date(tx.timestamp), { addSuffix: true, locale: zhCN })}</div>
</div>
</div>
<div className="text-right text-xs">
<div className="text-gray-900 dark:text-white">{tx.tx_type}</div>
<div className="text-gray-500">{tx.gas_fee} MSG</div>
</div>
</div>
))}
</div>
<div className="px-4 py-2 border-t border-gray-200 dark:border-gray-700">
<Link href="/txs" className="text-sm text-blue-600 dark:text-blue-400 hover:underline">查看全部交易 →</Link>
</div>
</div>
);
}
5.12 区块列表与详情页
// src/app/blocks/page.tsx
'use client';
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { api } from '@/lib/api';
import { BlockList } from '@/components/blocks/BlockList';
import { Pagination } from '@/components/common/Pagination';
export default function BlocksPage() {
const [page, setPage] = useState(1);
const { data, isLoading } = useQuery({ queryKey: ['blocks', page], queryFn: () => api.getBlocks(page) });
return (
<div>
<h1 className="text-2xl font-bold mb-6">区块</h1>
{isLoading ? <div className="animate-pulse space-y-3">{[1,2,3,4,5].map(i => <div key={i} className="h-16 bg-gray-200 dark:bg-gray-700 rounded-lg" />)}</div> : (
<>
<BlockList blocks={data?.data || []} />
<Pagination page={page} total={data?.total || 0} pageSize={data?.page_size || 25} onChange={setPage} />
</>
)}
</div>
);
}
// src/components/common/Pagination.tsx
'use client';
export function Pagination({ page, total, pageSize, onChange }: { page: number; total: number; pageSize: number; onChange: (p: number) => void }) {
const totalPages = Math.ceil(total / pageSize);
if (totalPages <= 1) return null;
return (
<div className="flex items-center justify-center space-x-2 mt-6">
<button disabled={page <= 1} onClick={() => onChange(page - 1)} className="px-3 py-1.5 rounded-lg text-sm border border-gray-300 dark:border-gray-600 disabled:opacity-50 hover:bg-gray-100 dark:hover:bg-gray-800">上一页</button>
<span className="text-sm text-gray-600 dark:text-gray-400">第 {page} / {totalPages} 页 (共 {total} 条)</span>
<button disabled={page >= totalPages} onClick={() => onChange(page + 1)} className="px-3 py-1.5 rounded-lg text-sm border border-gray-300 dark:border-gray-600 disabled:opacity-50 hover:bg-gray-100 dark:hover:bg-gray-800">下一页</button>
</div>
);
}
5.13 区块详情页
// src/app/blocks/[height]/page.tsx
'use client';
import { useQuery } from '@tanstack/react-query';
import { useParams } from 'next/navigation';
import { api } from '@/lib/api';
import { CopyButton } from '@/components/common/CopyButton';
export default function BlockDetailPage() {
const { height } = useParams();
const { data, isLoading } = useQuery({ queryKey: ['block', height], queryFn: () => api.getBlock(Number(height)) });
if (isLoading) return <div className="animate-pulse space-y-4"><div className="h-8 w-48 bg-gray-200 rounded" /><div className="h-64 bg-gray-200 rounded" /></div>;
const block = data?.data;
if (!block) return <div className="text-red-500">未找到区块 #{height}</div>;
return (
<div className="space-y-6">
<h1 className="text-2xl font-bold">区块 #{block.height.toLocaleString()}</h1>
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-sm border p-6 space-y-4">
<DetailRow label="区块高度" value={`#${block.height.toLocaleString()}`} />
<DetailRow label="区块哈希" value={block.hash} copyable />
<DetailRow label="时间" value={new Date(block.block_time).toLocaleString('zh-CN')} />
<DetailRow label="交易数" value={`${block.num_txs} 笔`} />
<DetailRow label="Gas Used" value={block.gas_used?.toLocaleString()} />
<DetailRow label="Gas Wanted" value={block.gas_wanted?.toLocaleString()} />
<DetailRow label="提议者" value={block.proposer} copyable />
<DetailRow label="父哈希" value={block.parent_hash} copyable />
<DetailRow label="App Hash" value={block.app_hash} copyable />
<DetailRow label="Chain ID" value={block.chain_id} />
</div>
{block.transactions?.length > 0 && (
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-sm border p-6">
<h2 className="text-lg font-semibold mb-4">交易列表</h2>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead><tr className="border-b text-left text-gray-500"><th className="pb-2">哈希</th><th className="pb-2">类型</th><th className="pb-2">发送者</th><th className="pb-2">状态</th></tr></thead>
<tbody>
{block.transactions.map((tx: any) => (
<tr key={tx.hash} className="border-b last:border-0 hover:bg-gray-50 dark:hover:bg-gray-700/50">
<td className="py-2"><a href={`/txs/${tx.hash}`} className="font-mono text-blue-600 hover:underline">{tx.hash.substring(0, 20)}...</a></td>
<td className="py-2">{tx.tx_type}</td>
<td className="py-2 font-mono text-xs">{tx.signer?.substring(0, 16)}...</td>
<td className="py-2">
<span className={`px-2 py-0.5 rounded text-xs font-medium ${tx.status === 'success' ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'}`}>{tx.status}</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</div>
);
}
function DetailRow({ label, value, copyable }: { label: string; value: string; copyable?: boolean }) {
return (
<div className="flex items-center justify-between py-2 border-b border-gray-100 dark:border-gray-700 last:border-0">
<span className="text-sm text-gray-500 dark:text-gray-400">{label}</span>
<div className="flex items-center space-x-2">
<span className="text-sm font-mono text-gray-900 dark:text-white">{value}</span>
{copyable && <CopyButton text={value} />}
</div>
</div>
);
}
5.14 交易详情页
// src/app/txs/[hash]/page.tsx
'use client';
import { useQuery } from '@tanstack/react-query';
import { useParams } from 'next/navigation';
import { api } from '@/lib/api';
import { CopyButton } from '@/components/common/CopyButton';
export default function TxDetailPage() {
const { hash } = useParams();
const { data, isLoading } = useQuery({ queryKey: ['tx', hash], queryFn: () => api.getTransaction(hash as string) });
if (isLoading) return <div className="animate-pulse space-y-4"><div className="h-8 w-64 bg-gray-200 rounded" /><div className="h-96 bg-gray-200 rounded" /></div>;
const tx = data?.data;
if (!tx) return <div className="text-red-500">未找到交易</div>;
return (
<div className="space-y-6">
<h1 className="text-2xl font-bold break-all">交易详情</h1>
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-sm border p-6 space-y-4">
<div className="flex items-center space-x-2 mb-4">
<span className={`px-3 py-1 rounded-full text-xs font-medium ${tx.status === 'success' ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'}`}>{tx.status === 'success' ? '成功' : '失败'}</span>
<span className="text-xs text-gray-500">{new Date(tx.timestamp).toLocaleString('zh-CN')}</span>
</div>
<DetailRow label="交易哈希" value={tx.hash} copyable />
<DetailRow label="区块高度" value={`#${tx.height?.toLocaleString()}`} />
<DetailRow label="交易类型" value={tx.tx_type} />
<DetailRow label="发送者" value={tx.signer} copyable />
<DetailRow label="Gas Fee" value={`${tx.gas_fee} ${tx.fee_denom || 'umsg'}`} />
<DetailRow label="Gas Used / Wanted" value={`${tx.gas_used?.toLocaleString()} / ${tx.gas_wanted?.toLocaleString()}`} />
<DetailRow label="Memo" value={tx.memo || '(空)'} />
</div>
{tx.messages?.length > 0 && (
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-sm border p-6">
<h2 className="text-lg font-semibold mb-4">消息 ({tx.messages.length})</h2>
<pre className="text-xs bg-gray-50 dark:bg-gray-900 p-4 rounded-lg overflow-auto max-h-96">{JSON.stringify(tx.messages, null, 2)}</pre>
</div>
)}
{tx.events?.length > 0 && (
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-sm border p-6">
<h2 className="text-lg font-semibold mb-4">事件 ({tx.events.length})</h2>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead><tr className="border-b text-left text-gray-500"><th className="pb-2">类型</th><th className="pb-2">属性</th></tr></thead>
<tbody>
{tx.events.map((event: any, i: number) => (
<tr key={i} className="border-b last:border-0"><td className="py-2 font-medium">{event.event_type || event.type}</td><td className="py-2"><pre className="text-xs">{JSON.stringify(event.attributes)}</pre></td></tr>
))}
</tbody>
</table>
</div>
</div>
)}
</div>
);
}
function DetailRow({ label, value, copyable }: { label: string; value: string; copyable?: boolean }) {
return (
<div className="flex items-center justify-between py-2 border-b border-gray-100 dark:border-gray-700 last:border-0">
<span className="text-sm text-gray-500 dark:text-gray-400">{label}</span>
<div className="flex items-center space-x-2">
<span className="text-sm font-mono text-gray-900 dark:text-white break-all">{value}</span>
{copyable && <CopyButton text={value} />}
</div>
</div>
);
}
5.15 账户详情页
// src/app/accounts/[address]/page.tsx
'use client';
import { useQuery } from '@tanstack/react-query';
import { useParams } from 'next/navigation';
import { api } from '@/lib/api';
import { CopyButton } from '@/components/common/CopyButton';
export default function AccountPage() {
const { address } = useParams();
const { data, isLoading } = useQuery({ queryKey: ['account', address], queryFn: () => api.getAccount(address as string) });
if (isLoading) return <div className="animate-pulse space-y-4"><div className="h-8 w-64 bg-gray-200 rounded" /><div className="h-64 bg-gray-200 rounded" /></div>;
const account = data?.data;
if (!account) return <div className="text-red-500">未找到账户</div>;
return (
<div className="space-y-6">
<h1 className="text-2xl font-bold flex items-center space-x-2">
<span>账户详情</span>
{account.is_contract && (
<span className="px-2 py-0.5 rounded text-xs font-medium bg-purple-100 text-purple-700">合约</span>
)}
{/* X-Local-Only=true: 子门禁验证标记 */}
{account.contract_info?.is_verified && (
<span className="px-2 py-0.5 rounded text-xs font-medium bg-green-100 text-green-700">已验证 ✓</span>
)}
</h1>
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-sm border p-6 space-y-4">
<DetailRow label="地址" value={account.address} copyable />
<div className="py-2 border-b border-gray-100">
<span className="text-sm text-gray-500">余额</span>
<div className="mt-1 space-y-1">
{account.balance?.length > 0 ? account.balance.map((coin: any, i: number) => (
<div key={i} className="text-sm font-mono text-gray-900 dark:text-white">{parseInt(coin.amount || '0') / 1_000_000} {coin.denom}</div>
)) : <div className="text-sm text-gray-400">0 MSG</div>}
</div>
</div>
<DetailRow label="交易数" value={account.tx_count?.toLocaleString()} />
<DetailRow label="是否为合约" value={account.is_contract ? '是' : '否'} />
</div>
{account.contract_info && (
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-sm border p-6">
<h2 className="text-lg font-semibold mb-4">合约信息</h2>
<div className="space-y-3">
<DetailRow label="Code ID" value={`#${account.contract_info.code_id}`} />
<DetailRow label="创建者" value={account.contract_info.creator} copyable />
<DetailRow label="管理员" value={account.contract_info.admin || '(无)'} />
<DetailRow label="Label" value={account.contract_info.label} />
<DetailRow label="创建高度" value={`#${account.contract_info.created_height?.toLocaleString()}`} />
{/* X-Local-Only=true: 子门禁元数据 */}
{account.contract_info.source_metadata && (
<div className="mt-4 p-3 bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg">
<p className="text-xs text-yellow-700 dark:text-yellow-300 mb-2">X-Local-Only=true — 源码元数据来自本地子门禁</p>
<pre className="text-xs overflow-auto">{JSON.stringify(account.contract_info.source_metadata, null, 2)}</pre>
</div>
)}
</div>
</div>
)}
</div>
);
}
function DetailRow({ label, value, copyable }: { label: string; value: string; copyable?: boolean }) {
return (
<div className="flex items-center justify-between py-2 border-b border-gray-100 dark:border-gray-700 last:border-0">
<span className="text-sm text-gray-500">{label}</span>
<div className="flex items-center space-x-2">
<span className="text-sm font-mono break-all">{value}</span>
{copyable && <CopyButton text={value} />}
</div>
</div>
);
}
5.16 全局样式
/* src/app/globals.css */
@tailwind base;
@tailwind components;
@tailwind utilities;
:root { --foreground-rgb: 15, 23, 42; --background-rgb: 248, 250, 252; }
.dark { --foreground-rgb: 226, 232, 240; --background-rgb: 2, 6, 23; }
body { color: rgb(var(--foreground-rgb)); background: rgb(var(--background-rgb)); }
@layer utilities {
.text-balance { text-wrap: balance; }
}
// tailwind.config.ts
import type { Config } from 'tailwindcss';
const config: Config = {
content: ['./src/**/*.{js,ts,jsx,tsx,mdx}'],
darkMode: 'class',
theme: {
extend: {
fontFamily: { mono: ['JetBrains Mono', 'Fira Code', 'monospace'] },
},
},
plugins: [],
};
export default config;
6. 合约源码验证
6.1 验证流程概述
合约源码验证确保链上部署的 WASM 字节码与公开的源代码一致。流程类似 Etherscan 的合约验证:
开发者上传源码 → 服务端编译 → 计算 WASM hash → 对比链上 code_hash → 生成验证结果
6.2 验证数据结构
// X-Stub=true: 合约验证尚未完全实现开发参考级
interface ContractVerificationRequest {
address: string; source_code: string; compiler_version: string;
wasm_hash: string; optimization: boolean; license?: string;
interface_hashes?: string[];
}
interface ContractVerificationResponse {
id: number; contract_addr: string; verified: boolean;
expected_hash: string; actual_hash: string; match: boolean;
local_only: boolean;
}
6.3 验证上传页面
// src/components/contracts/ContractVerification.tsx
'use client';
import { useState } from 'react';
import { api } from '@/lib/api';
import toast from 'react-hot-toast';
export function ContractVerification({ address }: { address: string }) {
const [sourceCode, setSourceCode] = useState('');
const [compilerVersion, setCompilerVersion] = useState('1.70.0');
const [wasmHash, setWasmHash] = useState('');
const [optimization, setOptimization] = useState(true);
const [submitting, setSubmitting] = useState(false);
const compilerVersions = ['1.70.0','1.71.0','1.72.0','1.73.0','1.74.0','1.75.0'];
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!sourceCode || !compilerVersion || !wasmHash) { toast.error('请填写所有必填字段'); return; }
setSubmitting(true);
try {
const res = await api.verifyContract({ address, source_code: sourceCode, compiler_version: compilerVersion, wasm_hash: wasmHash, optimization });
if (res.success) toast.success('合约验证提交成功');
} catch (err: any) { toast.error(err.message || '验证失败'); }
finally { setSubmitting(false); }
};
return (
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-sm border p-6">
<h2 className="text-lg font-semibold mb-4">合约源码验证</h2>
<p className="text-sm text-gray-500 mb-4">上传合约源码以验证其与链上部署的 WASM 字节码一致</p>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium mb-1">合约地址</label>
<input value={address} disabled className="w-full px-3 py-2 bg-gray-50 dark:bg-gray-900 border rounded-lg text-sm font-mono" />
</div>
<div>
<label className="block text-sm font-medium mb-1">编译器版本</label>
<select value={compilerVersion} onChange={e => setCompilerVersion(e.target.value)}
className="w-full px-3 py-2 bg-gray-50 dark:bg-gray-900 border rounded-lg text-sm">
{compilerVersions.map(v => <option key={v} value={v}>{v}</option>)}
</select>
</div>
<div>
<label className="block text-sm font-medium mb-1">WASM Hash</label>
<input value={wasmHash} onChange={e => setWasmHash(e.target.value)} placeholder="编译后的 WASM 文件 SHA256 哈希"
className="w-full px-3 py-2 bg-gray-50 dark:bg-gray-900 border rounded-lg text-sm font-mono" />
</div>
<div>
<label className="block text-sm font-medium mb-1">源码 (.rs)</label>
<textarea value={sourceCode} onChange={e => setSourceCode(e.target.value)} rows={12}
placeholder="粘贴合约 Rust 源码..."
className="w-full px-3 py-2 bg-gray-50 dark:bg-gray-900 border rounded-lg text-sm font-mono" />
</div>
<div className="flex items-center space-x-2">
<input type="checkbox" id="optimization" checked={optimization} onChange={e => setOptimization(e.target.checked)} />
<label htmlFor="optimization" className="text-sm">启用编译优化 (--optimize)</label>
</div>
<div className="flex items-center space-x-2">
<span className="px-2 py-0.5 rounded text-xs font-medium bg-yellow-100 text-yellow-700">X-Local-Only</span>
<span className="text-xs text-gray-500">当前验证仅在本地子门禁模式生效</span>
</div>
<button type="submit" disabled={submitting}
className="w-full px-4 py-2 bg-blue-600 text-white rounded-lg text-sm font-medium hover:bg-blue-700 disabled:opacity-50">
{submitting ? '验证中...' : '提交验证'}
</button>
</form>
</div>
);
}
6.4 验证状态徽标
// src/components/contracts/VerifiedBadge.tsx
'use client';
export function VerifiedBadge({ isVerified, localOnly }: { isVerified: boolean; localOnly?: boolean }) {
if (!isVerified) return <span className="inline-flex px-2 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-600">未验证</span>;
return (
<span className={`inline-flex items-center space-x-1 px-2 py-0.5 rounded-full text-xs font-medium ${localOnly ? 'bg-yellow-100 text-yellow-700' : 'bg-green-100 text-green-700'}`}>
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
</svg>
<span>{localOnly ? '本地已验证' : '已验证'}</span>
</span>
);
}
7. WebSocket 实时更新
7.1 WebSocket 服务器
// src/websocket.ts (在 Explorer API 中)
import { Server as HTTPServer } from 'http';
import { WebSocketServer as WSS, WebSocket } from 'ws';
export class WebSocketServer {
private wss: WSS; private clients: Set<WebSocket> = new Set();
constructor() { this.wss = new WSS({ noServer: true }); }
attach(server: HTTPServer) {
server.on('upgrade', (request, socket, head) => {
if (request.url === '/ws') this.wss.handleUpgrade(request, socket, head, (ws) => this.wss.emit('connection', ws, request));
});
this.wss.on('connection', (ws) => {
this.clients.add(ws);
ws.send(JSON.stringify({ type: 'connected', message: 'Connected to MSG Chain Explorer WS' }));
ws.on('message', (data) => {
try { const msg = JSON.parse(data.toString()); if (msg.type === 'subscribe' && msg.channel) ws.send(JSON.stringify({ type: 'subscribed', channel: msg.channel })); }
catch { ws.send(JSON.stringify({ type: 'error', message: 'Invalid message' })); }
});
ws.on('close', () => this.clients.delete(ws));
});
}
broadcast(type: string, data: any) {
const msg = JSON.stringify({ type, data, timestamp: new Date().toISOString() });
for (const client of this.clients) { if (client.readyState === WebSocket.OPEN) client.send(msg); }
}
broadcastBlock(block: any) { this.broadcast('new_block', block); }
broadcastTx(tx: any) { this.broadcast('new_tx', tx); }
close() { this.wss.close(); }
}
7.2 前端 WebSocket Hook
// src/hooks/useWebSocket.ts
'use client';
import { useEffect, useRef, useState, useCallback } from 'react';
export function useWebSocket(url = 'ws://localhost:3000/ws') {
const [isConnected, setIsConnected] = useState(false);
const [lastMessage, setLastMessage] = useState<any>(null);
const wsRef = useRef<WebSocket | null>(null);
useEffect(() => {
const ws = new WebSocket(url); wsRef.current = ws;
ws.onopen = () => setIsConnected(true);
ws.onmessage = (event) => { try { setLastMessage(JSON.parse(event.data)); } catch {} };
ws.onclose = () => { setIsConnected(false); setTimeout(() => { /* 自动重连逻辑 */ }, 5000); };
return () => ws.close();
}, [url]);
return { isConnected, lastMessage };
}
8. 本地 Explorer 子门禁集成
8.1 子门禁架构
MSG Chain 的"本地子门禁"(Local Sub-gates)是一组已实现但仅在本地模式下可用的功能。
┌─────────────────────────────────────┐
│ 浏览器前端 │
│ ├─ 合约源码元数据 → 黄色标记 │
│ ├─ 规范源查询 → 本地标记 │
│ ├─ 事件日志过滤 → fail-closed 503 │
│ └─ Hash 搜索 → 503 状态展示 │
└─────────────────────────────────────┘
8.2 子门禁 503 展示组件
// src/components/common/LocalGateData.tsx
'use client';
export function LocalGateData({ data, type = 'metadata' }: { data: any; type?: string }) {
if (!data) return null;
// 503 fail-closed
if (data.status === 503 || data.code === 503) {
return (
<div className="p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
<div className="flex items-center space-x-2 mb-2">
<span className="text-xs font-medium text-red-700 px-2 py-0.5 bg-red-100 rounded">503</span>
<span className="text-sm font-medium text-red-700">X-Local-Only: 服务不可用</span>
</div>
<p className="text-xs text-red-600">{data.error || 'hash not canonical or stale'}</p>
<p className="text-xs text-red-500 mt-1">此端点仅在本地子门禁模式下通过 commit index 验证后可用</p>
</div>
);
}
return (
<div className="p-4 bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg">
<div className="flex items-center space-x-2 mb-3">
<span className="text-xs font-medium px-2 py-0.5 rounded bg-yellow-100 text-yellow-700">X-Local-Only=true</span>
<span className="text-sm font-medium">子门禁数据</span>
</div>
<pre className="text-xs overflow-auto max-h-60 bg-white dark:bg-gray-900 p-3 rounded border">{JSON.stringify(data, null, 2)}</pre>
</div>
);
}
9. Docker 部署
9.1 docker-compose.yml
version: '3.8'
services:
postgres:
image: postgres:16-alpine
container_name: msg-explorer-db
restart: unless-stopped
environment:
POSTGRES_USER: explorer
POSTGRES_PASSWORD: ${DB_PASSWORD:-explorer_password}
POSTGRES_DB: msg_chain_explorer
ports: ["5432:5432"]
volumes:
- postgres_data:/var/lib/postgresql/data
- ./init.sql:/docker-entrypoint-initdb.d/init.sql
healthcheck: { test: ["CMD-SHELL", "pg_isready -U explorer -d msg_chain_explorer"], interval: 10s, timeout: 5s, retries: 5 }
networks: [explorer-net]
redis:
image: redis:7-alpine
container_name: msg-explorer-redis
restart: unless-stopped
ports: ["6379:6379"]
volumes: [redis_data:/data]
healthcheck: { test: ["CMD", "redis-cli", "ping"], interval: 10s, timeout: 3s, retries: 5 }
networks: [explorer-net]
indexer:
build: ./msg-chain-indexer
container_name: msg-explorer-indexer
restart: unless-stopped
depends_on: { postgres: { condition: service_healthy } }
environment:
RPC_ENDPOINT: ${RPC_ENDPOINT:-http://host.docker.internal:26657}
REST_ENDPOINT: ${REST_ENDPOINT:-http://host.docker.internal:1317}
DB_HOST: postgres; DB_PORT: 5432
DB_NAME: msg_chain_explorer; DB_USER: explorer
DB_PASSWORD: ${DB_PASSWORD:-explorer_password}
LOG_LEVEL: info; NODE_ENV: production
LOCAL_GATES_ENABLED: "${LOCAL_GATES_ENABLED:-true}"
networks: [explorer-net]
extra_hosts: ["host.docker.internal:host-gateway"]
api:
build: ./msg-chain-explorer-api
container_name: msg-explorer-api
restart: unless-stopped
depends_on:
postgres: { condition: service_healthy }
redis: { condition: service_healthy }
ports: ["3000:3000"]
environment:
PORT: 3000; NODE_ENV: production
RPC_ENDPOINT: ${RPC_ENDPOINT:-http://host.docker.internal:26657}
REST_ENDPOINT: ${REST_ENDPOINT:-http://host.docker.internal:1317}
DB_HOST: postgres; DB_PORT: 5432; DB_NAME: msg_chain_explorer; DB_USER: explorer
DB_PASSWORD: ${DB_PASSWORD:-explorer_password}
REDIS_HOST: redis; REDIS_PORT: 6379
CORS_ORIGIN: ${CORS_ORIGIN:-http://localhost:3001}
LOCAL_GATES_ENABLED: "${LOCAL_GATES_ENABLED:-true}"
networks: [explorer-net]
extra_hosts: ["host.docker.internal:host-gateway"]
frontend:
build:
context: ./msg-chain-explorer-frontend
args:
NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-http://localhost:3000}
container_name: msg-explorer-frontend
restart: unless-stopped
depends_on: [api]
ports: ["3001:3000"]
environment:
NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-http://localhost:3000}
networks: [explorer-net]
nginx:
image: nginx:alpine
container_name: msg-explorer-nginx
restart: unless-stopped
depends_on: [api, frontend]
ports: ["80:80", "443:443"]
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf:ro
- ./ssl:/etc/nginx/ssl:ro
networks: [explorer-net]
volumes:
postgres_data: {}; redis_data: {}
networks:
explorer-net: { driver: bridge }
9.2 Dockerfile 示例
# msg-chain-indexer/Dockerfile
FROM node:20-alpine AS builder
WORKDIR /app; COPY package*.json ./; RUN npm ci
COPY tsconfig.json ./; COPY src/ ./src/; RUN npm run build
FROM node:20-alpine
WORKDIR /app; COPY --from=builder /app/dist ./dist
COPY --from=builder /app/package*.json ./; RUN npm ci --production
CMD ["node", "dist/index.js"]
# msg-chain-explorer-api/Dockerfile
FROM node:20-alpine AS builder
WORKDIR /app; COPY package*.json ./; RUN npm ci
COPY tsconfig.json ./; COPY src/ ./src/; RUN npm run build
FROM node:20-alpine
WORKDIR /app; COPY --from=builder /app/dist ./dist
COPY --from=builder /app/package*.json ./; RUN npm ci --production
CMD ["node", "dist/index.js"]
# msg-chain-explorer-frontend/Dockerfile
FROM node:20-alpine AS builder
WORKDIR /app; COPY package*.json ./; RUN npm ci
COPY . .; ARG NEXT_PUBLIC_API_URL; ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
RUN npm run build
FROM node:20-alpine
WORKDIR /app; COPY --from=builder /app/.next ./.next
COPY --from=builder /app/public ./public; COPY --from=builder /app/package*.json ./
RUN npm ci --production
CMD ["npm", "start"]
9.3 部署脚本
#!/bin/bash
# deploy.sh — 一键部署
set -euo pipefail
echo "MSG Chain Explorer 部署开始..."
# 检查依赖
for cmd in docker docker-compose curl; do
if ! command -v $cmd &>/dev/null; then echo "❌ $cmd 未安装"; exit 1; fi
done
# 构建与启动
docker-compose build && docker-compose up -d
# 等待就绪
until curl -s http://localhost:3000/api/v1/health | grep -q "healthy"; do sleep 2; done
until curl -s http://localhost:3001 | grep -q "MSG"; do sleep 2; done
echo ""
echo "=========================================="
echo " MSG Chain Explorer 已启动! "
echo "=========================================="
echo " 前端: http://localhost:3001"
echo " API: http://localhost:3000/api/v1"
echo " WebSocket: ws://localhost:3000/ws"
echo ""
echo " X-Local-Only=true: 本地子门禁模式"
echo "=========================================="
9.4 Nginx 配置
events { worker_connections 1024; }
http {
upstream api { server api:3000; }
upstream frontend { server frontend:3000; }
server {
listen 80; server_name explorer.msgchain.io;
location / { proxy_pass http://frontend; }
location /api/ { proxy_pass http://api; }
location /ws { proxy_pass http://api; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_read_timeout 86400; }
}
}
10. 标记与边界
10.1 状态标记总览
| 标记 | 含义 | 使用场景 |
|---|---|---|
X-Local-Only=true |
功能仅在本地子门禁模式可用 | 合约元数据、规范源查询、事件日志、Hash 搜索 |
X-Stub=true |
功能为桩实现,生产环境需替换 | 合约验证编译、cosmjs 解码、告警系统 |
X-Mainnet-Roadmap |
主网上线后实现 | 发布签名、真实日志后端、开发参考级监控 |
10.2 功能矩阵
| 功能 | 当前状态 | 本地子门禁 | 生产就绪 | 标记 |
|---|---|---|---|---|
| 区块同步 | ✅ 已实现 | - | 部分 | - |
| 交易索引 | ✅ 已实现 | - | 部分 | - |
| 事件索引 | ✅ 已实现 | - | 部分 | - |
| 合约元数据 | ✅ 已实现 | ✅ 子门禁 | ❌ | X-Local-Only |
| 规范源查询 | ✅ 已实现 | ✅ 子门禁 | ❌ | X-Local-Only |
| 事件日志过滤 | ✅ 已实现 | ✅ 子门禁 | ❌ | X-Local-Only |
| Hash 搜索 | ✅ 已实现 | ✅ 子门禁 | ❌ | X-Local-Only |
| 合约编译验证 | 🟡 桩实现 | - | ❌ | X-Stub |
| 发布签名 | ❌ 未实现 | - | ❌ | X-Mainnet-Roadmap |
| 公开 Explorer | ❌ 未实现 | - | ❌ | X-Mainnet-Roadmap |
10.3 生产就绪检查清单
## 主网部署检查清单
### 安全
- [ ] 关闭 LOCAL_GATES_ENABLED
- [ ] PostgreSQL 强密码 + 网络隔离
- [ ] Redis 密码认证
- [ ] Nginx HTTPS (Let's Encrypt)
- [ ] API 请求频率限制
- [ ] API 认证令牌
### 可靠性
- [ ] Indexer 断点续传测试
- [ ] 数据库备份策略
- [ ] 多节点高可用
- [ ] 监控告警 (Prometheus + Grafana)
### 性能
- [ ] PostgreSQL 索引优化
- [ ] Redis 缓存命中率优化
- [ ] CDN 缓存静态文件
- [ ] API 响应时间 p99 < 500ms
### 合约验证
- [ ] 完整编译管道 (Docker sandbox)
- [ ] WASM hash 精确比对
- [ ] 多版本编译器支持
### 发布签名
- [ ] 发布签名机制实现
- [ ] 签名验证子门禁
- [ ] 公共签名密钥分发
10.4 主网后路线图
## 主网后路线图
### Phase 1: 生产就绪 (2 周)
- 关闭所有 X-Local-Only 子门禁
- 部署到生产 Kubernetes
- 配置 CI/CD 流水线
### Phase 2: 数据深化 (1 个月)
- 历史数据回填脚本
- 治理投票可视化
- Staking 奖励图表
- IBC 交易跟踪
### Phase 3: 高级功能 (2 个月)
- 合约交互页面
- 交易模拟器
- 自定义仪表盘
- API 密钥管理
### Phase 4: 生态建设 (3 个月)
- 公开 API 文档 (OpenAPI)
- 移动端适配
- 多语言支持
- Explorer 品牌定制
10.5 与主流浏览器对比
| 功能 | 本指南 | Etherscan | Subscan | Mintscan |
|---|---|---|---|---|
| 区块/交易浏览 | ✅ | ✅ | ✅ | ✅ |
| 账户查询 | ✅ | ✅ | ✅ | ✅ |
| 验证者列表 | ✅ | - | ✅ | ✅ |
| 合约验证 | 🟡 部分 | ✅ | ✅ | 🟡 部分 |
| 合约交互 | ❌ | ✅ | ✅ | ❌ |
| 代币持有者 | 🟡 基础 | ✅ | ✅ | ✅ |
| IBC 跟踪 | ❌ | - | ✅ | ✅ |
| 治理投票 | 🟡 基础 | 🟡 | ✅ | ✅ |
| WebSocket 实时 | ✅ | ✅ | ✅ | ✅ |
| 主题切换 | ✅ | ✅ | ✅ | ✅ |
本指南为 MSG Chain 提供功能完备的区块浏览器起步方案,高级功能在主网上线后逐步完善。
附录 A: 运维命令速查
# Indexer
npm run dev # 开发模式
./indexer.sh logs # 查看日志
# API
curl localhost:3000/api/v1/health # 健康检查
# Docker
docker-compose up -d # 启动所有服务
docker-compose logs -f api # 查看 API 日志
docker-compose down # 停止
# 数据库
psql -h localhost -U explorer -d msg_chain_explorer
# 查看同步进度
psql -h localhost -U explorer -d msg_chain_explorer \
-c "SELECT last_processed_height, is_syncing FROM indexer_status;"
# 统计
psql -h localhost -U explorer -d msg_chain_explorer \
-c "SELECT COUNT(*) FROM blocks;"
附录 B: 端口映射
| 服务 | 端口 | 说明 |
|---|---|---|
| Tendermint RPC | 26657 | 链 RPC |
| Cosmos REST | 1317 | 链 REST |
| gRPC | 9090 | 链 gRPC |
| JSON-RPC | 8545 | EVM 兼容 |
| PostgreSQL | 5432 | 数据库 |
| Redis | 6379 | 缓存 |
| Explorer API | 3000 | 后端 |
| Frontend | 3001 | 前端 |
| Nginx | 80/443 | 反向代理 |
附录 C: 常见问题
Q: Indexer 不进块?
A: 检查 RPC 连接 curl localhost:26657/status。确认 PostgreSQL 配置。
Q: 前端无法连接 API?
A: 检查 NEXT_PUBLIC_API_URL 配置。确认 CORS 设置正确。
Q: 合约验证报错?
A: 确认编译器版本一致。确认 wasm_hash 正确。X-Stub=true — 当前是桩实现。
Q: 搜索返回 503?
A: X-Local-Only=true 的正常行为。Hash 搜索在 commit index 不匹配时 fail-closed。
Q: 重置 Indexer 状态?
A: ./indexer.sh reset 或 SQL: TRUNCATE blocks,transactions,events CASCADE; UPDATE indexer_status SET last_processed_height=0;
