剖析DeFi借贷产品之Compound:Subgraph篇

subgraph 是由开发者创建并部署到 The Graph 的开放索引服务

剖析DeFi借贷产品之Compound:概述篇

剖析DeFi借贷产品之Compound:合约篇


什么是Subgraph

Subgraph 是由 The Graph 所提供的一种技术。The Graph 是一种去中心化的索引协议,用于查询类似以太坊和 IPFS 的区块链网络数据,目前支持了 Ethereum 主网和几个测试网,以及 BSC、Matic、Fantom 等。任何人都可以基于其创建和部署开放 API,即 subgraph,使得区块链数据更容易访问。

即是说,subgraph 是由开发者创建并部署到 The Graph 的开放索引服务。

基于 The Graph 和 subgraph,数据流大致如下:

  1. 一个 DApp 通过合约执行一个 transaction,合约 emit 了一个或多个 event;
  2. The Graph 的节点持续扫描区块数据,而你定义的 subgraph 设置了所需要监听的事件;
  3. Graph 节点监听到你的 subgraph 所关心的事件,会根据你的 subgraph 所定义的处理方法创建或更新实体对象的数据,这些数据会保存在 Graph 节点中;
  4. 前端框架连接到 Graph 节点,通过编写 GraphQL 查询相应的实体对象数据并展示到页面上。

简单地说,通过 subgraph 可以对链上数据进行再封装,形成更便于查询的数据。因为有些数据,是难以在链上直接查询得到的。

官方文档中举了一个例子,比如:谁是 2018 年 1 月至 2 月出生的 CryptoKitties 的所有者? 要回答这个问题,必须处理所有 Birth事件 ,然后 为每个出生的 CryptoKitty 调用 ownerOf方法。即使对于这个相对简单的问题,运行在浏览器中的 Dapp 也需要数小时甚至数天才能得到答案。索引区块链数据很困难。区块链属性,如最终性、链重整或未处理的块,使这个过程进一步复杂化,不仅耗时,而且在概念上很难从区块链数据中检索正确的查询结果。The Graph 通过一个为区块链数据编制索引的托管服务解决了这个问题。然后可以使用标准 GraphQL API 查询这些索引(subgraph)。

image20210616064827773.png

Graph Node

Graph Node 是 Subgraph 的运行环境,而目前其实存在三种方式的运行环境:

  • Hosted Service:The Graph官方团队提供的中心化的托管服务,也是目前使用最广泛便利的一种方式。
  • Private Node:私有节点,由 Dapp 团队根据开源的 graph-node 项目自主搭建。
  • The Graph Network:去中心化网络,主网已上线,但目前还不稳定,开发团队目前也无法直接将 Subgraph 部署上去,需 The Graph 技术团队协同才能迁移到主网。

使用 Hosted Service 是最方便的,也是目前最推荐的一种方式。基于 Hosted Service 的 subgraph 部署流程如下:

1. 在官网注册账号

第一步,需要在 The Graph 官网(https://thegraph.com/explorer/)用 Github 账号进行注册,如果没有 Github 账号的话,还需要先去注册一个 Github 账号。

image.png

2. 在官网添加 Subgraph

创建账户并登录之后,进入 Dashboard 就可以开始通过界面创建 subgraph。进入你的 Dashboard,并点击 Add Subgraph。另外,每个账户都有一个 Access token,在将 subgraph 部署到 Graph 节点时需要用到。

image.png

需要填写以下信息:

image.png

其中,除了 Subgraph NameAccount 无法修改,其他字段都是后续可以修改的。另外,Subgraph Name 是区分大小写的,后续创建本地项目时需指定 Subgraph Name,切记这点。

3. 本地安装 Graph CLI

在本地环境安装 Graph CLI,即 Graph 的客户端工具,可以用 npm 或 yarn:

$ npm install -g @graphprotocol/graph-cli
$ yarn global add @graphprotocol/graph-cli

在这个安装的过程中,可能会因为国内的网络问题导致失败,可以使用翻墙工具并通过设置代理的方式解决,而且可能还需要设置为 socks5 代理。我安装的过程中,曾经只设置为 http 代理,但有一个依赖的库总是报 ESOCKETTIMEOUT。

另外,npm 和 yarn 只支持设置 http 代理,并不支持设置 socks5 代理,因此,还需要一个工具将 http 代理转为 socks5 代理。这个工具为 http-proxy-to-socks

# 假设本地socks5代理端口为1086
# 首先安装转换工具
npm install -g http-proxy-to-socks
# 然后使用这个工具监听8002端口,支持http代理,然后所有8002的http代理数据都将转换成socks的代理数据发送到1086上
hpts -s 127.0.0.1:1086 -p 8002
# 最后设置npm代理
npm config set proxy http://127.0.0.1:8002
npm config set https-proxy http://127.0.0.1:8002

使用 yarn 同理。对应的几个 npm 命令可改为:

# 安装转换工具
yarn global add http-proxy-to-socks
# 设置代理为
yarn config set proxy http://127.0.0.1:8002
yarn config set https-proxy http://127.0.0.1:8002

另外,使用期间,hpts -s 127.0.0.1:1086 -p 8002 需要一直开着。

当然,这是我遇到的情况,有些人可能遇到的问题不一样,我也不确定我的这种方案是否能解决你的问题,但至少可以尝试一下。

最后再补充一点,如果因为 git 原因发生网络问题,那可以将 git 也设置为使用代理。

4. 创建本地 subgraph 项目

在本地环境初始化一个 subgraph 项目主要使用 graph init 命令,后续参数可以指定 --from-contract--from-example。--from-contract 是从一个合约地址新建项目,--from-example 则是用一个官方提供的案例进行初始化。

--from-contract 是比较实用的一种方式,使用的命令格式为:

graph init \
  --from-contract <CONTRACT_ADDRESS> \
  [--network <ETHEREUM_NETWORK>] \
  [--abi <FILE>] \
  <GITHUB_USER>/<SUBGRAPH_NAME> [<DIRECTORY>]
  • --from-contract:配置合约地址
  • --network:指定所使用的网络,目前支持了多种网络,包括 mainnet、kovan、rinkeby、ropsten、goerli、poa-core、xdai、poa-sokol、matic、mumbai、fantom、bsc、clover
  • --abi:指定所使用的 abi 文件,如不指定,则会用 etherscan 获取,但因为网络原因,基本获取不到,所以最好指定本地文件
  • <GITHUB_USER>/<SUBGRAPH_NAME>:就是前面在官网所创建的 subgraph,比如我之前创建的为 keeganlee/First
  • [DIRECTORY]:指定创建的 subgraph 项目存放的目录,不指定的情况下和 SUBGRAPH_NAME 同名

比如,我创建的示例项目所使用的具体参数如下:

graph init \
  --from-contract 0x3d9819210A31b4961b30EF54bE2aeD79B9c9Cd3B \
  --network mainnet \
  --abi Comptroller.json \
  keeganlee/First

创建的过程中,还会下载一些依赖的库,可能也会因为国内网络原因而失败,因此,可以用上面所提到的方法设置代理。

创建成功后,项目的目录结构如下:

subgraph.yaml     #manifest文件,也是subgraph的入口点,很多信息都是在该文件中配置
schema.graphql    #schema主要定义了保存到Graph节点中的各种实体数据的数据结构
package.json
yarn.lock
abis              #合约的abi文件都放在这里
  - Contract.json
src               #主要编写对合约事件监听后的处理,处理逻辑一般就是创建或更新实体数据
  - mapping.ts
generated         #这是自动生成的,修改以上的文件后可用graph codegen命令重新生成
  - schema.ts
  - Contract
    - Contract.ts

5. 编码

根据实际的业务需要对subgraph项目进行编码工作,主要需要修改和编写的文件为:

  • subgraph.yaml
  • schema.graphql
  • mapping.ts

这里面涉及到较多的知识点,后面再说。

6. 部署

当编码工作完成,也没什么问题之后,就可以将其部署到 The Graph 的 Hosted Service 中去。

部署之前,第一步是先进行授权,执行以下命令:

graph auth https://api.thegraph.com/deploy/ &lt;access-token>

其中,access-token 就是你的 Dashboard 中所显示的那个 Access token,也是前面提到的。

接着,就可以在你的 subgraph 项目的根目录下调用以下命令进行部署:

yarn deploy

部署完成之后,就可以在 The Graph 官网中进入你的 subgraph 查看,比如我的 subgraph 部署完之后如下:

image.png

里面,有一个进度条,显示 Syncing(4.6%),这是在同步区块数据。

Playground 中右边展示了项目中所定义的 schema,左边则可以用 GraphQL 编写查询语句,点击 Play 按钮,则会将查询结果的数据展示在中间那片空白区域中。

私有节点

若要自己搭建私有节点,可按照 Github 上的 graph-node 项目的说明进行部署。其 Github 地址为:

部署 graph-node 也有两种方式,一是 README 上所描述的步骤,二是用 Docker 进行部署。两种方式我都尝试过,但第一种方式以失败告终,多次尝试解决问题依然无果,第二种方式很快就成功了,所以我强烈推荐用 Docker 方式进行部署。

首先,在需要部署的服务器安装好 docker 和 docker-compose。

其次,打开 graph-node/docker/docker-compose.yml 文件,修改其中一行:

ethereum: 'mainnet:http://host.docker.internal:8545'

该行指定了使用的网络和节点,比如,我部署接入 kovan 网络,节点使用 infura 的,那设置的值为:

ethereum: 'kovan:https://kovan.infura.io/v3/&lt;PROJECT_ID>'

其中,<PROJECT_ID> 是在 infura 注册项目时所分配的项目ID。

最后,在 graph-node/docker 目录下,执行以下命令:

docker-compose up -d

将会自动下载 IPFS、Postgres 和 Graph Node 三个 Docker 镜像,并以后台的方式运行容器。

至此,私有的 Graph 节点就启动了,就可以访问以下这些:

  • Graph Node:
    • GraphiQL: http://localhost:8000/
    • HTTP: http://localhost:8000/subgraphs/name/&lt;subgraph-name>
    • WebSockets: ws://localhost:8001/subgraphs/name/&lt;subgraph-name>
    • Admin: http://localhost:8020/
  • IPFS:
    • 127.0.0.1:5001 or /ip4/127.0.0.1/tcp/5001
  • Postgres:
    • postgresql://graph-node:let-me-in@localhost:5432/graph-node

再配置下对外的端口和权限,外部也可以访问节点了。

而本地的 subgraph 项目想要部署到此私有节点的话,需要先在本地执行以下命令:

graph create &lt;SUBGRAPH_NAME> --node http://&lt;NODE_IP>:8020

其中,<SUBGRAPH_NAME> 替换成你自己的 subgraph 名称,<NODE_IP> 则替换成你的节点地址。

接着,需要修改下 subgraph 项目中的 package.json 的脚本配置。比如,使用 The Graph 官方的 Hosted Service 时,我的 package.json 中的 scripts 配置是这样的:

"scripts": {
    "codegen": "graph codegen --output-dir src/types/",
    "build": "graph build --ipfs https://api.staging.thegraph.com/ipfs/",
    "create-local": "graph create keeganlee/First --node http://127.0.0.1:8020",
    "deploy-local": "graph deploy keeganlee/First --debug --ipfs http://localhost:5001 --node http://127.0.0.1:8020/",
    "deploy": "graph deploy keeganlee/First --debug --ipfs https://api.thegraph.com/ipfs/ --node https://api.thegraph.com/deploy/",
    "deploy-staging": "graph deploy --debug --ipfs https://api.staging.thegraph.com/ipfs/ --node https://api.staging.thegraph.com/deploy/ keeganlee/First",
    "prettier": "./node_modules/.bin/prettier —-write '**/*.ts'"
  },

其中,build、deploy、deploy-staging 这三个命令的 --ipfs--node 参数后面的 URL 都需要更改为自己搭建的私有节点地址,以 deploy 的为例:

"deploy": "graph deploy keeganlee/First --debug --ipfs http://&lt;NODE_IP>:5001 --node http://&lt;NODE_IP>:8020",

之后就可以执行 yarn deploy 命令进行部署了。

The Graph Network

关于去中心化的 The Graph Network,虽然主网已经上线,但目前还没有文档说明如何将 subgraph 部署到 Network,目前那些迁移到了 Network 的 subgraph 都是官方技术团队协同迁移过去的。官方团队人员还表示,最近会等主网表现稳定后,再把其他 subgraph 也迁移到主网。

目前对 Network 最详细的介绍,就是官方文档了:

https://thegraph.com/docs/network

至于详细内容,我自己还没有深入去研究,所以就不展开了,等以后深入研究后再另外发文讲解这一块。

Subgraph 编码

我们先获取 Compound 的 Subgraph 项目源码,其实存在两份不同的源码,注意以下两个地址的区别:

第一个是 Compound 官方的 Github 地址,而第二个是 The Graph 的 Github 地址。第一个已经超过两年没有提交过代码,第二个则是 fork 自第一个的,增加扩展了较多内容,最后一次提交是 8 个月前。而第二个也是部署在了 The Graph 的 Hosted Service 上并公开展示的:

因此,从结果来看,第二个地址的源码项目更值得研究学习。下载源码之后,剔除掉一些不相关的文件之后,项目结构如下:

├── abis
│   ├── cERC20.json
│   ├── cETH.json
│   ├── comptroller.json
│   ├── ctoken.json
│   ├── erc20.json
│   ├── priceOracle.json
│   └── priceOracle2.json
├── src
│   ├── mappings
│   │   ├── comptroller.ts
│   │   ├── ctoken.ts
│   │   ├── helpers.ts
│   │   └── markets.ts
├── package.json
├── schema.graphql
├── subgraph.yaml
└── yarn.lock

abis 里都是需要用到的合约的 ABI 文件,当我们开发自己的 subgraph 项目时,也要将我们所需要的 ABI 文件放到该目录下。package.json 是项目的描述文件,我们最需要关心的是里面封装的脚本命令,在前面我们其实已经有讲到。yarn.lock 文件列出了项目所有的依赖库,这是自动生成的,我们不需要去改动它。

剩下的,subgraph.yaml、schema.graphql 和 src/mappings 下的文件,才是编码工作的核心。

Manifest

Manifest 文件就是 subgraph.yaml 文件,是核心的入口点,主要定义需要建立索引的合约、需要监听的合约事件,以及处理事件数据和保存到 Graph 节点的实体数据之间的映射函数,等等。最核心的配置就是数据源 dataSources,可以配置多个数据源,每个数据源指定名称、目标网络、对应的合约信息、映射信息等。另外,模板 templates 则可用于设置支持动态创建的合约。

以下文档有对 manifest 文件完整的描述:

Compound 的 subgraph.yaml 中就只设置了一个数据源和一个模板,数据源为 Comptroller,模板则是 CToken。有一点需要注意,因为 Comptroller 合约使用了代理模式,所以 Comptroller 数据源所绑定的合约地址其实是其代理合约 Unitroller 的。

Comptroller 数据源中设置监听了 MarketListed(address) 事件,该事件也是 CToken 合约添加进市场时触发的,因此,在该事件的处理函数中,就可以使用 CToken 模板初始化每个 CToken 数据源。

Schema

GraphQL schema 都是在 schema.graphql 文件中所定义,是使用 GraphQL IDL(Interface Definition Language) 来定义 schema 的。如果之前没了解过 GraphQL schema,可以学习下以下两篇文档:

在 schema.graphql 中,最核心的就是定义各种 entity,即实体,如下示例:

type Account @entity {
    "User ETH address"
    id: ID!
    "Array of CTokens user is in"
    tokens: [AccountCToken!]! @derivedFrom(field: "account")
    "Count user has been liquidated"
    countLiquidated: Int!
    "Count user has liquidated others"
    countLiquidator: Int!
    "True if user has ever borrowed"
    hasBorrowed: Boolean!
}

每种实体可以类比为 MySQL 数据库中的一张表,以上代码可以简单理解为定义了 Account 的表结构

下图则是 Compound 的 subgraph 中所定义的所有 schema:

image.png

其中,最后的两个,CTokenTransferUnderlyingTransferinterface,其他的则都是 entity

Mappings

mappings 是在一个 subgraph 项目中需要做最多编码工作的模块,是用 称为 AssemblyScriptTypeScript 子集编写的, 它可以编译为 WASM ( WebAssembly )。AssemblyScript 比普通的 TypeScript 更严格,但提供了常用的语法。

mappings 所映射的就是链上数据schema entities,通过编写各种 handler 函数来处理监听到的合约事件或函数调用等,从而得到链上数据,并将这些数据转化为实体数据进行存储。

比如,我们前面提到过,Comptroller 数据源会监听一个 MarketListed(address) 事件,并指定了处理该事件的 handler:

eventHandlers:
  - event: MarketListed(address)
    handler: handleMarketListed

另外,Comptroller 数据源中也指定了 mapping:

mapping:
  kind: ethereum/events
  apiVersion: 0.0.4
  language: wasm/assemblyscript
  file: ./src/mappings/comptroller.ts
  entities:
    - Comptroller
    - Market

其中,file 指定为 ./src/mappings/comptroller.ts,即是说 Comptroller 数据源中定义的所有 handler 函数都将在该文件中找到。另外,entities 指明了 Comptroller 和 Market,表示在 comptroller.ts 中将会用到这两个实体,不过,事实上,comptroller.ts 还用到了 Account 实体,这是配置中写少了的。

不过,在编写实际的 mappings 之前,需要先执行 graph codegen 自动生成一些 ts 代码。该命令也可以指定生成的代码存放的路径,如下:

graph codegen --output-dir src/types/

则会将生成的 ts 代码存放在当前项目的 src/types/ 目录下。如果不指定目录,默认为当前项目的 generated 目录。

compound-v2-subgraph 生成的该目录如下:

types
├── Comptroller
│   ├── CToken.ts
│   ├── Comptroller.ts
│   ├── ERC20.ts
│   ├── PriceOracle.ts
│   └── PriceOracle2.ts
├── schema.ts
├── templates
│   └── CToken
│       ├── CToken.ts
│       ├── ERC20.ts
│       ├── PriceOracle.ts
│       └── PriceOracle2.ts
└── templates.ts

Comptroller 目录对应于 Comptroller 数据源,其下是该数据源下所设置的 abis 所生成的,即将合约的 ABI 文件转成了 ts 文件。

schema.ts 则是 schema.graphql 文件中所定义的所有 schema 的转化结果了。

templates 目录下则是定义的每一个模板,因为只定义了一个 CToken 模板,所以就只有一个 CToken 子目录,而该目录下的这些 ts 文件则是 CToken 模板中所定义的 abi 的映射了。

templates.ts 就很简单,其代码如下:

import {
  Address,
  DataSourceTemplate,
  DataSourceContext
} from "@graphprotocol/graph-ts";

export class CToken extends DataSourceTemplate {
  static create(address: Address): void {
    DataSourceTemplate.create("CToken", [address.toHex()]);
  }

  static createWithContext(address: Address, context: DataSourceContext): void {
    DataSourceTemplate.createWithContext("CToken", [address.toHex()], context);
  }
}

其实就是根据模板创建 CToken 合约对象的函数封装。

接着,我们来看看 ./src/mappings/comptroller.ts 具体的 handler 函数 handleMarketListed 的实现:

export function handleMarketListed(event: MarketListed): void {
  // Dynamically index all new listed tokens
  CToken.create(event.params.cToken)
  // Create the market for this token, since it's now been listed.
  let market = createMarket(event.params.cToken.toHexString())
  market.save()
}

第一行会创建一个 CToken 合约对象,其中的 CToken 其实就是上面 templates.ts 所定义的 CToken 类。

第二行和第三行则会创建一个 market 对象并保存,这是一个 entity 实例,也可以理解为就是生成了 Market 表的一条新记录。

另外,每个 event 对象其实都继承自 ethereum.Event,其封装了一些基本属性,如下:

export class Event {
  address: Address
  logIndex: BigInt
  transactionLogIndex: BigInt
  logType: string | null
  block: Block
  transaction: Transaction
  parameters: Array&lt;EventParam>
}

其中,Address、Block 和 Transaction 又封装了对应的一些基本属性,方便调用。

当然,最多的还是获取 event 的参数,可以通过 event.params.XXX 读取出对应的参数。

最后,可以看下以下文档学习如何编写 mappings:

GraphQL API

当将完整的 subgraph 开发完并成功部署到 Graph 节点之后,就可以实现查询操作了。而查询就是用 GraphQL API 进行查询的。 Dapp 前端页面所展示的数据,大部分都是可以用 GraphQL API 编写对应的查询语句实现的。

GraphQL API 的查询语句主要也是基于 schema 中所定义的各种实体。比如,以下面这个 entity 为例:

type Market @entity {
  id: ID!
  name: String!
  symbol: String!
  borrowRate: BigDecimal!
  supplyRate: BigDecimal!
}

现在,想要查出所有市场数据,那查询语句就可以这么写:

{
  markets {
    id
    name
    symbol
    borrowRate
    supplyRate
  }
}

而如果要进行条件查询,GraphQL API 提供了 where 参数,比如要查询 borrowRate 为 15 的,语句就可以这么写:

{
  markets(where: {borrowRate: 15}) {
    id
    name
    symbol
    borrowRate
    supplyRate
  }
}

而如果想要查询 borrowRate 大于 15 的,则是这么写:

{
  markets(where: {borrowRate_gt: 15}) {
    id
    name
    symbol
    borrowRate
    supplyRate
  }
}

即是在查询的字段名后面添加上 _gt 后缀。GraphQL API 提供了多个这种后缀:

  • _not
  • _gt
  • _lt
  • _gte
  • _lte
  • _in
  • _not_in
  • _contains
  • _not_contains
  • _starts_with
  • _ends_with
  • _not_starts_with
  • _not_ends_with

除了 where 参数,GraphQL API 还提供了其他参数,包括:

  • id:指定 id 查询
  • orderBy:指定排序的字段
  • orderDirection:排序方向,asc | desc
  • first:查询条数,比如设为 10,则最多只查出 10 条记录
  • skip:跳过不查询的条数
  • block:指定区块查询,可以指定区块 number 或 hash
  • text:全文检索

最后,GraphQL API 也同样有学习文档:

总结

Subgraph 涉及到的内容细节其实也比较多,但限于篇幅,我也无法都一一讲解,本篇文章更多只是为一些还不太懂 Subgraph 的小伙伴提供一些指引,要深入学习 Subgraph 还是需要多学习那些文档以及学习实际项目的源码。

下一篇,我将讲讲 Compound 清算服务该如何设计,这块 Compound 并没有开源项目,所以只能根据我自己的设计经验来讲解。


扫描以下二维码即可关注公众号(公众号名称:Keegan小钢)

点赞 7
收藏 4
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

6 条评论

请先 登录 后评论
Keegan小钢
Keegan小钢
公众号自媒体「Keegan小钢」,Web3从业者、培训讲师