Creating a Decentralised Crowd Funding Contract with Solidity - Part 2: Creating a subgraph
This is the second article of the "Creating a Decentralized Crowdfunding Contract with Solidity" series.
In this section, we'll make a subgraph for our smart contracts. We will use The Graph Protocol's tooling and build a subgraph from scratch to index data from the CrowdFundingContract
. You can find the first installment of the series here
Brief Intro to The Graph
The Graph is a decentralized protocol for indexing and querying data from Ethereum-based blockchains. It allows you to query data that is otherwise difficult to query directly. TheGraph is like the Google of the decentralized world. The Graph indexes smart contract data to IPFS and makes this data easily searchable with GraphQL. In this post, we will create a subgraph for the CrowdFundingContract
to index the events generated by the contract.
Before proceeding further, we will need to install the graph CLI on our development machine if we have not done that before. Open a terminal on your machine and type the command below to install the CLI:
yarn global add @graphprotocol/graph-cli
Using npm:
npm install -g @graphprotocol/graph-cli
Check if the installation succeeds by typing:
graph -v
Project Setup and Installation
The Graph operates a hosted service where we can deploy our subgraph. To get started, navigate to thegraph.com and sign in using your GitHub account.
Proceed to the "My Dashboard" tab and click the button "Add a subgraph." Fill in the required fields and click the “Create subgraph” button.
Navigate to where you want to place the subgraph on your computer and type the command
graph init --product hosted-service <GITHUB_USER>/<SUBGRAPH_NAME>
Replace <GITHUB_USER>
with your GitHub username and <SUBGRAPH_NAME>
with the name of the subgraph created. For example, if your GitHub username is bluecoding
and the name of the subgraph is fundingsubgraph
, then your graph init command will be:
graph init --product hosted-service bluecoding/fundingsubgraph
Next, select Ethereum as the protocol on which we will build the subgraph and give the subgraph a name.
Select the directory where the subgraph is created.
On the Ethereum network, select the network that the CrowdFundingContract
is deployed. I deployed mine to the Goerli, so I picked goerli
.
The next step is to enter the contract address. This will be the contract address of the factory contract used to create clones of the 'CrowdFundingContract' in our case.
Note: In the first series, we had two contracts: the
CrowdSourcingFactory
(factory contract) and theCrowdFundingContract
(implementation contract). The factory clones the implementation contract to produce a child crowdfunding contract.
The CLI tool will try to retrieve the contract's ABI from Etherscan. If the fetch fails, you should manually copy the ABI and point to the directory where it is stored.
Next, we enter the contract's name, CrowdSourcingFactory
(factory contract).
Note: you can’t have spaces or dashes in the contract name, or the setup will fail.
The CLI installs all dependencies, and we are ready to build the subgraph.
Subgraph Folder Structure
The schema.graphql
File
We will define a schema within this file that will represent the shape of the data we want to save from our events. We want to save the data of anyone who has donated to a campaign as well as the data of those who created a campaign in the CrowdFundingContract
.
Entity and Relationship Type Definition
The data model or shape created is called an Entity
. An Entity
may have relationships with one another. We have one-to-one entity relationships, one-to-many entity relationships, and many-to-many entity relationships.
A one-to-one entity relationship exists where each row, for example, entity A has a relationship with precisely one other row in entity B. For example, a person can only have one birth certificate.
//sample birth certificate entity
type BirthCertificate @entity {
id: ID!
SSN: String!
DateOfBirth: BigInt!
}
Note: A field defined with an
!
at the end means the field can not benull
.
//sample person entity
type Person @entity {
id: ID!
name: String!
birthCertificate: BirthCertificate
}
We can see above that a Person
entity has a one-to-one relationship with a BirthCertificate
entity. The birthCertificate
field of the Person
entity represents a BirthCertificate
entity.
One-to-many relationships exist between entities where one record of Entity A has a reference with many records of entity B. A person can create many contracts.
//contract
type Contract @entity {
id: ID
address: ID!
creator: Person!
}
//person
type Person @entity {
id: ID
name: String!
createdContracts: [Contract!] @derivedFrom(field: "creator")
}
In this example, a Person
entity can create multiple contracts. The many-side of the relationship is the Person
entity. When saving the relationship, we only keep one-side of the relationship, which is that of the Contract
entity. A reverse lookup query will derive the many-side. The general rule is to store the entity on one-side and derive the many-side.
Crowdfunding Contract Schema Definition
Delete the content of the schema.graphql
file and replace it with the following content
enum MilestoneStatus {
Approved
Declined
Pending
}
type CampaignCreator @entity {
id: ID!
createdCampaigns: [Campaign!] @derivedFrom(field: "owner")
fundingGiven: BigInt
fundingWithdrawn: BigInt
}
type Campaign @entity {
id: ID!
campaignCID: String!
details: String
milestone: [Milestone!] @derivedFrom(field: "campaign")
currentMilestone: ID
dateCreated: BigInt!
campaignRunning: Boolean!
owner: CampaignCreator!
amountSought: BigInt!
donors: [Donors!] @derivedFrom(field: "campaign")
}
type Milestone @entity {
id: ID!
milestoneCID: String!
details: String
campaign: Campaign!
milestonestatus: MilestoneStatus!
periodToVote: BigInt!
dateCreated: BigInt!
}
type Donors @entity {
id: ID!
campaign: Campaign!
amount: BigInt!
donorAddress: Bytes!
date: BigInt!
}
We define a schema with the data type that will be indexed and queried later. So,
we have defined four entity types CampaignCreator
, Campaign
, Milestone
, and Donors
.
Note: After creating the schema, we run on the project's terminal
yarn run codegen
ornpm run codegen
to generate entity types from the schema for use in themapping.ts
file.
The CampaignCreator
Entity
The CampaignCreator
entity saves details about the person that creates the crowdfunding campaign. Remember that using the factory contract, anyone can create a CrowdFunding
contract. It has fields:
id
: typeID
and is a primary key that cannot benull
.createdCampaigns
: a derived field ofCampaign
entity.fundingGiven
: this is of typeBigInt
, which tracks the amount of funding a creator has received.fundingWithdrawn
: typeBigInt
, and it tracks the amount a creator has withdrawn
The Campaign
Entity
The Campaign
entity saves details for each fundraising campaign created. It has and tracks the following fields:
id
primary key of the entity and not nullablecampaignCID
: type String. We use it to save the IPFS content hash of the campaign detailsdetails
: type String and saves the details about what the crowdfunding campaign is for. Since keeping data on-chain is very expensive, the details of a campaign are held off-chain. Only the IPFS hash of the campaign details is saved on-chain.milestone
: this is a derived field from theMilestone
entity. It keeps track of all the milestones created for the campaign. Remember, a milestone is created and voted on before a creator can withdraw funds.currentMilestone
: typeID
. The current active milestone of a campaign.dateCreated
: typeBigInt
and non nullable.campaignRunning
typeBoolean
and cannot be null.owner
: typeCampaignCreator
. The one-side of the relationship.amountSought
: of typeBigInt
and can't benull
. The campaign amount sought.donors
: typeDonor
. A derived field, resolved by a reverse lookup.
The Milestone
Entity
This entity tracks the Milestone
created for a campaign. A campaign creator can withdraw funds three times according to the logic defined in the contract. The fields of the Milestone
entity are:
id
: typeID
milestoneCID
: the hash of the milestone details stored on IPFSdetails
: the details of the milestonecampaign
: typeCampaign
. The campaign the milestone is for.milestonestatus
: this is an ènumof type
MilestoneStatuswith values of
approved,
pending, and
rejected`.periodToVote
: type BigInt. It saves value for the period to vote.dateCreated
: type BigInt. The creation date of the milestone.
The Donors
Entity
The Donors
entity saved donors to a campaign. The fields are enumerated below:
id
: typeID
. Primary non-nullable fieldcampaign
: typeCampaign
. The `campaign the donor is donating to.amount
: type BigInt. The amount the person is donating.donorAddress
: typeBytes
. The wallet address of the donordate
: typeBigInt
: the date of the donation.
The subgraph.yaml
File
This file is a configuration file. It contains information about the contract we are indexing. Open the subgraph.yaml
file and notice that some details are already filled in for us. We are going to make some edits to this file. The CLI used the provided ABI to auto-fill the file.
//subgraph.yaml file content
specVersion: 0.0.4
schema:
file: ./schema.graphql
features:
- ipfsOnEthereumContracts
dataSources:
- kind: ethereum/contract
name: CrowdSourcingFactory
network: goerli
source:
address: "0xeB1F85B8bc1694Dc74789A744078D358cb88117f"
abi: CrowdSourcingFactory
startBlock: 7763910
mapping:
kind: ethereum/events
apiVersion: 0.0.6
language: wasm/assemblyscript
entities:
- CampaignCreator
- Campaign
- Milestone
- Donors
abis:
- name: CrowdSourcingFactory
file: ./abis/CrowdSourcingFactory.json
eventHandlers:
- event: newCrowdFundingCreated(indexed address,uint256,address,string)
handler: handlenewCrowdFundingCreated
file: ./src/mapping.ts
templates:
- kind: ethereum/contract
name: CrowdFundingContract
network: goerli
source:
abi: CrowdFundingContract
mapping:
kind: ethereum/events
apiVersion: 0.0.6
language: wasm/assemblyscript
file: ./src/mapping.ts
entities:
- CampaignCreator
- Campaign
- Milestone
- Donors
abis:
- name: CrowdFundingContract
file: ./abis/CrowdFundingContract.json
- name: CrowdSourcingFactory
file: ./abis/CrowdSourcingFactory.json
eventHandlers:
- event: fundsDonated(indexed address,uint256,uint256)
handler: handleFundsDonated
- event: fundsWithdrawn(indexed address,uint256,uint256)
handler: handleFundsWithdrawn
- event: milestoneCreated(indexed address,uint256,uint256,string)
handler: handleMilestoneCreated
- event: milestoneRejected(uint256,uint256)
handler: handleMilestoneRejected
Notable Portions of the subgraph.yaml
File
The
ipfsOnEthereumContracts
of the features key allows the subgraph to pull data from IPFS. If your subgraph makes use of data stored on IPFS, you should include the features with a value ofipfsOnEthereumContracts
dataSources
:- kind : ethereum/contract
- network: goerli (blockchain network where the contract is deployed)
- source:
- address: "0xeB1F85B8bc1694Dc74789A744078D358cb88117f" ( address contract
was deployed - abi: CrowdSourcingFactory ( the abi of the contract)
- startBlock: 7763910 ( the block to start indexing from. This is usually from the block the contract was deployed
- address: "0xeB1F85B8bc1694Dc74789A744078D358cb88117f" ( address contract
- mapping:
- entities:
- CampaignCreator
- Campaign
- Milestone
- Donors
- eventHandlers:
- event: newCrowdFundingCreated(indexed address,uint256,address,string)
- handler: handlenewCrowdFundingCreated
- entities:
The contract factory creates new contracts using clones. The newCrowdFundingCreated
event is emitted when a new CrowdFundingContract
is created. We have defined a handler called handlenewCrowdFundingCreated
that gets fired anytime the contract emits an event. The newCrowdFundingCreated
event takes four parameters as input.
Note: Remove all spaces when writing your event.
Write like so
newCrowdFundingCreated(indexed address,uint256,address,string)
don't include spaces within your parameters like so below:
newCrowdFundingCreated(indexed address, uint256, address, string)
The compiler will run into errors when compiling the subgraph.
- templates
Only at the point of creation can the contract address of a contract created from a factory contract be known. As a result, we cannot use datasources
in the subgraph.yaml
file for the CrowdFundingContract
. We use templates
to define the entities
, events
, and eventHandlers
. The templates
fields are similar to the datasources
. Most of the events we are interested in are in the templates
portion of the subgraph.yaml
file.
eventHandlers:
- event: fundsDonated(indexed address,uint256,uint256)
handler: handleFundsDonated
- event: fundsWithdrawn(indexed address,uint256,uint256)
handler: handleFundsWithdrawn
- event: milestoneCreated(indexed address,uint256,uint256,string)
handler: handleMilestoneCreated
- event: milestoneRejected(uint256,uint256)
handler: handleMilestoneRejected
The Mappings File
We use AssemblyScript to write the handlers for event processing. AssemblyScript is a more stringent version of Typescript. When an event is fired or emitted by the indexed smart contract, the handlers handle the logic determining how data should be retrieved and stored. The data is translated and stored following the defined schema.
handlenewCrowdFundingCreated
export function handlenewCrowdFundingCreated(event: newCrowdFundingCreated):void{
let newCampaign = Campaign.load(event.params.cloneAddress.toHexString());
let campaignCreator = CampaignCreator.load(event.params.owner.toHexString());
if ( newCampaign === null ){
newCampaign = new Campaign(event.params.cloneAddress.toHexString());
let metadata = ipfs.cat(event.params.fundingCID + "/data.json");
newCampaign.campaignCID = event.params.fundingCID;
if ( metadata ){
const value = json.fromBytes(metadata).toObject();
const details = value.get("details");
if ( details ){
newCampaign.details = details.toString();
}
}
newCampaign.owner = event.params.owner.toHexString();
newCampaign.dateCreated = event.block.timestamp;
newCampaign.amountSought = event.params.amount;
newCampaign.campaignRunning = true;
}
if (campaignCreator === null){
campaignCreator = new CampaignCreator(event.params.owner.toHexString());
campaignCreator.fundingGiven = integer.ZERO;
campaignCreator.fundingWithdrawn = integer.ZERO;
}
CrowdFundingContract.create(event.params.cloneAddress);
newCampaign.save();
campaignCreator.save();
}
This mapping function receives an event called newCrowdFundingCreated
as a parameter. The newCrowdFundingCreated
event has the following parameters in the contract:
event newCrowdFundingCreated(
address indexed owner,
uint256 amount,
address cloneAddress,
string fundingCID
);
The newCrowdFundingCreated
event is fired when a new CrowdFundingContract
is created. Inside the function, we first try to load a Campaign
from the graph datastore using the address of a newly created CrowdFundingContract
, which we access from the event.params
object of the emitted event. toHexString()
is appended to convert it to a string.
let newCampaign = Campaign.load(event.params.cloneAddress.toHexString());
Next, we also do the same for the CampaignCreator
.
let campaignCreator = CampaignCreator.load(event.params.owner.toHexString());
Note: The
Campaign
andCampaignCreator
are from the defined schema. Don't forget tonpm run codegen
on the terminal after making any change to the schema.
The CampaignCreator
is the owner of the fundraising campaign.
If the value of newCampaign
is null, which means a contract with that address has not been created before, we then create a new Campaign using the contract address of the deployed CrowdFundingContract
.
newCampaign = new Campaign(event.params.cloneAddress.toHexString());
We then retrieve the campaign metadata on IPFS. Remember that we are saving the IPFS hash in the event parameters as the fundingCID
.
import { ipfs, json } from "@graphprotocol/graph-ts";
let metadata = ipfs.cat(event.params.fundingCID + "/data.json");
In our subgraph manifest, we added ipfsOnEthereumContracts
so that we will be able to query data on IPFS.
if ( metadata ){
const value = json.fromBytes(metadata).toObject();
const details = value.get("details");
if ( details ){
newCampaign.details = details.toString();
}
}
We deconstruct the data inside if the metadata
is retrieved from IPFS. We retrieve the details
key of the object and save it as a string to newCampaign.details
.
Note: The json file uploaded to IPFS is called
data.json
, and inside the JSON object, we have a key ofdetails
inside the file.
We also save other fields in the Campaign
entities:
newCampaign.owner = event.params.owner.toHexString();
newCampaign.dateCreated = event.block.timestamp;
newCampaign.amountSought = event.params.amount;
newCampaign.campaignRunning = true;
The amountSought
is got from event.params.amount
, dateCreated
is from the event.block.timestamp
(current time of the block) and campaignRunning
is set to true
.
If the campaign owner record is not already saved, we create a new one like so.
if (campaignCreator === null){
campaignCreator = new CampaignCreator(event.params.owner.toHexString());
campaignCreator.fundingGiven = integer.ZERO;
campaignCreator.fundingWithdrawn = integer.ZERO;
}
fundingGiven
and fundingWithdrawn
is set to 0.
The code below creates the template used for this particular CrowdFundingContract
instance.
CrowdFundingContract.create(event.params.cloneAddress);
Finally, we save the newCampaign
and campaignCreator
instances.
newCampaign.save();
campaignCreator.save();
We call the above sequence for every new CrowdfundingContract
created by the factory contract.
Key take away from this handler is:
Check if data is saved for a
Campaign
andCampaignCreator
by loading from the data store using thecloneAddress
andowner
address from theevent.params
object.Get metadata from IPFS and save it to the
Campaign
.- Create a new template for the contract instance.
- save all data back to the store by calling the
save
method of the created instances ofCampaign
andCampaignCreator
.
The handleFundsDonated
Event
The contract emits this event when an address donates funds to a campaign. The function receives as an input a fundsDonated
event which has the following structure:
event fundsDonated(address indexed donor, uint256 amount, uint256 date);
export function handleFundsDonated(event: fundsDonated ):void {
//get the campaign we are donating to
const campaign = Campaign.load(event.transaction.to!.toHexString());
if ( campaign ){
//we save the donation in the Donor entity
const newDonor = new Donors(event.transaction.hash.toHexString().concat("-").concat(event.transaction.from.toHexString()))
newDonor.amount = event.params.amount;
newDonor.campaign = campaign.id;
newDonor.donorAddress = event.params.donor;
newDonor.date = event.params.date;
newDonor.save();
//get the campaignCreator and add the donation to the campainCreator
const campaignCreator = CampaignCreator.load(campaign.owner);
if ( campaignCreator && campaignCreator.fundingGiven ){
campaignCreator.fundingGiven = campaignCreator.fundingGiven!.plus(event.params.amount);
campaignCreator.save();
}
}
}
This code below gets the campaign we are donating to:
const campaign = Campaign.load(event.transaction.to!.toHexString());
Remember that we saved a new Campaign
entity using its deployed address. We can load a saved campaign using the to
field of the event.transaction
. The !
mark after to
tells the compiler that, hey, the to
field will not be nullable.
After retrieving the Campaign
we are donating to, we need to save a new Donors
entity using a combination of the event.transaction.hash.toHexString()
andevent.transaction.from.toHexString()
to form a unique id
const newDonor = new Donors(event.transaction.hash.toHexString().concat("-").concat(event.transaction.from.toHexString()))
The donor's field parameter is saved, and we retrieve the `CampaignCreator
saved entity using the owner
field value of the Campaign
entity.
const campaignCreator = CampaignCreator.load(campaign.owner);
We increment the fundingGiven
field of the campaignCreator
with the amount donated by the donor.
handleMilestoneCreated
This function handles the creation of a new milestone. Contract creators must create milestones that donors approve of the campaign before they can withdraw donated funds. The milestoneCreated
event has the following signature:
event milestoneCreated(address indexed owner,uint256 datecreated,uint256 period,
string milestoneCID
);
export function handleMilestoneCreated(event: milestoneCreated):void {
const newMilestone = new Milestone(event.transaction.hash.toHexString().concat("-").concat(event.transaction.from.toHexString()))
const campaign = Campaign.load(event.transaction.to!.toHexString());
if ( campaign ){
newMilestone.campaign = campaign.id;
newMilestone.milestonestatus = "Pending";
newMilestone.milestoneCID = event.params.milestoneCID;
let metadata = ipfs.cat(event.params.milestoneCID + "/data.json");
if ( metadata ){
const value = json.fromBytes(metadata).toObject();
const details = value.get("details");
if ( details ){
newMilestone.details = details.toString();
}
}
newMilestone.periodToVote = event.params.period;
newMilestone.dateCreated = event.params.datecreated;
newMilestone.save();
//update the campaign with the current milestone
campaign.currentMilestone = newMilestone.id;
campaign.save()
}
}
The function creates a new Milestone
entity using a combination of the transaction hash and the address of the transaction sender. We load the Campaign
entity that the milestone is for using the contract address in the transaction. We retrieve the contract address from event.transaction.to
.
We load milestone details data from IPFS and fill all fields of the newly created milestone. We then update the currentMilestone
of the campaign with the value of the newly created milestone.
The handleFundsWithdrawn
Function
This function handles the event when a campaign owner withdraws funds from the contract. It takes as an input an event titled fundsWithdrawn
, which has the following signature:
event fundsWithdrawn(address indexed owner, uint256 amount, uint256 date);
export function handleFundsWithdrawn(event: fundsWithdrawn):void {
let campaignCreator = CampaignCreator.load(event.params.owner.toHexString());
if ( campaignCreator && campaignCreator.fundingWithdrawn){
//increment the amount already withdrawan
const totalWithdrawal = campaignCreator.fundingWithdrawn!.plus((event.params.amount))
campaignCreator.fundingWithdrawn = totalWithdrawal;
campaignCreator.save();
}
//set the current milestone to Approved
//load the milestone and set it to Approved
let campaign = Campaign.load((event.transaction.to!.toHexString()))
if ( campaign && campaign.currentMilestone ){
const currentMilestoneId = campaign.currentMilestone;
//load the milestone
const milestone = Milestone.load(currentMilestoneId!);
if ( milestone && milestone.milestonestatus === "Pending" ){
//check if the milestonestatus is pending
//update it to Approved
milestone.milestonestatus = "Approved";
milestone.save();
}
}
}
The function loads the campaign creator data from the datastore using the owner
field of the event.params
object. It increments the totalWithdrawal
field of the campaignCreator
and saves it. Next, it loads the Campaign
, that the owner is withdrawing funds from using event.transaction.to
.
let campaign = Campaign.load((event.transaction.to!.toHexString()))
It loads a Milestone
entity using the campaign.currentMilestone
as an id
.
const milestone = Milestone.load(currentMilestoneId!);
The loaded milestonestatus
is set to "Approved"
if it is pending.
Note: A contract owner can only make three withdrawals from the crowdfunding contract balance. For this to happen, they have to create milestones.
milestone.milestonestatus = "Approved";
handleMilestoneRejected
This handler is fired when a created milestone fails.
export function handleMilestoneRejected(event: milestoneRejected):void{
const campaign = Campaign.load(event.transaction.to!.toHexString())
if ( campaign && campaign.currentMilestone){
const currentMilestoneId = campaign.currentMilestone
//load the milestone
const milestone = Milestone.load(currentMilestoneId!);
if ( milestone && milestone.milestonestatus === "Pending" ){
//check if the milestonestatus is pending
//update it to Approved
milestone.milestonestatus = "Rejected";
milestone.save();
}
}
}
It sets the status of the current milestone of a campaign to "Rejected"
.
Compiling and Deploying the Subgraph
Currently, our subgraph is fully defined and ready for deployment to the hosted service.
Select the "Show commands" button under Deploy in the subgraph you created on The Graph's Hosted Service.
Run the command below to authenticate you as the subgraph owner to the hosted service:
graph auth --product hosted-service <ACCESS_TOKEN>
Note: replace
<ACCESS_TOKEN>
with your access token. You find it on the subgraph page.
Next, run the command to deploy your subgraph to the hosted service.
graph deploy --product hosted-service <GITHUB_USER>/<SUBGRAPH NAME>
Replace with your GitHub username and with the name of the subgraph.
After deploying, your subgraph will get indexed, and you can query the subgraph for data after it has finished indexing.
Conclusion
In this series, we have created a subgraph from the smart contract deployed in the first series. Next, we will create a frontend to consume data in the final part of this tutorial exercise.
You can find the code for this post on Github. The subgraph for the tutorial is here