The Subgraph Mappings
Lesson 9
We're on to the last of the three aspects of subgraph definition - mapping.ts. It's also the most complicated of the three - so saved the best for last.
- subgraph.yaml - the subgraph manifest. It defines the data sources of interest and how they should be processed. NEAR is a new kind of data source.
- schema.graphql - a schema file that defines what data is stored for your subgraph, and how to query it via GraphQL.
- AssemblyScript mapping.ts - AS code that translates from the log data to the entities defined in our schema. For NEAR, there are NEAR-specific data types and JSON parsing functionality.
mapping.ts
If you remember, a couple of lessons ago we tweaked the manifest (subgraph.yaml) and defined a receiptHandler called handleReceipt. It looked like this:
receiptHandlers:
- handler: handleReceipt
Don't forget that we could also have defined a blockHandler if we were interested in block related data. As mentioned before, currently blockHandler and receiptHandler are the only two handlers available for NEAR.
For each handler that is defined in `subgraph.yaml` under `mapping` we will create an exported function of the same name. Each receipt handler must accept a single parameter called receipt with a type of `near.ReceiptWithOutcome`.
This receipt handler is what we call a "mapping" and it goes in `src/mapping.ts`. It will transform the NEAR logging data into entities defined in your schema.
Implementing the Receipt Handler
Now we have to implement the `handleReceipt` handler to be able to process the log data from an outcome in a receipt and turn it into an piece of data that can be persisted in the Hosted Service's Postgres database.
In mapping.ts, we first import some code and prototype the function:
import {near, log, json, JSONValueKind} from '@graphprotocol/graph-ts';
import {Account, Log} from '../generated/schema';
export function handleReceipt(receipt: near.ReceiptWithOutcome): void {
// Implement the function here
}
`Account` and `Log` are the imported objects (entities) we've just defined, and `receipt` is referencing the definition of a `receiptWithOutcome` made available in The Graph NEAR implementation. Specifically:
class ReceiptWithOutcome {
outcome: ExecutionOutcome,
receipt: ActionReceipt,
block: Block
}
and `ExecutionOutcome` is where we get at the logs emitted.
class ExecutionOutcome {
gasBurnt: u64,
blockHash: Bytes,
id: Bytes,
logs: Array<string>,
receiptIds: Array<Bytes>,
tokensBurnt: BigInt,
executorId: string,
}
Recall that NEAR has two types of receipts: action receipts or data receipts. Data Receipts are receipts that contain some data for some ActionReceipt with the same receiver_id. Data receipts are not currently handled by The Graph.
ActionReceipts are the result of a transaction execution or another ActionReceipt processing. They'll show up for one of the seven actions that might occur on NEAR:
- FunctionCall
- TransferAction
- StakeAction
- AddKeyAction
- DeleteKeyAction
- CreateAccountAction
- DeleteAccountAction
Our implementation is only concerned with the ActionReceipts from FunctionCall actions, but you can easily extrapolate what is happening to listed for the other actions as needed.
Remember from the lesson on developing NEAR contracts to work with the Graph, contract function calls are where we'll typically add our log output to emit on completion of the FunctionCall. Because of that, those functionCalls are what we want The Graph to listen for.
First, we'll need to grab the actions from the receipt:
const actions = receipt.receipt.actions;
Then we'll loop through the actions and call a handleAction function to deal with each action in the receipt. The handleAction looks like this:
for (let i = 0; i < actions.length; i++) {
handleAction(
actions[i],
receipt.receipt,
receipt.block.header,
receipt.outcome,
);
}
So, now we need to define the handleAction function. First, we confirm we're working with a functionCall action, exiting if not.
Next, we will check to see if an 'Account' entity exists and create one if not.
Then, we'll get the name of the functionCall method and compare it to the method name we want to listen for in the contract. If that functionCall equal that method name ("putDID" below), then we'll start the mapping. If not, it will continue on and check for the next method.
Let's take a look at that code before going further:
function handleAction(
action: near.ActionValue,
receipt: near.ActionReceipt,
blockHeader: near.BlockHeader,
outcome: near.ExecutionOutcome
): void {
if (action.kind != near.ActionKind.FUNCTION_CALL) {
log.info("Early return: {}", ["Not a function call"]);
return;
}
let account: Account
if (account == null) {
let account = new Account(receipt.signerId);
const functionCall = action.toFunctionCall();
if (functionCall.methodName == "putDID") {
...
When the `putDID` function is called, The Graph processes its ActionReceipt and puts the logs in the ExecutionOutcome which we can then access an array of `outcome.logs`.
Now comes the mapping.
First we want to create a new Log and then check that the function actually emitted a log. If it did, we get the receiptID and set it to logs.id:
let logs = new Log(`${receiptId}`);
if(outcome.logs[0]!=null){
logs.id = receipt.signerId;
...
Knowing now that there is a log, we can use the json.fromString method provided to make a JSON object from the log string. Next, we confirm it is, in fact, an object and then parse it into object form.
let parsed = json.fromString(outcome.logs[0])
if(parsed.kind == JSONValueKind.OBJECT){
let entry = parsed.toObject()
At this point we have the top level JSON object in entry. Let's take another look at how the log string is structured:
logging.log(`{"EVENT_JSON":{
"standard":"nep171",
"version":"1.0.0",
"event":"putDID",
"data":{
"accountId":"${accountId}",
"did":"${did}",
"registered":${Context.blockTimestamp},
"owner":"${Context.predecessor}"
}}}`);
In addition to the top level object there are two more - EVENT_JSON and data, so we want to create objects out of them as well. Starting with EVENT_JSON:
//EVENT_JSON
let eventJSON = entry.entries[0].value.toObject();
At this point, we can start the mapping, looping through each of the eventJSON object's keys and assigning the corresponding value to the appropriate entity properties like so:
//standard, version, event (these stay the same for a NEP 171 emitted log)
for (let i = 0; i < eventJSON.entries.length; i++) {
let key = eventJSON.entries[i].key.toString();
switch (true) {
case key == 'standard':
logs.standard = eventJSON.entries[i].value.toString();
break;
case key == 'event':
logs.event = eventJSON.entries[i].value.toString();
break;
case key == 'version':
logs.version = eventJSON.entries[i].value.toString();
break;
}
}
See how the keys in the switch statement match the keys in the log emitted from the contract? Notice how the keys also correspond to the entity property names and types?
And we do the same thing for the data object:
//data
let data = eventJSON.entries[0].value.toObject();
for (let i = 0; i < data.entries.length; i++) {
let key = data.entries[i].key.toString();
switch (true) {
case key == 'accountId':
logs.accountId = data.entries[i].value.toString();
break;
case key == 'did':
logs.did = data.entries[i].value.toString();
break;
case key == 'registered':
logs.registered = data.entries[i].value.toBigInt();
break;
case key == 'owner':
logs.owner = data.entries[i].value.toString();
break;
}
}
And finally, we just need to save the log entity and push it onto the account entity log array. Here we're also closing off the original if statement confirming we were working with an object and providing the else in the event this wasn't the functionCall we were wanting to listen for.
}
logs.save()
}
accounts.log.push(logs.id);
} else {
log.info("Not processed - FunctionCall is: {}", [functionCall.methodName]);
}
Action Steps
Above we built the part of the receiptHandler handling and mapping the "putDID" contract function. Over to you to do the same, but for the "init" function. The answer is already in the template, but follow the steps above and give it a try before going straight there. Specifically, you'll want to:
- implement an if statement to find the appropriate function call
- if it is there, set the receiptId
- set the signerId
- create a new Log
- parse the objects and loop through the keys to assign the values to the entity properties (don't forget correct types)
- save the log and push in the the accounts log array
Here's the init log for reference as you do the mappings:
logging.log(`{"EVENT_JSON":{
"standard":"nep171",
"version":"1.0.0",
"event":"init",
"data":{
"adminId":"${adminId}",
"adminSet":${Context.blockTimestamp},
"accountId":"${adminId}"
}}}`)
Next Steps
Maybe you'd rather have us build your NEAR subgraph for you?
Not a problem and it can be pretty quick and inexpensive depending on the complexity of your contract. Get in touch using one of the methods below.