An example of implementing this design pattern can be seen in the X-App-Template demo project available for you to use as a starting point to build your x2earn app.
In this example the backend handles the submission of sustainable actions, uses ChatGPT to prove the authenticity and validity of the action then calls a smart contract to distribute the rewards to the user.
The smart contract in this example is created in a way to handle rewards divided in cycles: each time an allocation round ends and your app receives B3TR tokens you need to start a new distribution cycle, by specifying how many B3TR tokens are available to distribute in that cycle.
Please visit the GitHub page to view the source code and better understand how to use that template and experiment with it. Following are the key code snippets (edited to show only core logic) that you should look at:
Submission controller
import { NextFunction, Request, Response } from'express';import { Container } from'typedi';import { Submission } from'@/interfaces/submission.interface';import { HttpException } from'@/exceptions/HttpException';import { ContractsService, CaptchaService, OpenaiService } from'@/services';exportclassSubmissionController {public openai =Container.get(OpenaiService);public contracts =Container.get(ContractsService);public captcha =Container.get(CaptchaService);// this function is called by the user through an endpoint to submit the photo of a receiptpublicsubmitReceipt=async (req:Request, res:Response, next:NextFunction):Promise<void> => {try {constbody:Omit<Submission,'timestamp'> =req.body;constsubmissionRequest:Submission= {...body, timestamp:Date.now(), };// Ask ChatGPT if this submission is authentic and validconstvalidationResult=awaitthis.openai.validateImage(body.image);if (validationResult ==undefined||!('validityFactor'in (validationResult asobject))) {thrownewHttpException(500,'Error validating image'); }// ChatGPT will answer with a validity factor, a number from 0 to 1constvalidityFactor= validationResult['validityFactor'];// If submission is valid call the smart contract to send rewards to the userif (validityFactor ===1) awaitthis.contracts.registerSubmission(submissionRequest);res.status(200).json({ validation: validationResult }); } catch (error) {next(error); } };}
Services to validate and send rewards
import { HttpException } from'@/exceptions/HttpException';import { openAIHelper } from'@/server';import { isBase64Image } from'@/utils/data';import { Service } from'typedi';@Service()exportclassOpenaiService {publicasyncvalidateImage(image:string):Promise<unknown> {if (!isBase64Image(image)) thrownewHttpException(400,'Invalid image format');constprompt=` Analyze the image provided. The image MUST satisfy all of the following criteria: 1. It must have as subject a receipt of purchase of at least one product. 2. It must not be a screenshot. 3. It must include the date of the purchase. 4. It must include the name of the store where the purchase was made. Please respond using a JSON object without comments and do not add any other descriptions and comments:
{ 'validityFactor': number, // 0-1, 1 if it satisfies all the criteria, 0 otherwise 'descriptionOfAnalysis': string, // indicate your analysis of the image and why it satisfies or not the criteria. The analysis will be shown to the user so make him understand why the image doesn't satisfy the criteria if it doesn't without going into detail on exact criteria. Remember we are rewarding users that drink coffee in a sustainable way.
} `;constgptResponse=awaitopenAIHelper.askChatGPTAboutImage({ base64Image: image, prompt, });constresponseJSONStr=openAIHelper.getResponseJSONString(gptResponse);returnopenAIHelper.parseChatGPTJSONString(responseJSONStr); }}
To make this work you will need to add an ADMIN_MNEMONIC in the env file of your backend. The address related to this mnemonic should be the user authorized to call registerValidSubmission on your contract.
Smart Contract to distribute the rewards
// SPDX-License-Identifier: MITpragmasolidity ^0.8.19;import'@openzeppelin/contracts/access/AccessControl.sol';import'./interfaces/IX2EarnRewardsPool.sol';/** * @title EcoEarn Contract * @dev This contract manages a reward system based on cycles. Participants can make valid submissions to earn rewards. * Rewards are being distributed by interacting with the VeBetterDAO's X2EarnRewardsPool contract. * * @notice To distribute rewards this contract necesitates of a valid APP_ID provided by VeBetterDAO. This contract * can be initially deployed without this information and DEFAULT_ADMIN_ROLE can update it later through {EcoEarn-setAppId}.
* This contract must me set as a `rewardDistributor` inside the X2EarnApps contract to be able to send rewards to users and withdraw.
*/contractEcoEarnisAccessControl {// The X2EarnRewardsPool contract used to distribute rewards IX2EarnRewardsPool public x2EarnRewardsPoolContract;// AppID given by the X2EarnApps contract of VeBetterDAO bytes32public appId;// Mapping from cycle to total rewardsmapping(uint256=>uint256) public rewards;// Mapping from cycle to remaining rewardsmapping(uint256=>uint256) public rewardsLeft;// Next cycle numberuint256public nextCycle;/** * @dev Constructor for the EcoEarn contract * @param _admin Address of the admin * @param _x2EarnRewardsPoolContract Address of the X2EarnRewardsPool contract * @param _cycleDuration Duration of each cycle in blocks * @param _maxSubmissionsPerCycle Maximum submissions allowed per cycle * @param _appId The appId generated by the X2EarnApps contract when app was added to VeBetterDAO */constructor(address_admin,address_x2EarnRewardsPoolContract,uint256_cycleDuration,bytes32_appId) {require(_admin !=address(0),"EcoEarn: _admin address cannot be the zero address"); require(_x2EarnRewardsPoolContract != address(0), "EcoEearn: x2EarnRewardsPool contract address cannot be the zero address");
x2EarnRewardsPoolContract =IX2EarnRewardsPool(_x2EarnRewardsPoolContract); cycleDuration = _cycleDuration; nextCycle =1; appId = _appId;_grantRole(DEFAULT_ADMIN_ROLE, _admin); }/** * @dev Function to trigger a new cycle */functiontriggerCycle() publiconlyRole(DEFAULT_ADMIN_ROLE) { lastCycleStartBlock = block.number; // Update the start block to the current block nextCycle++;emitCycleStarted(lastCycleStartBlock); }/** * @dev Set the allocation for the next cycle * @param amount Amount of tokens to be allocated */functionsetRewardsAmount(uint256 amount) publiconlyRole(DEFAULT_ADMIN_ROLE) { require(amount <= x2EarnRewardsPoolContract.availableFunds(appId), 'EcoEarn: Insufficient balance on the X2EarnRewardsPool contract');
rewards[nextCycle] = amount; rewardsLeft[nextCycle] = amount;emitClaimedAllocation(nextCycle, amount); }/** * @dev Registers a valid submission * @param participant Address of the participant * @param amount Amount of rewards to be given for the submission */functionregisterValidSubmission(address participant,uint256 amount) externalonlyRole(DEFAULT_ADMIN_ROLE) {require(amount >0,'EcoEarn: Amount must be greater than 0');require(rewardsLeft[getCurrentCycle()] >= amount,'EcoEarn: Not enough rewards left');require(block.number <getNextCycleBlock(),'EcoEarn: Cycle is over');// Decrease the rewards left rewardsLeft[getCurrentCycle()] -= amount;// Transfer the reward to the participant, will revert if the transfer fails x2EarnRewardsPoolContract.distributeReward(appId, amount, participant,'');emitSubmission(participant, amount); }}
You are all set: you now have a backend that receives api call of user submitting images (in this example a receipt) which is being validated by ChatGPT and, if validation is successful, it distributes the rewards to the user.
The only thing missing is to whitelist your contract address to be able to distribute rewards: only specific addresses can access the funds and call the distributeReward function. To allow your contract to perform this action you will need to go to your app management section in the VeBetterDAO dApp and add the address where your contract was deployed as a Reward Distributor.