About the tutorial
In this tutorial, we shall be building an RSVP decentralized application (dApp) on CELO to organize the process of sending, receiving, and tracking RSVPs for events. One important use case for this is for large-scale events such as conferences and concerts where attendees are required to register and RSVP ahead so that organizers can manage their registrations and track their attendance.
Prerequisites
Before you begin this tutorial, kindly ensure you have all the necessary tools installed. The following should be installed on your computer
- Node.js
- NPM (Node Package Manager)
Starter Guide
For a starter guide, you can check out the codebase here
I recommend you take a look at the env.sample file in the code as it gives you a hint of what should be in your environment variables
Celo Composer
Celo Composer is built on Celo to make it simple to build dApps using a variety of front-end frameworks, and libraries. For more information on how to start off your project with Celo Composer, visit here. Please keep in mind that these are the frameworks you should select while installing Celo Composer
- React
- Rainbow-kit celo
- Hardhat
- Subgraph
If you are stuck, kindly refer to the Celo Composer installation section of my previous tutorial here.
The tiny difference is that when asked if you need to use a subgraph, please choose yes
Smart Contract
Once your dApp is up, navigate to the packages>> hardhat >> contract folder and create an Rsvp.sol
file and copy this starter code
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.4;
contract RSVP {
event NewEventCreated(
bytes32 eventId,
address creator,
uint256 eventTime,
uint256 capacity,
uint256 depositAmount,
string eventDataHash
);
event NewRSVP(bytes32 eventId, address attendee);
event AttendeeConfirmed(bytes32 eventId, address attendee);
event DepositsPaidOut(bytes32 eventId);
struct Event {
bytes32 eventId;
string eventDataHash;
address eventOwner;
uint256 eventTime;
uint256 depositAmount;
uint256 maxCapacity;
address[] confirmedRSVPs;
address[] claimedRSVPs;
bool paidOut;
}
mapping(bytes32 => Event) public events;
function createNewEvent(
uint256 eventTime,
uint256 depositAmount,
uint256 maxCapacity,
string calldata eventDataHash
) external {
// generate an event ID based on other variables to create a hash
bytes32 eventId = keccak256(
abi.encodePacked(
msg.sender,
address(this),
eventTime,
depositAmount,
maxCapacity
)
);
address[] memory confirmedRSVPs;
address[] memory claimedRSVPs;
// create a new Event struct and add it to the events mapping
events[eventId] = Event(
eventId,
eventDataHash,
msg.sender,
eventTime,
depositAmount,
maxCapacity,
confirmedRSVPs,
claimedRSVPs,
false
);
emit NewEventCreated(
eventId,
msg.sender,
eventTime,
maxCapacity,
depositAmount,
eventDataHash
);
}
function createNewRSVP(bytes32 eventId) external payable {
// look up the event
Event storage myEvent = events[eventId];
// transfer the deposit to our contract and require that enough ETH was sent
require(msg.value == myEvent.depositAmount, "INSUFFICIENT FUNDS");
// require that the event hasn't already happened
require(block.timestamp <= myEvent.eventTime, "ALREADY HAPPENED");
// make sure the event is under the maximum capacity
require(
myEvent.confirmedRSVPs.length < myEvent.maxCapacity,
"EVENT AT CAPACITY"
);
// require that msg.sender isn't already in myEvent.confirmedRSVPs
for (uint8 i = 0; i < myEvent.confirmedRSVPs.length; i++) {
require(myEvent.confirmedRSVPs[i] != msg.sender, "ALREADY RSVP'D");
}
myEvent.confirmedRSVPs.push(payable(msg.sender));
emit NewRSVP(eventId, msg.sender);
}
function confirmAllAttendees(bytes32 eventId) external {
// look up the event
Event memory myEvent = events[eventId];
// require that msg.sender is the owner of the event
require(msg.sender == myEvent.eventOwner, "NOT AUTHORIZED");
// confirm each attendee
for (uint8 i = 0; i < myEvent.confirmedRSVPs.length; i++) {
confirmAttendee(eventId, myEvent.confirmedRSVPs[i]);
}
}
function confirmAttendee(bytes32 eventId, address attendee) public {
// look up the event
Event storage myEvent = events[eventId];
// require that msg.sender is the owner of the event
require(msg.sender == myEvent.eventOwner, "NOT AUTHORIZED");
// require that attendee is in myEvent.confirmedRSVPs
address rsvpConfirm;
for (uint8 i = 0; i < myEvent.confirmedRSVPs.length; i++) {
if (myEvent.confirmedRSVPs[i] == attendee) {
rsvpConfirm = myEvent.confirmedRSVPs[i];
}
}
require(rsvpConfirm == attendee, "NO RSVP TO CONFIRM");
// require that attendee is NOT in the claimedRSVPs list
for (uint8 i = 0; i < myEvent.claimedRSVPs.length; i++) {
require(myEvent.claimedRSVPs[i] != attendee, "ALREADY CLAIMED");
}
// require that deposits are not already claimed
require(myEvent.paidOut == false, "ALREADY PAID OUT");
// add them to the claimedRSVPs list
myEvent.claimedRSVPs.push(attendee);
// sending eth back to the staker https://solidity-by-example.org/sending-ether
(bool sent, ) = attendee.call{value: myEvent.depositAmount}("");
//if this fails
if (!sent) {
myEvent.claimedRSVPs.pop();
}
require(sent, "Failed to send Ether");
emit AttendeeConfirmed(eventId, attendee);
}
function withdrawUnclaimedDeposits(bytes32 eventId) external {
// look up event
Event memory myEvent = events[eventId];
// check if already paid
require(!myEvent.paidOut, "ALREADY PAID");
// check if it's been 7 days past myEvent.eventTimestamp
require(block.timestamp >= (myEvent.eventTime + 7 days), "TOO EARLY");
// only the event owner can withdraw
require(msg.sender == myEvent.eventOwner, "MUST BE EVENT OWNER");
// calculate how many people didn't claim by comparing
uint256 unclaimed = myEvent.confirmedRSVPs.length -
myEvent.claimedRSVPs.length;
uint256 payout = unclaimed * myEvent.depositAmount;
// mark as paid before sending to avoid reentrancy attack
myEvent.paidOut = true;
// send the payout to the owner
(bool sent, ) = msg.sender.call{value: payout}("");
// if this fails
if (!sent) {
myEvent.paidOut = false;
}
require(sent, "Failed to send Ether");
emit DepositsPaidOut(eventId);
}
}
This smart contract will allow event owners create events with a certain capacity (number of attendees) and deposit amount, Attendees will then RSVP to those events by sending that deposit amount.
Proceed to write your deployment scripts. Navigate to packages/hardhat/contracts. Firstly, you need to install hardhat. Run this command to do so
npm i hardhat && npx hardhat compile
Your final output should look like this
x Solidity files compiled
In the deploy folder, create a script file. You could name it deploy.js and write your deployment code. Your code might look similar to this
const hre = require("hardhat");
const main = async () => {
const contractFactory = await hre.ethers.getContractFactory('RSVP');
const contract = await contractFactory.deploy();
await contract.deployed();
console.log("Contract deployed to this address:", contract.address);
};
const runMain = async () => {
try {
await main();
process.exit(0);
} catch (error) {
console.log(error);
process.exit(1);
}
};
runMain();
Go back to your terminal and run the following command
npx hardhat --network alfajores run deploy/deploy.js
Your output should look similar to this
Contract deployed to this address: 0xc85CA226772cA949801EfCA6B9d153fB4a833aF7
Finally, verify your contract address with this command
npx hardhat --network alfajores verify 0xc85CA226772cA949801EfCA6B9d153fB4a833aF7
This is what your output should look like after the verification
Nothing to compile
Successfully submitted source code for contract
contracts/Rsvp.sol:RSVP at 0xc85CA226772cA949801EfCA6B9d153fB4a833aF7
for verification on the block explorer. Waiting for verification result...
Successfully verified contract RSVP on Etherscan.
https://alfajores.celoscan.io/address/0xc85CA226772cA949801EfCA6B9d153fB4a833aF7#code
Note: You need to have an etherscan apikey for a successful verification. You can get your API key from etherscan. Just login and generate one. In your hardhatconfig, please check that you have this variable
etherscan: {
apiKey: {
alfajores: process.env.CELOSCAN_API_KEY,
celo: process.env.CELOSCAN_API_KEY,
},
},
Subgraph
If you aren’t familiar with what a subgraph is, it is a blockchain indexing tool built by The Graph. The good thing is that it is part of the tools that can be added when setting up using Celo Composer. You should have had that set up earlier. Visit the hosted service on the Graph to create a subgraph. Sign in with your Github account
Select My Dasboard and Add Subgraph
You will be asked to fill in some information about the subgraph. I named my subgraph RSVP
Then proceed to click on the Create
button. Once that’s done, you will be give some command templates such as for initialization , building, code generation and authentication.
Navigate to the packages/subgraphs folder and run the initialize command from your subgraph account. For mine, this was my command
graph init --product hosted-service amoweolubusayo/rsvp
You should select ethereum
Also choose hosted service
You will also be asked which specific ethereum service you want to work with. Select celo-alfajores
After this, you will be asked for the contract address you want to index. Provide this in the terminal as well
And with this, you have initialize your subgraph project.
For a sneak peek of how a working folder should look like, check out RSVP-composer/packages/subgraphs at main · amoweolubusayo/RSVP-composer · GitHub. Do not copy any build or generated folders.
Still in the packages/subgraphs folder, make the following changes to the schema.graphql
file. No worries if you don’t have it, create one in that root folder. For starters, you can use this code
type Event @entity {
id: ID!
eventId: Bytes!
name: String
description: String
link: String
imageURL: String
creator: Bytes!
eventTime: BigInt!
maxCapacity: BigInt!
depositAmount: BigInt!
paidOut: Boolean!
totalRSVPs: BigInt!
totalConfirmedAttendees: BigInt!
rsvps: [RSVP!] @derivedFrom(field: "event")
confirmedAttendees: [Confirmation!] @derivedFrom(field: "event")
}
type Account @entity {
id: ID!
totalRSVPs: BigInt!
totalAttendedEvents: BigInt!
rsvps: [RSVP!] @derivedFrom(field: "attendee")
attendedEvents: [Confirmation!] @derivedFrom(field: "attendee")
}
type RSVP @entity {
id: ID!
attendee: Account!
event: Event!
}
type Confirmation @entity {
id: ID!
attendee: Account!
event: Event!
}
In my own project, my smart contract’s abi was created in the abi/alfajores folder. If by any chance, yours didn’t get created at all in any folder, no need to panic, you can create any folder of your choice and create a file to hold the abi of your smart contract. So the file could be named RSVP.json
. Go to your smart contract on alfajores explorer and copy the abi. The images below will guide you as to where to find that
Copy the contract abi to your RSVP.json file.
Also , we need to make changes to our subgraph.yaml
file. Again, if you don’t have it for an reason, just create one in the root folder same place the schema.graphql
file is. Mine looks like this so properly reference the folder/file where your abi is. For example, mine is in the abis/alfajores
folder. Check your deployed smart contract in the explorer for the startBlock as well. You will find this in the transactions tab. Mine was created at 17702677
specVersion: 0.0.4
features:
- ipfsOnEthereumContracts
schema:
file: ./schema.graphql
dataSources:
- kind: ethereum
name: RSVP
network: celo-alfajores
source:
address: "0xc85CA226772cA949801EfCA6B9d153fB4a833aF7"
abi: RSVP
startBlock: 17702677
mapping:
kind: ethereum/events
apiVersion: 0.0.6
language: wasm/assemblyscript
entities:
- Event
- Account
- RSVP
- Confirmation
abis:
- name: RSVP
file: ./abis/alfajores/RSVP.json
eventHandlers:
- event: AttendeeConfirmed(bytes32,address)
handler: handleConfirmedAttendee
- event: DepositsPaidOut(bytes32)
handler: handleDepositsPaidOut
- event: NewEventCreated(bytes32,address,uint256,uint256,uint256,string)
handler: handleNewEventCreated
- event: NewRSVP(bytes32,address)
handler: handleNewRSVP
file: ./src/mapping.ts
Proceed to run the code generation command. Run this command
graph codegen
It will create a generated folder and/ or a mapping file which we will be working with shortly.
Proceed to the src folder or the folder where your mapping file got created. You should find a code template with the handlers you have in your subgraph.yaml
file as functions. You need to map out the data you want from the subgraph in this file. So this code is a very good starting point.
import { Address, ipfs, json } from "@graphprotocol/graph-ts";
import {
AttendeeConfirmed,
NewEventCreated,
NewRSVP,
DepositsPaidOut,
} from "../generated/RSVP/RSVP";
import { Account, RSVP, Confirmation, Event } from "../generated/schema";
import { integer } from "@protofire/subgraph-toolkit";
export function handleNewEventCreated(event: NewEventCreated): void {
let newEvent = Event.load(event.params.eventId.toHex());
if (newEvent == null) {
newEvent = new Event(event.params.eventId.toHex());
newEvent.eventID = event.params.eventId;
newEvent.eventOwner = event.params.creator;
newEvent.eventTimestamp = event.params.eventTime;
newEvent.maxCapacity = event.params.capacity;
newEvent.deposit = event.params.depositAmount;
newEvent.paidOut = false;
newEvent.totalRSVPs = integer.ZERO;
newEvent.totalConfirmedAttendees = integer.ZERO;
let metadata = ipfs.cat(event.params.eventDataHash + "/data.json");
if (metadata) {
const value = json.fromBytes(metadata).toObject();
if (value) {
const name = value.get("name");
const description = value.get("description");
const link = value.get("link");
const imagePath = value.get("image");
if (name) {
newEvent.name = name.toString();
}
if (description) {
newEvent.description = description.toString();
}
if (link) {
newEvent.link = link.toString();
}
if (imagePath) {
const imageURL =
"https://ipfs.io/ipfs/" +
event.params.eventDataHash +
imagePath.toString();
newEvent.imageURL = imageURL;
} else {
const fallbackURL =
"https://ipfs.io/ipfs/bafybeibssbrlptcefbqfh4vpw2wlmqfj2kgxt3nil4yujxbmdznau3t5wi/event.png";
newEvent.imageURL = fallbackURL;
}
}
}
newEvent.save();
}
}
function getOrCreateAccount(address: Address): Account {
let account = Account.load(address.toHex());
if (account == null) {
account = new Account(address.toHex());
account.totalRSVPs = integer.ZERO;
account.totalAttendedEvents = integer.ZERO;
account.save();
}
return account;
}
export function handleNewRSVP(event: NewRSVP): void {
let id = event.params.eventId.toHex() + event.params.attendee.toHex();
let newRSVP = RSVP.load(id);
let account = getOrCreateAccount(event.params.attendee);
let thisEvent = Event.load(event.params.eventId.toHex());
if (newRSVP == null && thisEvent != null) {
newRSVP = new RSVP(id);
newRSVP.attendee = account.id;
newRSVP.event = thisEvent.id;
newRSVP.save();
thisEvent.totalRSVPs = integer.increment(thisEvent.totalRSVPs);
thisEvent.save();
account.totalRSVPs = integer.increment(account.totalRSVPs);
account.save();
}
}
export function handleConfirmedAttendee(event: AttendeeConfirmed): void {
let id = event.params.eventId.toHex() + event.params.attendee.toHex();
let newConfirmation = Confirmation.load(id);
let account = getOrCreateAccount(event.params.attendee);
let thisEvent = Event.load(event.params.eventId.toHex());
if (newConfirmation == null && thisEvent != null) {
newConfirmation = new Confirmation(id);
newConfirmation.attendee = account.id;
newConfirmation.event = thisEvent.id;
newConfirmation.save();
thisEvent.totalConfirmedAttendees = integer.increment(
thisEvent.totalConfirmedAttendees
);
thisEvent.save();
account.totalAttendedEvents = integer.increment(
account.totalAttendedEvents
);
account.save();
}
}
export function handleDepositsPaidOut(event: DepositsPaidOut): void {
let thisEvent = Event.load(event.params.eventId.toHex());
if (thisEvent) {
thisEvent.paidOut = true;
thisEvent.save();
}
}
We introduced two new libraries here, so you can install them by running these commands
npm i @graphprotocol/graph-ts
npm i @protofire/subgraph-toolkit
This is all the code we need. So proceed to build and deploy
Run this command
graph build
Your output should look like this
Go back to your subgraph and pick your auth command, without it you won’t be able to deploy. Run the auth command
graph auth --product hosted-service YOUR_KEY
Expect this output
Finally, run the deploy command
graph deploy --product hosted-service amoweolubusayo/rsvp
Your output should look like this
Go back to your subgraph dashboard to see changes
We did it! You did it!
Conclusion
I am glad you made it this far with this tutorial! You were able to create smart contract, deploy and verify for our usecase. You also learnt how to create a subgraph , build and deploy as well! This doesn’t end here as I am sure you are interested in seeing how this works with a UI. In my next tutorial, I will teach you how to build a front-end interface that implements all we have learnt here.