dApp Docs/区块浏览器搭建指南
Development reference. Not independently verified for production.

MSG Chain 区块浏览器搭建指南

数据来源:MSG Chain 代码库核实

主网状态: No-Go — 当前 MSGChain 主网裁决为 No-Go,以下内容反映代码实际状态,不代表生产可用。


目录

  1. 概述
  2. 链上数据查询基础
  3. Indexer 搭建
  4. Explorer 后端 API
  5. Explorer 前端
  6. 合约源码验证
  7. WebSocket 实时更新
  8. 本地 Explorer 子门禁集成
  9. Docker 部署
  10. 标记与边界

1. 概述

1.1 什么是区块浏览器

区块浏览器(Block Explorer)是区块链网络的"搜索引擎"和"可视化仪表盘"。用户可以通过它查询:

类比传统互联网:

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;