Pattern 2: Use Smart Contracts and Backend

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';

export class SubmissionController {
  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 receipt
  public submitReceipt = async (req: Request, res: Response, next: NextFunction): Promise<void> => {
    try {
      const body: Omit<Submission, 'timestamp'> = req.body;

      const submissionRequest: Submission = {
        ...body,
        timestamp: Date.now(),
      };
      
      // Ask ChatGPT if this submission is authentic and valid
      const validationResult = await this.openai.validateImage(body.image);

      if (validationResult == undefined || !('validityFactor' in (validationResult as object))) {
        throw new HttpException(500, 'Error validating image');
      }
      
      // ChatGPT will answer with a validity factor, a number from 0 to 1
      const validityFactor = validationResult['validityFactor'];
      
      // If submission is valid call the smart contract to send rewards to the user
      if (validityFactor === 1) await this.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()
export class OpenaiService {
  public async validateImage(image: string): Promise<unknown> {
    if (!isBase64Image(image)) throw new HttpException(400, 'Invalid image format');

    const prompt = `
                    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.
                    }
                    `;

    const gptResponse = await openAIHelper.askChatGPTAboutImage({
      base64Image: image,
      prompt,
    });

    const responseJSONStr = openAIHelper.getResponseJSONString(gptResponse);

    return openAIHelper.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: MIT
pragma solidity ^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.
 */
contract EcoEarn is AccessControl {
    // The X2EarnRewardsPool contract used to distribute rewards
    IX2EarnRewardsPool public x2EarnRewardsPoolContract;

    // AppID given by the X2EarnApps contract of VeBetterDAO 
    bytes32 public appId;

    // Mapping from cycle to total rewards
    mapping(uint256 => uint256) public rewards;

    // Mapping from cycle to remaining rewards
    mapping(uint256 => uint256) public rewardsLeft;

    // Next cycle number
    uint256 public 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
     */
    function triggerCycle() public onlyRole(DEFAULT_ADMIN_ROLE) {
        lastCycleStartBlock = block.number; // Update the start block to the current block
        nextCycle++;
        emit CycleStarted(lastCycleStartBlock);
    }
    
    /**
     * @dev Set the allocation for the next cycle
     * @param amount Amount of tokens to be allocated
     */
    function setRewardsAmount(uint256 amount) public onlyRole(DEFAULT_ADMIN_ROLE) {
        require(amount <= x2EarnRewardsPoolContract.availableFunds(appId),  'EcoEarn: Insufficient balance on the X2EarnRewardsPool contract');
        rewards[nextCycle] = amount;
        rewardsLeft[nextCycle] = amount;
        emit ClaimedAllocation(nextCycle, amount);
    }

    /**
     * @dev Registers a valid submission
     * @param participant Address of the participant
     * @param amount Amount of rewards to be given for the submission
     */
    function registerValidSubmission(address participant, uint256 amount) external onlyRole(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, '');

        emit Submission(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.

Last updated