LensAPI Oracle: Lens Treasure Hunt

Joshua Waller
13 min readSep 26, 2023

--

Overview

What I originally sought out to do was to create a simple example of using LensAPI Oracle deployed on Phala Network to get profile stats from Lens API. I would then transform on the data returned from Lens API and compute a number to send to the consumer contract on Polygon. After I realized I could do this successfully, I quickly created a proof of concept to create a Lens Treasure Hunt where users can contribute to a lottery pool by “digging” profiles for gold.

Initial Design

Here is the high-level design of a simple app that uses LensAPI Oracle to generate a number w/ JS of 1–5 based on number of followers the Lens Profile has. If the Lens Profile has X followers & hasn’t collected their claim to the NFT then they can click “Claim” and get airdropped the NFT correlating to number computed from the LensAPI Oracle.

I was pleased to see that I could deploy my LensAPI Oracle on Phala Network and have my consumer contract be an approved minter of my test NFT contract. In the video below, I test out my proof of concept quickly and prove that I can mint an NFT based on the returned data from my LensAPI Oracle.

Creative Idea — Lens Treasure Hunt based on LensAPI Oracle

Now that the proof of concept worked, I created a new design based on an interesting off-chain dynamic as the LensAPI Oracle will not reveal the custom JS code that allows participants to “dig” a Lens profile then potentially mint the first NFT of each collection. When all 5 NFTs are minted, the 5 minters of the first NFT in a collection will earn a piece of the treasure at the end of the treasure hunt. Below you can see the initial design.

Rules:

  • Pay X MATIC to execute a dig(string calldata profileId) which will send a new action request to the Rollup Anchor for the LensAPI Oracle to query Lens API.
  • Note the payment is configurable and the Owner of the contract will get half of the proceeds for the game.
  • The other half of proceeds go into a pot for participants to potentially win a share of.
  • Participants must guess which profileIds will return a number 1 through 5 (0 means no valuable treasure was found)
  • Each number 1 through 5 represents a NFT Collection and the goal for participants is to mint NFT ID 0 in each collection to qualify for a share of the overall treasure.
  • The more participants there are, the bigger the treasure rewards grow.
  • When all 5 NFT ID 0s are minted then the treasure hunt concludes and anyone can call the rewardTreasureRecipients() for the funds to be paid out to the winners.

Test NFTs

To expedite the NFT creation process, I leveraged ThirdWeb to easily deploy these NFT contracts with ability to set the minter account to my consumer contract on Polygon that will allow users to call “dig” and have a chance to mint the genesis NFT of each collection. Below are the NFTs I generated of my dog Mojo from Midjourney AI.

  • Phase One: ipfs://QmUL6zuhq4vWb1fSKwPBGqEe8yiuEZ3LqUo5GTuY6AEXBc/FirstPhase.png
  • Second Phase: ipfs://QmUL6zuhq4vWb1fSKwPBGqEe8yiuEZ3LqUo5GTuY6AEXBc/SecondPhase.png
  • Third Phase: ipfs://QmUL6zuhq4vWb1fSKwPBGqEe8yiuEZ3LqUo5GTuY6AEXBc/ThirdPhase.png
  • Fourth Phase: ipfs://QmUL6zuhq4vWb1fSKwPBGqEe8yiuEZ3LqUo5GTuY6AEXBc/FourthPhase.png
  • Fifth Phase: ipfs://QmUL6zuhq4vWb1fSKwPBGqEe8yiuEZ3LqUo5GTuY6AEXBc/FifthPhase.png

Deployed Contracts:

Here are the Contracts that I deployed on Polygon Mumbai that power the treasure hunt.

LensTreasureHunt.sol | 0xad5C96e026D4D00c9a6Fcd1AA2E4958a5155AD03 |
TestLensTreasureFirst.sol | 0x7163fc5fdCd5474f026C9017a0f633992Da6b339 |
TestLensTreasureHuntSecond.sol | 0x501eB5CDF76fb493ae0E60691c3c0C30E153F6fb |
TestLensTreasureHuntThird.sol | 0x29Af1dEd078e72A061469185e66b2946AEFD837C |
TestLensTreasureHuntFourth.sol | 0x78e9344cfe3aAC2DDB3f4ce2B9d5cc80fc320788 |
TestLensTreasureHuntFifth.sol | 0x2DA3F14E9cA3b51F29bc31D6aeD5B33B1708AEeE |

LensAPI Oracle Endpoint:

The Phala Phat Contract 2.0 UI is upgraded from this version, but originally this is a look at my deployed LensAPI Oracle.

LensAPI Oracle Endpoint | 0x0e9e628d715003ff5045fc92002c67ddab364683

Deploying the contract was very simple. In the code below, you can see MUMBAI_LENSAPI_ORACLE_ENDPOINT local .env variable which is the Oracle Endpoint seen above, then I set the NFT URI for each NFT collection when a mint is triggered from the action response in the consumer contract on Polygon.

import { ethers } from "hardhat";
import "dotenv/config";
async function main() {
const LensTreasureHunt = await ethers.getContractFactory("LensTreasureHunt");
  const [deployer] = await ethers.getSigners();
  console.log('Deploying...');
const attestor = process.env['MUMBAI_LENSAPI_ORACLE_ENDPOINT'] || deployer.address; // When deploy for real e2e test, change it to the real attestor wallet.
const consumer = await LensTreasureHunt.deploy(attestor, 0);
await consumer.deployed();
console.log('Deployed', {
LensTreasureHunt: consumer.address,
});
  console.log('Configuring...');
const digCost = ethers.utils.parseEther("0.0001");
await consumer.connect(deployer).setDigCost(digCost);
await consumer.connect(deployer).setLensTreasureHuntNftURI(1, "ipfs://QmUL6zuhq4vWb1fSKwPBGqEe8yiuEZ3LqUo5GTuY6AEXBc/FirstPhase.png");
await consumer.connect(deployer).setLensTreasureHuntNftURI(2, "ipfs://QmUL6zuhq4vWb1fSKwPBGqEe8yiuEZ3LqUo5GTuY6AEXBc/SecondPhase.png");
await consumer.connect(deployer).setLensTreasureHuntNftURI(3, "ipfs://QmUL6zuhq4vWb1fSKwPBGqEe8yiuEZ3LqUo5GTuY6AEXBc/ThirdPhase.png");
await consumer.connect(deployer).setLensTreasureHuntNftURI(4, "ipfs://QmUL6zuhq4vWb1fSKwPBGqEe8yiuEZ3LqUo5GTuY6AEXBc/FourthPhase.png");
await consumer.connect(deployer).setLensTreasureHuntNftURI(5, "ipfs://QmUL6zuhq4vWb1fSKwPBGqEe8yiuEZ3LqUo5GTuY6AEXBc/FifthPhase.png");
console.log('Done');
}
// We recommend this pattern to be able to use async/await everywhere
// and properly handle errors.
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
➜  lensapi-oracle-devdao-workshop git:(main) ✗ yarn test-deploy
Deploying...
Deployed { LensTreasureHunt: '0xad5C96e026D4D00c9a6Fcd1AA2E4958a5155AD03' }
Configuring...
Done

Now with the test consumer contract deployed, I run a command to verify the contract in polygonscan with the yarn test-verify command.

➜  lensapi-oracle-devdao-workshop git:(main) ✗ yarn test-verify 0xad5C96e026D4D00c9a6Fcd1AA2E4958a5155AD03
Nothing to compile
No need to generate any newer typings.
Successfully submitted source code for contract
contracts/LensTreasureHunt.sol:LensTreasureHunt at 0xad5C96e026D4D00c9a6Fcd1AA2E4958a5155AD03
for verification on the block explorer. Waiting for verification result...
Successfully verified contract LensTreasureHunt on Etherscan.
https://mumbai.polygonscan.com/address/0xad5C96e026D4D00c9a6Fcd1AA2E4958a5155AD03#code

Now that I have a Contract ID from Polygon Mumbai testnet, I then set the consumer contract in Phat Contract 2.0 UI Dashboard & check console logs through Inspect in the browser:

Now lets test out the implementation by executing a dig(string calldata profileId).

import { ethers } from "hardhat";
import "dotenv/config";
async function main() {
const LensTreasureHunt = await ethers.getContractFactory("LensTreasureHunt");
    const [deployer] = await ethers.getSigners();
    const consumerSC = process.env['MUMBAI_CONSUMER_CONTRACT_ADDRESS'] || "";
const consumer = LensTreasureHunt.attach(consumerSC);
await Promise.all([
consumer.deployed(),
])
    console.log('Pushing a request to dig...');
const digCost = await consumer.connect(deployer).digCost();
console.log(`digCost: ${digCost}`);
await consumer.connect(deployer).dig("0x8df1", {value: digCost, gasLimit: 1_000_000});
console.log('Done');
}
// We recommend this pattern to be able to use async/await everywhere
// and properly handle errors.
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});

Before the frontend is implemented to handle the contract interaction of getting the right string calldata here is what we are looking to get before calling dig(profileId)

Once we update the push-dig.ts script we can make the call through hardhat run command:

➜  lensapi-oracle-devdao-workshop git:(main) ✗ yarn test-push-dig
$ hardhat run --network mumbai ./scripts/mumbai/push-dig.ts
Pushing a request to dig...
digCost: 100000000000000
Done
✨ Done in 2.02s.

Check polygonscan for mumbai testnet for transaction and there should be a new Dig transaction in the transactions tab.

Now when we look to see the LensAPI Oracle’s reply, we will see an error, but this is expected as we have not yet configured our NFT contracts to set our LensTreasureHunt contract address as a Minter, but the good news is our functionality works as expected and this can allow us to move forward in implementing the UI.

Connecting LensTreasureHunt.sol to a UI

With a working proof of concept, we can start to explore how to build a map of Lens Profiles that can be selected by a user and provide the user with a button to “Dig ⛏️” for treasure. Typically a user can choose their own stack for the frontend implementation. There are many great places to start including the following templates for the Lens v1 API/SDK.

This exhaustive list above includes all the different options that I went through to eventually build out a UI for the Lens Treasure Hunt. Given that I am working with limited data, I decided to opt for hacking my way to integrate my Lens Treasure Hunt dApp into Scaffold-ETH-2 due to the following:

  • Perfect for speed run deployments with little UI dev skills
  • Debug Console to interact with my compiled LensTreasureHunt.sol contract without having to write any custom code
  • An example-ui tab for me to hack my way to a useable UI for the Lens Treasure Hunt Game

So I forked the scaffold-eth-2 repo, slapped phat-scaffold-eth as the name and decided to start with the basics…integrate the lensapi-oracle-consumer-contract into phat-scaffold-eth

Hacking it Together

First steps were very simple, copy over the Consumer Contract and rename it to PhatConsumerContract.sol contract and place the Smart Contracts in the ./packages/hardhat/contracts/ folder. Next, compile the contract with yarn compile which will execute hardhat compile and the resulting artifacts of the compile contracts will be available. Before deploying, it is important to alter the deploy script called 00_deploy_your_contract.ts with the PhatConsumerContract.sol information. Your file should look like the following:

import { HardhatRuntimeEnvironment } from "hardhat/types";
import { DeployFunction } from "hardhat-deploy/types";
/**
* Deploys a contract named "PhatConsumerContract" using the deployer account and
* constructor arguments set to the deployer address
*
* @param hre HardhatRuntimeEnvironment object.
*/
const deployPhatConsumerContract: DeployFunction = async function (hre: HardhatRuntimeEnvironment) {
/*
On localhost, the deployer account is the one that comes with Hardhat, which is already funded.
    When deploying to live networks (e.g `yarn deploy --network goerli`), the deployer account
should have sufficient balance to pay for the gas fees for contract creation.
    You can generate a random account with `yarn generate` which will fill DEPLOYER_PRIVATE_KEY
with a random private key in the .env file (then used on hardhat.config.ts)
You can run the `yarn account` command to check your balance in every network.
*/
const { deployer } = await hre.getNamedAccounts();
const { deploy } = hre.deployments;
  await deploy("PhatConsumerContract", {
from: deployer,
// Contract constructor arguments
args: [deployer], //TODO
log: true,
// autoMine: can be passed to the deploy function to make the deployment process faster on local networks by
// automatically mining the contract deployment transaction. There is no effect on live networks.
autoMine: true,
});
  // Get the deployed contract
// const yourContract = await hre.ethers.getContract("YourContract", deployer);
};
export default deployPhatConsumerContract;
// Tags are useful if you have multiple deploy files and only want to run one of them.
// e.g. yarn deploy --tags YourContract
deployPhatConsumerContract.tags = ["PhatConsumerContract"];

If you pay attention to the comments, make sure that you .env file is up-to-date with the variables that are needed to deploy your contract. For now, we are testing locally which makes this step easy as we can leverage the default configuration and generate a random dev account to use for testing with the yarn generate command.

Now that we have the basics finished, lets try to deploy the phat-scaffold-eth UI locally by executing the steps in the README.md.

  • yarn - install dependencies
  • yarn chain - run the local hardhat node
  • yarn deploy - deploy the PhatConsumerContract.sol Smart Contract to the hardhat local network
  • yarn start - spin up the UI and access at https://localhost:3000

Here is a video of what you should expect (note: this took 2 mins 44 seconds to execute these steps):

Customize the example-ui for the Lens Treasure Hunt

I’m not a fullstack developer nor am I experienced with frontend development, but learning through trial and error has always been a great teacher. This is the prime reason I decided to build on multiple stacks to best understand what developers will face when developing in the trenches. So if you are a developer that feels intimidated, do not worry as I am living proof you can know nothing and create something with the help of open source code, and this prompt for ChatGPT:

"I want you to act as a software developer. I will provide some specific information about a web app requirements, and it will be your job to come up with an architecture and code for developing secure app with <Insert Stack Here>. My first request is 'I want a system that allow users to view all the profiles of a social media platform that displays all users in a map with thumbnails of each users profile pictures. When a user clicks on a profile, the user will enact an action. Write a component example in Typescript of how to query all of the profiles and load them into a map to display all the profiles.'”

From here, I was able to ask ChatGPT for examples on what I wanted to create and use my programming knowledge to verify and modify the code to best solve what I needed to get a functional example of the Lens Treasure Hunt UI.

The basic idea I wanted to be able to achieve was displaying every Lens Profile in one UI page and keep the user experience as simple as “Scroll, Click, Dig”. However, with little experience in building UIs, I leaned on ChatGPT to get me to a level of understanding that 24 hours searching on Google could not do. This was a huge relief because the most important piece of information I needed to create the Dig ⛏️ function was the Lens Profile ID string. I had tried Lens SDK at first, but I wanted this dApp to be useful for those that may not have access to Lens, so I opted to use the Lens API Client instead.

To enable this in the phat-scaffold-eth repo I cd nextjs and executed yarn add @lens-protocol/client to add to the nextjs/package.json file. With access to this package, I can now build out the proof of concept UI with the new knowledge and help from ChatGPT. A quick note, I also added the react-modal packages to enable a Modal to popup when selecting a Lens Profile in the UI. Here is an what the initial ExploreProfiles.tsx file looks like:

import {development, LensClient, PaginatedResult, ProfileFragment, ProfileSortCriteria} from "@lens-protocol/client";
import React, {useCallback, useEffect, useRef, useState} from 'react';
import Modal from 'react-modal';
Modal.setAppElement(':root');
const lensClient = new LensClient({
environment: development
});
export const ExploreProfiles = () => {
const [profilesMap, setProfilesMap] = useState<Map<string, ProfileFragment>>(new Map());
const [paginatedResult, setPaginatedResult] = useState<PaginatedResult<ProfileFragment> | null>(null);
const [selectedProfile, setSelectedProfile] = useState<ProfileFragment | null>(null);
  const observer = useRef<IntersectionObserver | null>(null);
const lastProfileElementRef = useCallback(node => {
if (observer.current) observer.current?.disconnect();
observer.current = new IntersectionObserver(entries => {
if (entries[0].isIntersecting && paginatedResult?.next) {
loadMoreProfiles();
}
});
if (node) observer.current?.observe(node);
}, [paginatedResult]);
  useEffect(() => {
if(!paginatedResult) {
lensClient.explore.profiles({
sortCriteria: ProfileSortCriteria.MostFollowers
})
.then(result => {
const newProfilesMap = new Map(profilesMap);
result.items.forEach(profile => {
newProfilesMap.set(profile.id, profile);
});
setProfilesMap(newProfilesMap);
setPaginatedResult(result);
})
}
}, [paginatedResult]);
  const onProfileClick = (profileId) => {
const profile = profilesMap.get(profileId);
// @ts-ignore
setSelectedProfile(profile);
};
  const getProfilePicture = (profileId: string) => {
const picture = profilesMap.get(profileId)?.picture;
if (picture?.__typename === 'MediaSet') {
return picture.original.url;
} else if (picture?.__typename === 'NftImage') {
return picture.uri;
}
return '/assets/theplugs-spark-creativity.png';
}
  const loadMoreProfiles = () => {
if (paginatedResult && paginatedResult.next) {
paginatedResult.next().then(result => {
if (result) {
const newProfilesMap = new Map(profilesMap);
result.items.forEach(profile => {
newProfilesMap.set(profile.id, profile);
});
setProfilesMap(newProfilesMap);
setPaginatedResult(result);
}
});
}
};
  const profileIds = Array.from(profilesMap.keys());
return (
<div className="profiles-container" >
{profileIds.map((profileId, index) => (
<div ref={index === profileIds.length - 1 ? lastProfileElementRef : null} key={profileId} onClick={() => onProfileClick(profileId)} className="profile-card">
<picture><img className="thumbnail" src={getProfilePicture(profileId)} alt="Profile picture" /></picture>
<h3>{profilesMap.get(profileId)?.name}</h3>
<text>{profilesMap.get(profileId)?.id}</text>
</div>
))}
<Modal
isOpen={selectedProfile !== null}
onRequestClose={() => setSelectedProfile(null)}
className="flex flex-col justify-center items-center bg-[url('/assets/mojo-da-king.png')] bg-[length:50%_100%] py-10 px-5 sm:px-0 lg:py-auto max-w-[100vw] "
>
<h2>
{selectedProfile?.name ?? selectedProfile?.id}.lens
</h2>
<picture><img className="thumbnail" src={getProfilePicture(selectedProfile?.id as string)} alt="Profile picture" /></picture>
<p>{selectedProfile?.id}</p>
<p>{selectedProfile?.bio}</p>
<p>Total Followers: {selectedProfile?.stats.totalFollowers}</p>
<p>Total Following: {selectedProfile?.stats.totalFollowing}</p>
<button className="button-dig" onClick={() => setSelectedProfile(null)}>Dig⛏</button>
<button className="button-dig" onClick={() => setSelectedProfile(null)}>Close</button>
</Modal>
</div>
);
};

Since I have not connected this UI to the LensTreasureHunt.sol Smart Contract, I left the Dig⛏️ button to do the same action as the Close button. The last file to edit will be the nextjs/pages/example-ui.ts where we will import ExploreProfiles and add to example-ui.ts

import type {NextPage} from "next";
import {MetaHeader} from "~~/components/MetaHeader";
import { ExploreProfiles } from "~~/components/example-ui/ExploreProfiles";
const ExampleUI: NextPage = () => {
return (
<>
<MetaHeader
title="Example UI | Scaffold-ETH 2"
description="Example UI created with 🏗 Scaffold-ETH 2, showcasing some of its features."
>
{/* We are importing the font this way to lighten the size of SE2. */}
<link rel="preconnect" href="<https://fonts.googleapis.com>" />
<link href="<https://fonts.googleapis.com/css2?family=Bai+Jamjuree&display=swap>" rel="stylesheet" />
</MetaHeader>
<div className="grid flex-grow" data-theme="exampleUi">
<ExploreProfiles />
</div>
</>
);
};
export default ExampleUI;

This is what the initial UI looked like after adding a few images and displaying the most important information for determining if a Dig ⛏️ is worth the digCost.

As you can see, I replaced images that were missing with my personal logo and other broken images are for the next debug journey. Now we have the basic UI functionality needed to move forward in deploying our Lens Treasure Hunt to the Polygon Mumbai Testnet. So now let’s begin the effort of porting the LensTreasureHunt.sol Smart Contract to our phat-scaffold-eth repo.

Final Version:

For this example, I did not set the Consumer Contract as a Minter role for the NFT contracts which is why the expected output would be a failed mint.

Closing

Here I show how a user can create a treasure hunt without revealing how to “game” the treasure hunt by leveraging Phat Contract 2.0 LensAPI Oracle to transform the stats returned from a profile and generated a number 0 to 5 to be returned to the Polygon consumer contract. Though this example looks simple, there are not many web3 applications that can do this in a way that requires paying gas on 2 chains or sacrificing decentralization by offloading compute to a centralized service. Feel free to reach out to me @hashwarlock on X | Lens | Farcaster | Telegram

--

--

Joshua Waller

Director of Tech Evangelism at Phala Network | 🔌⚡️Spark Creativity