GraphQL DAO 数据查询层:提案状态、投票权重与委托关系的实时聚合查询
一、引言
DAO 治理数据的查询痛点不在于"有没有数据",而在于"如何高效地聚合多个合约的状态"。一个提案仪表盘需要同时查询:提案基本信息(Governor 合约)、投票计数(GovernorCountingSimple)、委托关系(ERC20Votes)、时间锁状态(TimelockController)。如果使用传统 REST 或 JSON-RPC 方式,每次页面加载需要发起 4-6 次独立 RPC 调用,不仅增加前端复杂度,也浪费 RPC 配额。
更关键的是,跨合约关联查询——例如「查找所有投了赞成票且委托权重超过 10 万 VOTE 的地址」——需要前端自行 join 多次 RPC 返回结果,在链上数据的延迟叠加下体验不可接受。传统方案要么退化为在服务端维护独立索引数据库,引入额外的运维成本和同步延迟;要么在合约层暴露出为前端量身定制的聚合视图函数,增加了合约的复杂度和 Gas 消耗。GraphQL 的类型系统与声明式查询恰好消解了这一矛盾——从数据消费者视角定义需求,由索引层负责优化执行计划。
二、子图架构与数据模型
2.1 实体关系图
2.2 索引策略
数据流的三个步骤:
索引性能考虑:
Proposal.id使用链上proposalId作为主键,避免额外映射Vote实体的id使用proposalId-voter组合键,保证唯一性且可快速查询单个提案的所有投票Delegate实体使用delegator作为 id,每地址仅一条记录(当前委托状态)
三、代码实现
3.1 子图清单 (subgraph.yaml)
# subgraph.yaml # 关键设计决策: # 1. startBlock 从合约部署区块开始 — 保证历史数据完整性 # 2. 事件处理器按实体依赖排序 — VoteCast 需要 Proposal 已存在 # 3. 使用 abis 分离 — 每个合约一个 ABI 文件,支持多合约索引 specVersion: 1.0.0 schema: file: ./schema.graphql dataSources: - kind: ethereum name: Governor network: mainnet source: address: "0x..." abi: Governor startBlock: 18000000 mapping: kind: ethereum/events apiVersion: 0.0.7 language: wasm/assemblyscript entities: - Proposal - Vote - DAO abis: - name: Governor file: ./abis/Governor.json eventHandlers: - event: ProposalCreated(uint256,address,address[],uint256[],string[],bytes[],uint256,uint256,string) handler: handleProposalCreated - event: VoteCast(indexed address,uint256,uint8,uint256,string) handler: handleVoteCast - event: ProposalExecuted(uint256) handler: handleProposalExecuted file: ./src/governor.ts - kind: ethereum name: Token network: mainnet source: address: "0x..." abi: ERC20Votes startBlock: 18000000 mapping: kind: ethereum/events apiVersion: 0.0.7 language: wasm/assemblyscript entities: - Delegate - TokenHolder abis: - name: ERC20Votes file: ./abis/ERC20Votes.json eventHandlers: - event: DelegateChanged(indexed address,indexed address,indexed address) handler: handleDelegateChanged - event: DelegateVotesChanged(indexed address,uint256,uint256) handler: handleDelegateVotesChanged file: ./src/token.ts3.2 Schema 定义
# schema.graphql # 实体设计依据: # 1. ID 全部使用 String — AssemblyScript 中 BigInt 作为 ID 需转为 hex string # 2. 派生字段(如 Proposal.votes)使用 @derivedFrom — 不需要处理器手动维护 # 3. DAO 作为根实体 — 多 DAO 子图共用同一 schema type DAO @entity { id: ID! # DAO 合约地址 name: String! governorAddress: Bytes! tokenAddress: Bytes! totalSupply: BigInt! quorumNumerator: BigInt! proposals: [Proposal!]! @derivedFrom(field: "dao") } type Proposal @entity { id: ID! # proposalId 的 hex 编码 dao: DAO! proposalId: BigInt! proposer: Bytes! description: String! # targets/values/calldatas 存为数组,查询时可用 first/skip 分页 targets: [Bytes!]! values: [BigInt!]! signatures: [String!]! calldatas: [Bytes!]! startBlock: BigInt! endBlock: BigInt! # 状态使用枚举: Pending/Active/Canceled/Defeated/Succeeded/Queued/Executed/Expired status: ProposalStatus! # 投票计数 — 从 Vote 事件聚合 forVotes: BigInt! againstVotes: BigInt! abstainVotes: BigInt! # 关联 votes: [Vote!]! @derivedFrom(field: "proposal") createdAt: BigInt! executedAt: BigInt } enum ProposalStatus { Pending Active Canceled Defeated Succeeded Queued Executed Expired } type Vote @entity { id: ID! # "proposalId-voter" proposal: Proposal! voter: Bytes! support: Int! # 0=Against, 1=For, 2=Abstain weight: BigInt! reason: String blockNumber: BigInt! timestamp: BigInt! } type Delegate @entity { id: ID! # delegator 地址 delegator: Bytes! delegatee: Bytes! delegatedPower: BigInt! # 委托出的总权重 } type TokenHolder @entity { id: ID! # 持有者地址 balance: BigInt! votingPower: BigInt! # 当前投票权重(含接收的委托) delegates: Delegate! # 委托关系 }3.3 事件处理器 (AssemblyScript)
// src/governor.ts // 关键设计决策: // 1. 使用 DataSourceContext 存储 DAO 信息 — 避免每次事件都查询 DAO 实体 // 2. Proposal.status 在每次 VoteCast 时重新计算 — 尾部区块投票可能改变状态 // 3. BigInt 比较使用 .gt()/.lt() 而非 >/< — AssemblyScript 限制 import { BigInt, Bytes, log } from "@graphprotocol/graph-ts"; import { ProposalCreated, VoteCast, ProposalExecuted, } from "../generated/Governor/Governor"; import { Proposal, Vote, DAO } from "../generated/schema"; // 状态枚举常量 const STATUS_PENDING = "Pending"; const STATUS_ACTIVE = "Active"; const STATUS_SUCCEEDED = "Succeeded"; const STATUS_DEFEATED = "Defeated"; const STATUS_EXECUTED = "Executed"; export function handleProposalCreated(event: ProposalCreated): void { let dao = DAO.load(event.address.toHexString()); if (dao == null) { log.warning("DAO not found for address {}", [ event.address.toHexString(), ]); return; } // proposalId 转 hex 作为实体 ID let proposalId = event.params.proposalId; let entity = new Proposal(proposalId.toHexString()); entity.dao = dao.id; entity.proposalId = proposalId; entity.proposer = event.params.proposer; entity.description = event.params.description; entity.targets = event.params.targets; entity.values = event.params.values; entity.signatures = event.params.signatures; entity.calldatas = event.params.calldatas; entity.startBlock = event.params.startBlock; entity.endBlock = event.params.endBlock; entity.status = STATUS_PENDING; entity.forVotes = BigInt.fromI32(0); entity.againstVotes = BigInt.fromI32(0); entity.abstainVotes = BigInt.fromI32(0); entity.createdAt = event.block.timestamp; entity.executedAt = null; entity.save(); } export function handleVoteCast(event: VoteCast): void { let proposalId = event.params.proposalId.toHexString(); let proposal = Proposal.load(proposalId); if (proposal == null) { log.warning("Proposal {} not found for vote", [proposalId]); return; } // 累加投票计数 let weight = event.params.weight; let support = event.params.support; // 0=Against, 1=For, 2=Abstain if (support == 0) { proposal.againstVotes = proposal.againstVotes.plus(weight); } else if (support == 1) { proposal.forVotes = proposal.forVotes.plus(weight); } else { proposal.abstainVotes = proposal.abstainVotes.plus(weight); } // 状态更新: 投票期结束后根据投票结果判定 // 注意: block.number 与 endBlock 的比较需要 .gt() 操作符 if (event.block.number.gt(proposal.endBlock)) { let totalVotes = proposal.forVotes .plus(proposal.againstVotes) .plus(proposal.abstainVotes); // 法定人数检查: quorum 通过 DAO 实体获取 let dao = DAO.load(proposal.dao); if (dao != null) { let quorum = dao.totalSupply .times(dao.quorumNumerator) .div(BigInt.fromI32(100)); if (totalVotes.ge(quorum) && proposal.forVotes.gt(proposal.againstVotes)) { proposal.status = STATUS_SUCCEEDED; } else { proposal.status = STATUS_DEFEATED; } } } else { proposal.status = STATUS_ACTIVE; } proposal.save(); // 创建 Vote 实体 let voteId = proposalId.concat("-").concat( event.params.voter.toHexString() ); let vote = new Vote(voteId); vote.proposal = proposalId; vote.voter = event.params.voter; vote.support = support; vote.weight = weight; vote.reason = event.params.reason; vote.blockNumber = event.block.number; vote.timestamp = event.block.timestamp; vote.save(); } export function handleProposalExecuted(event: ProposalExecuted): void { let proposal = Proposal.load(event.params.proposalId.toHexString()); if (proposal != null) { proposal.status = STATUS_EXECUTED; proposal.executedAt = event.block.timestamp; proposal.save(); } }3.4 常用 GraphQL 查询
# 查询 1: 提案列表(含投票摘要和状态) query GetProposals($daoId: String!, $first: Int!, $skip: Int!) { proposals( where: { dao: $daoId } orderBy: createdAt orderDirection: desc first: $first skip: $skip ) { id proposalId proposer description status forVotes againstVotes abstainVotes startBlock endBlock createdAt } } # 查询 2: 单个投票者的委托关系与投票记录 query GetVoterProfile($voter: Bytes!) { delegate(id: $voter) { delegatee delegatedPower } # 该地址作为委托接收人收到的委托 incomingDelegations: delegates( where: { delegatee: $voter } ) { delegator delegatedPower } # 该地址最近的投票记录(最近 20 条) votes( where: { voter: $voter } orderBy: timestamp orderDirection: desc first: 20 ) { proposal { id description status } support weight reason } } # 查询 3: 提案详情 + 所有投票明细(分页) query GetProposalDetail($proposalId: String!, $first: Int!, $skip: Int!) { proposal(id: $proposalId) { id proposalId proposer description targets values signatures status forVotes againstVotes abstainVotes startBlock endBlock createdAt executedAt votes( orderBy: weight orderDirection: desc first: $first skip: $skip ) { voter support weight reason timestamp } } }四、边界与优化
子图同步延迟:The Graph 的事件索引通常有 1-3 分钟的延迟。对于需要实时状态的场景(如投票截止后的状态切换),前端应同时监听合约事件(通过 wagmi 的watchContractEvent),在子图更新前提供乐观UI。
大量投票数据的分页:千人以上的 DAO 单提案可能有数千条 Vote 记录。GraphQL 的first/skip分页在 skip 较大时性能下降。替代方案:使用基于时间戳的游标分页(where: { timestamp_gt: $cursor })。
多链 DAO 支持:如果 DAO 部署在多条链上,同一子图无法跨链索引。方案:每个链部署独立子图,在前端用 GraphQL Mesh 或 Apollo Federation 做跨链聚合查询。
IPFS 元数据解析:提案description字段可能存储为 IPFS CID 而非完整文本。前端在获取列表后需批量解析 CID 内容。可使用ipfs-only-hash预先计算 CID 作缓存键。
子图查询复杂度限制:The Graph 托管服务对单次查询有 10,000 实体的上限。对成员数过万的 DAO,全量 TokenHolder 查询需分页循环遍历。建议在 schema 层增加预计算聚合实体(如ProposalStats),将常用统计从 O(n) 遍历转化为 O(1) 查询。对于投票截止倒计时等秒级精度需求,应直接调用合约 view 函数而非依赖子图同步延迟。
子图初始同步加速:新建子图首次同步需回溯数万历史区块,耗时可达数小时。通过graph build --start-block指定起始区块跳过无事件的历史段,可将同步时间压缩至分钟级。
跨合约事件关联的索引复杂度:当同一个提案触发多个合约的事件(Governor 的 ProposalCreated + ERC20Votes 的 DelegateChanged),子图的事件处理器需要维护跨数据源(dataSource)的实体引用一致性。AssemblyScript 的有限类型系统在此时成为瓶颈——建议将复杂的跨合约关联逻辑下沉到 GraphQL 查询层,由前端按 proposalId 做二次聚合,避免在 handler 中写脆弱的跨 source 实体加载。
五、总结
The Graph 子图为 DAO 治理数据提供了一个"一次索引、任意查询"的聚合层。核心设计点在于:实体关系的扁平化建模(Proposal-Vote-Delegate 三角关系)、事件处理器的增量更新(VoteCast 时同时更新 Proposal 计数和 Vote 实体)、以及 GraphQL 查询的声明式组合能力。对于不想自行托管子图节点的团队,The Graph 的去中心化网络(已迁移至 Arbitrum)提供了托管索引服务,成本可控且查询延迟可接受。