Creating a Decentralised Crowd Funding Contract with Solidity - Part 2: Creating a subgraph

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

D_D Newsletter CTA

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 the CrowdFundingContract (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

image.png

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 be null.

//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 or npm run codegen to generate entity types from the schema for use in the mapping.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: type ID and is a primary key that cannot be null.
  • createdCampaigns : a derived field of Campaign entity.
  • fundingGiven: this is of type BigInt, which tracks the amount of funding a creator has received.
  • fundingWithdrawn: type BigInt, and it tracks the amount a creator has withdrawn

The Campaign Entity

The Campaignentity saves details for each fundraising campaign created. It has and tracks the following fields:

  • id primary key of the entity and not nullable
  • campaignCID: type String. We use it to save the IPFS content hash of the campaign details
  • details: 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 the Milestone 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: type ID. The current active milestone of a campaign.

  • dateCreated: type BigInt and non nullable.
  • campaignRunning type Boolean and cannot be null.
  • owner: type CampaignCreator. The one-side of the relationship.
  • amountSought: of type BigInt and can't be null. The campaign amount sought.
  • donors: type Donor. 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: type ID
  • milestoneCID: the hash of the milestone details stored on IPFS
  • details: the details of the milestone
  • campaign : type Campaign. The campaign the milestone is for.
  • milestonestatus: this is an ènumof typeMilestoneStatuswith values ofapproved,pending, andrejected`.
  • 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 : type ID. Primary non-nullable field
  • campaign: type Campaign. The `campaign the donor is donating to.
  • amount: type BigInt. The amount the person is donating.
  • donorAddress: type Bytes. The wallet address of the donor
  • date: type BigInt: 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 of ipfsOnEthereumContracts

  • 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
    • mapping:
      • entities:
        • CampaignCreator
        • Campaign
        • Milestone
        • Donors
      • eventHandlers:
        • event: newCrowdFundingCreated(indexed address,uint256,address,string)
        • handler: handlenewCrowdFundingCreated

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

D_D Newsletter CTA

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 and CampaignCreator are from the defined schema. Don't forget to npm 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 of details 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 and CampaignCreator by loading from the data store using the cloneAddress and owner address from the event.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 of Campaign and CampaignCreator.

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() and
event.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.

D_D Newsletter CTA

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