Presentando Olive Oil Trust: subgrafo
Esta publicación es parte de una serie de publicaciones sobre Olive Oil Trust:
- Presentando Olive Oil Trust
- Presentando Olive Oil Trust: contratos inteligentes
- Presentando Olive Oil Trust: subgrafo
- Presentando Olive Oil Trust: front end
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 ejecuta
npm 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:
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:
- 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 ejecutanpm 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:
# ----------------------------------
# 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:
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:
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:
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:
/// @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
:
- 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:
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
:
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 haceripfs.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.