Presentando Olive Oil Trust: subgrafo

Ver en GitHub

Esta publicación es parte de una serie de publicaciones sobre Olive Oil Trust:

Como vimos en la publicación anterior, los contratos de Olive Oil Trust emiten muchos eventos con datos importantes que deben recopilarse, junto con metadatos que deben agregarse.

Suscribirse a todos estos eventos en una aplicación de front end sería ineficiente y cualquier dato que estos eventos hubiesen emitido antes de la suscripción no se consideraría.

Además, procesar todos los eventos emitidos desde el bloque en el que se desplegó el contrato en adelante y filtrarlos también sería inconveniente.

Es por eso que utilizo un subgrafo, el cual actúa en la práctica como una capa de indexación entre los contratos y la interfaz de usuario.

En esta publicación trato de explicar cómo crear un subgrafo utilizando TheGraph que se puede usar para consultar datos de Olive Oil Trust de manera eficiente.

En este artículo

El manifiesto del subgrafo

El manifiesto del subgrafo especifica qué contratos indexa el subgrafo, a qué eventos reaccionar y cómo se asignan los datos de eventos a entidades que Graph Node almacena y permite consultar.

El workspace hardhat-env en el monorepositorio de Olive Oil Trust genera una carpeta deployments que contiene una carpeta para cada red en la que se despliegan los contratos.

Cada carpeta de una red contiene un archivo JSON para cada contrato que se despliega en esa red.

Estos archivos JSON reúnen información clave que se necesita para generar un manifiesto que permita que el subgrafo recopile datos de la cadena de bloques de manera eficiente.

El manifiesto se genera automáticamente a partir de una plantilla, utilizando el script generateSubgraph en subgraph/scripts/index.ts, para admitir múltiples despliegues en diferentes redes de forma dinámica.

Esto se realiza en el script npm "pre" npm run precodegen.El script npm run codegen ejecuta el comando graph codegen:

"precodegen": "ts-node scripts generateSubgraph --deployment localhost",
"codegen": "graph codegen subgraph.yaml --output-dir src/generated/types/",

generateSubgraph reemplazará los campos clave con valores de un archivo JSON en subgraph/src/generated/config con el nombre que coincida con la red en el parámetro--despliegue.

Ese archivo JSON se genera cuando se ejecutanpm ejecutar hardhat: compartir. Si hay implementaciones en varias redes, se generará un archivo JSON para cada red.

Campos

subgraph/templates/subgraph.template.yaml contiene cinco campos con datos estáticos:

subgraph/templates/subgraph.template.yaml
specVersion: 0.0.4
description: Olive Oil Trust for Ethereum
repository: https://github.com/albertobas/olive-oil-trust/subgraph
schema:
  file: ./schema.graphql
features:
  - ipfsOnEthereumContracts
  - fullTextSearch

Puede encontrar la especificación completa para los manifiestos de subgrafos aquí.

La plantilla de subgrafo también contiene el campo dataSources, que presenta algunos campos con claves entre corchetes que son reemplazadas por valores por generateSubgraph.

dataSources define los datos que se incorporan, así como la lógica de transformación para derivar el estado de las entidades del subgrafo en función de los datos de origen.

Hay una fuente de datos por contrato. Por ejemplo, veamos la fuente de datos del contrato de Bottling Company:

subgraph/templates/subgraph.template.yaml
- name: {{BottlingCompanyModule}}DataSource
  kind: ethereum/contract
  network: {{network}}
  source:
    address: '{{BottlingCompanyAddress}}'
    abi: {{BottlingCompanyModule}}
    startBlock: {{BottlingCompanyStartBlock}}
  mapping:
    kind: ethereum/events
    apiVersion: 0.0.6
    language: wasm/assemblyscript
    file: ./src/mappings/{{BottlingCompanyModule}}.ts
    entities:
      - Member
      - OwnershipTransferred
      - Contract
      - Transaction
      - Account
    abis:
      - name: {{BottlingCompanyModule}}
        file: ./src/generated/abis/{{BottlingCompanyModule}}.json
    eventHandlers:
      - event: NameSet(string)
        handler: handleNameSet
      - event: OwnershipTransferred(indexed address,indexed address)
        handler: handleOwnershipTransferred
      - event: TokenAncestrySet(indexed address,indexed bytes32,indexed bytes32,address[][],bytes32[][],bytes32[][],uint256[][])
        handler: handleTokenAncestrySet

Las claves {{BottlingCompanyModule}}, {{network}}, {{BottlingCompanyAddress}} y {{BottlingCompanyStartBlock}} serán reemplazadas por el módulo de ese contrato, el nombre de la red, la dirección del contrato y el bloque de inicio respectivamente.

El manifiesto del subgrafo de Olive Oil Trust está diseñado de tal manera que mappings se pueden reutilizar entre los contratos de miembros que heredan del mismo contrato de Olive Oil Trust.

Por ejemplo, Bottling Company 2 -siendo una planta embotelladora- usaría el mismo mapping que Bottling Company ya que ambas heredarían de BottlingPlantUpgradeable.sol.

Los ABIs están en src/generated/abis y también se generan cuando se ejecuta npm run hardhat:share.

La primera fuente de datos de los contratos del mismo tipo (por ejemplo, tokens dependientes) contiene una clave del nombre del módulo del que hereda en lugar del nombre de ese contrato:

subgraph/templates/subgraph.template.yaml
# ----------------------------------
#          DEPENDENT TOKENS
# ----------------------------------
- name: {{BottlingCompanyOliveOilBottleModule}}DataSource
  kind: ethereum/contract
  network: {{network}}
  source:
    address: '{{BottlingCompanyOliveOilBottleAddress}}'
    abi: {{BottlingCompanyOliveOilBottleModule}}
    startBlock: {{BottlingCompanyOliveOilBottleStartBlock}}
  mapping:
    kind: ethereum/events
    apiVersion: 0.0.6
    language: wasm/assemblyscript
    file: ./src/mappings/{{BottlingCompanyOliveOilBottleModule}}.ts
    entities:
      - Token
      - Account
      - Contract
      - TokenOperator
      - Transaction
      - TokenBalance
      - TokenTransfer
      - OwnershipTransferred
      - TokenTypeInfo
      - TokenTypeInstructionsSet
      - Member
    abis:
      - name: {{BottlingCompanyOliveOilBottleModule}}
        file: ./src/generated/abis/{{BottlingCompanyOliveOilBottleModule}}.json
    eventHandlers:
      - event: TokenTransferred(indexed address,indexed address,indexed address,bytes32,bytes32,uint256)
        handler: handleTokenTransferred
      - event: BatchTransferred(indexed address,indexed address,indexed address,bytes32[],bytes32[],uint256[])
        handler: handleBatchTransferred
      - event: TokenTypeInstructionsSet(indexed address,bytes32,address[],bytes32[],uint256[])
        handler: handleTokenTypeInstructionsSet
      - event: TokenTypesInstructionsSet(indexed address,bytes32[],address[][],bytes32[][],uint256[][])
        handler: handleTokenTypesInstructionsSet
      - event: ApprovalForAll(indexed address,indexed address,bool)
        handler: handleApprovalForAll
      - event: OwnershipTransferred(indexed address,indexed address)
        handler: handleOwnershipTransferred
- name: OliveOilMillCompanyOliveOilDataSource
  kind: ethereum/contract
  network: {{network}}
  source:
    address: '{{OliveOilMillCompanyOliveOilAddress}}'
    abi: {{OliveOilMillCompanyOliveOilModule}}
    startBlock: {{OliveOilMillCompanyOliveOilStartBlock}}
  mapping:
    kind: ethereum/events
    apiVersion: 0.0.6
    language: wasm/assemblyscript
    file: ./src/mappings/{{OliveOilMillCompanyOliveOilModule}}.ts
    entities:
      - Token
      - Account
      - Contract
      - TokenOperator
      - Transaction
      - TokenBalance
      - TokenTransfer
      - OwnershipTransferred
      - TokenTypeInfo
      - TokenTypeInstructionsSet
      - Member
    abis:
      - name: {{OliveOilMillCompanyOliveOilModule}}
        file: ./src/generated/abis/{{OliveOilMillCompanyOliveOilModule}}.json
    eventHandlers:
      - event: TokenTransferred(indexed address,indexed address,indexed address,bytes32,bytes32,uint256)
        handler: handleTokenTransferred
      - event: BatchTransferred(indexed address,indexed address,indexed address,bytes32[],bytes32[],uint256[])
        handler: handleBatchTransferred
      - event: TokenTypeInstructionsSet(indexed address,bytes32,address[],bytes32[],uint256[])
        handler: handleTokenTypeInstructionsSet
      - event: TokenTypesInstructionsSet(indexed address,bytes32[],address[][],bytes32[][],uint256[][])
        handler: handleTokenTypesInstructionsSet
      - event: ApprovalForAll(indexed address,indexed address,bool)
        handler: handleApprovalForAll
      - event: OwnershipTransferred(indexed address,indexed address)
        handler: handleOwnershipTransferred

Esto es para que existan tipos generados con la nomenclatura de los módulos de Olive Oil Trust que se pueden importar en los archivos AssemblyScript de mapping.

El esquema GraphQL

Para llegar a un buen concepto sobre cómo diseñar el esquema GraphQL, me he centrado en cómo se definen las entidades en los subgrafos de OpenZeppelin.

La idea es construir un subgrafo denso, vinculando tantos tipos de datos como tenga sentido para hacer consultas complejas tan fácil y eficientemente como sea posible.

He seguido algunas pautas de este artículo del blog de OpenZeppelin para diseñar el esquema.

Todo comienza con cómo se diseñan los contratos para que todo pueda ser indexado únicamente usando eventos, pero también hay algunos otros aspectos que se resumen en las siguientes secciones.

Creación de entidades para conceptos de alto nivel

En Olive Oil Trust existen conceptos de alto nivel como tokens, escrows, certificados, cuentas, saldos, etc.

Por ejemplo, las entidades Account son objetos que se utilizan para recopilar datos sobre cualquier dirección que aparece en un evento (incluida la dirección del contrato que emitió el evento).

Podemos ver el tipo Account abajo:

subgraph/schema.graphql
type Account @entity {
  id: Bytes!
  asCertificateContract: CertificateContract
  asEscrowContract: EscrowContract
  asMemberContract: MemberContract
  asTokenContract: TokenContract
  escrowBalance: Balance
  events: [IEvent!] @derivedFrom(field: "emitter")
  ownerOfMember: [MemberContract!] @derivedFrom(field: "owner")
  ownerOfCertificateContract: [CertificateContract!]! @derivedFrom(field: "owner")
  ownerOfEscrowContract: [EscrowContract!]! @derivedFrom(field: "owner")
  ownerOfTokenContract: [TokenContract!]! @derivedFrom(field: "owner")
  ownershipTransferred: [OwnershipTransferred!] @derivedFrom(field: "owner")
  tokenBalances: [Balance!] @derivedFrom(field: "tokenAccount")
  tokenOperatorOwner: [TokenOperator!] @derivedFrom(field: "owner")
  tokenOperatorOperator: [TokenOperator!] @derivedFrom(field: "operator")
  tokenTransferFromEvent: [TokenTransfer!] @derivedFrom(field: "from")
  tokenTransferToEvent: [TokenTransfer!] @derivedFrom(field: "to")
  tokenTransferOperatorEvent: [TokenTransfer!] @derivedFrom(field: "operator")
}

Creación de entidades para conceptos de bajo nivel

En Olive Oil Trust, los conceptos de bajo nivel se refieren a los eventos que emiten los contratos de Olive Oil Trust.

Por ejemplo, el contrato de certificado emite un evento de tipo TokenCertified cuando un certificador certifica un tipo de token y entidad TokenCertification dará la estructura del objeto que contendrá los datos de la emisión del evento:

subgraph/schema.graphql
type TokenCertification implements IEvent @entity(immutable: true) {
  id: String!
  certificate: Certificate!
  certificateContract: CertificateContract!
  emitter: Account!
  timestamp: BigInt!
  tokenContract: TokenContract!
  transaction: Transaction!
}

Todas las entidades para conceptos de bajo nivel son inmutables e implementan la interfaz IEvent ya que todos comparten algunos campos.

Proporcionando enlaces cruzados entre entidades

Como mencionamos anteriormente, los subgrafos densos ayudan a manejar consultas complejas ya que hay muchas relaciones entre entidades.

Digamos que iniciamos sesión como certificador en una aplicación de front end que consume datos de este subgrafo y querríamos que la aplicación reciba en una sola consulta el nombre del miembro que inició sesión, su función, todos los certificados que ha emitido junto con los tipos de token que estos certificados certifican y todos los tipos de token que existen en Olive Oil Trust.

Un ejemplo de esa consulta podría ser:

query CertificatesByMember($id: String!) {
  memberContract(id: $id) {
    id
    name
    role
    asAccount {
      ownerOfCertificateContract {
        id
        certificates {
          id
          tokenTypes {
            tokenType {
              id
            }
          }
        }
      }
    }
  }
  tokenTypes {
    id
  }
}

Un certificado puede certificar varios tipos de tokens y un tipo de token puede estar certificado por varios certificados.

Podemos ver en la consulta anterior que el campo resaltado certificates, que representa un array de entidades Certificate, contiene el campo tokenTypes.

Este campo representa un array de entidades TokenTypeCertificateMapping que es una relación de muchos a muchos.

Se recomienda utilizar tablas de mapping para almacenar datos de relaciones de muchos a muchos de manera eficaz

El tipo TokenTypeCertificateMapping se puede ver a continuación:

subgraph/schema.graphql
type TokenTypeCertificateMapping @entity(immutable: true) {
  id: String!
  tokenType: TokenType!
  certificate: Certificate!
}

Escribiendo mappings

Los mappings son funciones que asignan datos de Ethereum a objetos definidos como entidades.

Como vimos anteriormente, las entidades pueden verse como objetos que contienen datos, estos datos se mapean desde la cadena de bloques a través de funciones de mapeo.

Todas los mappings en el subgrafo de Olive Oil Trust se pueden encontrar en subgraph/src/mappings.

npm run subgraph:codegen genera el manifiesto de subgrafo y los tipos que se necesitan en las funciones de mapeo.

Digamos que Bottling Company acuña un nuevo lote de tokens de botellas de aceite de oliva de cierto tipo.

La función mint en hardhat-env/contracts/tokens/BottlingCompanyOliveOilBottle.sol emitirá un evento TokenTransferred que contiene los siguientes datos:

hardhat-env/contracts/OliveOilTrust/interfaces/IBaseToken.sol
/// @dev Equivalent to IERC1155Upgradeable TransferSingle event but with a bytes32 id and tokenTypeId
event TokenTransferred(
    address indexed operator,
    address indexed from,
    address indexed to,
    bytes32 tokenTypeId,
    bytes32 tokenId,
    uint256 tokenAmount
);

Para almacenar estos datos en el Graph Node y dado que BottlingCompanyOliveOilBottle es un token dependiente, el subgrafo manejará este evento con la función de mapeo handleTokenTransferred en subgraph/src/mappings/DependentTokenUpgradeable.ts:

subgraph/templates/subgraph.template.yaml
- name: {{BottlingCompanyOliveOilBottleModule}}DataSource
  kind: ethereum/contract
  network: {{network}}
  source:
    address: '{{BottlingCompanyOliveOilBottleAddress}}'
    abi: {{BottlingCompanyOliveOilBottleModule}}
    startBlock: {{BottlingCompanyOliveOilBottleStartBlock}}
  mapping:
    kind: ethereum/events
    apiVersion: 0.0.6
    language: wasm/assemblyscript
    file: ./src/mappings/{{BottlingCompanyOliveOilBottleModule}}.ts
    entities:
      - Token
      - Account
      - Contract
      - TokenOperator
      - Transaction
      - TokenBalance
      - TokenTransfer
      - OwnershipTransferred
      - TokenTypeInfo
      - TokenTypeInstructionsSet
      - Member
    abis:
      - name: {{BottlingCompanyOliveOilBottleModule}}
        file: ./src/generated/abis/{{BottlingCompanyOliveOilBottleModule}}.json
    eventHandlers:
      - event: TokenTransferred(indexed address,indexed address,indexed address,bytes32,bytes32,uint256)
        handler: handleTokenTransferred
      - event: BatchTransferred(indexed address,indexed address,indexed address,bytes32[],bytes32[],uint256[])
        handler: handleBatchTransferred
      - event: TokenTypeInstructionsSet(indexed address,bytes32,address[],bytes32[],uint256[])
        handler: handleTokenTypeInstructionsSet
      - event: TokenTypesInstructionsSet(indexed address,bytes32[],address[][],bytes32[][],uint256[][])
        handler: handleTokenTypesInstructionsSet
      - event: ApprovalForAll(indexed address,indexed address,bool)
        handler: handleApprovalForAll
      - event: OwnershipTransferred(indexed address,indexed address)
        handler: handleOwnershipTransferred

La función handleTokenTransferred luego escribirá varias entidades con los datos procesados en el store del Graph Node:

subgraph/src/mappings/DependentTokenUpgradeable.ts
function handleTransferred(
  event: ethereum.Event,
  tokenId: Bytes,
  tokenTypeId: Bytes,
  operatorAddress: Address,
  fromAddress: Address,
  toAddress: Address,
  value: BigInt
): void {
  let operator = ensureAccount(operatorAddress);
  let from = ensureAccount(fromAddress);
  let to = ensureAccount(toAddress);
  let token = ensureToken(tokenTypeId, tokenId, event.address);
  registerTokenTransfer(event, token, operator.id, from.id, to.id, value);
  if (from.id == Address.zero()) {
    let tokenType = ensureTokenType(token.contract, tokenTypeId, event.block.timestamp);
    token.tokenType = tokenType.id;
    token.mintingDate = event.block.timestamp;
    token.save();
  }
}
 
export function handleTokenTransferred(event: TokenTransferred): void {
  handleTransferred(
    event,
    event.params.tokenId,
    event.params.tokenTypeId,
    event.params.operator,
    event.params.from,
    event.params.to,
    event.params.tokenAmount
  );
}
 
export function handleBatchTransferred(event: BatchTransferred): void {
  for (let i = 0; i < event.params.tokenIds.length; i++) {
    handleTransferred(
      event,
      event.params.tokenIds[i],
      event.params.tokenTypeIds[i],
      event.params.operator,
      event.params.from,
      event.params.to,
      event.params.tokenAmounts[i]
    );
  }
}

Como podemos ver, se crearán varias entidades nuevas en este proceso, incluida una entidad TokenTransfer que tiene una relación con la entidad Balance a través del campo balance.

Estas entidades contendrán datos emitidos por el evento y reflejarán los cambios en el saldo de la dirección de Bottling Company en BottlingCompanyOliveOilBottle.sol.

Metadatos

Para simular el flujo de trabajo de la larga cadena de valor del aceite de oliva en la aplicación de front-end (que veremos en la próxima publicación), agregamos metadatos figurados a los datos escritos en las entidades de Certificate y TokenType:

subgraph/src/utils/entities/TokenType.ts
export function ensureTokenType(contractId: Bytes, tokenTypeId: Bytes, timestamp: BigInt): TokenType {
  let id = getTokenTypeId(contractId, tokenTypeId);
  let tokenType = TokenType.load(id);
  if (tokenType === null) {
    let tokenTypeIdStr = tokenTypeId.toString();
    tokenType = new TokenType(id);
    tokenType.contract = contractId;
    tokenType.identifier = tokenTypeIdStr;
    tokenType.creationDate = timestamp;
    let metadata = getMetadata(tokenTypeIdStr);
    if (metadata) {
      tokenType.bottleQuality = metadata.bottleQuality;
      tokenType.bottleMaterial = metadata.bottleMaterial;
      if (metadata.bottleSize) {
        tokenType.bottleSize = BigInt.fromString(metadata.bottleSize!);
      }
      if (metadata.imageHeight) {
        tokenType.imageHeight = BigInt.fromString(metadata.imageHeight!);
      }
      tokenType.imagePath = metadata.imagePath;
      if (metadata.imageWidth) {
        tokenType.imageWidth = BigInt.fromString(metadata.imageWidth!);
      }
      tokenType.description = metadata.description;
      tokenType.oliveQuality = metadata.oliveQuality;
      if (metadata.oliveOilAcidity) {
        tokenType.oliveOilAcidity = BigDecimal.fromString(metadata.oliveOilAcidity!);
      }
      tokenType.oliveOilAroma = metadata.oliveOilAroma;
      tokenType.oliveOilBitterness = metadata.oliveOilBitterness;
      tokenType.oliveOilColour = metadata.oliveOilColour;
      tokenType.oliveOilFruitness = metadata.oliveOilFruitness;
      tokenType.oliveOilIntensity = metadata.oliveOilIntensity;
      tokenType.oliveOilItching = metadata.oliveOilItching;
      tokenType.oliveOrigin = metadata.oliveOrigin;
      tokenType.title = metadata.title;
    }
    tokenType.save();
  }
  return tokenType;
}

En este ejemplo, los metadatos son constantes locales, pero podrían almacenarse en IPFS.

@graphprotocol/graph-ts nos proporciona soporte para tratar con ellos. Una vez que tuviéramos el hash de IPFS, simplemente podríamos hacer ipfs.cat(hash) para obtener los datos JSON.

Desplegando el subgrafo

Una vez que el nodo de gráfico local está en funcionamiento, el proceso de creación y despliegue del subgrafo es bastante sencillo:

npm run subgraph:create && npm run subgraph:deploy

Ahora la interfaz de usuario podrá consumir datos del subgrafo una vez que se desplieguen los contratos y el subgrafo indexe los datos de los eventos a los que reacciona.


Artículos relacionados

Microservicios

Propuesta de una aplicación bancaria con arquitectura basada en microservicios

Aplicación bancaria de arquitectura basada en microservicios que incluye aplicaciones de back end, front end,...

Trazabilidad

Presentando Olive Oil Trust

Introducción a una serie de artículos acerca de Olive Oil Trust


¿Preparado para #buidl?

¿Está interesado en Web3 o en las sinergias entre la tecnología blockchain, la inteligencia artificial y el conocimiento cero?. Entonces, no dude en contactarme por e-mail o en mi perfil de LinkedIn. También me puede encontrar en GitHub.