MSG Chain 链上治理 dApp 完整前端实现指南
版本:v1.0 | 主网状态:No-Go | 链:msg-chain-1 | Bech32 前缀:msg
目录
1. 概述
1.1 什么是链上治理 dApp
链上治理 dApp(Decentralized Governance Application)是一个去中心化前端应用,允许用户通过区块链网络参与协议的治理决策。在 MSG Chain 上,治理 dApp 连接了 Cosmos SDK x/gov 模块(第一层链治理)和 dao_governance_v1 智能合约(第二层 DAO 治理),为用户提供统一的界面进行提案浏览、投票、创建和执行。
用户浏览器
|
├── Keplr 钱包 ─────→ MSG Chain (msg-chain-1)
| |
| ┌─────┴─────┐
| | |
| x/gov 模块 dao_governance_v1
| (链治理) (DAO 合约治理)
| | |
| └─────┬─────┘
| |
└──────────────────→ foundation_treasury_v2
(金库多签)
1.2 核心功能特性
| 功能 | 描述 | 后端来源 |
|---|---|---|
| 提案列表 | 浏览所有链上提案 | x/gov REST API + CosmWasm Query |
| 提案详情 | 查看单个提案完整元数据 | x/gov + DAO 合约 |
| 投票 | 对活跃提案进行投票 | SigningClient execute |
| 提案创建 | 提交新提案 | 不同类型不同消息结构 |
| 计票统计 | 可视化投票结果 | Recharts 图表组件 |
| 参数查看 | 治理模块参数一览 | REST API + 合约 Query |
| DAO 多签提案 | 金库支出提案管理 | DAO 合约 + Treasury 合约 |
1.3 技术栈选择
React 18 → UI 框架
TypeScript → 类型安全
@cosmjs/* → Cosmos 链交互
@cosmjs/cosmwasm-stargate → 合约交互
@cosmjs/stargate → SDK 链查询
@cosmjs/proto-signing → 交易签名
Recharts → 图表可视化
CSS Modules → 样式方案
Vite → 构建工具
1.4 项目初始化
npm create vite@latest governance-dapp -- --template react-ts
cd governance-dapp
npm install @cosmjs/cosmwasm-stargate @cosmjs/stargate @cosmjs/proto-signing
npm install @cosmjs/amino
npm install recharts react-router-dom
npm install @keplr-wallet/types
1.5 项目结构
src/
|-- main.tsx
|-- App.tsx
|-- index.css
|-- types/
| |-- governance.ts # Cosmos SDK x/gov 类型
| |-- dao.ts # DAO 合约类型
| |-- treasury.ts # 金库合约类型
|-- config/
| |-- chain.ts # 链配置
|-- context/
| |-- WalletContext.tsx # 钱包连接上下文
| |-- DaoContext.tsx # DAO 合约上下文
|-- hooks/
| |-- useProposals.ts # 提案查询 Hook
| |-- useVote.ts # 投票 Hook
| |-- useGovernanceParams.ts # 治理参数 Hook
|-- components/
| |-- Layout.tsx
| |-- ConnectWallet.tsx
| |-- ProposalList/
| | |-- ProposalList.tsx
| | |-- ProposalCard.tsx
| | |-- FilterBar.tsx
| | |-- SearchBar.tsx
| | |-- ProposalList.css
| |-- ProposalDetail/
| | |-- ProposalDetail.tsx
| | |-- VoteTallyChart.tsx
| | |-- Timeline.tsx
| | |-- VoterList.tsx
| | |-- ProposalDetail.css
| |-- ProposalForm/
| | |-- CreateProposal.tsx
| | |-- TextProposalForm.tsx
| | |-- ParamChangeForm.tsx
| | |-- SpendForm.tsx
| | |-- UpgradeForm.tsx
| | |-- DepositSelector.tsx
| | |-- ProposalForm.css
| |-- VotePanel/
| | |-- VotePanel.tsx
| | |-- VoteConfirmModal.tsx
| | |-- VoteStatusBadge.tsx
| | |-- VotePanel.css
| |-- GovernanceParams/
| | |-- GovernanceParams.tsx
| | |-- GovernanceParams.css
| |-- DaoProposals/
| |-- DaoProposalList.tsx
| |-- DaoProposalDetail.tsx
| |-- TreasuryDashboard.tsx
| |-- DaoProposals.css
|-- pages/
| |-- HomePage.tsx
| |-- ProposalsPage.tsx
| |-- ProposalDetailPage.tsx
| |-- CreateProposalPage.tsx
| |-- GovernanceParamsPage.tsx
| |-- DaoPage.tsx
|-- utils/
|-- format.ts
|-- cosmosQueries.ts
|-- daoQueries.ts
2. 提案类型与数据结构
2.1 Cosmos SDK x/gov 提案类型
MSG Chain 的 x/gov 模块支持四种原生提案类型,每种类型通过 @type 字段区分。
2.1.1 TextProposal(文本提案)
纯信号提案,无链上执行动作。
interface TextProposalContent {
"@type": "/cosmos.gov.v1beta1.TextProposal";
title: string;
description: string;
}
2.1.2 ParameterChangeProposal(参数变更提案)
修改链上模块参数。
interface ParamChange {
subspace: string; // 模块名,如 "gov"、"staking"
key: string; // 参数名,如 "voting_period"
value: string; // JSON 编码的值
}
interface ParamChangeProposalContent {
"@type": "/cosmos.gov.v1beta1.ParameterChangeProposal";
title: string;
description: string;
changes: ParamChange[];
}
2.1.3 SoftwareUpgradeProposal(软件升级提案)
触发链上软件升级。
interface UpgradePlan {
name: string; // 升级名称,如 "v1.1.0"
height: number; // 升级高度
info: string; // 升级信息(二进制哈希等)
}
interface SoftwareUpgradeProposalContent {
"@type": "/cosmos.gov.v1beta1.SoftwareUpgradeProposal";
title: string;
description: string;
plan: UpgradePlan;
}
2.1.4 CommunityPoolSpendProposal(社区池支出提案)
从社区池拨款。
interface Coin {
denom: string;
amount: string;
}
interface CommunityPoolSpendProposalContent {
"@type": "/cosmos.gov.v1beta1.CommunityPoolSpendProposal";
title: string;
description: string;
recipient: string;
amount: Coin[];
}
2.2 DAO 合约提案类型
2.2.1 SubmitProposal 标准提案
DAO 合约的标准提案,包含标题、描述和可选的合约执行消息。
interface DaoSubmitProposalMsg {
submit_proposal: {
title: string;
description: string;
target_contract?: string | null;
execute_msg?: string | null;
deposit_amount?: string | null;
};
}
interface DaoProposal {
id: number;
title: string;
description: string;
proposer: string;
status: "active" | "passed" | "rejected" | "executed" | "canceled";
created_at: number;
voting_start: number | null;
voting_end: number | null;
target_contract: string | null;
execute_msg: string | null;
deposit_amount: string;
}
2.2.2 TreasuryProposal(金库支出提案)
金库合约的支出提案,用于多签管理资金。
interface TreasuryProposal {
id: number;
recipient: string;
amount: string;
denom: string;
purpose: string;
status: "pending" | "approved" | "executed" | "rejected";
approvers: string[];
created_at: number;
}
2.3 统一提案联合类型
type ProposalType =
| "text"
| "parameter_change"
| "software_upgrade"
| "community_spend"
| "dao_generic"
| "dao_contract_exec"
| "treasury_spend";
type ProposalContent =
| TextProposalContent
| ParamChangeProposalContent
| SoftwareUpgradeProposalContent
| CommunityPoolSpendProposalContent;
interface CosmosProposal {
proposal_id: string;
content: ProposalContent;
status: CosmosProposalStatus;
final_tally_result: TallyResult;
submit_time: string;
deposit_end_time: string;
voting_start_time: string;
voting_end_time: string;
total_deposit: Coin[];
}
interface TallyResult {
yes: string;
abstain: string;
no: string;
no_with_veto: string;
}
type CosmosProposalStatus =
| "PROPOSAL_STATUS_UNSPECIFIED"
| "PROPOSAL_STATUS_DEPOSIT_PERIOD"
| "PROPOSAL_STATUS_VOTING_PERIOD"
| "PROPOSAL_STATUS_PASSED"
| "PROPOSAL_STATUS_REJECTED"
| "PROPOSAL_STATUS_FAILED";
type VoteOption =
| "VOTE_OPTION_YES"
| "VOTE_OPTION_NO"
| "VOTE_OPTION_ABSTAIN"
| "VOTE_OPTION_NO_WITH_VETO";
type DaoVoteOption = "yes" | "no" | "abstain" | "veto";
2.4 提案类型检测工具
function getProposalType(content: ProposalContent): ProposalType {
switch (content["@type"]) {
case "/cosmos.gov.v1beta1.TextProposal":
return "text";
case "/cosmos.gov.v1beta1.ParameterChangeProposal":
return "parameter_change";
case "/cosmos.gov.v1beta1.SoftwareUpgradeProposal":
return "software_upgrade";
case "/cosmos.gov.v1beta1.CommunityPoolSpendProposal":
return "community_spend";
default:
return "text";
}
}
function getProposalTypeLabel(type: ProposalType): string {
const labels: Record<ProposalType, string> = {
text: "文本提案",
parameter_change: "参数修改",
software_upgrade: "软件升级",
community_spend: "社区支出",
dao_generic: "DAO 通用提案",
dao_contract_exec: "合约执行提案",
treasury_spend: "金库支出提案",
};
return labels[type];
}
function getProposalStatusLabel(status: string): string {
const labels: Record<string, string> = {
PROPOSAL_STATUS_DEPOSIT_PERIOD: "保证金期",
PROPOSAL_STATUS_VOTING_PERIOD: "投票中",
PROPOSAL_STATUS_PASSED: "已通过",
PROPOSAL_STATUS_REJECTED: "已拒绝",
PROPOSAL_STATUS_FAILED: "失败",
active: "投票中",
passed: "已通过",
rejected: "已拒绝",
executed: "已执行",
canceled: "已取消",
pending: "待批准",
approved: "已批准",
};
return labels[status] || status;
}
function getStatusColor(status: string): string {
const colors: Record<string, string> = {
PROPOSAL_STATUS_DEPOSIT_PERIOD: "#FF9800",
PROPOSAL_STATUS_VOTING_PERIOD: "#2196F3",
PROPOSAL_STATUS_PASSED: "#4CAF50",
PROPOSAL_STATUS_REJECTED: "#F44336",
PROPOSAL_STATUS_FAILED: "#9E9E9E",
active: "#2196F3",
passed: "#4CAF50",
rejected: "#F44336",
executed: "#9E9E9E",
canceled: "#FF9800",
pending: "#FF9800",
approved: "#4CAF50",
};
return colors[status] || "#9E9E9E";
}
2.5 链配置
// config/chain.ts
export const CHAIN_CONFIG = {
chainId: "msg-chain-1",
chainName: "MSG Chain",
rpcEndpoint: "https://rpc.msgchain.org",
restEndpoint: "https://api.msgchain.org",
prefix: "msg",
denom: "umsg",
symbol: "MSG",
decimals: 6,
gasPrice: "1000000000attoMSG",
gasAdjustment: 1.5,
coinType: 118,
};
export const CONTRACT_ADDRESSES = {
genesisRegistry: "msg1registryaddressplaceholder...",
};
export const PAGE_SIZE = 20;
3. 提案列表頁
3.1 数据类型定义
// types/governance.ts
export interface ProposalListProps {
status: "all" | "voting" | "passed" | "rejected" | "executed";
onSelect: (id: number) => void;
source: "cosmos" | "dao";
}
3.2 Cosmos 提案查询 Hook
// hooks/useProposals.ts
import { useState, useEffect, useCallback } from "react";
import { CHAIN_CONFIG, PAGE_SIZE } from "../config/chain";
const STATUS_MAP: Record<string, string> = {
all: "",
voting: "PROPOSAL_STATUS_VOTING_PERIOD",
passed: "PROPOSAL_STATUS_PASSED",
rejected: "PROPOSAL_STATUS_REJECTED",
executed: "",
};
export function useCosmosProposals(
status: string,
restEndpoint: string = CHAIN_CONFIG.restEndpoint
) {
const [proposals, setProposals] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [page, setPage] = useState(1);
const [hasMore, setHasMore] = useState(true);
const fetchProposals = useCallback(async (pageNum: number) => {
setLoading(true);
setError(null);
try {
let url = `${restEndpoint}/cosmos/gov/v1beta1/proposals`
+ `?pagination.limit=${PAGE_SIZE}`
+ `&pagination.offset=${(pageNum - 1) * PAGE_SIZE}`;
const mappedStatus = STATUS_MAP[status];
if (mappedStatus) url += `&status=${mappedStatus}`;
const res = await fetch(url);
const data = await res.json();
const newProposals = data.proposals || [];
setProposals(prev =>
pageNum === 1 ? newProposals : [...prev, ...newProposals]
);
setHasMore(newProposals.length === PAGE_SIZE);
} catch (err: any) {
setError(err.message);
} finally {
setLoading(false);
}
}, [status, restEndpoint]);
useEffect(() => {
setPage(1);
fetchProposals(1);
}, [fetchProposals]);
const loadMore = useCallback(() => {
if (!loading && hasMore) {
const nextPage = page + 1;
setPage(nextPage);
fetchProposals(nextPage);
}
}, [loading, hasMore, page, fetchProposals]);
const refresh = useCallback(() => {
setPage(1);
fetchProposals(1);
}, [fetchProposals]);
return { proposals, loading, error, loadMore, hasMore, refresh };
}
3.3 DAO 提案查询 Hook
export function useDaoProposals(
status: string,
client: any,
daoAddress: string | null
) {
const [proposals, setProposals] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!client || !daoAddress) return;
let cancelled = false;
async function fetch() {
setLoading(true);
try {
const queryMsg: any = { list_proposals: { limit: 50 } };
if (status !== "all") {
queryMsg.list_proposals.status = status;
}
const result: any = await client.queryContractSmart(
daoAddress,
queryMsg
);
if (!cancelled) setProposals(result.proposals || []);
} catch (err: any) {
if (!cancelled) setError(err.message);
} finally {
if (!cancelled) setLoading(false);
}
}
fetch();
return () => { cancelled = true; };
}, [client, daoAddress, status]);
return { proposals, loading, error };
}
3.4 ProposalCard 组件
// components/ProposalList/ProposalCard.tsx
import React from "react";
import {
getProposalTypeLabel,
getStatusColor,
getProposalStatusLabel,
} from "../../types/governance";
interface ProposalCardProps {
proposal: any;
onClick: () => void;
source: "cosmos" | "dao";
}
function truncateAddress(addr: string): string {
if (!addr) return "";
return `${addr.slice(0, 8)}...${addr.slice(-6)}`;
}
function formatAmount(amount: string, denom: string = "umsg"): string {
const val = Number(amount) / 1_000_000;
return (
val.toLocaleString("zh-CN", {
minimumFractionDigits: 2,
maximumFractionDigits: 6,
}) +
` ${denom.replace("u", "").toUpperCase()}`
);
}
function getTimeAgo(timestamp: string | number): string {
const now = Date.now();
const time =
typeof timestamp === "string"
? new Date(timestamp).getTime()
: timestamp * 1000;
const diff = now - time;
const hours = Math.floor(diff / 3600000);
if (hours < 1) return "刚刚";
if (hours < 24) return `${hours}小时前`;
const days = Math.floor(hours / 24);
if (days < 30) return `${days}天前`;
return `${Math.floor(days / 30)}月前`;
}
export function ProposalCard({ proposal, onClick, source }: ProposalCardProps) {
const isCosmos = source === "cosmos";
const id = isCosmos ? Number(proposal.proposal_id) : proposal.id;
const title = isCosmos ? proposal.content?.title : proposal.title;
const description = isCosmos
? proposal.content?.description
: proposal.description;
const status = proposal.status;
const proposer = isCosmos ? "" : proposal.proposer;
const type = isCosmos
? getProposalTypeLabel("text")
: proposal.target_contract
? "合约执行提案"
: "DAO 通用提案";
return (
<div className="proposal-card" onClick={onClick}>
<div className="proposal-card-header">
<span className="proposal-id">#{id}</span>
<span
className="status-badge"
style={{ backgroundColor: getStatusColor(status) }}
>
{getProposalStatusLabel(status)}
</span>
<span className="proposal-type">{type}</span>
</div>
<h3 className="proposal-card-title">{title}</h3>
<p className="proposal-card-desc">
{description && description.length > 120
? description.slice(0, 120) + "..."
: description}
</p>
<div className="proposal-card-meta">
{proposer && (
<span className="meta-item">
<span className="meta-label">提案人</span>
<span className="meta-value">
{truncateAddress(proposer)}
</span>
</span>
)}
{isCosmos && proposal.submit_time && (
<span className="meta-item">
<span className="meta-label">提交</span>
<span className="meta-value">
{getTimeAgo(proposal.submit_time)}
</span>
</span>
)}
{!isCosmos && proposal.created_at && (
<span className="meta-item">
<span className="meta-label">提交</span>
<span className="meta-value">
{getTimeAgo(proposal.created_at)}
</span>
</span>
)}
</div>
{isCosmos && proposal.final_tally_result && (
<div className="mini-tally">
<TallyMiniBar tally={proposal.final_tally_result} />
</div>
)}
</div>
);
}
function TallyMiniBar({
tally,
}: {
tally: { yes: string; no: string; abstain: string; no_with_veto: string };
}) {
const total = [tally.yes, tally.no, tally.abstain, tally.no_with_veto].reduce(
(sum, v) => sum + Number(v),
0
);
if (total === 0) return <div className="mini-tally-empty">暂无投票</div>;
const yesPct = (Number(tally.yes) / total) * 100;
const noPct = (Number(tally.no) / total) * 100;
const vetoPct = (Number(tally.no_with_veto) / total) * 100;
const abstainPct = (Number(tally.abstain) / total) * 100;
return (
<div className="mini-tally-bar">
{yesPct > 0 && (
<div
className="mini-bar-segment yes"
style={{ width: `${yesPct}%` }}
title={`赞成: ${formatAmount(tally.yes)}`}
/>
)}
{noPct > 0 && (
<div
className="mini-bar-segment no"
style={{ width: `${noPct}%` }}
title={`反对: ${formatAmount(tally.no)}`}
/>
)}
{abstainPct > 0 && (
<div
className="mini-bar-segment abstain"
style={{ width: `${abstainPct}%` }}
title={`弃权: ${formatAmount(tally.abstain)}`}
/>
)}
{vetoPct > 0 && (
<div
className="mini-bar-segment veto"
style={{ width: `${vetoPct}%` }}
title={`否决: ${formatAmount(tally.no_with_veto)}`}
/>
)}
</div>
);
}
3.5 FilterBar 组件
// components/ProposalList/FilterBar.tsx
import React from "react";
interface FilterBarProps {
activeFilter: string;
onFilterChange: (filter: string) => void;
source: "cosmos" | "dao";
}
const COSMOS_FILTERS = [
{ key: "all", label: "全部" },
{ key: "voting", label: "投票中" },
{ key: "passed", label: "已通过" },
{ key: "rejected", label: "已拒绝" },
];
const DAO_FILTERS = [
{ key: "all", label: "全部" },
{ key: "active", label: "投票中" },
{ key: "passed", label: "已通过" },
{ key: "executed", label: "已执行" },
{ key: "canceled", label: "已取消" },
];
export function FilterBar({
activeFilter,
onFilterChange,
source,
}: FilterBarProps) {
const filters = source === "cosmos" ? COSMOS_FILTERS : DAO_FILTERS;
return (
<div className="filter-bar">
{filters.map((f) => (
<button
key={f.key}
className={`filter-btn ${activeFilter === f.key ? "active" : ""}`}
onClick={() => onFilterChange(f.key)}
>
{f.label}
</button>
))}
</div>
);
}
3.6 SearchBar 组件
// components/ProposalList/SearchBar.tsx
import React, { useState, useCallback } from "react";
interface SearchBarProps {
onSearch: (query: string) => void;
placeholder?: string;
}
export function SearchBar({
onSearch,
placeholder = "搜索提案标题...",
}: SearchBarProps) {
const [value, setValue] = useState("");
const handleChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
setValue(e.target.value);
},
[]
);
const handleSubmit = useCallback(
(e: React.FormEvent) => {
e.preventDefault();
onSearch(value);
},
[value, onSearch]
);
const handleClear = useCallback(() => {
setValue("");
onSearch("");
}, [onSearch]);
return (
<form className="search-bar" onSubmit={handleSubmit}>
<input
type="text"
value={value}
onChange={handleChange}
placeholder={placeholder}
className="search-input"
/>
{value && (
<button
type="button"
className="search-clear"
onClick={handleClear}
>
✕
</button>
)}
<button type="submit" className="search-submit">
搜索
</button>
</form>
);
}
3.7 完整 ProposalList 组件
// components/ProposalList/ProposalList.tsx
import React, { useState, useMemo, useCallback } from "react";
import { useCosmosProposals, useDaoProposals } from "../../hooks/useProposals";
import { ProposalCard } from "./ProposalCard";
import { FilterBar } from "./FilterBar";
import { SearchBar } from "./SearchBar";
import "./ProposalList.css";
interface ProposalListProps {
status: "all" | "voting" | "passed" | "rejected" | "executed";
onSelect: (id: number) => void;
source: "cosmos" | "dao";
client: any;
daoAddress: string | null;
}
export function ProposalList({
status,
onSelect,
source,
client,
daoAddress,
}: ProposalListProps) {
const [filter, setFilter] = useState<string>(status);
const [searchQuery, setSearchQuery] = useState("");
const {
proposals: cosmosProposals,
loading: cosmosLoading,
error: cosmosError,
loadMore,
hasMore,
} = useCosmosProposals(filter);
const {
proposals: daoProposals,
loading: daoLoading,
error: daoError,
} = useDaoProposals(filter, client, daoAddress);
const proposals = source === "cosmos" ? cosmosProposals : daoProposals;
const loading = source === "cosmos" ? cosmosLoading : daoLoading;
const error = source === "cosmos" ? cosmosError : daoError;
const filteredProposals = useMemo(() => {
if (!searchQuery) return proposals;
const q = searchQuery.toLowerCase();
return proposals.filter((p: any) => {
const title = source === "cosmos" ? p.content?.title : p.title;
return title?.toLowerCase().includes(q);
});
}, [proposals, searchQuery, source]);
const handleSearch = useCallback((query: string) => {
setSearchQuery(query);
}, []);
const handleFilterChange = useCallback((newFilter: string) => {
setFilter(newFilter);
}, []);
if (loading && proposals.length === 0) {
return <div className="loading-state">加载提案中...</div>;
}
if (error) {
return <div className="error-state">加载失败: {error}</div>;
}
return (
<div className="proposal-list">
<div className="proposal-list-toolbar">
<FilterBar
activeFilter={filter}
onFilterChange={handleFilterChange}
source={source}
/>
<SearchBar onSearch={handleSearch} />
</div>
{filteredProposals.length === 0 ? (
<div className="empty-state">
<p>暂无提案</p>
{searchQuery && <p>尝试修改搜索关键词</p>}
</div>
) : (
<>
<div className="proposal-cards">
{filteredProposals.map((proposal: any) => {
const id =
source === "cosmos"
? Number(proposal.proposal_id)
: proposal.id;
return (
<ProposalCard
key={id}
proposal={proposal}
onClick={() => onSelect(id)}
source={source}
/>
);
})}
</div>
{source === "cosmos" && hasMore && (
<div className="load-more">
<button
onClick={loadMore}
disabled={loading}
className="load-more-btn"
>
{loading ? "加载中..." : "加载更多"}
</button>
</div>
)}
</>
)}
</div>
);
}
3.8 ProposalList CSS
.proposal-list {
max-width: 960px;
margin: 0 auto;
padding: 20px;
}
.proposal-list-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
flex-wrap: wrap;
gap: 12px;
}
.filter-bar {
display: flex;
gap: 8px;
}
.filter-btn {
padding: 8px 16px;
border: 1px solid #ddd;
border-radius: 6px;
background: #fff;
cursor: pointer;
font-size: 14px;
transition: all 0.2s;
}
.filter-btn:hover {
background: #f0f0f0;
}
.filter-btn.active {
background: #1976D2;
color: #fff;
border-color: #1976D2;
}
.search-bar {
display: flex;
gap: 8px;
align-items: center;
}
.search-input {
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 6px;
font-size: 14px;
width: 220px;
outline: none;
}
.search-input:focus {
border-color: #1976D2;
}
.search-clear {
background: none;
border: none;
cursor: pointer;
padding: 4px;
color: #999;
}
.search-submit {
padding: 8px 16px;
background: #1976D2;
color: #fff;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
}
.search-submit:hover {
background: #1565C0;
}
.proposal-cards {
display: flex;
flex-direction: column;
gap: 12px;
}
.proposal-card {
background: #fff;
border: 1px solid #e0e0e0;
border-radius: 8px;
padding: 16px 20px;
cursor: pointer;
transition: all 0.2s;
}
.proposal-card:hover {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
border-color: #1976D2;
}
.proposal-card-header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 8px;
}
.proposal-id {
font-weight: 600;
color: #666;
font-size: 13px;
}
.status-badge {
padding: 2px 10px;
border-radius: 12px;
color: #fff;
font-size: 12px;
font-weight: 500;
}
.proposal-type {
font-size: 12px;
color: #999;
background: #f5f5f5;
padding: 2px 8px;
border-radius: 4px;
}
.proposal-card-title {
font-size: 16px;
font-weight: 600;
margin: 0 0 8px;
color: #222;
line-height: 1.4;
}
.proposal-card-desc {
font-size: 14px;
color: #666;
margin: 0 0 12px;
line-height: 1.5;
}
.proposal-card-meta {
display: flex;
gap: 20px;
font-size: 13px;
color: #888;
}
.meta-item {
display: flex;
gap: 4px;
}
.meta-label {
color: #aaa;
}
.meta-value {
color: #666;
}
.mini-tally {
margin-top: 12px;
}
.mini-tally-bar {
display: flex;
height: 6px;
border-radius: 3px;
overflow: hidden;
background: #eee;
}
.mini-bar-segment.yes {
background: #4CAF50;
}
.mini-bar-segment.no {
background: #F44336;
}
.mini-bar-segment.abstain {
background: #9E9E9E;
}
.mini-bar-segment.veto {
background: #FF9800;
}
.mini-tally-empty {
font-size: 12px;
color: #999;
text-align: center;
}
.loading-state,
.error-state,
.empty-state {
text-align: center;
padding: 60px 20px;
color: #888;
}
.load-more {
text-align: center;
padding: 20px;
}
.load-more-btn {
padding: 10px 24px;
background: #f5f5f5;
border: 1px solid #ddd;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
}
.load-more-btn:hover {
background: #e0e0e0;
}
.load-more-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
4. 提案詳情頁
4.1 数据类型
export interface ProposalDetailProps {
proposalId: number;
source: "cosmos" | "dao";
client: any;
signingClient: any;
daoAddress: string | null;
address: string | null;
isConnected: boolean;
}
4.2 Cosmos 提案查询工具
// utils/cosmosQueries.ts
import { CHAIN_CONFIG } from "../config/chain";
export async function fetchCosmosProposalDetail(proposalId: number) {
const url = `${CHAIN_CONFIG.restEndpoint}/cosmos/gov/v1beta1/proposals/${proposalId}`;
const res = await fetch(url);
const data = await res.json();
return data.proposal;
}
export async function fetchCosmosProposalTally(proposalId: number) {
const url = `${CHAIN_CONFIG.restEndpoint}/cosmos/gov/v1beta1/proposals/${proposalId}/tally`;
const res = await fetch(url);
const data = await res.json();
return data.tally;
}
export async function fetchCosmosProposalVotes(
proposalId: number,
limit: number = 100
) {
const url = `${CHAIN_CONFIG.restEndpoint}/cosmos/gov/v1beta1/proposals/${proposalId}/votes?pagination.limit=${limit}`;
const res = await fetch(url);
const data = await res.json();
return data.votes || [];
}
export async function fetchCosmosProposalDeposits(proposalId: number) {
const url = `${CHAIN_CONFIG.restEndpoint}/cosmos/gov/v1beta1/proposals/${proposalId}/deposits`;
const res = await fetch(url);
const data = await res.json();
return data.deposits || [];
}
4.3 Timeline 组件
// components/ProposalDetail/Timeline.tsx
import React from "react";
interface TimelineEvent {
label: string;
time: string | null;
status: "completed" | "active" | "pending";
}
interface TimelineProps {
submitTime: string | null;
depositEndTime: string | null;
votingStartTime: string | null;
votingEndTime: string | null;
proposalStatus: string;
}
function formatTime(time: string | null): string {
if (!time) return "—";
const d = new Date(time);
return d.toLocaleDateString("zh-CN", {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
}
export function Timeline({
submitTime,
depositEndTime,
votingStartTime,
votingEndTime,
proposalStatus,
}: TimelineProps) {
const isVoting = proposalStatus === "PROPOSAL_STATUS_VOTING_PERIOD";
const isPassed = proposalStatus === "PROPOSAL_STATUS_PASSED";
const isRejected =
proposalStatus === "PROPOSAL_STATUS_REJECTED" ||
proposalStatus === "PROPOSAL_STATUS_FAILED";
const isDeposit =
proposalStatus === "PROPOSAL_STATUS_DEPOSIT_PERIOD";
const events: TimelineEvent[] = [
{
label: "已提交",
time: submitTime,
status: submitTime ? "completed" : "pending",
},
{
label: "保证金期结束",
time: depositEndTime,
status:
depositEndTime &&
new Date(depositEndTime).getTime() < Date.now()
? "completed"
: isDeposit
? "active"
: "pending",
},
{
label: "投票开始",
time: votingStartTime,
status:
votingStartTime &&
new Date(votingStartTime).getTime() < Date.now()
? "completed"
: "pending",
},
{
label: "投票结束",
time: votingEndTime,
status: isVoting
? "active"
: votingEndTime &&
new Date(votingEndTime).getTime() < Date.now()
? "completed"
: "pending",
},
{
label: isRejected ? "已拒绝" : "已通过",
time: null,
status: isPassed ? "completed" : isRejected ? "completed" : "pending",
},
];
return (
<div className="timeline">
<h3 className="timeline-title">提案时间线</h3>
<div className="timeline-track">
{events.map((event, i) => (
<div
key={i}
className={`timeline-item timeline-${event.status}`}
>
<div className="timeline-dot" />
<div className="timeline-content">
<span className="timeline-label">{event.label}</span>
<span className="timeline-time">{formatTime(event.time)}</span>
</div>
{i < events.length - 1 && <div className="timeline-line" />}
</div>
))}
</div>
</div>
);
}
4.4 VoteTallyChart 组件(Recharts 饼图)
// components/ProposalDetail/VoteTallyChart.tsx
import React from "react";
import {
PieChart,
Pie,
Cell,
Tooltip,
Legend,
ResponsiveContainer,
} from "recharts";
interface VoteTally {
yes: string;
no: string;
abstain: string;
no_with_veto: string;
}
interface VoteTallyChartProps {
tally: VoteTally | null;
}
const COLORS = {
yes: "#4CAF50",
no: "#F44336",
abstain: "#9E9E9E",
veto: "#FF9800",
};
function formatBalance(amount: string): string {
return (
(Number(amount) / 1_000_000).toLocaleString("zh-CN", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}) + " MSG"
);
}
export function VoteTallyChart({ tally }: VoteTallyChartProps) {
if (!tally) {
return <div className="chart-empty">暂无投票数据</div>;
}
const total = ["yes", "no", "abstain", "no_with_veto"].reduce(
(sum, key) => sum + Number(tally[key as keyof VoteTally]),
0
);
if (total === 0) {
return <div className="chart-empty">暂无投票数据</div>;
}
const data = [
{ name: "赞成", value: Number(tally.yes), key: "yes" },
{ name: "反对", value: Number(tally.no), key: "no" },
{ name: "弃权", value: Number(tally.abstain), key: "abstain" },
{ name: "否决", value: Number(tally.no_with_veto), key: "veto" },
].filter((d) => d.value > 0);
const yesPct = (Number(tally.yes) / total) * 100;
const noPct = (Number(tally.no) / total) * 100;
const abstainPct = (Number(tally.abstain) / total) * 100;
const vetoPct = (Number(tally.no_with_veto) / total) * 100;
return (
<div className="vote-tally-chart">
<h3>投票分布</h3>
<div className="chart-container">
<ResponsiveContainer width="100%" height={280}>
<PieChart>
<Pie
cx="50%"
cy="50%"
innerRadius={60}
outerRadius={100}
dataKey="value"
data={data}
label={({ name, percent }) =>
`${name} ${(percent * 100).toFixed(1)}%`
}
>
{data.map((entry) => (
<Cell
key={entry.key}
fill={COLORS[entry.key as keyof typeof COLORS]}
/>
))}
</Pie>
<Tooltip
formatter={(value: number) => formatBalance(String(value))}
/>
<Legend />
</PieChart>
</ResponsiveContainer>
</div>
<div className="tally-breakdown">
<div className="tally-row">
<span className="tally-label">
<span className="dot yes" /> 赞成
</span>
<div className="tally-bar-bg">
<div
className="tally-bar-fill yes"
style={{ width: `${yesPct}%` }}
/>
</div>
<span className="tally-value">{yesPct.toFixed(1)}%</span>
<span className="tally-amount">{formatBalance(tally.yes)}</span>
</div>
<div className="tally-row">
<span className="tally-label">
<span className="dot no" /> 反对
</span>
<div className="tally-bar-bg">
<div
className="tally-bar-fill no"
style={{ width: `${noPct}%` }}
/>
</div>
<span className="tally-value">{noPct.toFixed(1)}%</span>
<span className="tally-amount">{formatBalance(tally.no)}</span>
</div>
<div className="tally-row">
<span className="tally-label">
<span className="dot abstain" /> 弃权
</span>
<div className="tally-bar-bg">
<div
className="tally-bar-fill abstain"
style={{ width: `${abstainPct}%` }}
/>
</div>
<span className="tally-value">{abstainPct.toFixed(1)}%</span>
<span className="tally-amount">{formatBalance(tally.abstain)}</span>
</div>
<div className="tally-row">
<span className="tally-label">
<span className="dot veto" /> 否决
</span>
<div className="tally-bar-bg">
<div
className="tally-bar-fill veto"
style={{ width: `${vetoPct}%` }}
/>
</div>
<span className="tally-value">{vetoPct.toFixed(1)}%</span>
<span className="tally-amount">
{formatBalance(tally.no_with_veto)}
</span>
</div>
</div>
<div className="tally-summary">
<span>总投票数: {formatBalance(String(total))}</span>
<span>参与率: {((total / 1_000_000_000_000) * 100).toFixed(1)}%</span>
</div>
</div>
);
}
4.5 VoterList 组件
// components/ProposalDetail/VoterList.tsx
import React, { useState, useEffect } from "react";
import { fetchCosmosProposalVotes } from "../../utils/cosmosQueries";
interface Voter {
voter: string;
option: string;
weight: string;
}
interface VoterListProps {
proposalId: number;
source: "cosmos" | "dao";
client: any;
daoAddress: string | null;
}
const VOTE_LABELS: Record<string, string> = {
VOTE_OPTION_YES: "赞成",
VOTE_OPTION_NO: "反对",
VOTE_OPTION_ABSTAIN: "弃权",
VOTE_OPTION_NO_WITH_VETO: "否决",
yes: "赞成",
no: "反对",
abstain: "弃权",
veto: "否决",
};
const VOTE_COLORS: Record<string, string> = {
VOTE_OPTION_YES: "#4CAF50",
VOTE_OPTION_NO: "#F44336",
VOTE_OPTION_ABSTAIN: "#9E9E9E",
VOTE_OPTION_NO_WITH_VETO: "#FF9800",
yes: "#4CAF50",
no: "#F44336",
abstain: "#9E9E9E",
veto: "#FF9800",
};
function truncateAddress(addr: string): string {
if (!addr) return "";
return `${addr.slice(0, 10)}...${addr.slice(-8)}`;
}
export function VoterList({
proposalId,
source,
client,
daoAddress,
}: VoterListProps) {
const [voters, setVoters] = useState<Voter[]>([]);
const [loading, setLoading] = useState(true);
const [sortBy, setSortBy] = useState<"weight" | "voter">("weight");
const [filterVote, setFilterVote] = useState<string>("");
useEffect(() => {
if (source === "cosmos") {
loadCosmosVoters();
} else if (client && daoAddress) {
loadDaoVoters();
}
}, [proposalId, source, client, daoAddress]);
async function loadCosmosVoters() {
setLoading(true);
try {
const votes = await fetchCosmosProposalVotes(proposalId);
setVoters(
votes.map((v: any) => ({
voter: v.voter,
option: v.option,
weight: v.weight || "0",
}))
);
} catch (err) {
console.error("加载投票人失败:", err);
} finally {
setLoading(false);
}
}
async function loadDaoVoters() {
if (!client || !daoAddress) return;
setLoading(true);
try {
setVoters([]);
} catch (err) {
console.error("加载 DAO 投票人失败:", err);
} finally {
setLoading(false);
}
}
const filteredVoters = voters
.filter((v) => !filterVote || v.option === filterVote)
.sort((a, b) => {
if (sortBy === "weight") return Number(b.weight) - Number(a.weight);
return a.voter.localeCompare(b.voter);
});
if (loading) {
return <div className="voter-list-loading">加载投票列表中...</div>;
}
return (
<div className="voter-list">
<div className="voter-list-header">
<h3>投票列表 ({voters.length})</h3>
<div className="voter-list-controls">
<select
value={filterVote}
onChange={(e) => setFilterVote(e.target.value)}
className="voter-filter-select"
>
<option value="">全部</option>
<option value="VOTE_OPTION_YES">赞成</option>
<option value="VOTE_OPTION_NO">反对</option>
<option value="VOTE_OPTION_ABSTAIN">弃权</option>
<option value="VOTE_OPTION_NO_WITH_VETO">否决</option>
</select>
<select
value={sortBy}
onChange={(e) =>
setSortBy(e.target.value as "weight" | "voter")
}
className="voter-sort-select"
>
<option value="weight">按权重</option>
<option value="voter">按地址</option>
</select>
</div>
</div>
{filteredVoters.length === 0 ? (
<p className="voter-list-empty">暂无投票记录</p>
) : (
<div className="voter-table-wrapper">
<table className="voter-table">
<thead>
<tr>
<th>#</th>
<th>投票人</th>
<th>选项</th>
<th>权重 (MSG)</th>
</tr>
</thead>
<tbody>
{filteredVoters.map((voter, index) => (
<tr key={voter.voter}>
<td>{index + 1}</td>
<td className="voter-address" title={voter.voter}>
{truncateAddress(voter.voter)}
</td>
<td>
<span
className="vote-option-badge"
style={{
backgroundColor:
VOTE_COLORS[voter.option] || "#999",
}}
>
{VOTE_LABELS[voter.option] || voter.option}
</span>
</td>
<td className="voter-weight">
{(Number(voter.weight) / 1_000_000).toLocaleString(
"zh-CN",
{ minimumFractionDigits: 2 }
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
}
4.6 完整的 ProposalDetail 组件
// components/ProposalDetail/ProposalDetail.tsx
import React, { useState, useEffect } from "react";
import {
fetchCosmosProposalDetail,
fetchCosmosProposalTally,
} from "../../utils/cosmosQueries";
import { VoteTallyChart } from "./VoteTallyChart";
import { Timeline } from "./Timeline";
import { VoterList } from "./VoterList";
import { VotePanel } from "../VotePanel/VotePanel";
import "./ProposalDetail.css";
interface ProposalDetailProps {
proposalId: number;
source: "cosmos" | "dao";
client: any;
signingClient: any;
daoAddress: string | null;
address: string | null;
isConnected: boolean;
onBack: () => void;
}
function formatAmount(amount: string, denom: string = "umsg"): string {
const val = Number(amount) / 1_000_000;
return (
val.toLocaleString("zh-CN", {
minimumFractionDigits: 2,
maximumFractionDigits: 6,
}) +
` ${denom.replace("u", "").toUpperCase()}`
);
}
function truncateAddress(addr: string): string {
if (!addr) return "";
return `${addr.slice(0, 12)}...${addr.slice(-8)}`;
}
export function ProposalDetail({
proposalId,
source,
client,
signingClient,
daoAddress,
address,
isConnected,
onBack,
}: ProposalDetailProps) {
const [proposal, setProposal] = useState<any>(null);
const [tally, setTally] = useState<any>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState<
"overview" | "votes" | "tally"
>("overview");
useEffect(() => {
if (source === "cosmos") {
loadCosmosDetail();
} else if (client && daoAddress) {
loadDaoDetail();
}
}, [proposalId, source, client, daoAddress]);
async function loadCosmosDetail() {
setLoading(true);
try {
const [proposalData, tallyData] = await Promise.all([
fetchCosmosProposalDetail(proposalId),
fetchCosmosProposalTally(proposalId),
]);
setProposal(proposalData);
setTally(tallyData);
} catch (err: any) {
setError(err.message);
} finally {
setLoading(false);
}
}
async function loadDaoDetail() {
if (!client || !daoAddress) return;
setLoading(true);
try {
const proposalData: any = await client.queryContractSmart(
daoAddress,
{ get_proposal: { proposal_id: proposalId } }
);
let tallyData = null;
try {
tallyData = await client.queryContractSmart(daoAddress, {
get_proposal_tally: { proposal_id: proposalId },
});
} catch {
// tally may not exist for non-voting proposals
}
setProposal(proposalData);
setTally(tallyData);
} catch (err: any) {
setError(err.message);
} finally {
setLoading(false);
}
}
async function handleExecute() {
if (!signingClient || !daoAddress || !address) return;
try {
const tx = await signingClient.execute(
address,
daoAddress,
{ execute_proposal: { proposal_id: proposalId } },
"auto"
);
alert(`提案已执行!交易哈希: ${tx.transactionHash}`);
loadDaoDetail();
} catch (err: any) {
alert(`执行失败: ${err.message}`);
}
}
if (loading) {
return (
<div className="detail-loading">
<div className="loading-spinner" />
<p>加载提案详情...</p>
</div>
);
}
if (error) {
return (
<div className="detail-error">
<p>加载失败: {error}</p>
<button onClick={onBack}>返回列表</button>
</div>
);
}
if (!proposal) return null;
const isCosmos = source === "cosmos";
const title = isCosmos ? proposal.content?.title : proposal.title;
const description = isCosmos
? proposal.content?.description
: proposal.description;
const status = proposal.status;
const isVoting = isCosmos
? status === "PROPOSAL_STATUS_VOTING_PERIOD"
: status === "active";
const isPassed = isCosmos
? status === "PROPOSAL_STATUS_PASSED"
: status === "passed";
const isExecuted = status === "executed";
const showExecute = !isCosmos && isPassed && !isExecuted;
const borderColor = isCosmos
? status === "PROPOSAL_STATUS_VOTING_PERIOD"
? "#2196F3"
: status === "PROPOSAL_STATUS_PASSED"
? "#4CAF50"
: "#F44336"
: status === "active"
? "#2196F3"
: status === "passed"
? "#4CAF50"
: status === "executed"
? "#9E9E9E"
: "#F44336";
const statusLabel = getStatusLabel(status);
return (
<div className="proposal-detail">
<div className="detail-header">
<button className="back-btn" onClick={onBack}>
← 返回
</button>
<div className="detail-title-row">
<h1 className="detail-title">
<span className="detail-id">#{proposalId}</span>
{title}
</h1>
<span
className="detail-status"
style={{ backgroundColor: borderColor }}
>
{statusLabel}
</span>
</div>
</div>
<div className="detail-tabs">
<button
className={`tab-btn ${activeTab === "overview" ? "active" : ""}`}
onClick={() => setActiveTab("overview")}
>
概览
</button>
<button
className={`tab-btn ${activeTab === "tally" ? "active" : ""}`}
onClick={() => setActiveTab("tally")}
>
投票统计
</button>
<button
className={`tab-btn ${activeTab === "votes" ? "active" : ""}`}
onClick={() => setActiveTab("votes")}
>
投票列表
</button>
</div>
<div className="detail-content">
{activeTab === "overview" && (
<div className="detail-overview">
<div className="detail-section">
<h3>描述</h3>
<p className="detail-description">{description}</p>
</div>
{isCosmos && proposal.content?.changes && (
<div className="detail-section">
<h3>参数变更</h3>
<table className="param-change-table">
<thead>
<tr>
<th>模块</th>
<th>参数</th>
<th>新值</th>
</tr>
</thead>
<tbody>
{proposal.content.changes.map(
(change: any, i: number) => (
<tr key={i}>
<td>{change.subspace}</td>
<td>{change.key}</td>
<td>
<code>{change.value}</code>
</td>
</tr>
)
)}
</tbody>
</table>
</div>
)}
{isCosmos && proposal.content?.plan && (
<div className="detail-section">
<h3>升级计划</h3>
<div className="upgrade-plan">
<div className="plan-row">
<span className="plan-label">名称</span>
<span className="plan-value">
{proposal.content.plan.name}
</span>
</div>
<div className="plan-row">
<span className="plan-label">高度</span>
<span className="plan-value">
{Number(proposal.content.plan.height).toLocaleString()}
</span>
</div>
{proposal.content.plan.info && (
<div className="plan-row">
<span className="plan-label">信息</span>
<span className="plan-value plan-info">
<code>{proposal.content.plan.info}</code>
</span>
</div>
)}
</div>
</div>
)}
{isCosmos && proposal.content?.recipient && (
<div className="detail-section">
<h3>社区支出</h3>
<div className="spend-detail">
<div className="spend-row">
<span className="spend-label">收款地址</span>
<span className="spend-value">
{truncateAddress(proposal.content.recipient)}
</span>
</div>
<div className="spend-row">
<span className="spend-label">金额</span>
<span className="spend-value">
{proposal.content.amount
?.map((coin: any) =>
formatAmount(coin.amount, coin.denom)
)
.join(", ")}
</span>
</div>
</div>
</div>
)}
{!isCosmos && (
<div className="detail-section">
<h3>提案信息</h3>
<div className="dao-proposal-info">
<div className="info-row">
<span className="info-label">提案人</span>
<span className="info-value">
{truncateAddress(proposal.proposer)}
</span>
</div>
<div className="info-row">
<span className="info-label">保证金</span>
<span className="info-value">
{formatAmount(proposal.deposit_amount)}
</span>
</div>
{proposal.target_contract && (
<div className="info-row">
<span className="info-label">目标合约</span>
<span className="info-value">
{truncateAddress(proposal.target_contract)}
</span>
</div>
)}
{proposal.execute_msg && (
<div className="info-row">
<span className="info-label">执行消息</span>
<span className="info-value">
<code>{proposal.execute_msg}</code>
</span>
</div>
)}
</div>
</div>
)}
<div className="detail-section">
<Timeline
submitTime={
isCosmos ? proposal.submit_time : null
}
depositEndTime={
isCosmos ? proposal.deposit_end_time : null
}
votingStartTime={
isCosmos ? proposal.voting_start_time : null
}
votingEndTime={
isCosmos ? proposal.voting_end_time : null
}
proposalStatus={status}
/>
</div>
{isVoting && (
<div className="detail-section">
<VotePanel
proposalId={proposalId}
source={source}
signingClient={signingClient}
daoAddress={daoAddress}
address={address}
isConnected={isConnected}
onVoteComplete={(txHash) => {
alert(`投票成功!交易哈希: ${txHash}`);
loadCosmosDetail();
}}
/>
</div>
)}
{showExecute && (
<div className="detail-section">
<div className="execute-section">
<h3>执行提案</h3>
<p>该提案已通过投票,可以执行。</p>
<button
className="execute-btn"
onClick={handleExecute}
>
执行提案 #{proposalId}
</button>
</div>
</div>
)}
</div>
)}
{activeTab === "tally" && (
<div className="detail-tally">
<VoteTallyChart tally={tally} />
</div>
)}
{activeTab === "votes" && (
<div className="detail-votes">
<VoterList
proposalId={proposalId}
source={source}
client={client}
daoAddress={daoAddress}
/>
</div>
)}
</div>
</div>
);
}
function getStatusLabel(status: string): string {
const labels: Record<string, string> = {
PROPOSAL_STATUS_DEPOSIT_PERIOD: "保证金期",
PROPOSAL_STATUS_VOTING_PERIOD: "投票中",
PROPOSAL_STATUS_PASSED: "已通过",
PROPOSAL_STATUS_REJECTED: "已拒绝",
PROPOSAL_STATUS_FAILED: "失败",
active: "投票中",
passed: "已通过",
rejected: "已拒绝",
executed: "已执行",
canceled: "已取消",
};
return labels[status] || status;
}
5. 提案創建
5.1 类型定义
export interface ProposalFormProps {
proposalType: "text" | "parameter" | "spend" | "upgrade";
onSubmit: (proposal: ProposalInput) => Promise<void>;
}
export interface ProposalInput {
title: string;
description: string;
proposalType: string;
changes?: ParamChangeInput[];
plan?: {
name: string;
height: number;
info: string;
};
recipient?: string;
amount?: string;
denom?: string;
initialDeposit: string;
}
export interface ParamChangeInput {
subspace: string;
key: string;
value: string;
}
5.2 提案创建上下文 Hook
// hooks/useProposalSubmit.ts
import { useState } from "react";
import { SigningCosmWasmClient } from "@cosmjs/cosmwasm-stargate";
import { CHAIN_CONFIG } from "../config/chain";
interface UseProposalSubmitOptions {
signingClient: SigningCosmWasmClient | null;
address: string | null;
}
export function useProposalSubmit({
signingClient,
address,
}: UseProposalSubmitOptions) {
const [submitting, setSubmitting] = useState(false);
const [txHash, setTxHash] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
async function submitCosmosProposal(proposalInput: ProposalInput) {
if (!signingClient || !address) {
throw new Error("钱包未连接");
}
setSubmitting(true);
setError(null);
setTxHash(null);
try {
let msg: any;
switch (proposalInput.proposalType) {
case "text":
msg = {
typeUrl: "/cosmos.gov.v1beta1.MsgSubmitProposal",
value: {
content: {
typeUrl: "/cosmos.gov.v1beta1.TextProposal",
value: {
title: proposalInput.title,
description: proposalInput.description,
},
},
initialDeposit: [
{
denom: CHAIN_CONFIG.denom,
amount: proposalInput.initialDeposit,
},
],
proposer: address,
},
};
break;
case "parameter":
msg = {
typeUrl: "/cosmos.gov.v1beta1.MsgSubmitProposal",
value: {
content: {
typeUrl: "/cosmos.gov.v1beta1.ParameterChangeProposal",
value: {
title: proposalInput.title,
description: proposalInput.description,
changes: proposalInput.changes?.map((c) => ({
subspace: c.subspace,
key: c.key,
value: c.value,
})),
},
},
initialDeposit: [
{
denom: CHAIN_CONFIG.denom,
amount: proposalInput.initialDeposit,
},
],
proposer: address,
},
};
break;
case "upgrade":
msg = {
typeUrl: "/cosmos.gov.v1beta1.MsgSubmitProposal",
value: {
content: {
typeUrl: "/cosmos.gov.v1beta1.SoftwareUpgradeProposal",
value: {
title: proposalInput.title,
description: proposalInput.description,
plan: proposalInput.plan,
},
},
initialDeposit: [
{
denom: CHAIN_CONFIG.denom,
amount: proposalInput.initialDeposit,
},
],
proposer: address,
},
};
break;
case "spend":
msg = {
typeUrl: "/cosmos.gov.v1beta1.MsgSubmitProposal",
value: {
content: {
typeUrl: "/cosmos.gov.v1beta1.CommunityPoolSpendProposal",
value: {
title: proposalInput.title,
description: proposalInput.description,
recipient: proposalInput.recipient,
amount: [
{
denom: proposalInput.denom || CHAIN_CONFIG.denom,
amount: proposalInput.amount,
},
],
},
},
initialDeposit: [
{
denom: CHAIN_CONFIG.denom,
amount: proposalInput.initialDeposit,
},
],
proposer: address,
},
};
break;
default:
throw new Error(`不支持的提案类型: ${proposalInput.proposalType}`);
}
const fee = {
amount: [{ denom: CHAIN_CONFIG.denom, amount: "5000" }],
gas: "300000",
};
const result = await signingClient.signAndBroadcast(
address,
[msg],
fee
);
setTxHash(result.transactionHash);
return result.transactionHash;
} catch (err: any) {
setError(err.message);
throw err;
} finally {
setSubmitting(false);
}
}
async function submitDaoProposal(
daoAddress: string,
title: string,
description: string,
depositAmount: string,
targetContract?: string,
executeMsg?: string
) {
if (!signingClient || !address) {
throw new Error("钱包未连接");
}
setSubmitting(true);
setError(null);
setTxHash(null);
try {
const msg = {
submit_proposal: {
title,
description,
target_contract: targetContract || null,
execute_msg: executeMsg || null,
deposit_amount: depositAmount,
},
};
const funds = [{ denom: CHAIN_CONFIG.denom, amount: depositAmount }];
const result = await signingClient.execute(
address,
daoAddress,
msg,
"auto",
undefined,
funds
);
setTxHash(result.transactionHash);
return result.transactionHash;
} catch (err: any) {
setError(err.message);
throw err;
} finally {
setSubmitting(false);
}
}
return {
submitCosmosProposal,
submitDaoProposal,
submitting,
txHash,
error,
};
}
5.3 TextProposalForm 组件
// components/ProposalForm/TextProposalForm.tsx
import React, { useState } from "react";
interface TextProposalFormProps {
onSubmit: (data: { title: string; description: string }) => void;
submitting: boolean;
}
export function TextProposalForm({
onSubmit,
submitting,
}: TextProposalFormProps) {
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const [errors, setErrors] = useState<Record<string, string>>({});
function validate(): boolean {
const newErrors: Record<string, string> = {};
if (!title.trim()) newErrors.title = "请输入提案标题";
else if (title.length > 200) newErrors.title = "标题不能超过200字";
if (!description.trim()) newErrors.description = "请输入提案描述";
else if (description.length < 20)
newErrors.description = "描述至少20字";
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
}
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (validate()) {
onSubmit({
title: title.trim(),
description: description.trim(),
});
}
}
return (
<form className="proposal-form" onSubmit={handleSubmit}>
<div className="form-group">
<label className="form-label">
提案标题 <span className="required">*</span>
</label>
<input
type="text"
className={`form-input ${errors.title ? "has-error" : ""}`}
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="输入清晰简洁的提案标题"
maxLength={200}
/>
{errors.title && (
<span className="form-error">{errors.title}</span>
)}
<span className="form-hint">{title.length}/200</span>
</div>
<div className="form-group">
<label className="form-label">
提案描述 <span className="required">*</span>
</label>
<textarea
className={`form-textarea ${errors.description ? "has-error" : ""}`}
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="详细描述提案的内容、动机和预期影响..."
rows={8}
/>
{errors.description && (
<span className="form-error">{errors.description}</span>
)}
<span className="form-hint">
建议包含:背景、方案、影响分析、参考资料
</span>
</div>
<button
type="submit"
className="form-submit"
disabled={submitting}
>
{submitting ? "提交中..." : "提交提案"}
</button>
</form>
);
}
5.4 ParamChangeForm 组件
// components/ProposalForm/ParamChangeForm.tsx
import React, { useState } from "react";
import { ParamChangeInput } from "../../types/governance";
const KNOWN_SUBSPACES = [
{ value: "staking", label: "Staking (质押)" },
{ value: "distribution", label: "Distribution (分配)" },
{ value: "mint", label: "Mint (铸造)" },
{ value: "gov", label: "Gov (治理)" },
{ value: "slashing", label: "Slashing (惩罚)" },
];
const KNOWN_PARAMS: Record<string, { value: string; label: string }[]> = {
staking: [
{ value: "MaxValidators", label: "最大验证者数" },
{ value: "UnbondingTime", label: "解委托期" },
{ value: "MaxEntries", label: "最大解委托条目" },
],
distribution: [
{ value: "communitytax", label: "社区池税率" },
{ value: "baseproposerreward", label: "基础提议者奖励" },
{ value: "bonusproposerreward", label: "额外提议者奖励" },
],
mint: [
{ value: "InflationRateChange", label: "通胀率变化" },
{ value: "InflationMax", label: "最大通胀率" },
{ value: "InflationMin", label: "最小通胀率" },
],
gov: [
{ value: "min_deposit", label: "最小保证金" },
{ value: "max_deposit_period", label: "最大保证金期" },
{ value: "voting_period", label: "投票期" },
{ value: "quorum", label: "法定人数" },
{ value: "threshold", label: "通过阈值" },
{ value: "veto_threshold", label: "否决阈值" },
],
slashing: [
{ value: "signed_blocks_window", label: "签名窗口" },
{ value: "min_signed_per_window", label: "最低签名率" },
],
};
function getParamHint(subspace: string, key: string): string {
const hints: Record<string, Record<string, string>> = {
gov: {
min_deposit: 'JSON 字符串,如 "10000000umsg"',
voting_period: '秒数,如 "604800" (7天)',
quorum: '比例,如 "0.334"',
threshold: '比例,如 "0.500"',
veto_threshold: '比例,如 "0.334"',
},
staking: {
UnbondingTime: '秒数,如 "1814400" (21天)',
MaxValidators: '整数,如 "100"',
},
};
return hints[subspace]?.[key] || "JSON 编码的值";
}
interface ParamChangeFormProps {
onSubmit: (data: {
title: string;
description: string;
changes: ParamChangeInput[];
}) => void;
submitting: boolean;
}
export function ParamChangeForm({
onSubmit,
submitting,
}: ParamChangeFormProps) {
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const [changes, setChanges] = useState<ParamChangeInput[]>([
{ subspace: "gov", key: "", value: "" },
]);
const [errors, setErrors] = useState<Record<string, string>>({});
function addChange() {
setChanges([
...changes,
{ subspace: "gov", key: "", value: "" },
]);
}
function removeChange(index: number) {
if (changes.length > 1) {
setChanges(changes.filter((_, i) => i !== index));
}
}
function updateChange(
index: number,
field: keyof ParamChangeInput,
value: string
) {
const updated = changes.map((c, i) =>
i === index ? { ...c, [field]: value } : c
);
setChanges(updated);
}
function validate(): boolean {
const newErrors: Record<string, string> = {};
if (!title.trim()) newErrors.title = "请输入提案标题";
if (!description.trim()) newErrors.description = "请输入提案描述";
changes.forEach((c, i) => {
if (!c.key) newErrors[`change_${i}_key`] = "请选择参数";
if (!c.value) newErrors[`change_${i}_value`] = "请输入参数值";
});
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
}
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (validate()) {
onSubmit({
title: title.trim(),
description: description.trim(),
changes,
});
}
}
return (
<form className="proposal-form" onSubmit={handleSubmit}>
<div className="form-group">
<label className="form-label">
提案标题 <span className="required">*</span>
</label>
<input
type="text"
className={`form-input ${errors.title ? "has-error" : ""}`}
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="如:调整治理参数以优化投票效率"
maxLength={200}
/>
{errors.title && (
<span className="form-error">{errors.title}</span>
)}
</div>
<div className="form-group">
<label className="form-label">
提案描述 <span className="required">*</span>
</label>
<textarea
className={`form-textarea ${errors.description ? "has-error" : ""}`}
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="说明为什么需要修改这些参数..."
rows={4}
/>
{errors.description && (
<span className="form-error">{errors.description}</span>
)}
</div>
<div className="form-group">
<div className="form-label-row">
<label className="form-label">参数变更</label>
<button
type="button"
className="btn-add"
onClick={addChange}
>
+ 添加变更
</button>
</div>
{changes.map((change, index) => (
<div key={index} className="param-change-row">
<div className="param-change-fields">
<select
className="form-select"
value={change.subspace}
onChange={(e) =>
updateChange(index, "subspace", e.target.value)
}
>
{KNOWN_SUBSPACES.map((s) => (
<option key={s.value} value={s.value}>
{s.label}
</option>
))}
</select>
<select
className={`form-select ${
errors[`change_${index}_key`] ? "has-error" : ""
}`}
value={change.key}
onChange={(e) =>
updateChange(index, "key", e.target.value)
}
>
<option value="">选择参数...</option>
{(KNOWN_PARAMS[change.subspace] || []).map((p) => (
<option key={p.value} value={p.value}>
{p.label} ({p.value})
</option>
))}
</select>
<div className="param-value-wrapper">
<input
type="text"
className={`form-input ${
errors[`change_${index}_value`] ? "has-error" : ""
}`}
value={change.value}
onChange={(e) =>
updateChange(index, "value", e.target.value)
}
placeholder='JSON 值,如 "0.334"'
/>
{change.subspace && change.key && (
<span className="param-hint">
{getParamHint(change.subspace, change.key)}
</span>
)}
</div>
<button
type="button"
className="btn-remove"
onClick={() => removeChange(index)}
disabled={changes.length <= 1}
>
✕
</button>
</div>
{errors[`change_${index}_key`] && (
<span className="form-error">
{errors[`change_${index}_key`]}
</span>
)}
{errors[`change_${index}_value`] && (
<span className="form-error">
{errors[`change_${index}_value`]}
</span>
)}
</div>
))}
</div>
<button
type="submit"
className="form-submit"
disabled={submitting}
>
{submitting ? "提交中..." : "提交参数修改提案"}
</button>
</form>
);
}
### 5.5 SpendForm 组件
```typescript
// components/ProposalForm/SpendForm.tsx
import React, { useState } from "react";
import { CHAIN_CONFIG } from "../../config/chain";
interface SpendFormProps {
onSubmit: (data: {
title: string;
description: string;
recipient: string;
amount: string;
denom: string;
}) => void;
submitting: boolean;
}
export function SpendForm({ onSubmit, submitting }: SpendFormProps) {
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const [recipient, setRecipient] = useState("");
const [amount, setAmount] = useState("");
const [denom, setDenom] = useState(CHAIN_CONFIG.denom);
const [errors, setErrors] = useState<Record<string, string>>({});
function isValidBech32(address: string): boolean {
return (
address.startsWith(`${CHAIN_CONFIG.prefix}1`) && address.length >= 39
);
}
function validate(): boolean {
const newErrors: Record<string, string> = {};
if (!title.trim()) newErrors.title = "请输入提案标题";
if (!description.trim()) newErrors.description = "请输入提案描述";
if (!recipient.trim()) {
newErrors.recipient = "请输入收款地址";
} else if (!isValidBech32(recipient)) {
newErrors.recipient = "无效的 MSG 地址";
}
if (!amount || Number(amount) <= 0) {
newErrors.amount = "请输入有效金额";
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
}
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (validate()) {
onSubmit({
title: title.trim(),
description: description.trim(),
recipient: recipient.trim(),
amount,
denom,
});
}
}
return (
<form className="proposal-form" onSubmit={handleSubmit}>
<div className="form-group">
<label className="form-label">
提案标题 <span className="required">*</span>
</label>
<input
type="text"
className={`form-input ${errors.title ? "has-error" : ""}`}
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="如:资助 MSG Chain 开发者社区"
maxLength={200}
/>
{errors.title && (
<span className="form-error">{errors.title}</span>
)}
</div>
<div className="form-group">
<label className="form-label">
提案描述 <span className="required">*</span>
</label>
<textarea
className={`form-textarea ${
errors.description ? "has-error" : ""
}`}
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="说明资金的用途、预期成果和受益人..."
rows={4}
/>
{errors.description && (
<span className="form-error">{errors.description}</span>
)}
</div>
<div className="form-group">
<label className="form-label">
收款地址 <span className="required">*</span>
</label>
<input
type="text"
className={`form-input monospace ${
errors.recipient ? "has-error" : ""
}`}
value={recipient}
onChange={(e) => setRecipient(e.target.value)}
placeholder={`${CHAIN_CONFIG.prefix}1...`}
/>
{errors.recipient && (
<span className="form-error">{errors.recipient}</span>
)}
<span className="form-hint">输入有效的 MSG 链地址</span>
</div>
<div className="form-row">
<div className="form-group flex-1">
<label className="form-label">
金额 <span className="required">*</span>
</label>
<input
type="number"
className={`form-input ${errors.amount ? "has-error" : ""}`}
value={amount}
onChange={(e) => setAmount(e.target.value)}
min="0"
step="0.000001"
placeholder="1000"
/>
{errors.amount && (
<span className="form-error">{errors.amount}</span>
)}
</div>
<div className="form-group flex-1">
<label className="form-label">代币</label>
<select
className="form-select"
value={denom}
onChange={(e) => setDenom(e.target.value)}
>
<option value="umsg">MSG (umsg)</option>
</select>
</div>
</div>
<div className="form-group amount-display">
<span className="amount-label">实际发送</span>
<span className="amount-value">
{amount
? `${(Number(amount) / 1_000_000).toLocaleString()} MSG`
: "—"}
</span>
</div>
<button
type="submit"
className="form-submit"
disabled={submitting}
>
{submitting ? "提交中..." : "提交社区支出提案"}
</button>
</form>
);
}
5.6 UpgradeForm 组件
// components/ProposalForm/UpgradeForm.tsx
import React, { useState } from "react";
interface UpgradeFormProps {
onSubmit: (data: {
title: string;
description: string;
plan: { name: string; height: number; info: string };
}) => void;
submitting: boolean;
}
export function UpgradeForm({ onSubmit, submitting }: UpgradeFormProps) {
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const [name, setName] = useState("");
const [height, setHeight] = useState("");
const [info, setInfo] = useState("");
const [errors, setErrors] = useState<Record<string, string>>({});
function validate(): boolean {
const newErrors: Record<string, string> = {};
if (!title.trim()) newErrors.title = "请输入提案标题";
if (!description.trim()) newErrors.description = "请输入提案描述";
if (!name.trim()) newErrors.name = "请输入升级名称";
if (!height || Number(height) <= 0)
newErrors.height = "请输入有效区块高度";
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
}
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (validate()) {
onSubmit({
title: title.trim(),
description: description.trim(),
plan: {
name: name.trim(),
height: Number(height),
info: info.trim(),
},
});
}
}
return (
<form className="proposal-form" onSubmit={handleSubmit}>
<div className="form-group">
<label className="form-label">
提案标题 <span className="required">*</span>
</label>
<input
type="text"
className={`form-input ${errors.title ? "has-error" : ""}`}
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="如:升级 MSG Chain 至 v2.0.0"
/>
{errors.title && (
<span className="form-error">{errors.title}</span>
)}
</div>
<div className="form-group">
<label className="form-label">
提案描述 <span className="required">*</span>
</label>
<textarea
className={`form-textarea ${
errors.description ? "has-error" : ""
}`}
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="列出升级内容、变更日志和兼容性说明..."
rows={5}
/>
{errors.description && (
<span className="form-error">{errors.description}</span>
)}
</div>
<div className="form-group">
<label className="form-label">
升级名称 <span className="required">*</span>
</label>
<input
type="text"
className={`form-input ${errors.name ? "has-error" : ""}`}
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="v2.0.0"
/>
{errors.name && (
<span className="form-error">{errors.name}</span>
)}
<span className="form-hint">版本号,如 v1.1.0</span>
</div>
<div className="form-group">
<label className="form-label">
升级高度 <span className="required">*</span>
</label>
<input
type="number"
className={`form-input ${errors.height ? "has-error" : ""}`}
value={height}
onChange={(e) => setHeight(e.target.value)}
min="1"
placeholder="8500000"
/>
{errors.height && (
<span className="form-error">{errors.height}</span>
)}
<span className="form-hint">
当前高度: 查询最新区块获取
</span>
</div>
<div className="form-group">
<label className="form-label">升级信息 (可选)</label>
<textarea
className="form-textarea"
value={info}
onChange={(e) => setInfo(e.target.value)}
placeholder='{"binaries":{"linux/amd64":"https://..."}}'
rows={3}
/>
<span className="form-hint">
JSON 格式,包含二进制下载链接和校验和
</span>
</div>
<button
type="submit"
className="form-submit"
disabled={submitting}
>
{submitting ? "提交中..." : "提交软件升级提案"}
</button>
</form>
);
}
5.7 DepositSelector 组件
// components/ProposalForm/DepositSelector.tsx
import React from "react";
interface DepositSelectorProps {
value: string;
onChange: (value: string) => void;
minDeposit?: string;
}
export function DepositSelector({
value,
onChange,
minDeposit = "10000000",
}: DepositSelectorProps) {
const minMsg = Number(minDeposit) / 1_000_000;
const presetAmounts = [
{ label: "最小", value: minDeposit },
{ label: "2x", value: String(Number(minDeposit) * 2) },
{ label: "5x", value: String(Number(minDeposit) * 5) },
{ label: "10x", value: String(Number(minDeposit) * 10) },
];
return (
<div className="deposit-selector">
<label className="form-label">保证金 (umsg)</label>
<div className="deposit-presets">
{presetAmounts.map((preset) => (
<button
key={preset.label}
type="button"
className={`preset-btn ${
value === preset.value ? "active" : ""
}`}
onClick={() => onChange(preset.value)}
>
{preset.label}:{" "}
{(Number(preset.value) / 1_000_000).toLocaleString()} MSG
</button>
))}
</div>
<input
type="number"
className="form-input"
value={value}
onChange={(e) => onChange(e.target.value)}
min={minDeposit}
step="1000000"
/>
<span className="form-hint">
最小保证金: {minMsg.toLocaleString()} MSG (
{Number(minDeposit).toLocaleString()} umsg)
</span>
</div>
);
}
5.8 Gas 预估工具
// utils/gasEstimate.ts
import { SigningCosmWasmClient } from "@cosmjs/cosmwasm-stargate";
import { CHAIN_CONFIG } from "../config/chain";
export const GAS_LIMITS = {
textProposal: 200_000,
paramChangeProposal: 250_000,
spendProposal: 220_000,
upgradeProposal: 240_000,
daoSubmitProposal: 300_000,
vote: 120_000,
execute: 180_000,
};
export const GAS_FEES: Record<string, string> = {
textProposal: "5000",
paramChangeProposal: "6250",
spendProposal: "5500",
upgradeProposal: "6000",
daoSubmitProposal: "7500",
vote: "3000",
execute: "4500",
};
export async function estimateGas(
client: SigningCosmWasmClient,
messages: any[],
address: string
): Promise<number> {
try {
const simulation = await client.simulate(
address,
messages,
undefined
);
return Math.ceil(simulation * CHAIN_CONFIG.gasAdjustment);
} catch {
return GAS_LIMITS.textProposal;
}
}
export function formatGasFee(gas: number): string {
const feeAtto = gas * 1_000_000_000;
const feeMsg = feeAtto / 1e18;
return `${feeAtto.toLocaleString()} attoMSG (${feeMsg.toFixed(10)} MSG)`;
}
5.9 完整 CreateProposal 页面
// components/ProposalForm/CreateProposal.tsx
import React, { useState } from "react";
import { useProposalSubmit } from "../../hooks/useProposalSubmit";
import { TextProposalForm } from "./TextProposalForm";
import { ParamChangeForm } from "./ParamChangeForm";
import { SpendForm } from "./SpendForm";
import { UpgradeForm } from "./UpgradeForm";
import { DepositSelector } from "./DepositSelector";
import "./ProposalForm.css";
interface CreateProposalProps {
signingClient: any;
address: string | null;
isConnected: boolean;
mode: "cosmos" | "dao";
daoAddress: string | null;
onCreated: (txHash: string) => void;
}
export function CreateProposal({
signingClient,
address,
isConnected,
mode,
daoAddress,
onCreated,
}: CreateProposalProps) {
const [proposalType, setProposalType] = useState<string>("text");
const [deposit, setDeposit] = useState("10000000");
const {
submitCosmosProposal,
submitDaoProposal,
submitting,
error,
} = useProposalSubmit({ signingClient, address });
async function handleCosmosSubmit(
extraData: Record<string, any>
) {
try {
const txHash = await submitCosmosProposal({
title: extraData.title as string,
description: extraData.description as string,
proposalType,
initialDeposit: deposit,
...extraData,
});
if (txHash) onCreated(txHash);
} catch (err: any) {
console.error("提交失败:", err);
}
}
async function handleDaoSubmit(data: {
title: string;
description: string;
}) {
if (!daoAddress) return;
try {
const txHash = await submitDaoProposal(
daoAddress,
data.title,
data.description,
deposit
);
if (txHash) onCreated(txHash);
} catch (err: any) {
console.error("提交失败:", err);
}
}
const cosmosTypes = [
{
value: "text",
label: "文本提案",
desc: "社区信号投票",
},
{
value: "parameter",
label: "参数修改",
desc: "修改链上模块参数",
},
{ value: "spend", label: "社区支出", desc: "从社区池拨款" },
{
value: "upgrade",
label: "软件升级",
desc: "触发链上软件升级",
},
];
return (
<div className="create-proposal">
<h2 className="create-title">
{mode === "cosmos"
? "创建链治理提案"
: "创建 DAO 提案"}
</h2>
{!isConnected && (
<div className="connect-warning">请先连接钱包</div>
)}
{mode === "cosmos" && (
<div className="proposal-type-selector">
<label className="form-label">提案类型</label>
<div className="type-cards">
{cosmosTypes.map((t) => (
<button
key={t.value}
type="button"
className={`type-card ${
proposalType === t.value ? "active" : ""
}`}
onClick={() => setProposalType(t.value)}
>
<span className="type-card-label">{t.label}</span>
<span className="type-card-desc">{t.desc}</span>
</button>
))}
</div>
</div>
)}
{mode === "cosmos" && (
<div className="deposit-section">
<DepositSelector
value={deposit}
onChange={setDeposit}
/>
</div>
)}
{mode === "cosmos" && proposalType === "text" && (
<TextProposalForm
onSubmit={(data) => handleCosmosSubmit(data)}
submitting={submitting}
/>
)}
{mode === "cosmos" && proposalType === "parameter" && (
<ParamChangeForm
onSubmit={(data) => handleCosmosSubmit(data)}
submitting={submitting}
/>
)}
{mode === "cosmos" && proposalType === "spend" && (
<SpendForm
onSubmit={(data) => handleCosmosSubmit(data)}
submitting={submitting}
/>
)}
{mode === "cosmos" && proposalType === "upgrade" && (
<UpgradeForm
onSubmit={(data) => handleCosmosSubmit(data)}
submitting={submitting}
/>
)}
{mode === "dao" && (
<TextProposalForm
onSubmit={handleDaoSubmit}
submitting={submitting}
/>
)}
{error && (
<div className="form-error-banner">{error}</div>
)}
</div>
);
}
6. 投票系統
6.1 类型定义
export interface VotePanelProps {
proposalId: number;
voterWeight?: string;
onVote: (option: VoteOption) => Promise<void>;
}
export type ClientVoteOption =
| "VOTE_OPTION_YES"
| "VOTE_OPTION_NO"
| "VOTE_OPTION_ABSTAIN"
| "VOTE_OPTION_NO_WITH_VETO";
export type DaoVoteOption = "yes" | "no" | "abstain" | "veto";
6.2 投票 Hook
// hooks/useVote.ts
import { useState } from "react";
import { SigningCosmWasmClient } from "@cosmjs/cosmwasm-stargate";
import { CHAIN_CONFIG } from "../config/chain";
interface UseVoteOptions {
signingClient: SigningCosmWasmClient | null;
address: string | null;
}
interface VoteResult {
txHash: string;
option: string;
}
export function useVote({ signingClient, address }: UseVoteOptions) {
const [submitting, setSubmitting] = useState(false);
const [result, setResult] = useState<VoteResult | null>(null);
const [error, setError] = useState<string | null>(null);
async function voteCosmos(
proposalId: number,
option: ClientVoteOption
): Promise<string> {
if (!signingClient || !address) {
throw new Error("钱包未连接");
}
setSubmitting(true);
setError(null);
setResult(null);
try {
const msg = {
typeUrl: "/cosmos.gov.v1beta1.MsgVote",
value: {
proposalId: BigInt(proposalId),
voter: address,
option: optionToNumber(option),
},
};
const fee = {
amount: [{ denom: CHAIN_CONFIG.denom, amount: "2500" }],
gas: "120000",
};
const txResult = await signingClient.signAndBroadcast(
address,
[msg],
fee
);
setResult({
txHash: txResult.transactionHash,
option,
});
return txResult.transactionHash;
} catch (err: any) {
setError(err.message);
throw err;
} finally {
setSubmitting(false);
}
}
async function voteDao(
daoAddress: string,
proposalId: number,
option: DaoVoteOption
): Promise<string> {
if (!signingClient || !address) {
throw new Error("钱包未连接");
}
setSubmitting(true);
setError(null);
setResult(null);
try {
const msg = {
vote: {
proposal_id: proposalId,
vote: option,
},
};
const txResult = await signingClient.execute(
address,
daoAddress,
msg,
"auto"
);
setResult({
txHash: txResult.transactionHash,
option,
});
return txResult.transactionHash;
} catch (err: any) {
setError(err.message);
throw err;
} finally {
setSubmitting(false);
}
}
function optionToNumber(option: ClientVoteOption): number {
const map: Record<ClientVoteOption, number> = {
VOTE_OPTION_YES: 1,
VOTE_OPTION_ABSTAIN: 2,
VOTE_OPTION_NO: 3,
VOTE_OPTION_NO_WITH_VETO: 4,
};
return map[option];
}
function reset() {
setResult(null);
setError(null);
}
return { voteCosmos, voteDao, submitting, result, error, reset };
}
6.3 VoteConfirmModal 组件
// components/VotePanel/VoteConfirmModal.tsx
import React from "react";
import { CHAIN_CONFIG } from "../../config/chain";
interface VoteConfirmModalProps {
open: boolean;
proposalId: number;
option: string;
optionLabel: string;
optionColor: string;
voterWeight: string | null;
onConfirm: () => void;
onCancel: () => void;
submitting: boolean;
}
export function VoteConfirmModal({
open,
proposalId,
option,
optionLabel,
optionColor,
voterWeight,
onConfirm,
onCancel,
submitting,
}: VoteConfirmModalProps) {
if (!open) return null;
const formattedWeight = voterWeight
? (Number(voterWeight) / 1_000_000).toLocaleString("zh-CN", {
minimumFractionDigits: 2,
})
: null;
return (
<div className="modal-overlay" onClick={onCancel}>
<div
className="modal-content"
onClick={(e) => e.stopPropagation()}
>
<div className="modal-header">
<h3>确认投票</h3>
<button className="modal-close" onClick={onCancel}>
✕
</button>
</div>
<div className="modal-body">
<div className="confirm-info">
<div className="confirm-row">
<span className="confirm-label">提案</span>
<span className="confirm-value">#{proposalId}</span>
</div>
<div className="confirm-row">
<span className="confirm-label">投票选项</span>
<span
className="confirm-value"
style={{ color: optionColor, fontWeight: 600 }}
>
{optionLabel}
</span>
</div>
{formattedWeight && (
<div className="confirm-row">
<span className="confirm-label">投票权重</span>
<span className="confirm-value">
{formattedWeight} {CHAIN_CONFIG.symbol}
</span>
</div>
)}
</div>
<div className="confirm-warning">
此操作将发送一笔链上交易并消耗 Gas 费用。
投票一旦提交不能撤回。
</div>
</div>
<div className="modal-footer">
<button
className="modal-btn cancel"
onClick={onCancel}
disabled={submitting}
>
取消
</button>
<button
className="modal-btn confirm"
onClick={onConfirm}
disabled={submitting}
style={{ backgroundColor: optionColor }}
>
{submitting ? "提交中..." : "确认投票"}
</button>
</div>
</div>
</div>
);
}
6.4 VoteStatusBadge 组件
// components/VotePanel/VoteStatusBadge.tsx
import React from "react";
interface VoteStatusBadgeProps {
hasVoted: boolean;
voteOption?: string;
txHash?: string;
}
const VOTE_LABELS: Record<string, string> = {
VOTE_OPTION_YES: "赞成",
VOTE_OPTION_NO: "反对",
VOTE_OPTION_ABSTAIN: "弃权",
VOTE_OPTION_NO_WITH_VETO: "否决",
yes: "赞成",
no: "反对",
abstain: "弃权",
veto: "否决",
};
const VOTE_COLORS: Record<string, string> = {
VOTE_OPTION_YES: "#4CAF50",
VOTE_OPTION_NO: "#F44336",
VOTE_OPTION_ABSTAIN: "#9E9E9E",
VOTE_OPTION_NO_WITH_VETO: "#FF9800",
yes: "#4CAF50",
no: "#F44336",
abstain: "#9E9E9E",
veto: "#FF9800",
};
export function VoteStatusBadge({
hasVoted,
voteOption,
txHash,
}: VoteStatusBadgeProps) {
if (!hasVoted || !voteOption) {
return (
<div className="vote-status-badge not-voted">
<span className="vote-status-icon">◻</span>
<span>尚未投票</span>
</div>
);
}
return (
<div
className="vote-status-badge voted"
style={{
backgroundColor: VOTE_COLORS[voteOption] + "15",
borderColor: VOTE_COLORS[voteOption],
}}
>
<span
className="vote-status-icon"
style={{ color: VOTE_COLORS[voteOption] }}
>
✓
</span>
<span style={{ color: VOTE_COLORS[voteOption] }}>
已投: {VOTE_LABELS[voteOption] || voteOption}
</span>
{txHash && (
<a
className="vote-tx-link"
href={`https://explorer.msgchain.org/tx/${txHash}`}
target="_blank"
rel="noopener noreferrer"
>
查看交易
</a>
)}
</div>
);
}
6.5 完整的 VotePanel 组件
// components/VotePanel/VotePanel.tsx
import React, { useState, useCallback } from "react";
import { useVote } from "../../hooks/useVote";
import { VoteConfirmModal } from "./VoteConfirmModal";
import { VoteStatusBadge } from "./VoteStatusBadge";
import "./VotePanel.css";
interface VotePanelProps {
proposalId: number;
source: "cosmos" | "dao";
signingClient: any;
daoAddress: string | null;
address: string | null;
isConnected: boolean;
voterWeight?: string;
existingVote?: { option: string; txHash?: string } | null;
onVoteComplete: (txHash: string) => void;
}
const VOTE_OPTIONS = [
{
value: "VOTE_OPTION_YES",
daoValue: "yes",
label: "赞成",
color: "#4CAF50",
icon: "\u2713",
description: "同意该提案的执行",
},
{
value: "VOTE_OPTION_NO",
daoValue: "no",
label: "反对",
color: "#F44336",
icon: "\u2715",
description: "不同意该提案的执行",
},
{
value: "VOTE_OPTION_ABSTAIN",
daoValue: "abstain",
label: "弃权",
color: "#9E9E9E",
icon: "\u2014",
description: "中立,不参与决策",
},
{
value: "VOTE_OPTION_NO_WITH_VETO",
daoValue: "veto",
label: "否决",
color: "#FF9800",
icon: "\u2691",
description: "强烈反对,触发否决阈值",
},
];
export function VotePanel({
proposalId,
source,
signingClient,
daoAddress,
address,
isConnected,
voterWeight,
existingVote,
onVoteComplete,
}: VotePanelProps) {
const [selectedOption, setSelectedOption] = useState<string>("");
const [showConfirm, setShowConfirm] = useState(false);
const { voteCosmos, voteDao, submitting, error, reset } = useVote({
signingClient,
address,
});
const hasVoted = !!existingVote;
const selectedVoteData = VOTE_OPTIONS.find(
(opt) => opt.value === selectedOption
);
const handleVoteClick = useCallback(
(value: string) => {
if (hasVoted) return;
setSelectedOption(value);
setShowConfirm(true);
},
[hasVoted]
);
const handleConfirm = useCallback(async () => {
if (!selectedOption || !address) return;
try {
let txHash: string;
if (source === "cosmos") {
txHash = await voteCosmos(
proposalId,
selectedOption as any
);
} else if (daoAddress) {
const daoOption =
VOTE_OPTIONS.find((o) => o.value === selectedOption)
?.daoValue || "yes";
txHash = await voteDao(
daoAddress,
proposalId,
daoOption as any
);
} else {
throw new Error("DAO 地址未配置");
}
setShowConfirm(false);
onVoteComplete(txHash);
} catch (err) {
console.error("投票失败:", err);
}
}, [
selectedOption,
address,
source,
daoAddress,
proposalId,
voteCosmos,
voteDao,
onVoteComplete,
]);
const handleCancel = useCallback(() => {
setShowConfirm(false);
setSelectedOption("");
reset();
}, [reset]);
const formattedWeight = voterWeight
? (Number(voterWeight) / 1_000_000).toLocaleString("zh-CN", {
minimumFractionDigits: 2,
})
: null;
return (
<div className={`vote-panel ${hasVoted ? "voted" : ""}`}>
<div className="vote-panel-header">
<h3>投票</h3>
{formattedWeight && (
<span className="voter-weight-badge">
权重: {formattedWeight} MSG
</span>
)}
</div>
{existingVote && (
<VoteStatusBadge
hasVoted={true}
voteOption={existingVote.option}
txHash={existingVote.txHash}
/>
)}
{!hasVoted && (
<>
<div className="vote-options-grid">
{VOTE_OPTIONS.map((opt) => (
<button
key={opt.value}
className={`vote-option-btn ${
selectedOption === opt.value ? "selected" : ""
}`}
style={{
borderColor:
selectedOption === opt.value
? opt.color
: "#e0e0e0",
backgroundColor:
selectedOption === opt.value
? `${opt.color}10`
: "#fff",
}}
onClick={() => handleVoteClick(opt.value)}
>
<span
className="vote-option-icon"
style={{ backgroundColor: opt.color }}
>
{opt.icon}
</span>
<span className="vote-option-label">
{opt.label}
</span>
<span className="vote-option-desc">
{opt.description}
</span>
</button>
))}
</div>
{error && (
<div className="vote-error">投票失败: {error}</div>
)}
</>
)}
<VoteConfirmModal
open={showConfirm}
proposalId={proposalId}
option={selectedOption}
optionLabel={selectedVoteData?.label || ""}
optionColor={selectedVoteData?.color || "#999"}
voterWeight={voterWeight || null}
onConfirm={handleConfirm}
onCancel={handleCancel}
submitting={submitting}
/>
</div>
);
}
6.6 VotePanel CSS
/* components/VotePanel/VotePanel.css */
.vote-panel {
background: #fff;
border: 1px solid #e0e0e0;
border-radius: 8px;
padding: 20px;
}
.vote-panel.voted {
background: #fafafa;
}
.vote-panel-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.vote-panel-header h3 {
margin: 0;
font-size: 16px;
font-weight: 600;
}
.voter-weight-badge {
padding: 4px 12px;
background: #e3f2fd;
color: #1976D2;
border-radius: 14px;
font-size: 13px;
font-weight: 500;
}
.vote-status-badge {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 16px;
border-radius: 8px;
font-size: 14px;
font-weight: 500;
margin-bottom: 16px;
}
.vote-status-badge.not-voted {
background: #f5f5f5;
color: #999;
border: 1px solid #e0e0e0;
}
.vote-status-badge.voted {
border: 1px solid;
}
.vote-status-icon {
font-size: 16px;
}
.vote-tx-link {
margin-left: auto;
font-size: 13px;
color: #1976D2;
text-decoration: none;
font-weight: 400;
}
.vote-tx-link:hover {
text-decoration: underline;
}
.vote-options-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 10px;
}
.vote-option-btn {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
padding: 16px 12px;
border: 2px solid #e0e0e0;
border-radius: 10px;
background: #fff;
cursor: pointer;
transition: all 0.2s;
text-align: center;
}
.vote-option-btn:hover {
transform: translateY(-2px);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
}
.vote-option-btn.selected {
transform: translateY(-2px);
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.12);
}
.vote-option-icon {
width: 36px;
height: 36px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-size: 16px;
font-weight: bold;
}
.vote-option-label {
font-size: 15px;
font-weight: 600;
color: #333;
}
.vote-option-desc {
font-size: 12px;
color: #888;
}
.vote-error {
margin-top: 12px;
padding: 10px;
background: #ffebee;
border: 1px solid #ffcdd2;
color: #c62828;
border-radius: 6px;
font-size: 13px;
}
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal-content {
background: #fff;
border-radius: 12px;
width: 420px;
max-width: 90vw;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px 24px 0;
}
.modal-header h3 {
margin: 0;
font-size: 18px;
}
.modal-close {
background: none;
border: none;
font-size: 20px;
cursor: pointer;
color: #999;
padding: 4px;
}
.modal-body {
padding: 20px 24px;
}
.confirm-info {
display: flex;
flex-direction: column;
gap: 12px;
margin-bottom: 16px;
}
.confirm-row {
display: flex;
justify-content: space-between;
align-items: center;
}
.confirm-label {
color: #888;
font-size: 14px;
}
.confirm-value {
font-size: 14px;
font-weight: 500;
}
.confirm-warning {
background: #fff3e0;
border: 1px solid #ffe0b2;
color: #e65100;
padding: 10px 14px;
border-radius: 6px;
font-size: 13px;
line-height: 1.5;
}
.modal-footer {
display: flex;
gap: 10px;
padding: 0 24px 20px;
justify-content: flex-end;
}
.modal-btn {
padding: 10px 24px;
border: none;
border-radius: 6px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: opacity 0.2s;
}
.modal-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.modal-btn.cancel {
background: #f5f5f5;
color: #666;
}
.modal-btn.confirm {
color: #fff;
}
7. 治理參數顯示
7.1 数据获取 Hook
// hooks/useGovernanceParams.ts
import { useState, useEffect, useCallback } from "react";
import { CosmWasmClient } from "@cosmjs/cosmwasm-stargate";
import { CHAIN_CONFIG } from "../config/chain";
interface GovParams {
min_deposit: { denom: string; amount: string }[];
max_deposit_period: string;
voting_period: string;
quorum: string;
threshold: string;
veto_threshold: string;
}
interface DaoConfig {
quorum: string;
voting_period: number;
threshold: string;
owner: string;
proposal_count: number;
timelock_duration: number;
}
export function useCosmosGovParams() {
const [params, setParams] = useState<GovParams | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const fetchParams = useCallback(async () => {
setLoading(true);
setError(null);
try {
const [deposit, voting, tally] = await Promise.all([
fetch(
`${CHAIN_CONFIG.restEndpoint}/cosmos/gov/v1beta1/params/deposit`
).then((r) => r.json()),
fetch(
`${CHAIN_CONFIG.restEndpoint}/cosmos/gov/v1beta1/params/voting`
).then((r) => r.json()),
fetch(
`${CHAIN_CONFIG.restEndpoint}/cosmos/gov/v1beta1/params/tally`
).then((r) => r.json()),
]);
setParams({
min_deposit: deposit.deposit_params?.min_deposit || [],
max_deposit_period:
deposit.deposit_params?.max_deposit_period || "",
voting_period: voting.voting_params?.voting_period || "",
quorum: tally.tally_params?.quorum || "",
threshold: tally.tally_params?.threshold || "",
veto_threshold: tally.tally_params?.veto_threshold || "",
});
} catch (err: any) {
setError(err.message);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchParams();
}, [fetchParams]);
return { params, loading, error, refresh: fetchParams };
}
export function useDaoConfig(
client: CosmWasmClient | null,
daoAddress: string | null
) {
const [config, setConfig] = useState<DaoConfig | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!client || !daoAddress) return;
let cancelled = false;
async function fetch() {
setLoading(true);
try {
const result: any = await client.queryContractSmart(
daoAddress,
{ get_config: {} }
);
if (!cancelled) setConfig(result);
} catch (err: any) {
if (!cancelled) setError(err.message);
} finally {
if (!cancelled) setLoading(false);
}
}
fetch();
return () => {
cancelled = true;
};
}, [client, daoAddress]);
return { config, loading, error };
}
7.2 格式化工具
// utils/format.ts
export function formatDuration(seconds: string | number): string {
const s = typeof seconds === "string" ? parseInt(seconds) : seconds;
const days = Math.floor(s / 86400);
const hours = Math.floor((s % 86400) / 3600);
const minutes = Math.floor((s % 3600) / 60);
const parts: string[] = [];
if (days > 0) parts.push(`${days}天`);
if (hours > 0) parts.push(`${hours}小时`);
if (minutes > 0) parts.push(`${minutes}分`);
if (parts.length === 0) parts.push(`${s}秒`);
return parts.join("");
}
export function formatPercent(value: string): string {
const pct = Number(value) * 100;
return pct.toFixed(1) + "%";
}
export function formatBalance(
amount: string,
decimals: number = 6
): string {
const val = Number(amount) / 10 ** decimals;
return val.toLocaleString("zh-CN", {
minimumFractionDigits: 2,
maximumFractionDigits: decimals,
});
}
7.3 GovernanceParams 组件
// components/GovernanceParams/GovernanceParams.tsx
import React from "react";
import {
useCosmosGovParams,
useDaoConfig,
} from "../../hooks/useGovernanceParams";
import { formatDuration, formatPercent, formatBalance } from "../../utils/format";
import "./GovernanceParams.css";
interface GovernanceParamsProps {
client: any;
daoAddress: string | null;
}
export function GovernanceParams({
client,
daoAddress,
}: GovernanceParamsProps) {
const {
params: govParams,
loading: govLoading,
error: govError,
refresh: refreshGov,
} = useCosmosGovParams();
const {
config: daoConfig,
loading: daoLoading,
error: daoError,
} = useDaoConfig(client, daoAddress);
return (
<div className="governance-params">
<h2>治理参数</h2>
<div className="params-grid">
<div className="params-card">
<div className="params-card-header">
<h3>Cosmos SDK Gov 参数</h3>
<button className="refresh-btn" onClick={refreshGov}>
刷新
</button>
</div>
{govLoading && <div className="params-loading">加载中...</div>}
{govError && (
<div className="params-error">加载失败: {govError}</div>
)}
{govParams && (
<table className="params-table">
<tbody>
<tr>
<td className="param-name">最小保证金</td>
<td className="param-value">
{govParams.min_deposit
.map(
(c) =>
`${formatBalance(c.amount)} ${c.denom
.replace("u", "")
.toUpperCase()}`
)
.join(", ")}
</td>
</tr>
<tr>
<td className="param-name">最大保证金期</td>
<td className="param-value">
{formatDuration(govParams.max_deposit_period)}
</td>
</tr>
<tr>
<td className="param-name">投票期</td>
<td className="param-value">
{formatDuration(govParams.voting_period)}
</td>
</tr>
<tr>
<td className="param-name">法定人数 (Quorum)</td>
<td className="param-value">
{formatPercent(govParams.quorum)}
</td>
</tr>
<tr>
<td className="param-name">通过阈值 (Threshold)</td>
<td className="param-value">
{formatPercent(govParams.threshold)}
</td>
</tr>
<tr>
<td className="param-name">否决阈值 (Veto)</td>
<td className="param-value">
{formatPercent(govParams.veto_threshold)}
</td>
</tr>
</tbody>
</table>
)}
</div>
{daoConfig && (
<div className="params-card">
<div className="params-card-header">
<h3>DAO 合约参数</h3>
</div>
{daoLoading && (
<div className="params-loading">加载中...</div>
)}
{daoError && (
<div className="params-error">
加载失败: {daoError}
</div>
)}
<table className="params-table">
<tbody>
<tr>
<td className="param-name">法定人数</td>
<td className="param-value">
{formatPercent(daoConfig.quorum)}
</td>
</tr>
<tr>
<td className="param-name">投票期</td>
<td className="param-value">
{formatDuration(daoConfig.voting_period)}
</td>
</tr>
<tr>
<td className="param-name">通过阈值</td>
<td className="param-value">
{formatPercent(daoConfig.threshold)}
</td>
</tr>
<tr>
<td className="param-name">时间锁</td>
<td className="param-value">
{formatDuration(daoConfig.timelock_duration)}
</td>
</tr>
<tr>
<td className="param-name">提案总数</td>
<td className="param-value">
{daoConfig.proposal_count}
</td>
</tr>
<tr>
<td className="param-name">合约拥有者</td>
<td className="param-value mono">
{daoConfig.owner.slice(0, 12)}...{daoConfig.owner.slice(-6)}
</td>
</tr>
</tbody>
</table>
</div>
)}
</div>
<div className="params-info-card">
<h3>参数说明</h3>
<div className="param-explanations">
<div className="explanation-item">
<strong>法定人数 (Quorum)</strong>
<p>
参与投票的票数占总票权的比例下限。
如果投票率低于此值,提案将因未达法定人数而失败。
</p>
</div>
<div className="explanation-item">
<strong>通过阈值 (Threshold)</strong>
<p>
赞成票占有效票(Yes + No + Veto)的比例下限。
Abstain 不计入此分母。
</p>
</div>
<div className="explanation-item">
<strong>否决阈值 (Veto Threshold)</strong>
<p>
否决票占总投票数的比例上限。
如果否决率超过此值,即使赞成率达标,提案也会被否决。
</p>
</div>
<div className="explanation-item">
<strong>时间锁 (Timelock)</strong>
<p>
DAO 提案通过后需等待此时间才能执行,
给社区留出反应和取消恶意提案的时间。
</p>
</div>
</div>
</div>
<div className="params-scenarios">
<h3>治理场景模拟</h3>
<div className="scenario-cards">
<div className="scenario-card">
<h4>场景 A: 高参与度</h4>
<p>总票权 1,000,000 MSG</p>
<p>投票率: 60% ✅</p>
<p>赞成率: 66.7% ✅</p>
<p>否决率: 8.3% ✅</p>
<span className="scenario-result pass">通过</span>
</div>
<div className="scenario-card">
<h4>场景 B: 低参与度</h4>
<p>总票权 1,000,000 MSG</p>
<p>投票率: 25% ❌ (<33.4%)</p>
<p>赞成率: 80% ✅</p>
<span className="scenario-result fail">失败</span>
</div>
<div className="scenario-card">
<h4>场景 C: 高否决率</h4>
<p>总票权 1,000,000 MSG</p>
<p>投票率: 50% ✅</p>
<p>赞成率: 50% ✅</p>
<p>否决率: 40% ❌ (>33.4%)</p>
<span className="scenario-result fail">否决</span>
</div>
</div>
</div>
</div>
);
}
7.4 GovernanceParams CSS
/* components/GovernanceParams/GovernanceParams.css */
.governance-params {
max-width: 960px;
margin: 0 auto;
padding: 20px;
}
.governance-params h2 {
font-size: 22px;
font-weight: 700;
margin: 0 0 24px;
}
.params-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
margin-bottom: 24px;
}
.params-card {
background: #fff;
border: 1px solid #e0e0e0;
border-radius: 8px;
padding: 20px;
}
.params-card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.params-card-header h3 {
margin: 0;
font-size: 16px;
font-weight: 600;
}
.refresh-btn {
padding: 4px 12px;
background: #f5f5f5;
border: 1px solid #ddd;
border-radius: 4px;
cursor: pointer;
font-size: 13px;
}
.refresh-btn:hover {
background: #e0e0e0;
}
.params-table {
width: 100%;
border-collapse: collapse;
font-size: 14px;
}
.params-table tr {
border-bottom: 1px solid #f0f0f0;
}
.params-table tr:last-child {
border-bottom: none;
}
.param-name {
padding: 10px 0;
color: #888;
width: 140px;
}
.param-value {
padding: 10px 0;
text-align: right;
font-weight: 500;
font-family: inherit;
}
.param-value.mono {
font-family: monospace;
font-size: 13px;
}
.params-loading,
.params-error {
text-align: center;
padding: 20px;
color: #888;
font-size: 14px;
}
.params-error {
color: #F44336;
}
.params-info-card {
background: #f9f9f9;
border: 1px solid #e0e0e0;
border-radius: 8px;
padding: 20px;
margin-bottom: 24px;
}
.params-info-card h3 {
margin: 0 0 16px;
font-size: 16px;
}
.param-explanations {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 16px;
}
.explanation-item strong {
display: block;
font-size: 14px;
margin-bottom: 4px;
color: #333;
}
.explanation-item p {
margin: 0;
font-size: 13px;
color: #666;
line-height: 1.5;
}
.params-scenarios {
margin-bottom: 24px;
}
.params-scenarios h3 {
font-size: 16px;
font-weight: 600;
margin: 0 0 16px;
}
.scenario-cards {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16px;
}
.scenario-card {
background: #fff;
border: 1px solid #e0e0e0;
border-radius: 8px;
padding: 16px;
}
.scenario-card h4 {
margin: 0 0 12px;
font-size: 14px;
font-weight: 600;
}
.scenario-card p {
margin: 4px 0;
font-size: 13px;
color: #555;
}
.scenario-result {
display: inline-block;
margin-top: 10px;
padding: 4px 12px;
border-radius: 4px;
font-size: 13px;
font-weight: 600;
}
.scenario-result.pass {
background: #e8f5e9;
color: #2e7d32;
}
.scenario-result.fail {
background: #ffebee;
color: #c62828;
}
8. DAO提案管理
8.1 DAO 提案查询工具
// utils/daoQueries.ts
import { CosmWasmClient, SigningCosmWasmClient } from "@cosmjs/cosmwasm-stargate";
export class DaoQueries {
private client: CosmWasmClient;
private daoAddress: string;
private treasuryAddress: string;
private signingClient?: SigningCosmWasmClient;
constructor(
client: CosmWasmClient,
daoAddress: string,
treasuryAddress: string,
signingClient?: SigningCosmWasmClient
) {
this.client = client;
this.daoAddress = daoAddress;
this.treasuryAddress = treasuryAddress;
this.signingClient = signingClient;
}
async getProposal(proposalId: number): Promise<any> {
return this.client.queryContractSmart(this.daoAddress, {
get_proposal: { proposal_id: proposalId },
});
}
async getTally(proposalId: number): Promise<any> {
return this.client.queryContractSmart(this.daoAddress, {
get_proposal_tally: { proposal_id: proposalId },
});
}
async getVote(proposalId: number, voter: string): Promise<any> {
return this.client.queryContractSmart(this.daoAddress, {
get_vote: { proposal_id: proposalId, voter },
});
}
async listProposals(
status?: string,
startAfter?: number,
limit: number = 30
): Promise<any[]> {
const query: any = { list_proposals: { limit } };
if (status) query.list_proposals.status = status;
if (startAfter) query.list_proposals.start_after = startAfter;
const result: any = await this.client.queryContractSmart(
this.daoAddress,
query
);
return result.proposals || [];
}
async getConfig(): Promise<any> {
return this.client.queryContractSmart(this.daoAddress, {
get_config: {},
});
}
async getTreasuryBalance(denom: string = "umsg"): Promise<string> {
const result: any = await this.client.queryContractSmart(
this.treasuryAddress,
{ get_balance: { denom } }
);
return result.balance;
}
async getTreasurySigners(): Promise<{
signers: string[];
threshold: string;
total_signers: number;
}> {
return this.client.queryContractSmart(this.treasuryAddress, {
get_signers: {},
});
}
async listTreasuryProposals(
status?: string,
limit: number = 30
): Promise<any[]> {
const query: any = { list_proposals: { limit } };
if (status) query.list_proposals.status = status;
const result: any = await this.client.queryContractSmart(
this.treasuryAddress,
query
);
return result.proposals || [];
}
}
8.2 DaoProposalList 组件
// components/DaoProposals/DaoProposalList.tsx
import React, { useState, useEffect } from "react";
import { CosmWasmClient } from "@cosmjs/cosmwasm-stargate";
import { useDao } from "../../context/DaoContext";
import "./DaoProposals.css";
interface DaoProposal {
id: number;
title: string;
description: string;
proposer: string;
status: string;
created_at: number;
voting_end: number | null;
target_contract: string | null;
deposit_amount: string;
}
const STATUS_LABELS: Record<string, string> = {
active: "投票中",
passed: "已通过",
rejected: "已拒绝",
executed: "已执行",
canceled: "已取消",
};
const STATUS_COLORS: Record<string, string> = {
active: "#2196F3",
passed: "#4CAF50",
rejected: "#F44336",
executed: "#9E9E9E",
canceled: "#FF9800",
};
function truncate(addr: string): string {
return `${addr.slice(0, 8)}...${addr.slice(-6)}`;
}
interface DaoProposalListProps {
onSelect: (id: number) => void;
}
export function DaoProposalList({ onSelect }: DaoProposalListProps) {
const { client, daoAddress } = useDao();
const [proposals, setProposals] = useState<DaoProposal[]>([]);
const [loading, setLoading] = useState(true);
const [filter, setFilter] = useState<string>("");
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!client || !daoAddress) return;
fetchProposals();
}, [client, daoAddress, filter]);
async function fetchProposals() {
if (!client || !daoAddress) return;
setLoading(true);
setError(null);
try {
const queryMsg: any = { list_proposals: { limit: 50 } };
if (filter) queryMsg.list_proposals.status = filter;
const result: any = await client.queryContractSmart(
daoAddress,
queryMsg
);
setProposals(result.proposals || []);
} catch (err: any) {
setError(err.message);
} finally {
setLoading(false);
}
}
function formatMsg(amount: string): string {
return (
(Number(amount) / 1_000_000).toLocaleString("zh-CN", {
minimumFractionDigits: 2,
}) + " MSG"
);
}
return (
<div className="dao-proposal-list">
<h2>DAO 提案列表</h2>
<div className="dao-filter-bar">
{["", "active", "passed", "executed", "canceled"].map((f) => (
<button
key={f}
className={`dao-filter-btn ${filter === f ? "active" : ""}`}
onClick={() => setFilter(f)}
>
{f === ""
? "全部"
: STATUS_LABELS[f] || f}
</button>
))}
</div>
{loading && <div className="dao-loading">加载中...</div>}
{error && <div className="dao-error">加载失败: {error}</div>}
{!loading && proposals.length === 0 && (
<div className="dao-empty">暂无 DAO 提案</div>
)}
<div className="dao-proposal-cards">
{proposals.map((p) => (
<div
key={p.id}
className="dao-proposal-card"
onClick={() => onSelect(p.id)}
>
<div className="dao-card-header">
<span className="dao-proposal-id">#{p.id}</span>
<span
className="dao-status-badge"
style={{
backgroundColor: STATUS_COLORS[p.status] || "#999",
}}
>
{STATUS_LABELS[p.status] || p.status}
</span>
{p.target_contract && (
<span className="dao-contract-badge">合约执行</span>
)}
</div>
<h4 className="dao-card-title">{p.title}</h4>
<p className="dao-card-desc">
{p.description.length > 100
? p.description.slice(0, 100) + "..."
: p.description}
</p>
<div className="dao-card-meta">
<span>提案人: {truncate(p.proposer)}</span>
<span>保证金: {formatMsg(p.deposit_amount)}</span>
</div>
{p.target_contract && (
<div className="dao-card-exec">
目标合约: {truncate(p.target_contract)}
</div>
)}
</div>
))}
</div>
</div>
);
}
8.3 TreasuryDashboard 组件
// components/DaoProposals/TreasuryDashboard.tsx
import React, { useState, useEffect } from "react";
import { useDao } from "../../context/DaoContext";
interface TreasuryBalance {
denom: string;
balance: string;
}
interface SignerInfo {
signers: string[];
threshold: string;
total_signers: number;
}
interface TreasuryProp {
id: number;
recipient: string;
amount: string;
denom: string;
purpose: string;
status: string;
approvers: string[];
created_at: number;
}
export function TreasuryDashboard() {
const {
client,
signingClient,
treasuryAddress,
address,
isConnected,
connect,
} = useDao();
const [balance, setBalance] = useState<TreasuryBalance | null>(null);
const [signers, setSigners] = useState<SignerInfo | null>(null);
const [proposals, setProposals] = useState<TreasuryProp[]>([]);
const [recipient, setRecipient] = useState("");
const [amount, setAmount] = useState("");
const [purpose, setPurpose] = useState("");
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
useEffect(() => {
if (!client || !treasuryAddress) return;
fetchData();
}, [client, treasuryAddress]);
async function fetchData() {
if (!client || !treasuryAddress) return;
setLoading(true);
try {
const [bal, sig, props] = await Promise.all([
client.queryContractSmart(treasuryAddress, {
get_balance: { denom: "umsg" },
}),
client.queryContractSmart(treasuryAddress, {
get_signers: {},
}),
client.queryContractSmart(treasuryAddress, {
list_proposals: { limit: 20 },
}),
]);
setBalance(bal);
setSigners(sig);
setProposals(props.proposals || []);
} catch (err: any) {
setError(err.message);
} finally {
setLoading(false);
}
}
function fmt(amt: string): string {
return (Number(amt) / 1_000_000).toLocaleString("zh-CN", {
minimumFractionDigits: 2,
maximumFractionDigits: 6,
});
}
async function proposeSpend() {
if (!isConnected) await connect();
if (!signingClient || !treasuryAddress || !address) return;
try {
await signingClient.execute(
address,
treasuryAddress,
{
propose_spend: {
recipient,
amount,
denom: "umsg",
purpose,
},
},
"auto"
);
setRecipient("");
setAmount("");
setPurpose("");
fetchData();
} catch (err: any) {
setError(err.message);
}
}
async function approve(proposalId: number) {
if (!isConnected) await connect();
if (!signingClient || !treasuryAddress || !address) return;
try {
await signingClient.execute(
address,
treasuryAddress,
{ approve_spend: { proposal_id: proposalId } },
"auto"
);
fetchData();
} catch (err: any) {
setError(err.message);
}
}
async function execute(proposalId: number) {
if (!isConnected) await connect();
if (!signingClient || !treasuryAddress || !address) return;
try {
await signingClient.execute(
address,
treasuryAddress,
{ execute_spend: { proposal_id: proposalId } },
"auto"
);
fetchData();
} catch (err: any) {
setError(err.message);
}
}
if (loading) return <div className="dao-loading">加载金库数据...</div>;
return (
<div className="treasury-dashboard">
<h2>DAO 金库仪表盘</h2>
<div className="treasury-summary">
<div className="treasury-balance-card">
<h3>金库余额</h3>
<div className="treasury-balance-amount">
{balance ? fmt(balance.balance) : "—"} MSG
</div>
</div>
{signers && (
<div className="treasury-signers-card">
<h3>金库签名人</h3>
<p>
共 {signers.total_signers} 人,需 {signers.threshold} 人批准
</p>
<div className="signer-list">
{signers.signers.map((s: string, i: number) => (
<span key={i} className="signer-tag" title={s}>
{s.slice(0, 8)}...{s.slice(-4)}
</span>
))}
</div>
</div>
)}
</div>
<div className="treasury-spend-form">
<h3>发起支出提案</h3>
<div className="spend-form-row">
<input
type="text"
placeholder="收款地址 (msg1...)"
value={recipient}
onChange={(e) => setRecipient(e.target.value)}
className="spend-input"
/>
<input
type="number"
placeholder="金额 (umsg)"
value={amount}
onChange={(e) => setAmount(e.target.value)}
className="spend-input"
/>
<input
type="text"
placeholder="支出用途说明"
value={purpose}
onChange={(e) => setPurpose(e.target.value)}
className="spend-input"
/>
<button className="spend-submit" onClick={proposeSpend}>
提交支出提案
</button>
</div>
</div>
<div className="treasury-proposals">
<h3>支出提案列表</h3>
{proposals.length === 0 ? (
<p className="dao-empty">暂无支出提案</p>
) : (
proposals.map((p) => (
<div key={p.id} className="treasury-proposal-card">
<div className="treasury-prop-header">
<span className="treasury-prop-id">#{p.id}</span>
<span
className={`treasury-prop-status ${p.status}`}
>
{p.status === "pending"
? "待批准"
: p.status === "approved"
? "已批准"
: p.status === "executed"
? "已执行"
: p.status}
</span>
</div>
<div className="treasury-prop-body">
<div className="treasury-prop-row">
<span className="treasury-prop-label">收款</span>
<span className="treasury-prop-value">
{p.recipient.slice(0, 12)}...{p.recipient.slice(-6)}
</span>
</div>
<div className="treasury-prop-row">
<span className="treasury-prop-label">金额</span>
<span className="treasury-prop-value">
{fmt(p.amount)} {p.denom.replace("u", "").toUpperCase()}
</span>
</div>
<div className="treasury-prop-row">
<span className="treasury-prop-label">用途</span>
<span className="treasury-prop-value">{p.purpose}</span>
</div>
<div className="treasury-prop-row">
<span className="treasury-prop-label">批准进度</span>
<span className="treasury-prop-value">
{p.approvers?.length || 0}/{signers?.threshold || "?"}
</span>
</div>
</div>
<div className="treasury-prop-actions">
{p.status === "pending" && (
<button
className="treasury-action-btn approve"
onClick={() => approve(p.id)}
>
批准
</button>
)}
{p.status === "approved" && (
<button
className="treasury-action-btn execute"
onClick={() => execute(p.id)}
>
执行转账
</button>
)}
</div>
</div>
))
)}
</div>
{error && <div className="dao-error-toast">{error}</div>}
</div>
);
}
8.4 DaoProposals CSS
/* components/DaoProposals/DaoProposals.css */
.dao-proposal-list {
max-width: 960px;
margin: 0 auto;
padding: 20px;
}
.dao-proposal-list h2 {
font-size: 22px;
font-weight: 700;
margin: 0 0 20px;
}
.dao-filter-bar {
display: flex;
gap: 8px;
margin-bottom: 20px;
}
.dao-filter-btn {
padding: 8px 16px;
border: 1px solid #ddd;
border-radius: 6px;
background: #fff;
cursor: pointer;
font-size: 14px;
transition: all 0.2s;
}
.dao-filter-btn:hover {
background: #f0f0f0;
}
.dao-filter-btn.active {
background: #1976D2;
color: #fff;
border-color: #1976D2;
}
.dao-loading,
.dao-empty,
.dao-error {
text-align: center;
padding: 40px;
color: #888;
}
.dao-error {
color: #F44336;
}
.dao-proposal-cards {
display: flex;
flex-direction: column;
gap: 12px;
}
.dao-proposal-card {
background: #fff;
border: 1px solid #e0e0e0;
border-radius: 8px;
padding: 16px 20px;
cursor: pointer;
transition: all 0.2s;
}
.dao-proposal-card:hover {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
border-color: #1976D2;
}
.dao-card-header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 8px;
}
.dao-proposal-id {
font-weight: 600;
color: #666;
font-size: 13px;
}
.dao-status-badge {
padding: 2px 10px;
border-radius: 12px;
color: #fff;
font-size: 12px;
}
.dao-contract-badge {
padding: 2px 8px;
background: #e3f2fd;
color: #1976D2;
border-radius: 4px;
font-size: 11px;
font-weight: 500;
}
.dao-card-title {
font-size: 16px;
font-weight: 600;
margin: 0 0 8px;
color: #222;
}
.dao-card-desc {
font-size: 14px;
color: #666;
margin: 0 0 10px;
line-height: 1.4;
}
.dao-card-meta {
display: flex;
gap: 20px;
font-size: 13px;
color: #888;
}
.dao-card-exec {
margin-top: 8px;
font-size: 12px;
color: #1976D2;
background: #e3f2fd;
padding: 4px 10px;
border-radius: 4px;
font-family: monospace;
}
.treasury-dashboard {
max-width: 960px;
margin: 0 auto;
padding: 20px;
}
.treasury-dashboard h2 {
font-size: 22px;
font-weight: 700;
margin: 0 0 20px;
}
.treasury-summary {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
margin-bottom: 24px;
}
.treasury-balance-card,
.treasury-signers-card {
background: #fff;
border: 1px solid #e0e0e0;
border-radius: 8px;
padding: 20px;
}
.treasury-balance-card h3,
.treasury-signers-card h3 {
margin: 0 0 12px;
font-size: 15px;
font-weight: 600;
color: #555;
}
.treasury-balance-amount {
font-size: 28px;
font-weight: 700;
color: #1976D2;
}
.treasury-signers-card p {
font-size: 14px;
color: #666;
margin: 0 0 10px;
}
.signer-list {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.signer-tag {
padding: 3px 8px;
background: #f0f0f0;
border-radius: 4px;
font-size: 12px;
font-family: monospace;
color: #555;
}
.treasury-spend-form {
background: #f9f9f9;
border: 1px solid #e0e0e0;
border-radius: 8px;
padding: 20px;
margin-bottom: 24px;
}
.treasury-spend-form h3 {
margin: 0 0 14px;
font-size: 16px;
}
.spend-form-row {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.spend-input {
flex: 1;
min-width: 180px;
padding: 10px 14px;
border: 1px solid #ddd;
border-radius: 6px;
font-size: 14px;
outline: none;
}
.spend-input:focus {
border-color: #1976D2;
}
.spend-submit {
padding: 10px 20px;
background: #1976D2;
color: #fff;
border: none;
border-radius: 6px;
font-size: 14px;
cursor: pointer;
white-space: nowrap;
}
.spend-submit:hover {
background: #1565C0;
}
.treasury-proposals h3 {
font-size: 16px;
font-weight: 600;
margin: 0 0 14px;
}
.treasury-proposal-card {
background: #fff;
border: 1px solid #e0e0e0;
border-radius: 8px;
padding: 16px 20px;
margin-bottom: 12px;
}
.treasury-prop-header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 12px;
}
.treasury-prop-id {
font-weight: 600;
color: #666;
font-size: 14px;
}
.treasury-prop-status {
padding: 2px 10px;
border-radius: 10px;
font-size: 12px;
font-weight: 500;
color: #fff;
}
.treasury-prop-status.pending {
background: #FF9800;
}
.treasury-prop-status.approved {
background: #4CAF50;
}
.treasury-prop-status.executed {
background: #9E9E9E;
}
.treasury-prop-body {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 12px;
}
.treasury-prop-row {
display: flex;
font-size: 14px;
}
.treasury-prop-label {
width: 80px;
color: #888;
flex-shrink: 0;
}
.treasury-prop-value {
flex: 1;
font-weight: 500;
word-break: break-all;
}
.treasury-prop-actions {
display: flex;
gap: 8px;
}
.treasury-action-btn {
padding: 8px 18px;
border: none;
border-radius: 6px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: background 0.2s;
}
.treasury-action-btn.approve {
background: #4CAF50;
color: #fff;
}
.treasury-action-btn.approve:hover {
background: #388E3C;
}
.treasury-action-btn.execute {
background: #1976D2;
color: #fff;
}
.treasury-action-btn.execute:hover {
background: #1565C0;
}
.dao-error-toast {
position: fixed;
bottom: 24px;
right: 24px;
background: #F44336;
color: #fff;
padding: 12px 20px;
border-radius: 8px;
font-size: 14px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
z-index: 1000;
animation: slideIn 0.3s;
}
@keyframes slideIn {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
9. 完整示例
9.1 WalletContext 上下文
// context/WalletContext.tsx
import React, {
createContext,
useContext,
useState,
useEffect,
useCallback,
} from "react";
import {
SigningCosmWasmClient,
CosmWasmClient,
} from "@cosmjs/cosmwasm-stargate";
import { CHAIN_CONFIG } from "../config/chain";
interface WalletContextType {
client: CosmWasmClient | null;
signingClient: SigningCosmWasmClient | null;
address: string | null;
isConnected: boolean;
connect: () => Promise<void>;
disconnect: () => void;
}
const WalletContext = createContext<WalletContextType>({
client: null,
signingClient: null,
address: null,
isConnected: false,
connect: async () => {},
disconnect: () => {},
});
export function WalletProvider({
children,
}: {
children: React.ReactNode;
}) {
const [client, setClient] = useState<CosmWasmClient | null>(null);
const [signingClient, setSigningClient] =
useState<SigningCosmWasmClient | null>(null);
const [address, setAddress] = useState<string | null>(null);
const [isConnected, setIsConnected] = useState(false);
const connect = useCallback(async () => {
try {
if (!(window as any).keplr) {
throw new Error("请安装 Keplr 钱包:https://keplr.app");
}
await (window as any).keplr.enable(CHAIN_CONFIG.chainId);
const offlineSigner = (window as any).keplr.getOfflineSigner(
CHAIN_CONFIG.chainId
);
const accounts = await offlineSigner.getAccounts();
const userAddress = accounts[0].address;
const cosmClient = await CosmWasmClient.connect(
CHAIN_CONFIG.rpcEndpoint
);
const signClient =
await SigningCosmWasmClient.connectWithSigner(
CHAIN_CONFIG.rpcEndpoint,
offlineSigner,
{
gasPrice: {
denom: CHAIN_CONFIG.denom,
amount: "1000000000",
},
}
);
setClient(cosmClient);
setSigningClient(signClient);
setAddress(userAddress);
setIsConnected(true);
} catch (error: any) {
console.error("连接失败:", error);
throw error;
}
}, []);
const disconnect = useCallback(() => {
setClient(null);
setSigningClient(null);
setAddress(null);
setIsConnected(false);
}, []);
useEffect(() => {
return () => {
client?.disconnect();
};
}, []);
return (
<WalletContext.Provider
value={{
client,
signingClient,
address,
isConnected,
connect,
disconnect,
}}
>
{children}
</WalletContext.Provider>
);
}
export const useWallet = () => useContext(WalletContext);
9.2 DAO 合约地址解析
// context/DaoContext.tsx
import React, {
createContext,
useContext,
useState,
useEffect,
} from "react";
import { CosmWasmClient } from "@cosmjs/cosmwasm-stargate";
import { CONTRACT_ADDRESSES } from "../config/chain";
import { useWallet } from "./WalletContext";
interface DaoContextType {
daoAddress: string | null;
treasuryAddress: string | null;
daoConfig: any;
}
const DaoContext = createContext<DaoContextType>({
daoAddress: null,
treasuryAddress: null,
daoConfig: null,
});
export function DaoProvider({ children }: { children: React.ReactNode }) {
const { client } = useWallet();
const [daoAddress, setDaoAddress] = useState<string | null>(null);
const [treasuryAddress, setTreasuryAddress] =
useState<string | null>(null);
const [daoConfig, setDaoConfig] = useState<any>(null);
useEffect(() => {
if (!client) return;
resolveAddresses();
}, [client]);
async function resolveAddresses() {
if (!client) return;
try {
const govResult: any = await client.queryContractSmart(
CONTRACT_ADDRESSES.genesisRegistry,
{ resolve_canonical: { key: "dao_governance" } }
);
const treasuryResult: any = await client.queryContractSmart(
CONTRACT_ADDRESSES.genesisRegistry,
{ resolve_canonical: { key: "foundation_treasury" } }
);
setDaoAddress(govResult.address);
setTreasuryAddress(treasuryResult.address);
const config: any = await client.queryContractSmart(
govResult.address,
{ get_config: {} }
);
setDaoConfig(config);
} catch (err) {
console.error("解析合约地址失败:", err);
}
}
return (
<DaoContext.Provider
value={{ daoAddress, treasuryAddress, daoConfig }}
>
{children}
</DaoContext.Provider>
);
}
export const useDao = () => useContext(DaoContext);
9.3 ConnectWallet 组件
// components/ConnectWallet.tsx
import React from "react";
import { useWallet } from "../context/WalletContext";
import { CHAIN_CONFIG } from "../config/chain";
export function ConnectWallet() {
const { address, isConnected, connect, disconnect } = useWallet();
function truncateAddress(addr: string): string {
return `${addr.slice(0, 10)}...${addr.slice(-8)}`;
}
if (isConnected && address) {
return (
<div className="wallet-connected">
<div className="wallet-info">
<span className="wallet-icon">🔑</span>
<span className="wallet-address">
{truncateAddress(address)}
</span>
<span className="wallet-chain">{CHAIN_CONFIG.chainName}</span>
</div>
<button className="wallet-disconnect" onClick={disconnect}>
断开
</button>
</div>
);
}
return (
<button className="wallet-connect" onClick={connect}>
连接 Keplr 钱包
</button>
);
}
9.4 App 主入口(整合示例)
// App.tsx
import React from "react";
import { BrowserRouter, Routes, Route, Link } from "react-router-dom";
import { WalletProvider } from "./context/WalletContext";
import { DaoProvider } from "./context/DaoContext";
import { ConnectWallet } from "./components/ConnectWallet";
import { ProposalList } from "./components/ProposalList/ProposalList";
import { ProposalDetail } from "./components/ProposalDetail/ProposalDetail";
import { CreateProposal } from "./components/ProposalForm/CreateProposal";
import { GovernanceParams } from "./components/GovernanceParams/GovernanceParams";
import { DaoProposalList } from "./components/DaoProposals/DaoProposalList";
import { TreasuryDashboard } from "./components/DaoProposals/TreasuryDashboard";
function HomePage() {
const navigate = (path: string) => {
window.history.pushState({}, "", path);
window.dispatchEvent(new PopStateEvent("popstate"));
};
return (
<div className="home-page">
<h1>MSG Chain 治理平台</h1>
<p className="home-desc">
参与 MSG Chain 的去中心化治理,浏览提案、投票、创建和执行链上决策。
</p>
<div className="home-cards">
<div
className="home-card"
onClick={() => navigate("/proposals")}
>
<h3>链治理提案</h3>
<p>浏览和参与 Cosmos SDK x/gov 模块的链上提案</p>
</div>
<div
className="home-card"
onClick={() => navigate("/dao")}
>
<h3>DAO 提案</h3>
<p>管理 DAO 合约提案和金库多签支出</p>
</div>
<div
className="home-card"
onClick={() => navigate("/create")}
>
<h3>创建提案</h3>
<p>提交新的文本、参数、社区支出或升级提案</p>
</div>
<div
className="home-card"
onClick={() => navigate("/params")}
>
<h3>治理参数</h3>
<p>查看和监控链上治理参数配置</p>
</div>
</div>
</div>
);
}
function ProposalsPage() {
const navigate = (id: number) => {
window.history.pushState({}, "", `/proposals/${id}`);
window.dispatchEvent(new PopStateEvent("popstate"));
};
const { client, daoAddress } = useDao();
const wallet = useWallet();
return (
<ProposalList
status="all"
onSelect={navigate}
source="cosmos"
client={client}
daoAddress={daoAddress}
/>
);
}
function ProposalDetailPage() {
const params = new URLSearchParams(window.location.search);
const id = Number(params.get("id"));
const { client, daoAddress } = useDao();
const wallet = useWallet();
return (
<ProposalDetail
proposalId={id}
source="cosmos"
client={client}
signingClient={wallet.signingClient}
daoAddress={daoAddress}
address={wallet.address}
isConnected={wallet.isConnected}
onBack={() => window.history.back()}
/>
);
}
function CreateProposalPage() {
const wallet = useWallet();
return (
<CreateProposal
signingClient={wallet.signingClient}
address={wallet.address}
isConnected={wallet.isConnected}
mode="cosmos"
daoAddress={null}
onCreated={(txHash) => {
alert(`提案创建成功!交易哈希: ${txHash}`);
window.location.href = "/proposals";
}}
/>
);
}
function GovernanceParamsPage() {
const { client, daoAddress } = useDao();
return (
<GovernanceParams client={client} daoAddress={daoAddress} />
);
}
function DaoPage() {
const { client, daoAddress, treasuryAddress } = useDao();
const wallet = useWallet();
const [tab, setTab] = useState<"proposals" | "treasury">("proposals");
return (
<div className="dao-page">
<h2>DAO 治理</h2>
<div className="dao-tabs">
<button
className={`dao-tab ${tab === "proposals" ? "active" : ""}`}
onClick={() => setTab("proposals")}
>
提案列表
</button>
<button
className={`dao-tab ${tab === "treasury" ? "active" : ""}`}
onClick={() => setTab("treasury")}
>
金库管理
</button>
</div>
{tab === "proposals" && (
<DaoProposalList onSelect={(id) => alert(`查看提案 #${id}`)} />
)}
{tab === "treasury" && <TreasuryDashboard />}
</div>
);
}
function Layout({ children }: { children: React.ReactNode }) {
return (
<div className="app-layout">
<header className="app-header">
<Link to="/" className="app-logo">
MSG 治理
</Link>
<nav className="app-nav">
<Link to="/proposals">提案</Link>
<Link to="/dao">DAO</Link>
<Link to="/create">创建</Link>
<Link to="/params">参数</Link>
</nav>
<ConnectWallet />
</header>
<main className="app-main">{children}</main>
</div>
);
}
export default function App() {
const [path, setPath] = React.useState(window.location.pathname);
React.useEffect(() => {
const handler = () => setPath(window.location.pathname);
window.addEventListener("popstate", handler);
return () => window.removeEventListener("popstate", handler);
}, []);
function renderPage() {
if (path.startsWith("/proposals/")) {
return <ProposalDetailPage />;
}
switch (path) {
case "/":
return <HomePage />;
case "/proposals":
return <ProposalsPage />;
case "/create":
return <CreateProposalPage />;
case "/params":
return <GovernanceParamsPage />;
case "/dao":
return <DaoPage />;
default:
return <HomePage />;
}
}
return (
<WalletProvider>
<DaoProvider>
<Layout>
{renderPage()}
</Layout>
</DaoProvider>
</WalletProvider>
);
}
9.5 全局样式
/* index.css */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
"Helvetica Neue", Arial, sans-serif;
background: #f5f7fa;
color: #333;
line-height: 1.5;
}
a {
text-decoration: none;
color: inherit;
}
.app-layout {
min-height: 100vh;
display: flex;
flex-direction: column;
}
.app-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 24px;
height: 60px;
background: #fff;
border-bottom: 1px solid #e0e0e0;
position: sticky;
top: 0;
z-index: 100;
}
.app-logo {
font-size: 18px;
font-weight: 700;
color: #1976D2;
}
.app-nav {
display: flex;
gap: 8px;
}
.app-nav a {
padding: 8px 14px;
border-radius: 6px;
font-size: 14px;
color: #555;
transition: all 0.2s;
}
.app-nav a:hover {
background: #f0f0f0;
color: #1976D2;
}
.app-main {
flex: 1;
padding: 24px;
}
.wallet-connect {
padding: 8px 18px;
background: #1976D2;
color: #fff;
border: none;
border-radius: 6px;
font-size: 14px;
cursor: pointer;
transition: background 0.2s;
}
.wallet-connect:hover {
background: #1565C0;
}
.wallet-connected {
display: flex;
align-items: center;
gap: 12px;
}
.wallet-info {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 14px;
background: #e3f2fd;
border-radius: 6px;
font-size: 13px;
}
.wallet-address {
font-family: monospace;
font-weight: 500;
color: #1976D2;
}
.wallet-chain {
padding: 2px 8px;
background: #bbdefb;
border-radius: 4px;
font-size: 11px;
color: #1565C0;
}
.wallet-disconnect {
padding: 6px 12px;
background: none;
border: 1px solid #ddd;
border-radius: 6px;
font-size: 13px;
cursor: pointer;
color: #888;
}
.wallet-disconnect:hover {
border-color: #F44336;
color: #F44336;
}
.home-page {
max-width: 800px;
margin: 40px auto;
text-align: center;
}
.home-page h1 {
font-size: 32px;
font-weight: 700;
margin-bottom: 12px;
}
.home-desc {
font-size: 16px;
color: #666;
margin-bottom: 40px;
}
.home-cards {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 16px;
}
.home-card {
background: #fff;
border: 1px solid #e0e0e0;
border-radius: 12px;
padding: 28px 24px;
cursor: pointer;
transition: all 0.2s;
text-align: left;
}
.home-card:hover {
border-color: #1976D2;
box-shadow: 0 4px 16px rgba(25, 118, 210, 0.1);
transform: translateY(-2px);
}
.home-card h3 {
font-size: 18px;
margin-bottom: 8px;
color: #1976D2;
}
.home-card p {
font-size: 14px;
color: #888;
line-height: 1.5;
}
.dao-page {
max-width: 960px;
margin: 0 auto;
padding: 20px;
}
.dao-page h2 {
font-size: 22px;
font-weight: 700;
margin: 0 0 20px;
}
.dao-tabs {
display: flex;
gap: 4px;
border-bottom: 2px solid #eee;
margin-bottom: 24px;
}
.dao-tab {
padding: 10px 20px;
background: none;
border: none;
cursor: pointer;
font-size: 14px;
color: #666;
border-bottom: 2px solid transparent;
margin-bottom: -2px;
transition: all 0.2s;
}
.dao-tab:hover {
color: #1976D2;
}
.dao-tab.active {
color: #1976D2;
border-bottom-color: #1976D2;
font-weight: 600;
}
9.6 端到端治理流程示例
// 完整治理流程示例
async function fullGovernanceFlow() {
// 1. 连接钱包
const { signingClient, address, client } = useWallet();
const { daoAddress } = useDao();
// 2. 创建提案
const proposalMsg = {
submit_proposal: {
title: "增加社区发展基金",
description:
"提议从社区池拨款 10,000 MSG 用于支持开发者生态建设。",
deposit_amount: "10000000",
},
};
const createTx = await signingClient!.execute(
address!,
daoAddress!,
proposalMsg,
"auto",
undefined,
[{ denom: "umsg", amount: "10000000" }]
);
console.log("提案创建交易:", createTx.transactionHash);
// 3. 查询提案
const newProposal: any = await client!.queryContractSmart(
daoAddress!,
{ list_proposals: { limit: 1 } }
);
const proposalId = newProposal.proposals[0].id;
console.log("新提案 ID:", proposalId);
// 4. 投票
const voteTx = await signingClient!.execute(
address!,
daoAddress!,
{ vote: { proposal_id: proposalId, vote: "yes" } },
"auto"
);
console.log("投票交易:", voteTx.transactionHash);
// 5. 查询计票
const tally: any = await client!.queryContractSmart(
daoAddress!,
{ get_proposal_tally: { proposal_id: proposalId } }
);
console.log("计票结果:", tally);
// 6. 执行提案(需投票期结束后)
const executeTx = await signingClient!.execute(
address!,
daoAddress!,
{ execute_proposal: { proposal_id: proposalId } },
"auto"
);
console.log("执行交易:", executeTx.transactionHash);
}
9.7 单元测试示例
// __tests__/governance.test.ts
import {
getProposalType,
getProposalStatusLabel,
formatBalance,
formatDuration,
formatPercent,
} from "../utils/format";
describe("Governance Utils", () => {
test("formatBalance converts umsg to MSG", () => {
expect(formatBalance("1000000")).toBe("1.00");
expect(formatBalance("1500000")).toBe("1.50");
expect(formatBalance("0")).toBe("0.00");
});
test("formatDuration handles various durations", () => {
expect(formatDuration(86400)).toBe("1天");
expect(formatDuration(90000)).toBe("1天1小时");
expect(formatDuration(604800)).toBe("7天");
expect(formatDuration(3600)).toBe("1小时");
});
test("formatPercent converts ratio to percentage", () => {
expect(formatPercent("0.334")).toBe("33.4%");
expect(formatPercent("0.5")).toBe("50.0%");
expect(formatPercent("1.0")).toBe("100.0%");
});
test("getProposalStatusLabel handles all statuses", () => {
expect(getProposalStatusLabel("PROPOSAL_STATUS_VOTING_PERIOD")).toBe(
"投票中"
);
expect(getProposalStatusLabel("PROPOSAL_STATUS_PASSED")).toBe("已通过");
expect(getProposalStatusLabel("PROPOSAL_STATUS_REJECTED")).toBe("已拒绝");
expect(getProposalStatusLabel("active")).toBe("投票中");
expect(getProposalStatusLabel("passed")).toBe("已通过");
expect(getProposalStatusLabel("executed")).toBe("已执行");
});
test("getProposalType identifies proposal types", () => {
expect(
getProposalType({
"@type": "/cosmos.gov.v1beta1.TextProposal",
title: "Test",
description: "Desc",
})
).toBe("text");
expect(
getProposalType({
"@type": "/cosmos.gov.v1beta1.ParameterChangeProposal",
title: "Test",
description: "Desc",
changes: [],
})
).toBe("parameter_change");
});
});
10. 部署指南
10.1 环境配置
# .env 文件配置
VITE_CHAIN_ID=msg-chain-1
VITE_RPC_ENDPOINT=https://rpc.msgchain.org
VITE_REST_ENDPOINT=https://api.msgchain.org
VITE_CHAIN_PREFIX=msg
VITE_DENOM=umsg
VITE_GAS_PRICE=1000000000attoMSG
VITE_GENESIS_REGISTRY=msg1registryaddressplaceholder...
VITE_EXPLORER_URL=https://explorer.msgchain.org
10.2 构建与部署
# 1. 安装依赖
npm install
# 2. 构建生产版本
npm run build
# 3. 预览构建结果
npm run preview
# 4. 部署到 IPFS
npm install -g @fleekhq/fleek-cli
fleek sites:init
fleek sites:deploy
# 或部署到 Vercel
npx vercel --prod
# 或部署到 Netlify
npx netlify deploy --prod --dir=dist
10.3 Nginx 配置
# /etc/nginx/sites-available/governance
server {
listen 80;
server_name governance.msgchain.org;
root /var/www/governance-dapp/dist;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
location ~* \.(js|css|png|jpg|svg|ico)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
location /api/ {
proxy_pass https://api.msgchain.org/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
10.4 Docker 部署
# Dockerfile
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
# docker-compose.yml
version: "3.8"
services:
governance-dapp:
build: .
ports:
- "80:80"
environment:
- VITE_CHAIN_ID=msg-chain-1
- VITE_RPC_ENDPOINT=https://rpc.msgchain.org
- VITE_REST_ENDPOINT=https://api.msgchain.org
10.5 安全检查清单
- [ ] 前端不存储私钥,所有签名通过 Keplr 完成
- [ ] API 调用使用 HTTPS,禁用 HTTP
- [ ] 交易模拟 (simulate) 用于 Gas 预估
- [ ] 输入验证:Bech32 地址格式校验
- [ ] 提案创建时检查最小保证金
- [ ] 投票确认弹窗防止误操作
- [ ] 提案执行按钮仅在通过后显示
- [ ] CORS 配置允许指定域名访问
- [ ] Content Security Policy 头配置
- [ ] XSS 防护:用户输入 HTML 转义
10.6 Gas 费用参考
| 操作 | Gas 消耗 | 费用 (umsg) |
|---|---|---|
| 查询提案列表 | 100k | 2,500 |
| 查询提案详情 | 100k | 2,500 |
| 创建文本提案 | 200k-300k | 5,000-7,500 |
| 创建参数修改提案 | 250k-400k | 6,250-10,000 |
| 创建社区支出提案 | 220k-350k | 5,500-8,750 |
| 创建升级提案 | 240k-400k | 6,000-10,000 |
| 提交 DAO 提案 | 200k-500k | 5,000-12,500 |
| 投票 (链上) | 100k-150k | 2,500-3,750 |
| 投票 (DAO) | 100k-200k | 2,500-5,000 |
| 执行提案 | 150k-400k | 3,750-10,000 |
| 金库提交支出 | 200k-400k | 5,000-10,000 |
| 金库批准 | 100k-150k | 2,500-3,750 |
| 金库执行 | 150k-300k | 3,750-7,500 |
10.7 故障排除
常见问题:
-
Keplr 无法连接
- 确保安装了 Keplr 浏览器扩展
- 检查是否添加了 MSG Chain 网络
- 尝试刷新页面重新连接
-
交易失败
- 检查账户余额是否足够支付 Gas
- 保证金金额是否达到最小值
- 提案状态是否正确(不能对已结束的提案投票)
-
查询为空
- 确认 RPC/REST 节点地址正确
- 检查网络连接是否正常
- 确保使用的链 ID 为
msg-chain-1
-
DAO 合约查询失败
- 确认
genesis_registry_v1地址正确 - 验证 DAO 合约已注册到 Registry
- 检查合约执行消息格式是否正确
- 确认
本文档基于 MSG Chain 代码库核实的技术事实。
白皮书系统: https://msgchain.org/whitepaper/
