GraphQL DAO 数据查询层:提案状态、投票权重与委托关系的实时聚合查询
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! # 0Against, 1For, 2Abstain 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; // 0Against, 1For, 2Abstain 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提供了托管索引服务成本可控且查询延迟可接受。