How to Create a Gasless NFT Minter using AA-SDK, Create Web3 Dapp & Userbase
Learn how to set up a full-stack end-to-end solution that implements account abstraction in order to enable gasless NFT minting.
How to Build a Gasless NFT Minter using Alchemy's Account Abstraction SDK
Ever wanted to drop an NFT collection where your users do not need to pay gas fees or even own a web3 wallet to mint? Well, look no further!
Please note: this guide is meant to show you the ropes around building AA-enabled dApps and therefore purely for educational purposes. The end-product of this guide should never be used for production without professional auditing.
In this guide, we will walk through the following:
- Setting up a NextJS-based application using Create Web3 Dapp
- Setting up Userbase in order to have our app have built-in user accounts & authentication without any database setup needed
- Deploying an ERC-721 contract to the Sepolia test network
- Using the Alchemy Account Abstraction SDK to rig up our application to gasless web3 interactions with the ERC-721 contract (ie, gasless minting, burning and transfering), all thanks to Alchemy's Gas Manager Services.
👀 Your end-product will look a little like this:
All of the code in this guide is available in this Github repo, please feel free to fork and modify!
Pre-Requisities
- Node.js version 16.14.0 or higher.
- This project will make use of Create Web3 Dapp, a web3-enabled wrapper of Create Next App. ⚠️ Please note: Create Web3 Dapp ships out of the box using NextJS 13 which means the project's main folder is
/app
instead of/pages
.
Step 1: Local Project Setup
Create Web3 Dapp Setup
Let's get to it! First up, let's set up our local development environment...
- Open a terminal and navigate to your preferred directory for local development - for the purposes of this guide, we'll use
~/Desktop/my-aa-project/
- Once in the
/my-aa-project
directory, runnpx create-web3-dapp@latest
- For the initialization wizard, please choose the following options:
- Project name:
gasless-nft-minter
- Choose how to start:
Boilerplate dapp
- Choose Typescript or Javascript:
Typescript
- Choose your blockchain development environment:
Skip
- Login to your Alchemy account (or sign up for one) to acquire an API key
Note: please be mindful of the application you use to create your Alchemy API key - you'll need to re-visit this in Step #2!
- As it should say in your terminal, run
cd gasless-nft-minter
and then runnpm run dev
Nice! You just used Create Web3 Dapp to startup your local development environment with important dependencies, out-of-the-box, such as:
- viem: viem delivers a great developer experience through modular and composable APIs, comprehensive documentation, and automatic type safety and inference.
- ConnectKit: ConnectKit is a powerful React component library for connecting a wallet to your dApp. It supports the most popular connectors and chains, and provides a beautiful, seamless experience.
- wagmi: wagmi is a collection of React Hooks containing everything you need to start working with Ethereum. wagmi makes it easy to "Connect Wallet," display ENS and balance information, sign messages, interact with contracts, and much more — all with caching, request deduplication, and persistence.
- and more!
ConnectKit Wallet Setup
Even though we won't be building anything that interfaces with a web3 browser wallet in this guide, let's still make sure we plug in the ConnectKit API key so that we don't get errors - and in case, you DO want to add web3
- Go to https://walletconnect.com/
- Create an account and go to the
Dashboard
- Select
+ New Project
- Copy the
Project ID
- Open your project's
.env.local
file and add the following variable:
CONNECT_KIT_PROJECT_ID=<PASTE-YOUR-WALLET-CONNECT-APP-ID-HERE>
- Save the file! Now, go into you your project's
layout.tsx
and make sure to changeline 10
so that it receives the variable you just set up.
Your config object should look like this:
const config = createConfig(
getDefaultConfig({
// Required API Keys
alchemyId: process.env.ALCHEMY_API_KEY,
walletConnectProjectId: process.env.CONNECT_KIT_PROJECT_ID!,
chains: [sepolia],
// Required
appName: "My Gasless NFT Minter",
})
);
Typescript @common
Setup
@common
SetupOne thing that'll make your development flow 100x easier is adding @common
folder pathing. This allows you to import components between files without having to explicitly type the path to the component.
All you need to do:
- In your
tsconfig.json
file, replace lines22-24
, with:
"baseUrl": "./",
"paths": {
"@common/*": ["common/*"],
"@public/*": ["public/*"]
}
- In your project's root folder, create a new folder called
common
Sweet! Now we can create our components in the /common
folder and easily import them across our app! 🏗️
Step 2: Set Up A Gas Policy on Alchemy + Install Styling Dependencies
Sponsored Gas Policy Setup
Thanks to Alchemy's Gas Manager Coverage API, you are able to create gas policies to build applications with "gasless" features - meaning: your users can interact and perform typical web3 actions such as owning, minting, burning and transfering an NFT - without users having to pay gas fees. This type of infrastructure can be super powerful depending on your use case.
Since this is an AA-enabled application, users will be represented as smart contract wallets - and we will choose to sponsor the minting of an NFT user operation for them! Let's set up a gas policy to sponsor our users and provide them the greatest UX ever! 🔥
- Go to https://www.alchemy.com/ and sign in to your account
- In the left sidebar, select
Account Abstraction
and once the menu opens, selectGas Manager
- Once you are in the
Gas Manager
page, select theCreate new policy
button
- For the Policy details, fill in the following:
- Policy Name:
Gas Fee Sponsorship for my Gasless NFT Minter App
- For App: Choose the app attached to the Alchemy API key you used in Step #1!
- Select
Next
- For the Spending rules, fill in the following:
- Select
Next
Since our app will strictly be on Sepolia, these don't really matter but are still good safeguards to input.
- For the
Access controls
, simply selectNext
- For the
Expiry and duration
, select the following (ie, end the policy effective 1 month from today's date AND make user op signatures valid for 10 minutes after they are sent):
-
Lastly select
Review Policy
and thenPublish Policy
-
Once you are routed to your dashboard, make sure to
Activate
your new policy! Your policy should look something like this:
Nice! Now, the Alchemy API key you have in your project's .env
file is directly tied to a gas policy to sponsor user operations.
Add the Gas Policy Id to Your .env.local
File
.env.local
FileIn the Alchemy dashboard, where you just created your Gas Manager Policy, you'll need to copy-paste the Policy ID
into your project:
- Open your project's
.env.local
file and create the following variable:
SEPOLIA_PAYMASTER_POLICY_ID=<COPY-PASTE-YOUR-GAS-POLICY-ID-HERE>
- Save your file!
Sweet. We're all done with gas policy setup! Let's add some styling dependencies to our project now... ⬇️
Styling: Tailwind CSS & DaisyUI
In order for this app to look pretty, let's install TailwindCSS and DaisyUI, just in case!
Tailwind CSS
This step is exactly the same as the official instructions EXCEPT, the
tailwind.config.css
here is a bit different to account for our use of@common
folder.
- In your project's root folder, run
npm install -D tailwindcss postcss autoprefixer daisyui@latest
- Then, run
npx tailwindcss init -p
You should see your terminal output:
Created Tailwind CSS config file: tailwind.config.js
Created PostCSS config file: postcss.config.js
- Copy-paste the following into your app's newly-created
tailwind.config.css
file:
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
"./app/**/*.{js,ts,jsx,tsx,mdx}",
"./common/**/*.{js,ts,jsx,tsx,mdx}",
"./public/**/*.{js,ts,jsx,tsx,mdx}",
],
theme: {
extend: {},
},
plugins: [require("daisyui")],
}
- Lastly, copy-paste the following into the very top of your app's
globals.css
file:
@tailwind base;
@tailwind components;
@tailwind utilities;
Daisy UI
This step is EXACTLY the same as the official DaisyUI installation instructions.
- Run
npm i -D daisyui@latest
- In your newly-created
tailwind.config.css
, add the following key to themodule.exports
in that file:
plugins: [require("daisyui")],
You're done! Now you have access to cutting-edge styling libraries - this will be great in order to deliver better UX! 🤩
Step 3: Set Up Authentication
One of the first things you'll need in an AA-enabled application is a way to authenticate/map a real-life user record to an account on your app. Since our goal is to NOT use web3 browser wallets, we need to rely on a more traditional way to authenticate a user.
The authentication setup we will use is simplistic but powerful - and NOT secure. Remember, this guide is for educational purposes and should never be used in a production setting!
Set Up Userbase Account
Let's set up our own authentication with Userbase!
- First of all, run
npm i userbase-js
in your terminal - Now, go to https://userbase.com/ and create an account
- Once you sign in, you should see a default Starter App
- Copy the Starter App's
App Id
- Go to your
.env.local
file, create a variable and paste your app id, like this:
NEXT_PUBLIC_USERBASE_APP_ID=<PASTE_YOUR_STARTER_APP_ID>
Note: In order to expose the app ID value to the client-side, you must add
NEXT_PUBLIC
to the variable.
- Next, go back to Userbase and go to the
Account
tab in the navbar - Scroll down on the page till you see the
Access Tokens
section - Type in your password and write
get-userbase-user
for the label, then hitGenerate Access Token
- Once you have your access token, go back to your
.env.local
and add a variable again like this:
USERBASE_ACCESS_TOKEN=<PASTE_YOUR_GENERATED_ACCESS_TOKEN>
Sweet! You've set up everything needed on the Userbase side, now let's continue building our auth implementation! 💪
Why did we use Userbase? Because it's a quick, powerful and easy solution for managing user accounts without needing to set up bulky servers or databases!
Set Up AuthProvider
AuthProvider component
- In your project's
/common
folder, create a new file calledAuthProvider.tsx
and copy-paste the following:
import {
ReactNode,
createContext,
useContext,
useEffect,
useState,
} from "react";
import userbase from "userbase-js";
interface User {
username: string;
isLoggedIn: boolean;
userId: string;
scwAddress?: string;
}
interface AuthContextType {
user: User | null;
login: (user: User) => void;
logout: () => void;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
export function useAuth(): AuthContextType {
const context = useContext(AuthContext);
if (!context) {
throw new Error("useAuth must be used within an AuthProvider");
}
return context;
}
interface AuthProviderProps {
children: ReactNode;
}
export function AuthProvider({ children }: AuthProviderProps) {
const [user, setUser] = useState<User | null>(null);
useEffect(() => {
userbase
.init({
appId: process.env.NEXT_PUBLIC_USERBASE_APP_ID!,
})
.then((session: any) => {
// SDK initialized successfully
if (session.user) {
// there is a valid active session
console.log(
`Userbase login succesful. ✅ Welcome, ${session.user.username}!`
);
console.log(session.user);
const userInfo = {
username: session.user.username,
isLoggedIn: true,
userId: session.user.userId,
scwAddress: session.user.profile.scwAddress,
};
login(userInfo);
console.log(
"Logged out in the authprovider, here is the user " + user?.username
);
}
})
.catch((e: any) => console.error(e));
}, []);
const login = (user: User) => {
setUser(user);
};
const logout = () => {
setUser(null);
};
return (
<AuthContext.Provider value={{ user, login, logout }}>
{children}
</AuthContext.Provider>
);
}
Use AuthProvider component in layout.tsx
layout.tsx
In order for your AuthProvider
component to take effect on your app, follow these steps:
- Go to your project's
layout.tsx
- Delete
lines 4-5
, we don't need them! (feel free to implement them yourself!) - After
line 10
, add the following:
chains: [sepolia],
- Remember to add the
sepolia
import from wagmi online 4
:
import { WagmiConfig, createConfig, sepolia } from "wagmi";
This is to protect our users! We want to only allow use of this app exclusively on the Sepolia test network.
- Then, add the following import at the top of the file (but not above the
'use client'
statement):
import { AuthProvider } from "@common/AuthProvider";
- Now, wrap your entire
RootLayout
export in theAuthProvider
component you just imported, it should look like this:
Remember to remove the
<Navbar>
and<Footer>
components!
<html lang="en">
<AuthProvider>
<WagmiConfig config={config}>
<ConnectKitProvider mode="dark">
<body>
<div style={{ display: "flex", flexDirection: "column", minHeight: "105vh" }}>
<div style={{flexGrow: 1}}>{children}</div>
</div>
</body>
</ConnectKitProvider>
</WagmiConfig>
</AuthProvider>
</html>
Your application, and any of its components, now has access to user authentication state - nice! 🔥
Step 3: Set Up Sign Up / Login Routes
Now, let's use NextJS 13 /app
infrastructure to set up some routes! This section is heavy so get ready! 🧗♂️
Sign Up
- Run
npm i @noble/secp256k1
- Create a new folder in the
/app
folder called/sign-up
and then in that folder create a file calledpage.tsx
This is how you create routes in NextJS 13! By doing the above step, your app will now expose the following route:
localhost:3000/sign-up
and render whatever component you put insidepage.tsx
- In the
/sign-up/page.tsx
file, copy-paste the following code:
"use client";
import "../globals.css";
import * as secp from "@noble/secp256k1";
import { useAuth } from "@common/AuthProvider";
import Loader from "@common/utils/Loader";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { publicClient } from "@common/utils/client";
import simpleFactoryAbi from "@common/utils/SimpleAccountFactory.json";
import userbase from "userbase-js";
export default function SignupForm() {
const { user, login } = useAuth();
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const router = useRouter();
useEffect(() => {
if (user?.isLoggedIn) {
router.push("/");
}
}, []);
const handleSignup = async (e: any) => {
setIsLoading(true);
e.preventDefault();
try {
const privKey = secp.utils.randomPrivateKey();
const privKeyHex = secp.etc.bytesToHex(privKey);
const data = {
pk: privKeyHex,
};
const response1 = await fetch("/api/get-signer/", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(data),
});
const responseData = await response1.json();
const ownerAddress = responseData.data; // access the signer object
const userScwAddress: string = (await publicClient.readContract({
address: "0x9406Cc6185a346906296840746125a0E44976454", // simple factory addr
abi: simpleFactoryAbi,
functionName: "getAddress",
args: [ownerAddress, 0],
})) as string;
const response2 = await userbase.signUp({
username,
password,
rememberMe: "local",
profile: {
scwAddress: userScwAddress,
pk: privKeyHex,
},
});
const userInfo = {
username: username,
isLoggedIn: true,
userId: response2.userId,
scwAddress: userScwAddress,
};
login(userInfo);
router.push("/?signup=success");
} catch (error: any) {
setIsLoading(false);
setError(error.message);
console.error(error);
}
};
return (
<div>
{isLoading ? (
<Loader />
) : (
<div className="flex items-center justify-center h-screen bg-gray-100">
<div className="w-full max-w-sm">
<form
className="bg-white rounded px-8 pt-6 pb-8 mb-24 font-mono"
onSubmit={handleSignup}
>
<label
className="block text-center text-gray-700 font-bold mb-2 text-xl"
htmlFor="username"
>
Sign Up 👋
</label>
<div className="divider"></div>
<div className="mb-4 text-xl">
<label
className="block text-gray-700 font-bold mb-2"
htmlFor="username"
>
Username
</label>
<input
className="bg-white shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
onChange={(e) => setUsername(e.target.value)}
id="username"
type="text"
placeholder="Username"
/>
</div>
<div className="mb-6 text-xl">
<label
className="block text-gray-700 font-bold mb-2"
htmlFor="password"
>
Password
</label>
<input
className="bg-white shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
id="password"
type="password"
placeholder="Password"
onChange={(e) => setPassword(e.target.value)}
/>
</div>
{error && <p className="text-red-500 mb-4">{error}</p>}{" "}
<div className="flex items-center justify-end">
<button className="btn text-white">Sign Up</button>
</div>
</form>
</div>
</div>
)}
</div>
);
}
There is quite a bit going on in this file. Particularly, there are a lot of things we are importing (the next step is setting all of these imports up!). Let's break this file down a bit by looking at some of the imports we haven't seen yet:
Sign Up Imports
import * as secp from "@noble/secp256k1";
When signing up, we use the @noble/secp256k1 crypto library to generate a private key for the user.
What? A private key? Since this is a simple implementation of account abstraction, we will use the Simple Account model which makes use of a private key to generate and own a smart contract wallet.
import { useAuth } from "@common/auth/AuthProvider";
Our good ole' authentication hook which will give us access to the user's state across our app.
import Loader from "@common/utils/Loader";
This is a simple Loader component that we will use from DaisyUI. The loader will display any time there is an API query being performed. This just makes for better UX.
import { publicClient } from "@common/utils/client";
import simpleFactoryAbi from "@common/utils/SimpleAccountFactory.json";
The important utility here is the SimpleAccountFactory.json
import. We import the abi
of the SimpleAccountFactory smart contract We will make use of one of the read functions in order to deterministically generate a user's smart contract wallet address.
import { useRouter } from "next/navigation";
Thanks to native next/navigation
package, we can make use of the useRouter
hook to handle routing across our application.
handleSignup()
Function
handleSignup()
FunctionThe handleSignup
function does quite a bit too, let's break it down:
- First, it generates a private key using
@noble/secp256k1
- Then it uses that private key to make a call to the server-side of the application (specifically to the
/api/get-signer
endpoint, which we have yet to set up)- The
get-signer
endpoint uses the private key to create aSigner
object paired with theSimpleSmartAccountOwner
imported from Alchemy's AA SDK. The endpoint simply returns the address of the signer that will own the smart contract wallet.
- The
- We then do some viem magic to interface with the SimpleAccountFactory smart contract (using the imported
abi
) in order to deterministically read the user's smart contract wallet address, calleduserScwAddress
.
NOTE: We don't the smart contract account just yet! We are just using the
getAddress
function of the SimpleAccountFactory contract
- After acquiring the user's smart contract wallet address, we have all of the info we want to create an account and store it in Userbase. The
userbase.signUp
API request sends the user's private key and smart contract wallet address - this creates a new user record on userbase with this data mapped to the user account.
NOTE: We choose to store the private key in plaintext on the Userbase server, a practice that should NEVER move outside the bounds of testing. You would need to set up more authentication servers to store the key more securely. For our purposes, this works.
response2
is an response object we receive back from Userbase. All that's left to do is use it to extract the user'suserId
. Then we create an object of all the user data we have up to this point:
const userInfo = {
username: username,
isLoggedIn: true,
userId: response2.userId,
scwAddress: userScwAddress,
};
login(userInfo);
router.push("/?signup=success");
-
login
is a function we import from ourAuthProvider
- all that passing in theuserInfo
object to it does is make all that user data available all across our app - we will need it! -
router.push(/?signup=success)
simply routes the dapp to the/
route (which is whatever is inapp/page.tsx
)
Log In
Now that we've set up a route to sign up, let's also set one up to allow our users to log in to the app! 🤝
-
Similar to the sign up step above, create a new folder in the
/app
folder called/login
and then in that folder create a file calledpage.tsx
-
In the
/login/page.tsx
file, copy-paste the following code:
"use client";
import { useAuth } from "@common/AuthProvider";
import Loader from "@common/utils/Loader";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import userbase from "userbase-js";
import "../globals.css";
export default function LoginForm() {
const { user, login } = useAuth();
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const router = useRouter();
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
if (user?.isLoggedIn) {
router.push("/");
}
}, []);
const handleLogin = async (e: any) => {
setIsLoading(true);
e.preventDefault();
try {
const response = await userbase.signIn({
username,
password,
rememberMe: "local",
});
const userInfo = {
username: username,
isLoggedIn: true,
userId: response.userId,
userScwAddress: response.profile?.scwAddress,
};
login(userInfo);
router.push("/?login=success");
console.log(`Userbase login succesful. ✅ Welcome, ${username}!`);
} catch (error: any) {
setIsLoading(false);
setError(error.message); // Update the error state
console.error(error);
}
};
return (
<div>
{isLoading ? (
<Loader />
) : (
<div className="flex items-center justify-center h-screen bg-gray-100">
<div className="w-full max-w-sm">
<form
className="bg-white rounded px-8 pt-6 pb-8 mb-24 font-mono"
onSubmit={handleLogin}
>
<label
className="block text-center text-gray-700 font-bold mb-2 text-xl text-white"
htmlFor="username"
>
Login 🧙♂️
</label>
<div className="divider"></div>
<div className="mb-4 text-xl ">
<label
className="block text-gray-700 mb-2 font-bold"
htmlFor="username"
>
Username
</label>
<input
className="bg-white shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
onChange={(e) => setUsername(e.target.value)}
id="username"
type="text"
placeholder="Username"
value={username}
/>
</div>
<div className="mb-6 text-xl ">
<label
className="block text-gray-700 font-bold mb-2"
htmlFor="password"
>
Password
</label>
<input
className="bg-white shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
id="password"
type="password"
placeholder="Password"
onChange={(e) => setPassword(e.target.value)}
value={password}
/>
</div>
{error && <p className="text-red-500 mb-4">{error}</p>}{" "}
<div className="flex items-center justify-between">
<div
className="link link-secondary cursor-pointer"
onClick={() => router.push("/sign-up")}
>
No account yet?
</div>
<button onClick={handleLogin} className="btn text-white">
Login
</button>
</div>
</form>
</div>
</div>
)}
</div>
);
}
This is a much simpler component than the sign up one we put together above. Let's break anything we haven't seen yet down:
handleLogin()
Function
handleLogin()
Function- This component sets up a simple input form. When the user submits it with a
username
andpassword
, thehandleLogin
function usesuserbase.signIn
to send the data to Userbase to authenticate. - We then put all that data into an object and use the
login
function, imported using theuseAuth
hook, in order to make all the logged in user's data available to our app throughout the user session. ✅
Noice! By this point, you have two super important routes set up but you should be seeing this in your project directory:
We don't like all that red! That's because we are using imports that we haven't yet initialized. ⬇️
Step 4: Add All The Necessary utils
& .env
Variables
utils
& .env
VariablesUtils
Phew, authentication can get heavy huh! We're almost there! Let's add some of the needed imports we used in Step 3 into our project.
- In the
/common
folder, create a new folder called/utils
- In the newly-created
/utils
folder, create a new file calledSimpleAccountFactory.json
and copy-paste the following:
[
{
"inputs": [
{
"internalType": "contract IEntryPoint",
"name": "_entryPoint",
"type": "address"
}
],
"stateMutability": "nonpayable",
"type": "constructor"
},
{
"inputs": [],
"name": "accountImplementation",
"outputs": [
{
"internalType": "contract SimpleAccount",
"name": "",
"type": "address"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [
{
"internalType": "address",
"name": "owner",
"type": "address"
},
{
"internalType": "uint256",
"name": "salt",
"type": "uint256"
}
],
"name": "createAccount",
"outputs": [
{
"internalType": "contract SimpleAccount",
"name": "ret",
"type": "address"
}
],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [
{
"internalType": "address",
"name": "owner",
"type": "address"
},
{
"internalType": "uint256",
"name": "salt",
"type": "uint256"
}
],
"name": "getAddress",
"outputs": [
{
"internalType": "address",
"name": "",
"type": "address"
}
],
"stateMutability": "view",
"type": "function"
}
]
- Still in the
/utils
folder, create a new file calledclient.ts
and copy-paste the following:
import { createPublicClient, http } from "viem";
import { sepolia } from "viem/chains";
export const publicClient = createPublicClient({
chain: sepolia,
transport: http(),
});
- Still in the
/utils
folder, create a new file calledLoader.tsx
and copy-paste the following:
const Loader: React.FC = () => {
return (
<div className="flex justify-center items-center min-h-screen">
<span className="loading loading-spinner loading-lg text-[#0a0ad0]"></span>
</div>
);
};
export default Loader;
Now, one more contract abi
to add: that of the actual NFT contract!
- In the
/utils
folder, create a new file calledNFTContract.json
and copy-paste this JSON:
[
{
"inputs": [],
"stateMutability": "nonpayable",
"type": "constructor"
},
{
"anonymous": false,
"inputs": [
{
"indexed": true,
"internalType": "address",
"name": "owner",
"type": "address"
},
{
"indexed": true,
"internalType": "address",
"name": "approved",
"type": "address"
},
{
"indexed": true,
"internalType": "uint256",
"name": "tokenId",
"type": "uint256"
}
],
"name": "Approval",
"type": "event"
},
{
"anonymous": false,
"inputs": [
{
"indexed": true,
"internalType": "address",
"name": "owner",
"type": "address"
},
{
"indexed": true,
"internalType": "address",
"name": "operator",
"type": "address"
},
{
"indexed": false,
"internalType": "bool",
"name": "approved",
"type": "bool"
}
],
"name": "ApprovalForAll",
"type": "event"
},
{
"inputs": [
{
"internalType": "address",
"name": "to",
"type": "address"
},
{
"internalType": "uint256",
"name": "tokenId",
"type": "uint256"
}
],
"name": "approve",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [
{
"internalType": "address",
"name": "recipient",
"type": "address"
}
],
"name": "mint",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function"
},
{
"anonymous": false,
"inputs": [
{
"indexed": true,
"internalType": "address",
"name": "previousOwner",
"type": "address"
},
{
"indexed": true,
"internalType": "address",
"name": "newOwner",
"type": "address"
}
],
"name": "OwnershipTransferred",
"type": "event"
},
{
"inputs": [],
"name": "renounceOwnership",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [
{
"internalType": "address",
"name": "from",
"type": "address"
},
{
"internalType": "address",
"name": "to",
"type": "address"
},
{
"internalType": "uint256",
"name": "tokenId",
"type": "uint256"
}
],
"name": "safeTransferFrom",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [
{
"internalType": "address",
"name": "from",
"type": "address"
},
{
"internalType": "address",
"name": "to",
"type": "address"
},
{
"internalType": "uint256",
"name": "tokenId",
"type": "uint256"
},
{
"internalType": "bytes",
"name": "data",
"type": "bytes"
}
],
"name": "safeTransferFrom",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [
{
"internalType": "address",
"name": "operator",
"type": "address"
},
{
"internalType": "bool",
"name": "approved",
"type": "bool"
}
],
"name": "setApprovalForAll",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [
{
"internalType": "string",
"name": "newBaseTokenURI",
"type": "string"
}
],
"name": "setBaseTokenURI",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function"
},
{
"anonymous": false,
"inputs": [
{
"indexed": true,
"internalType": "address",
"name": "from",
"type": "address"
},
{
"indexed": true,
"internalType": "address",
"name": "to",
"type": "address"
},
{
"indexed": true,
"internalType": "uint256",
"name": "tokenId",
"type": "uint256"
}
],
"name": "Transfer",
"type": "event"
},
{
"inputs": [
{
"internalType": "address",
"name": "from",
"type": "address"
},
{
"internalType": "address",
"name": "to",
"type": "address"
},
{
"internalType": "uint256",
"name": "tokenId",
"type": "uint256"
}
],
"name": "transferFrom",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [
{
"internalType": "address",
"name": "newOwner",
"type": "address"
}
],
"name": "transferOwnership",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [
{
"internalType": "address",
"name": "owner",
"type": "address"
}
],
"name": "balanceOf",
"outputs": [
{
"internalType": "uint256",
"name": "",
"type": "uint256"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [
{
"internalType": "uint256",
"name": "tokenId",
"type": "uint256"
}
],
"name": "getApproved",
"outputs": [
{
"internalType": "address",
"name": "",
"type": "address"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [
{
"internalType": "address",
"name": "owner",
"type": "address"
},
{
"internalType": "address",
"name": "operator",
"type": "address"
}
],
"name": "isApprovedForAll",
"outputs": [
{
"internalType": "bool",
"name": "",
"type": "bool"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [],
"name": "MAX_SUPPLY",
"outputs": [
{
"internalType": "uint256",
"name": "",
"type": "uint256"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [],
"name": "name",
"outputs": [
{
"internalType": "string",
"name": "",
"type": "string"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [],
"name": "owner",
"outputs": [
{
"internalType": "address",
"name": "",
"type": "address"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [
{
"internalType": "uint256",
"name": "tokenId",
"type": "uint256"
}
],
"name": "ownerOf",
"outputs": [
{
"internalType": "address",
"name": "",
"type": "address"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [
{
"internalType": "bytes4",
"name": "interfaceId",
"type": "bytes4"
}
],
"name": "supportsInterface",
"outputs": [
{
"internalType": "bool",
"name": "",
"type": "bool"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [],
"name": "symbol",
"outputs": [
{
"internalType": "string",
"name": "",
"type": "string"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [
{
"internalType": "uint256",
"name": "index",
"type": "uint256"
}
],
"name": "tokenByIndex",
"outputs": [
{
"internalType": "uint256",
"name": "",
"type": "uint256"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [
{
"internalType": "address",
"name": "owner",
"type": "address"
},
{
"internalType": "uint256",
"name": "index",
"type": "uint256"
}
],
"name": "tokenOfOwnerByIndex",
"outputs": [
{
"internalType": "uint256",
"name": "",
"type": "uint256"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [
{
"internalType": "uint256",
"name": "tokenId",
"type": "uint256"
}
],
"name": "tokenURI",
"outputs": [
{
"internalType": "string",
"name": "",
"type": "string"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [],
"name": "totalSupply",
"outputs": [
{
"internalType": "uint256",
"name": "",
"type": "uint256"
}
],
"stateMutability": "view",
"type": "function"
}
]
Nice, the localhost:3000/sign-up
and localhost:3000/login
routes should now be viewable without errors! 👀
Note, they still won't work and you'll get errors if you submit them! We need to set up the API requests... ⬇️ BUT before we do that, let's set up a gas policy on Alchemy. We want to get a special API key that our app will map to our gas policy of paying for user NFT mints! 🚀
Step 6: Set Up the /api
Folder + Routes
/api
Folder + RoutesLet's go ahead and set up all of the API requests, whether external or on the server-side of our NextJS application in this step. We will set up four endpoints to:
- Get the owner address for a smart contract wallet deterministically (uses the AA SDK)
- Get a smart contract wallet's owned NFTs (uses the Alchemy SDK)
- Submit a sponsored user operation on behalf of the user's smart contract wallet (uses the AA SDK)
- Get a user's private key from the Userbase server whenever necessary
Let's jump in! 🤿
Install More Dependencies
We need a lot of tools from Alchemy at this point:
- In your terminal, run
npm i @alchemy/[email protected] @alchemy/[email protected] alchemy-sdk
And that's it! 🧙♂️
Create API Endpoints
Get Owner of Smart Contract Wallet Deterministically
- In the
/app
folder of your project, create a new folder called/api
- Inside the newly-created
/app
folder, create a new folder called/get-signer
- And inside that folder, create a new file called
route.ts
This is how you create API routes in NextJS 13! ✨
- Inside the
route.ts
of the/get-signer
folder, copy-paste the following code:
import { withAlchemyGasManager } from "@alchemy/aa-alchemy";
import {
LocalAccountSigner,
SimpleSmartContractAccount,
SmartAccountProvider,
type SimpleSmartAccountOwner,
} from "@alchemy/aa-core";
import { NextRequest, NextResponse } from "next/server";
import { sepolia } from "viem/chains";
const ALCHEMY_API_URL = `https://eth-sepolia.g.alchemy.com/v2/${process.env.ALCHEMY_API_KEY}`;
const ENTRYPOINT_ADDRESS = process.env
.SEPOLIA_ENTRYPOINT_ADDRESS as `0x${string}`;
const SIMPLE_ACCOUNT_FACTORY_ADDRESS = process.env
.SEPOLIA_SIMPLE_ACCOUNT_FACTORY_ADDRESS as `0x${string}`;
export async function POST(request: NextRequest) {
const body = await request.json();
const { pk } = body;
const owner: SimpleSmartAccountOwner =
LocalAccountSigner.privateKeyToAccountSigner(`0x${pk}`);
const chain = sepolia;
const provider = new SmartAccountProvider(
ALCHEMY_API_URL,
ENTRYPOINT_ADDRESS,
chain,
undefined,
{
txMaxRetries: 10,
txRetryIntervalMs: 5000,
}
);
let signer = provider.connect(
(rpcClient) =>
new SimpleSmartContractAccount({
entryPointAddress: ENTRYPOINT_ADDRESS,
chain,
owner,
factoryAddress: SIMPLE_ACCOUNT_FACTORY_ADDRESS,
rpcClient,
})
);
// [OPTIONAL] Use Alchemy Gas Manager
signer = withAlchemyGasManager(signer, {
policyId: process.env.SEPOLIA_PAYMASTER_POLICY_ID!,
entryPoint: ENTRYPOINT_ADDRESS,
});
const ownerAccount = signer.account;
const ownerAddress = (ownerAccount as any).owner.owner.address;
return NextResponse.json({ data: ownerAddress });
}
All this script does is create a Signer
object, similar to the ethers.js Signer class that is allowed to sign and submit user operations.
Get a Smart Contract Wallet's NFTs
We'll want to build a simple wallet display so that when a user gaslessly mints an NFT to their smart contract wallet, they are able to see the change of state.
- In the
/api
folder, create a new folder called/get-user-nfts
and inside that folder create a file calledroute.ts
- Copy-paste the following code into
route.ts
:
import { NextRequest, NextResponse } from "next/server";
const { Alchemy, Network } = require("alchemy-sdk");
const settings = {
apiKey: process.env.ALCHEMY_API_KEY,
network: Network.ETH_SEPOLIA,
};
const alchemy = new Alchemy(settings);
export async function POST(request: NextRequest) {
const body = await request.json();
const { address } = body;
const nfts = await alchemy.nft.getNftsForOwner(address);
console.log(nfts);
return NextResponse.json({
data: nfts,
});
}
Nice and simple script that uses the Alchemy SDK to fetch a user's NFTs! One more endpoint...
Submit a sponsored user operation on behalf of the user's smart contract wallet
- Still in the
/api
folder, create a new folder called/mint-nft-user-op
and create a file inside that folder calledroute.ts
- Inside the
route.ts
file, copy-paste the following code:
Warning: This script is heavy!
import { withAlchemyGasManager } from "@alchemy/aa-alchemy";
import {
LocalAccountSigner,
SendUserOperationResult,
SimpleSmartAccountOwner,
SimpleSmartContractAccount,
SmartAccountProvider,
} from "@alchemy/aa-core";
import nftContractAbi from "@common/utils/SimpleAccountFactory.json";
import axios from "axios";
import { NextRequest, NextResponse } from "next/server";
import { encodeFunctionData, parseEther } from "viem";
import { sepolia } from "viem/chains";
export async function POST(request: NextRequest) {
const body = await request.json();
const { userId, userScwAddress } = body;
// get user's pk from server
const userResponse = await getUser(userId);
const userResponseObject = await userResponse?.json();
const pk = userResponseObject?.response?.profile?.pk;
const signer = await createSigner(pk);
const amountToSend: bigint = parseEther("0");
const data = encodeFunctionData({
abi: nftContractAbi,
functionName: "mint",
args: [userScwAddress], // User's Smart Contract Wallet Address
});
const result: SendUserOperationResult = await signer.sendUserOperation({
target: process.env.SEPOLIA_NFT_ADDRESS as `0x${string}`,
data: data,
value: amountToSend,
});
console.log("User operation result: ", result);
console.log(
"\nWaiting for the user operation to be included in a mined transaction..."
);
const txHash = await signer.waitForUserOperationTransaction(
result.hash as `0x${string}`
);
console.log("\nTransaction hash: ", txHash);
const userOpReceipt = await signer.getUserOperationReceipt(
result.hash as `0x${string}`
);
console.log("\nUser operation receipt: ", userOpReceipt);
const txReceipt = await signer.rpcClient.waitForTransactionReceipt({
hash: txHash,
});
return NextResponse.json({ receipt: txReceipt });
}
async function getUser(userId: any) {
try {
const response = await axios.get(
`https://v1.userbase.com/v1/admin/users/${userId}`,
{
headers: {
Authorization: `Bearer ${process.env.USERBASE_ACCESS_TOKEN}`,
},
}
);
console.log(response.data); // The user data will be available here
return NextResponse.json({ response: response.data });
} catch (error) {
console.error("Error fetching user:", error);
}
}
async function createSigner(USER_PRIV_KEY: any) {
const ALCHEMY_API_URL = `https://eth-sepolia.g.alchemy.com/v2/${process.env.ALCHEMY_API_KEY}`;
const ENTRYPOINT_ADDRESS = process.env
.SEPOLIA_ENTRYPOINT_ADDRESS as `0x${string}`;
const SIMPLE_ACCOUNT_FACTORY_ADDRESS = process.env
.SEPOLIA_SIMPLE_ACCOUNT_FACTORY_ADDRESS as `0x${string}`;
const owner: SimpleSmartAccountOwner =
LocalAccountSigner.privateKeyToAccountSigner(`0x${USER_PRIV_KEY}`);
const chain = sepolia;
const provider = new SmartAccountProvider(
ALCHEMY_API_URL,
ENTRYPOINT_ADDRESS,
chain,
undefined,
{
txMaxRetries: 10,
txRetryIntervalMs: 5000,
}
);
let signer = provider.connect(
(rpcClient) =>
new SimpleSmartContractAccount({
entryPointAddress: ENTRYPOINT_ADDRESS,
chain,
owner,
factoryAddress: SIMPLE_ACCOUNT_FACTORY_ADDRESS,
rpcClient,
})
);
// [OPTIONAL] Use Alchemy Gas Manager
signer = withAlchemyGasManager(signer, {
policyId: process.env.SEPOLIA_PAYMASTER_POLICY_ID!,
entryPoint: ENTRYPOINT_ADDRESS,
});
return signer;
}
Get a User's pk from Userbase
- Run
npm i axios
as this will be an external API call - In the
/api
folder, create a new folder called/get-user
and, same as all above, create a file inside it calledroute.ts
- In the
route.ts
file, copy-paste the following quick script:
import axios from "axios";
import { NextRequest, NextResponse } from "next/server";
export async function POST(request: NextRequest) {
const body = await request.json();
const { userId } = body;
try {
const response = await axios.get(
`https://v1.userbase.com/v1/admin/users/${userId}`,
{
headers: {
Authorization: `Bearer ${process.env.USERBASE_ACCESS_TOKEN}`,
},
}
);
console.log(response.data); // The user data will be available here
return NextResponse.json({ response: response.data });
} catch (error) {
console.error("Error fetching user:", error);
}
}
All in all, your /api
folder, after this step, should look like this:
You've just set up super powerful API routes in your NextJS 13 project! 💫 You'll notice we used a whole bunch of environment variables to make the scripts work - let's add the remaining values! ⬇️
Final Remaining .env
File Variables To Add
.env
File Variables To Add- In your project's
.env.local
file, set up these slate of variables - needed to make your API scripts work:
SEPOLIA_ENTRYPOINT_ADDRESS=
SEPOLIA_SIMPLE_ACCOUNT_FACTORY_ADDRESS=
SEPOLIA_NFT_ADDRESS=0x5700D74F864CE224fC5D39a715A744f8d1964429
By now, your .env.local
file should look something like this:
We're almost there!! 🏃♂️
Psst! You should be able to successfully sign up as a user now! The app will break since it won't know where to re-direct state successfully, but feel free to try signing up for an account, then go to Userbase.com and select your
Starter App
- by this point, you should see the user you just created successfully on the Userbase end!
Step 7: Set Up Your /
(Home!) Route
/
(Home!) RouteIt must be annoying to not be able to load localhost:3000/
without any errors or it still displaying old instructions, so let's fix that! 🛠️
We want our home component to do two things:
- If the user is not logged in, re-direct to
/login
- If the user is logged in, display a page where they can toggle either their wallet view (ie, a display to show the currently owned NFTs of the user's smart contract wallet) and a minter view (ie, a display to mint a new NFT to their smart contract wallet)
Let's do it! 🚴♀️
Setting up the Home Route
- First of alll, let's take a quick detour - add
background-color: white;
to thebody
tag inside theglobals.css
file - let's make our app light mode enabled for now! (Remember to save the file!) - Second, we're going to use the Random Avatar Generator package to give each of our users a cool avatar! Run
npm i random-avatar-generator
in your project terminal - Now, go to
/app/page.tsx
(this is your app's default component; whenever a user visits the/
route, this component will render!) - Copy-paste the following code:
"use client";
import GaslessMinter from "@common/GaslessMinter";
import WalletDisplay from "@common/WalletDisplay";
import "./globals.css";
import { useAuth } from "@common/AuthProvider";
import { useRouter } from "next/navigation";
import { AvatarGenerator } from "random-avatar-generator";
import { useState } from "react";
import userbase from "userbase-js";
export default function Home() {
const { user, logout } = useAuth();
const router = useRouter();
const [walletViewActive, setWalletViewActive] = useState(true);
const generator = new AvatarGenerator();
function handleLogout() {
try {
userbase
.signOut()
.then(() => {
// user logged out
console.log("User logged out!");
logout();
router.push("/");
})
.catch((e: any) => console.error(e));
} catch (error: any) {
console.log(error);
}
}
return (
<div>
{user?.isLoggedIn ? (
<div className="font-mono text-2xl mt-8">
<div className="flex items-center justify-center">
<div className="avatar">
<div className="rounded-full ml-12">
<img src={generator.generateRandomAvatar(user?.userId)} />
</div>
</div>
<div className="flex flex-col ml-6 gap-2">
<div className="text-black">
<b>User:</b> {user?.username}
</div>
<div className="text-black">
<b>SCW :</b>{" "}
<a
className="link link-secondary"
href={`https://sepolia.etherscan.io/address/${user?.scwAddress}`}
target="_blank"
>
{user?.scwAddress}
</a>
</div>
<div className="text-black">
{user?.isLoggedIn ? (
<div className="btn btn-outline" onClick={handleLogout}>
<a>Log out</a>
</div>
) : (
""
)}
</div>
</div>
</div>
<div className="tabs items-center flex justify-center mb-[-25px]">
<a
className={`tab tab-lg tab-lifted text-2xl ${
walletViewActive ? "tab-active text-white" : ""
}`}
onClick={() => setWalletViewActive(!walletViewActive)}
>
Your Wallet
</a>
<a
className={`tab tab-lg tab-lifted text-2xl ${
walletViewActive ? "" : "tab-active text-white"
}`}
onClick={() => setWalletViewActive(!walletViewActive)}
>
Mint an NFT
</a>
</div>
<div className="divider mx-16 mb-8"></div>
{walletViewActive ? <WalletDisplay /> : <GaslessMinter />}
</div>
) : (
<div>
<div className="text-black flex flex-col items-center justify-center mt-36 mx-8 text-4xl font-mono">
Please log in to continue! 👀
<button
onClick={() => router.push("/login")}
className="btn mt-6 text-white"
>
Login
</button>
</div>
</div>
)}
</div>
);
}
By now, your app /
route should look like this:
We are setting up the Home
component so that whenever a user loads the /
route, the app runs a quick hook to check whether they are logged in. If they are, display the Wallet + Minter components (the toggle between those two components relies on the walletViewActive
state variable), else display a simple Please log in to continue!
text.
Step 8: Set Up Wallet Display + Gasless Minter Components
You'll notice at this point, your code editor should be complaining that we are trying to use two components that we haven't created yet: WalletDisplay
and GaslessMinter
. Let's create each of these now...
WalletDisplay
- In your
/common
folder, create a new component calledWalletDisplay.tsx
- Open the
WalletDisplay.tsx
file and copy-paste the following:
import { useAuth } from "@common/AuthProvider";
import Loader from "@common/utils/Loader";
import { useEffect, useState } from "react";
interface Nft {
contract: object;
tokenId: string;
tokenType: string;
title: string;
description: string;
media: object;
}
interface Data {
ownedNfts: Nft[];
length: number;
}
export default function WalletDisplay() {
const { user } = useAuth();
const [ownedNftsArray, setOwnedNftsArray] = useState<Data | null>(null);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
fetchUserNfts();
}, []);
function truncateDescription(description: string, wordCount: number) {
const words = description.split(" ");
if (words.length > wordCount) {
const truncatedWords = words.slice(0, wordCount);
return `${truncatedWords.join(" ")} ...`;
}
return description;
}
async function fetchUserNfts() {
setIsLoading(true);
try {
const data = { address: user?.scwAddress };
const response = await fetch("/api/get-user-nfts/", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
const messageResponse = await response.json();
console.log(messageResponse.data.ownedNfts);
setOwnedNftsArray(messageResponse.data.ownedNfts);
setIsLoading(false);
} catch (error) {
console.error("Error fetching NFTs:", error);
}
}
return (
<div>
{isLoading ? (
<div className="flex items-center justify-center mt-[-350px]">
<Loader />
</div>
) : ownedNftsArray && ownedNftsArray.length >= 1 ? (
<div className="flex flex-col items-cente">
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4 mx-12 mb-6">
{ownedNftsArray &&
Array.isArray(ownedNftsArray) &&
ownedNftsArray.map((nft, index) => (
<div
key={index}
className="rounded-lg shadow-xl max-w-[250px] max-h-[600px] overflow-hidden"
>
<figure>
<img
src={
nft.tokenUri.gateway
? nft.tokenUri.gateway
: nft.tokenUri.raw
}
alt="user nft image"
className="w-full max-h-[300px]"
/>
</figure>
<div className="p-4">
<h2 className="text-xl font-semibold mb-2">{nft.title}</h2>
<p className="">
{truncateDescription(nft.description, 25)}
</p>
</div>
</div>
))}
</div>
</div>
) : (
<div>
<div className="flex flex-col items-center justify-center mx-8 mt-32 text-black">
Your smart contract wallet does not own any NFTs yet! 🤯
<div className="flex mt-4">
Mint one by selecting the <b> Mint an NFT </b> tab. ⬆️
</div>
</div>
</div>
)}
</div>
);
}
This component will, on-mount, immediately make a call to the get-user-nfts
endpoint we set up in Step #6, passing the user's smart contract wallet address as an argument. So, every time the page loads, a new query to check the user's smart contract wallet owned NFTs is performed.
GaslessMinter
-
There's a really awesome npm package called react-confetti that we'll use to celebrate whenever one of your application's users gaslessly mints an NFT, install it by running
npm i react-confetti
-
In your
/common
folder, create a new component file calledGaslessMinter.tsx
and copy-paste the following:
import { useAuth } from "@common/AuthProvider";
import { useState } from "react";
import Confetti from "react-confetti";
export default function GaslessMinter() {
const { user } = useAuth();
const [isLoading, setIsLoading] = useState(false);
const [hasMinted, setHasMinted] = useState(false);
async function handleMint() {
setIsLoading(true);
const data = {
userId: user?.userId,
userScwAddress: user?.scwAddress,
nameOfFunction: "mint",
};
await fetch("/api/mint-nft-user-op/", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(data),
});
setTimeout(() => {}, 10000); // 10 seconds
setIsLoading(false);
setHasMinted(true);
}
return (
<div className="flex items-center justify-center mt-12">
{hasMinted ? <Confetti /> : ""}
<div className="card lg:card-side shadow-xl w-[70%] mb-16">
<figure>
<img
src="https://github-production-user-asset-6210df.s3.amazonaws.com/83442423/267730896-dd9791c9-00b9-47ff-816d-0d626177909c.png"
alt="sample nft"
/>
</figure>
<div className="card-body text-black">
<h2 className="card-title text-2xl">
Generic Pudgy Penguin on Sepolia
</h2>
<p className="text-sm">
You are about to mint a fake NFT purely for testing purposes. The
NFT will be minted directly to your smart contract wallet!
</p>
<div className="flex items-center justify-end">
<div
className={`alert w-[75%] mr-4 ${
hasMinted ? "visible" : "hidden"
}`}
>
<svg
xmlns="http://www.w3.org/2000/svg"
className="stroke-current shrink-0 h-6 w-6"
fill="none"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<div className="flex justify-end text-right">
<span className="text-white">NFT minted. ✅</span>
</div>
</div>
<button className="btn btn-primary text-white" onClick={handleMint}>
<span
className={`${
isLoading ? "loading loading-spinner" : "hidden"
}`}
></span>
{isLoading ? "Minting" : hasMinted ? "Mint Again" : "Mint"}
</button>
</div>
</div>
</div>
</div>
);
}
Step 9: Mint Your NFT!
Woah, you just set up a full-stack end-to-end account abstraction solution for gaslessly minting NFTs - fantastic job! 💥
Here are some ther optimizations and features you can work on if you want an extra challenge at this point:
- can you make the NFT burnable and/or transferrable?
- can you make the UX even better?
- can you deploy this to a production server and share with your friends?
Here is the Github repo containing all of the code in this tutorial, please feel free to fork and make it your own!
Updated 10 months ago