Treasure Chests with AI Minting
Time to complete: 50-60 minutes
In this guide, we will create a web3 application built in React, leveraging tools from the Sequence Stack to mint AI generated loot from treasure chests using an Embedded Wallet for authentication, and Cloudflare workers for easy, no-confirmation transactions.
We've wrapped this in a dungeon crawler game to showcase these features in a game environment that you can play and earn rewards.
The tools will enable you to perform:
- Sequence Builder Console Signup & Project Creation: Create a project with the Builder
- Access Key Management: Claim a public, secret access key, and waas config key to interact with the Sequence stack
- Embedded Wallet Integration: Integrate an Embedded Wallet into the application
- Deploy a Contract & Sponsor gas: Deploy an items contract and sponsor gas
- Deploy a Cloudflare Worker: Deploy a Cloudflare worker for gasless and no-confirmation transactions
- Generating AI Prompts & Images: Craft AI prompts from an API and generate images to be uploaded
- Store Media to Sequence Metadata service: Upload collection and token metadata to Sequence
- Securing your Cloudflare Worker: Prevent outside requests to your Cloudflare worker by restricting the referrer URL
- (Optional) Native Mint Restriction Per Wallet: Restrict daily mints per wallet
1. Sequence Builder Console Signup & Project Creation
First start by following this walkthrough for how to sign up to the Sequence Builder Console and to learn how to create a project.
To use certain features, like Gas Sponsoring
and the use of the Transactions API
, you'll need to upgrade your project plan to Developer
using this walkthrough
2. Access Key Management
Now that you have a project, you'll need to acquire 3 different Access Keys for your project in order to authenticate your application with the Sequence Stack:
Waas Config Key
used for the Embedded Wallet, which can be learned about herePublic Access Key
used for the Embedded Wallet & Transactions API, which can be done hereSecret Access Key
used for the Metadata Service, with the following steps
Secret Access Key Creation
Access Settings
First start by accessing settings, and selecting the API Keys
Add Service Account
Scroll down and select + Add Service Account
Select Write Permission
Then change the access to Write
and Confirm
Finally copy
the key and store it in a safe location, as you will not have access to this in the future from the Builder Console.
3. Embedded Wallet Integration
We'll start from scratch building up the project with the necessary pieces, to enable the use of a Sequence Embedded Wallet which enables users to be onboarded to your application using web2 authentication providers.
First start by creating a project folder with mkdir <project>
, then cd <project>
and create a vite
project using React:
pnpm create vite
# or
yarn create vite
# or
npm create vite
Next, we'll install the correct Wallet-as-a-Service (Waas) package to use the Embedded Wallet:
pnpm install @0xsequence/waas
# or
npm install @0xsequence/waas
# or
yarn add @0xsequence/waas
For all of the new files created in the follows steps, have them be created in /src
First, create a file called something like SequenceEmbeddedWallet.ts
with the following initialization code:
import { SequenceWaaS } from '@0xsequence/waas'
const sequence = new SequenceWaaS({
projectAccessKey: import.meta.env.VITE_PROJECT_ACCESS_KEY!,
waasConfigKey: import.meta.env.VITE_WAAS_CONFIG_KEY!,
network: 'arbitrum-nova'
})
export default sequence;
Then create another file called useSessionHash.ts
that generates a session hash from the SDK unique to the user:
import sequence from './SequenceEmbeddedWallet.ts'
import { useEffect, useState } from "react";
export function useSessionHash() {
const [sessionHash, setSessionHash] = useState("")
const [error, setError] = useState<any>(undefined)
useEffect(() => {
const handler = async () => {
try {
setSessionHash(await sequence.getSessionHash())
} catch (error) {
console.error(error)
setError(error)
}
}
handler()
return sequence.onSessionStateChanged(handler)
}, [setSessionHash, setError])
return {
sessionHash,
error,
loading: !!sessionHash,
}
}
Finally, to implement Google auth, you will need the GoogleOAuthProvider
to wrap your application. The following command will install it and Apple Auth sign in, which will be used later:
pnpm i @react-oauth/google react-apple-signin-auth
Then, the starter code is implemented with the previous imported files, in the following code within the main.tsx
file:
import React from 'react'
import ReactDOM from 'react-dom/client'
import './index.css'
import App from './App.tsx'
import { useSessionHash } from "./useSessionHash.ts";
import { ThemeProvider } from '@0xsequence/design-system'
import { GoogleOAuthProvider } from '@react-oauth/google'
function Dapp() {
const { sessionHash } = useSessionHash()
return (
<GoogleOAuthProvider clientId="<GOOGLE_CLIENT_ID>" nonce={sessionHash} key={sessionHash}>
<App />
</GoogleOAuthProvider>
);
}
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<Dapp />
</React.StrictMode>
)
Once your main.tsx
is ready, lets create the login buttons, which will look like this:
In App.tsx
use the following code that checks to see if a user is connected and presents a wallet address based on the signed in user, with the various social auth buttons and handlers:
import { useState, useEffect } from 'react'
import './App.css'
import sequence from './SequenceEmbeddedWallet'
import { useSessionHash } from './useSessionHash'
import { CredentialResponse, GoogleLogin } from '@react-oauth/google';
import AppleSignin from 'react-apple-signin-auth';
import playImage from './assets/play.svg'
function LoginScreen () {
const { sessionHash } = useSessionHash()
const [wallet, setWallet] = useState<any>(null)
const handleGoogleLogin = async (tokenResponse: CredentialResponse) => {
const res = await sequence.signIn({
idToken: tokenResponse.credential! // inputted id credential from google
}, "template")
setWallet(res.wallet)
}
const handleAppleLogin = async (response: any) => {
const res = await sequence.signIn({
idToken: response.authorization.id_token! // inputted id token from apple
}, "template")
setWallet(res.wallet)
}
// checks to see if there is a logged in user
useEffect(() => {
setTimeout(async () => {
if(await sequence.isSignedIn()){
setWallet(await sequence.getAddress())
}
}, 0)
}, [])
useEffect(() => {
}, [wallet])
const signOut = async () => {
try {
const sessions = await sequence.listSessions()
for(let i = 0; i < sessions.length; i++){
await sequence.dropSession({ sessionId: sessions[i].id })
}
}catch(err){
console.log(err)
}
}
return (
<>
{
!wallet
?
<>
<span className='sign-in-via'>SIGN IN VIA</span>
<br/>
<br/>
<br/>
<div className="login-container">
<div className='dashed-box-google'>
<p className='content'>
<div className='gmail-login' style={{overflow: 'hidden', opacity: '0', width: '90px', position: 'absolute', zIndex: 1, height: '100px'}}>
<GoogleLogin
nonce={sessionHash}
key={sessionHash}
onSuccess={handleGoogleLogin} shape="circle" width={230} />
</div>
<span className='gmail-login'>Gmail</span>
</p>
</div>
<div className='dashed-box-apple'>
<p className='content'
style={{position:'relative'}}>
<span className='apple-login'>
{/* @ts-ignore */}
<AppleSignin
key={sessionHash}
authOptions={{
clientId: '<replce with com. bundle id>',
scope: 'openid email',
redirectURI: '<must be a deployed URL>',
usePopup: true,
nonce: sessionHash
}}
onError={(error: any) => console.error(error)}
onSuccess={handleAppleLogin}
/>Apple
</span>
</p>
</div>
</div>
</>
:
<>
<div className="login-container">
<p style={{cursor: 'pointer'}} onClick={() =>signOut()}>sign out</p>
<span >{wallet}</span>
</div>
</>
}
</>
)
}
function App() {
return (
<LoginScreen/>
)
}
export default App
Then, include a .env
file in the root of your project, adding it to .gitignore
, and updating the file with the following values from the Sequence Builder:
VITE_PROJECT_ACCESS_KEY=
VITE_WAAS_CONFIG_KEY=
Run your code with the following command in the root folder and give it a try:
pnpm run dev
4. Deploy A Contract & Sponsor Gas
We will deploy an token contract so we are able to link the AI-generated images to the metadata for any given token. When deploying your contract, the recommended approach is to use an ERC1155
over an ERC721
. The benefits of using an ERC1155
- Semi-Fungible: which is ideal for game assets that can have multiple copies of the same underlying item.
- Gas Savings: for projects that require multiple tokens as a single ERC1155 can hold many different varieties.
In terms of the gas savings benefit, instead of deploying a new contract for each token type, a single ERC1155 token contract can hold the entire system state, reducing deployment costs and complexity.
To deploy a contract, you can follow this guide to deploy your ERC1155, and update your wrangler.toml
with the CONTRACT_ADDRESS
.
Then, for the minting function to work programmatically so that transactions are gasless for your relayer, you will need to make the Transactions API draw from your account credits on your upgraded billing plan by sponsoring your deployed smart contract address.
To allow the Transactions API to relay transactions without a fee, sponsor gas by following this guide for the deployed contract.
5. Deploy Transactions API on a Cloudflare Worker
Following the previous step, the Sequence Transactions API can be implemented on a serverless Cloudflare worker so a game or app user interaction is seamless without a confirmation signature or gas payment. In this case, the worker will leverage the Sequence Transactions API to mint tokens to the user's address. You'll also benefit from not having to be worried about transaction speed, throughput or re-orgs, and experience automatic scaling with Cloudflare.
Minting A Token
If you want to learn how to deploy a Cloudflare Worker from scratch, you can follow this guide on minting a serverless NFT minting service with your deployed ERC1155
contract or simply clone the template specific to this guide.
Once setup, we will call the endpoint of the cloudflare instance to mint our NFTs in a later step.
6. Generating AI Prompts & Images
When beginning your journey into AI image generation, you will require a source of AI model prompts to produce media. For this guide and demo, we've sourced prompts from the items contained in the Diablo game.
In the template, we've included code to call an already deployed API and code to parse the response.
With this API, we will showcase how to generate images using the prompt from the deployed Diablo API within the generate
function within the Cloudflare worker:
const generate = async () => {
const url = 'https://flask-production-2641.up.railway.app/'; // External API endpoint
const init = {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
};
const response = await fetch(url, init); // Fetch data from external API
const data: any= await response.json();
const defend = Math.random() >= 0.5 ? true : false
const attributes = []
// parse the data to create the attributes
return {loot: data[defend ? 'armor' : 'weapon'], attributes: attributes}
}
Then complete the getInferenceWithItem
function in order to get the instantiated inference reference from the Scenario API and pass in a prompt
which is the generated loot name
and type
, as well as some additional parameters model parameters, which can be customized via the Scenario API docs:
const getInferenceWithItem = async (env: Env, prompt: any) => {
try {
const res: any = await fetch(`https://api.cloud.scenario.com/v1/models/${env.SCENARIO_MODEL_ID}/inferences`, {
method: 'POST',
headers: {
'Authorization': `Basic ${env.SCENARIO_API_KEY}`,
'accept': 'application/json',
'content-type': 'application/json'
},
body: JSON.stringify({
"parameters": {
"numSamples": 1,
"qualityBoostScale": 4,
"qualityBoost": false,
"type": "txt2img",
"disableMerging": false,
"hideResults": false,
"referenceAdain": false,
"intermediateImages": false,
"scheduler": 'EulerDiscreteScheduler',
"referenceAttn": false,
"prompt": prompt + ' single object on black background no people'
}
})
})
const data = await res.json()
console.log(data)
return {inferenceId: data.inference.id}
}catch(err){
console.log(err)
return {inferenceId: null, err: "ERROR"}
}
}
Then we simply implement the above functions in the react code:
...
if(mint){
...
} else {
const loot = await generate()
const inferenceId = await getInferenceWithItem(env, loot.loot.name + " " + loot.loot.type)
...
}
...
Once we have the inferenceId
we can call poll the inference status and return when complete, signified by the succeeded
status descriptor:
const getInferenceObjectWithPolling = async (env: Env, id: any) => {
console.log('getting inference status for: ', id.inferenceId)
const inferenceId = id.inferenceId
const headers = {
'Authorization': `Basic ${env.SCENARIO_API_KEY}`,
'accept': 'application/json',
'content-type': 'application/json'
}
// Function to poll the inference status
const pollInferenceStatus = async () => {
let status = '';
let inferenceData: any = null;
while (!['succeeded', 'failed'].includes(status)) {
// Fetch the inference details
try {
const inferenceResponse = await fetch(`https://api.cloud.scenario.com/v1/models/${env.SCENARIO_MODEL_ID}/inferences/${inferenceId}`, {
method: 'GET',
headers
})
if (inferenceResponse.ok) {
console.log(inferenceResponse.statusText)
inferenceData = await inferenceResponse.json();
}
}catch(err){
console.log(err)
}
status = inferenceData.inference.status;
console.log(`Inference status: ${status}`);
// Wait for a certain interval before polling again
await new Promise(resolve => setTimeout(resolve, 5000)); // Polling every 5 seconds
}
// Handle the final status
if (status === 'succeeded') {
console.log('Inference succeeded!');
console.log(inferenceData); // Print inference data
return inferenceData
} else {
console.log('Inference failed!');
console.log(inferenceData); // Print inference data
throw new Error("Scenario API Failed")
}
};
// Start polling the inference status
return await pollInferenceStatus();
}
Again, we add the above function to the react code and pass the inferenceId. When you receive the response, you can obtain the image url with resObject.inference.images[0].url
:
...
if(mint){
...
} else {
const loot = await generate()
const inferenceId = await getInferenceWithItem(env, loot.loot.name + " " + loot.loot.type)
const resObject = await getInferenceObjectWithPolling(env, inferenceId)
console.log(resObject.inference.images[0].url) // prints image url
...
}
...
7. Store Media to Sequence Metadata Service
With our media url
from the Scenario API in hand, we can move onto storing the asset to the Sequence Metadata Service
. This enables you to link the AI-generated image to the specific token metadata - all through REST-API calls.
Each Dungeon Minter treasure chest reward follows the same process where metadata is first stored using the Sequence Metadata API, the url
and the randomly generated tokenID
(which allows for parallel requests) is returned to the client. The user then consents, after collectible inspection, to mint the token where the tokenID
and the user's address
passed back to the worker created in Step 5.
Implementation
Complete and integrate this guide in order to build your serverless Media Service leveraging the Sequence Metadata API that uses Cloudflare workers or simply clone our cloudflare template for this guide.
Once complete, pass the stored media tokenID
and url
to the frontend to be rendered, to allow a user to mint after first viewing what they're minting:
const randomTokenIDSpace = ethers.parseUnits(String('10000'), 18)
...
const jsonCreateAsset = await collectionsService.createAsset({...})
...
const response = await uploadAsset(env, projectID, collectionID, jsonCreateAsset.asset.id, String(randomTokenIDSpace), imageUrl)
return new Response(JSON.stringify({tokenID: String(randomTokenIDSpace), image: response.url }), { status: 200 });
8. Minting with your Cloudflare Worker
The last step is to finally mint the corresponding tokenId that you linked the metadata to previously to the user's address. Here we post a request to the Cloudflare Worker we created in Step 5 which will mint the token to the user.
const data = {
address: address,
mint: true,
tokenID: tokenID
};
const res = await fetch(ENDPOINT, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(data),
})
const json = await res.json()
One important note is that you will likely want to ensure that your Cloudflare workers only process requests from a certain frontend origin, you can simply check the request.headers
for the Referrer
value and compare it against the CLIENT_URL
in the wrangler.toml
:
async function handleRequest(request: any, env: Env, ctx: ExecutionContext) {
const originUrl = new URL(request.url);
const referer = request.headers.get('Referer');
if(referer.toString() != env.CLIENT_URL){
return new Response('Bad Origin', { status: 500 }); // Handle errors
}
...
}
Conclusion
Let's recap what we've done during this tutorial:
We've covered how to create a Sequence project and obtain access to our suite of APIs. We've deployed and setup an embedded wallet to ensure smooth gameplay for an example dungeon crawler game. Additionally, we've used the Sequence platform to deploy a contract, sponsor gas for that contract to simplify the user experience. We also deployed a serverless NFT minter using the Sequence Transaction API which enables your game to scale to millions of players and handle complex blockchain interactions like reorgs. Furthermore, we've leveraged the scenario.gg API to dynamically create game assets as rewards for players. These images are then linked to the metadata of an NFT using the Sequence Metadata API. You should now understand how to mint AI art for your Scenario.gg and Sequence powered game.It's clear how many components go into creating a scalable, secure, and fun blockchain-enabled game, but the Sequence platform with Scenario has you covered.
Lastly, you can see all the steps above incorporated in a complete experience with our dungeon crawler game to brave the maze and get your own dungeon loot.
Happy building!
9. (Optional) Native Mint Restriction Per Wallet
As an option to prevent overuse of the treasure chest minting from specific wallets, a parameter called DAILY_MINT_RESTRICTION
can be set in the wrangler.toml
as a maximum mint allowance per day. And, if you feel it's necessary to add an ADMIN
to your protocol to be able to mint an infinite amount.
These features can be implemented in the code with the following steps:
async function handleRequest(request: any, env: Env, ctx: ExecutionContext) {
...
const payload = await request.json()
const { address, tokenID }: any = payload
// check for admin
if(address.toLowerCase() != env.ADMIN.toLowerCase() && !await hasDailyMintAllowance(env, address)){
// check for daily mint allowance
return new Response(JSON.stringify({limitExceeded: true}), { status: 400 })
}
...
}
Where hasDailyMintAllowance
is broken down into 2 functions:
fullPaginationDay
of transactions of the usersaddress
mintCount
that corresponds to thefrom
being the0x
address
Full Pagination Of Indexer For A Day
In order to use the Sequence Indexer, we'll need to pnpm install @0xsequence/indexer
Then, to implement we use a while loop that gets the first batch of transactions and the page.after
value from the indexer, and continuously checks if the timestamp is less than 24 hours appending to a temporary array for each pass. This ensures we get all of the available transactions:
import { SequenceIndexer } from '@0xsequence/indexer'
const isLessThan24Hours = (isoDate: string) => {
const dateProvided: any = new Date(isoDate);
const currentDate: any = new Date();
const twentyFourHours = 24 * 60 * 60 * 1000; // 24 hours in milliseconds
// Calculate the difference in milliseconds
const difference = currentDate - dateProvided;
// Check if the difference is less than 24 hours
return difference < twentyFourHours && difference > 0;
}
const fullPaginationDay = async (env: Env, address: string) => {
const txs: any = []
const indexer = new SequenceIndexer(`https://{env.CHAIN_HANDLE}-indexer.sequence.app`, env.PROJECT_ACCESS_KEY)
const filter = {
accountAddress: address,
};
// query Sequence Indexer for all token transaction history
let txHistory: any
let firstLoop = true;
let finished = true;
// if there are more transactions to log, proceed to paginate
while(firstLoop || (!finished && txHistory.page.more)){
if(firstLoop){
firstLoop = false
txHistory = await indexer.getTransactionHistory({
filter: filter,
page: { pageSize: 50 }
})
for(let i = 0; i < txHistory.transactions.length; i++){
if(!isLessThan24Hours(txHistory.transactions[i].timestamp)){
finished = true
}
txs.push(txHistory.transactions[i])
}
}
txHistory = await indexer.getTransactionHistory({
filter: filter,
page: {
pageSize: 50,
// use the after cursor from the previous indexer call
after: txHistory!.page!.after!
}
})
for(let i = 0; i < txHistory.transactions.length; i++){
if(!isLessThan24Hours(txHistory.transactions[i].timestamp)){
finished = true
}
txs.push(txHistory.transactions[i])
}
}
return txs
}
Mint Count For A Day
All collectibles minted from the ERC721
and ERC1155
standard Sequence contracts are from
the 0x
address:
const mintCount = (env: Env, txs: any) => {
let count = 0
for(let i = 0; i < txs.length; i++){
if(
txs[i].transfers[0].from == '0x0000000000000000000000000000000000000000'
&& txs[i].transfers[0].contractAddress == env.CONTRACT_ADDRESS.toLowerCase()
) count++
}
return count
}
Has Daily Mint Allowance
const hasDailyMintAllowance = async (env: Env, address: string) => {
const txs = await fullPaginationDay(env, address)
const count = mintCount(env, txs)
return count < env.DAILY_MINT_RESTRICTION
}