Blog home

How to Build a Transaction Frame on Farcaster

Steve

Published on

15 min read

How to Build a Transaction Frame on Farcaster

Unlock the power of Farcaster Transaction Frames for dynamic onchain experiences.

One of the most popular use cases for Farcaster Fames is minting, and in a previous tutorial, we showed you how you could build one that minted NFTs to a user’s wallet. Since then Frames have evolved and they have been given a new feature: Frame Transactions. These are fundamentally different from minting frames in the past because before you could only really airdrop an NFT. The user had no ability to pay or sign transactions from their wallet. With this new Frame spec implementation the doors open up to so many on-chain experiences, all within a social feed.

We decided to take this and run with an interesting idea: a Pinnie Hat store. First, we built a custom ERC-1155 smart contract that minted discount NFTs but also had a “buy hat” function. Inside the frame you could either buy a hat at full price, or you could watch an add to get the discount coupon NFT. Owning this NFT would give you 50% off the original hat price, and was enforced on-chain! All on Base with minimal gas fees.

__wf_reserved_inherit

In this tutorial we’ll show you how to build one yourself!

⚠️CAUTION: This tutorial will walk you through using a frame transaction on Base Sepolia testnet, so if you go to mainnet please practice caution with moving your Private key into providers like Vercel!  

Setup

Before we get into the code let’s go over a few things you’ll need first.

Pinata Account

One of the features of our hat store is analytics on the Frame, giving us valuable data like how many people viewed the ad. There are so many applications of Frame analytics, and its super easy to set up! Just sign up for a free account, then create an API key with these instructions. We’ll just need the JWT for our app; that’s it!

Alchemy

Since our Frame is going to be running live transactions and we’ll have our own transactions to run under the hood, like minting coupon NFTs, we’ll need an Ethereum RPC node. Alchemy makes this a breeze, just visit their site, make an account, and then create a new app on Base Sepolia. Afterwards, you should be able to access an HTTPS API key.

Developer Stack

There are many ways to make Frames these days, and for this one we’ll be using Frog + the Next.js template. With that said you will need Node.js v20 installed, a text editor, and some familiarity with developing in Next.js and deploying on Vercel. We’ll also use Hardhat for our smart contract development, and with that, we’ll need a crypto wallet with Eth on Base (optional for writing code, required at the moment for production).

Contract

Before we start building our hat store, we've got to make a smart contract. We’ll do a really simple one that’s a modified ERC-1155 contract. To get started open your terminal and run the following command.

mkdir hat-store-contract && cd hat-store-contract && npx hardhat init

This will create a new project folder, change into that directory, and run the Hardhat initialization. I would recommend running with the Typescript and Viem options, as we’ll also be using Viem inside our Frame code. Once its completed run npm install @openzeppelin/contracts dotenv to install some contract and env dependencies, and delete some of the boilerplate including the contents of contracts, scripts, and test. Then make a new file inside contracts called PinnieHatStore.sol with the following contents.

// SPDX-License-Identifier: MIT
// Compatible with OpenZeppelin Contracts ^5.0.0
pragma solidity ^0.8.20;

import "@openzeppelin/contracts/token/ERC1155/ERC1155.sol";
import "@openzeppelin/contracts/token/ERC1155/extensions/ERC1155Pausable.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/token/ERC1155/extensions/ERC1155Burnable.sol";

contract PinnieHatStore is ERC1155, ERC1155Pausable, Ownable, ERC1155Burnable {
    uint256 public totalSupply = 25;
    mapping(address => bool) public addressPurchasedHat;

    constructor(address initialOwner)
        ERC1155("ipfs://QmS7EELNg1AVFp7vhww7sisL8ThsPVfJWhWdDHXakMKyGc")
        Ownable(initialOwner)
    {}

    event HatPurchased(uint256 FID, address buyer);

    function pause() public onlyOwner {
        _pause();
    }

    function unpause() public onlyOwner {
        _unpause();
    }

    function mint(address account) public onlyOwner {
        _mint(account, 0, 1, new bytes(0));
    }

    function buyHat(uint256 FID) public payable {
        require(
            !addressPurchasedHat[msg.sender],
            "Each address can only buy one hat."
        );
        require(totalSupply > 0, "All the hats are gone!");

        if (balanceOf(msg.sender, 0) > 0) {
            require(
                msg.value == 0.0025 ether,
                "Incorrect payment amount for NFT holder"
            );
        } else {
            require(msg.value == 0.005 ether, "Incorrect payment amount");
        }

        totalSupply--;
        addressPurchasedHat[msg.sender] = true;
        emit HatPurchased(FID, msg.sender);
    }

    function mintBatch(
        address to,
        uint256[] memory ids,
        uint256[] memory amounts,
        bytes memory data
    ) public onlyOwner {
        _mintBatch(to, ids, amounts, data);
    }

    function safeTransferFrom(
        address from,
        address to,
        uint256 id,
        uint256 amount,
        bytes memory data
    ) public virtual override onlyOwner {
        super.safeTransferFrom(from, to, id, amount, data);
    }

    function safeBatchTransferFrom(
        address from,
        address to,
        uint256[] memory ids,
        uint256[] memory amounts,
        bytes memory data
    ) public virtual override onlyOwner {
        super.safeBatchTransferFrom(from, to, ids, amounts, data);
    }

    function withdraw() public onlyOwner {
        uint256 balance = address(this).balance;
        payable(owner()).transfer(balance);
    }

    // The following functions are overrides required by Solidity.

    function _update(
        address from,
        address to,
        uint256[] memory ids,
        uint256[] memory values
    ) internal override(ERC1155, ERC1155Pausable) {
        super._update(from, to, ids, values);
    }
}

contacts/PinnieHatStore.sol

Most of this is standard ERC-1155 boilerplate but with some slight tweaks. First, we have some state at the top of the contract, including our totalSupply and a mapping to see if an address purchased a hat. We’ll also make a custom event called HatPurchased which will take in the FID of the Farcaster user and the address of their wallet. Next we’ll be making minting a default of 1 NFT with an ID of 0, with no call data, and only accessible by the owner of the contract. This will prevent someone from getting coupons without watching our ad first. We’ll also override the safeTranserFrom and safeBatchTransferFrom as onlyOwner functions too, so there isn’t any secondhand trading.

In the buyHat function, we’ll make it public and payable, with the FID as a parameter. In there, we’ll make sure they haven’t purchased a hat already to make it one per address, as well as making sure the total supply is more than zero. Next, we’ll see if the purchaser has a coupon NFT; if they do then the payable amount should equal 0.0025 Ether, otherwise it needs to be 0.005 ETH. From there we reduce the totalSupply by 1, add the buyer’s address to the addressPurchasedHat mapping, and we’ll emit the HatPurchased event. Finally, we add a withdraw function at the end to get our funds after all the purchases.

With our contract ready to go we need to update our hardhat.config.ts file.

import { HardhatUserConfig } from "hardhat/config";
import "@nomicfoundation/hardhat-toolbox-viem";
require("dotenv").config();

const config: HardhatUserConfig = {
  solidity: "0.8.20",
  networks: {
    baseSepolia: {
      url: `${process.env.ALCHEMY_URL_BASE_SEPOLIA}`,
      accounts: [(process.env.TEST_PRIVATE_KEY as `0x`) || ""],
      gasPrice: 1500000000,
    },
    baseMainnet: {
      url: `${process.env.ALCHEMY_URL_BASE_MAINNET}`,
      accounts: [(process.env.PRIVATE_KEY as `0x`) || ""],
      gasPrice: 1500000000,
    },
  },
  etherscan: {
    apiKey: {
            "base-mainnet": process.env.ETHERSCAN_API_KEY || "",
      //"base-sepolia": process.env.ETHERSCAN_API_KEY || "",
    },
    customChains: [
      {
        network: "base-mainnet",
        chainId: 8453,
        urls: {
          apiURL: "https://api.basescan.org/api",
          browserURL: "https://basescan.org/",
        },
      },
      {
        network: "base-sepolia",
        chainId: 84532,
        urls: {
          apiURL: "https://api-sepolia.basescan.org/api",
          browserURL: "https://sepolia.basescan.org",
        },
      },
    ],
  },
};

export default config;

hardhat.config.ts

In this file I’ve included both Base Sepolia testnet and Base Mainnet just so I can be sure everything works before going live. Now with this code done we can write a simply deployment script to make sure everything works!

import { privateKeyToAccount } from "viem/accounts"
import hre from "hardhat";

async function main() {

  const account = privateKeyToAccount(process.env.TEST_PRIVATE_KEY as `0x` || "")

  const contract = await hre.viem.deployContract("PinnieHatStore", [account.address]);

  console.log("Contract deployed to:", contract.address)
}

// 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;
});

Nothing really crazy going on here, just creating an account with my PRIVATE_KEY variable, deploying "PinnieHatStore", and logging the address! You can now run this in the terminal to deploy it

npx hardhat run scripts/deploy.ts --network baseSepolia

This should compile the contracts, deploy them to Base Sepolia, and return the contract address. Once you are comfortable launching to mainnet, make sure you update TEST_PRIVATE_KEY in deploy.ts to PRIVATE_KEY (or whatever you have it set to in your .env file) and make sure you use --network baseMainnet in the terminal command. You will need Eth on Base in the wallet of the private key!

Once you have deployed to mainnet, there are a few things we’ll need in order to use it on our transaction Frame. The first thing you’ll need of course is the contract address that was given after you deployed the contract - copy that down for later. The other thing you’ll need is the contract ABI. These are instructions that allow other apps and clients to communicate with the contract. If you used Hardhat like we did in this tutorial, it would be located in artifacts/contracts/PinnieHatStore.sol/PinnieHatStore.json. Of course names might differ depending on the name of your contract. Copy the contents of that file and we’ll use it soon!

Frame

With our contract all set to go, we can start building our frame! To do this we’ll be using Frog and Next.js, both make it pretty easy to get this up and running. In the terminal we’ll make a new project with the following command:

npm init frog

This will give us some options like the name of the repo (e.g. hat-store-frame) and Next.js as the starting point. Once it is done initializing, you will want to cd into the repo then run this command:

npm install pinata-fdk

This will install all the dependencies for Frog, Next, and the Pinata FDK so we can get analytics on our store. Now we can open the repo with our text editor, and you should see the following structure.

.
├── app
│  ├── api
│  │  └── [[...routes]]
│  │     └── route.tsx
│  ├── favicon.ico
│  ├── globals.css
│  ├── layout.tsx
│  ├── page.module.css
│  └── page.tsx
├── next.config.mjs
├── package-lock.json
├── package.json
├── public
│  ├── next.svg
│  └── vercel.svg
├── README.md
└── tsconfig.json

Before we go too far let’s go ahead and put some other small things together. First grab the contract ABI from the previous steps and paste it into a new file called abi.json and put it in the same [[...routes]] folder next to routes.tsx. Next, let’s make a new file in the root of the project directory called .env.local and make sure we have the following variables filled out

PRIVATE_KEY=
PINATA_JWT=
CONTRACT_ADDRESS=
ALCHEMY_URL_BASE=

.env.local

You will only need PRIVATE_KEY if you are going to be minting coupons to the people who watch the ad, but if you are just doing straight transactions or minting then this wouldn’t be necessary. You will need the PINATA_JWT for analytics, the CONTRACT_ADDRESS for interacting with the contract, and ALCHEMY_URL_BASE if you want a better transport for your own internal transactions, which you’ll see here soon.

Now we can go ahead and open route.tsx and make some changes.

/** @jsxImportSource frog/jsx */

import { Button, Frog, TextInput, parseEther } from "frog";
import { handle } from "frog/next";
import { createWalletClient, http, createPublicClient } from "viem";
import { privateKeyToAccount } from "viem/accounts";
import { baseSepolia } from "viem/chains";
import { PinataFDK } from "pinata-fdk";
import abi from "./abi.json";

const fdk = new PinataFDK({
  pinata_jwt: process.env.PINATA_JWT || "",
  pinata_gateway: "",
});

const CONTRACT = process.env.CONTRACT_ADDRESS as `0x` || ""

const account = privateKeyToAccount((process.env.PRIVATE_KEY as `0x`) || "");

const publicClient = createPublicClient({
  chain: baseSepolia,
  transport: http(process.env.ALCHEMY_URL),
});

const walletClient = createWalletClient({
  account,
  chain: baseSepolia,
  transport: http(process.env.ALCHEMY_URL),
});

app/api/[[…routes]]/route.tsx

There’s a lot going on here so let’s walk through our initial setup and steps. First, we import all our dependencies for Frog, Viem, and the Pinata FDK. Then we’ll do some initializations, starting with the Pinata FDK which we only need our PINATA_JWT for the analytics. Then we’ll create a constant for CONTRACT that we can use throughout the app, and we’ll setup our accout, publicClient, and walletClient for different transactions we’ll be making.

Now let's add some helper functions, our Frog app, and our analytics.

// imports, viem clients, etc.. 

async function checkBalance(address: any) {
  try {
    const balance = await publicClient.readContract({
      address: CONTRACT,
      abi: abi.abi,
      functionName: "balanceOf",
      args: [address, 0],
    });
    const readableBalance = Number(balance);
    return readableBalance;
  } catch (error) {
    console.log(error);
    return error;
  }
}

async function remainingSupply() {
  try {
    const balance = await publicClient.readContract({
      address: CONTRACT,
      abi: abi.abi,
      functionName: "totalSupply",
    });
    const readableBalance = Number(balance);
    return readableBalance;
  } catch (error) {
    console.log(error);
    return error;
  }
}

const app = new Frog({
  assetsPath: "/",
  basePath: "/api",
});

app.use(
  "/ad",
  fdk.analyticsMiddleware({ frameId: "hats-store", customId: "ad" }),
);
app.use(
  "/finish",
  fdk.analyticsMiddleware({ frameId: "hats-store", customId: "purchased" }),
);

app/api/[[…routes]]/route.tsx

First, we make two helper functions that interact with our smart contract. The first one will check any address we passed in to see if they already have a coupon NFT, and the other will see how many hats we have left since we hard-coded a cap of 25. Then we create our app with Frog with some base settings. After that we set up our analytics with Pinata, using a frameId with the title of our frame, but then using customId for particular paths so we can track who watched the ad and who purchased the hat. Now let’s add our Frame routes one at a time.

// app analytics...

app.frame("/", async (c) => {
  const balance = await remainingSupply();
  if (typeof balance === "number" && balance === 0) {
    return c.res({
      image:
        "https://dweb.mypinata.cloud/ipfs/QmeeXny8775RQBZDhSppkRN15zn5nFjQUKeKAvYvdNx986",
      imageAspectRatio: "1:1",
      intents: [
        <Button.Link href="https://warpcast.com/~/channel/pinata">
          Join the Pinata Channel
        </Button.Link>,
      ],
      title: "Pinta Hat Store - SOLD OUT",
    });
  } else {
    return c.res({
      action: "/finish",
      image:
        "https://dweb.mypinata.cloud/ipfs/QmeC7uQZqkjmc1T6sufzbJWQpoeoYjQPxCXKUSoDrXfQFy",
      imageAspectRatio: "1:1",
      intents: [
        <Button.Transaction target="/buy/0.0005">
          Buy for 0.005 ETH
        </Button.Transaction>,
        <Button action="/ad">Watch ad for 1/2 off</Button>,
      ],
      title: "Pinta Hat Store",
    });
  }
});

app.frame("/finish", (c) => {
  return c.res({
    image:
      "https://dweb.mypinata.cloud/ipfs/QmZPysm8ZiR9PaNxNGQvqdT2gBjdYsjNskDkZ1vkVs3Tju",
    imageAspectRatio: "1:1",
    intents: [
      <Button.Link href="https://warpcast.com/~/channel/pinata">
        Join the Pinata Channel
      </Button.Link>,
    ],
    title: "Pinta Hat Store",
  });
});

app/api/[[…routes]]/route.tsx

This one is pretty straightforward, as we’ll use our remainingSupply function to check how many hats we have left. If we’re out, we’ll show a sold out image along with a button to join our /pinata Warpcast channel (you should too). Otherwise, we’ll show two options: buy full price or watch an ad for half off. If they happen to buy full price it will take them to /finish which is almost identical to the sold-out screen except we’ll show a “purchase complete” image. Now let’s build the /ad frame.

app.frame("/ad", async (c) => {
  return c.res({
    action: "/coupon",
    image:
      "https://dweb.mypinata.cloud/ipfs/QmeUmBtAMBfwcFRLdoaCVJUNSXeAPzEy3dDGomL32X8HuP",
    imageAspectRatio: "1:1",
    intents: [
      <TextInput placeholder="Wallet Address (not ens)" />,
      <Button>Receive Coupon</Button>,
    ],
    title: "Pinta Hat Store",
  });
});

This one is also pretty simple but we have a few extra pieces. First, we show the ad image, but we also have a text input where the user can paste in the wallet address they want to receive the coupon. In some cases you could try getting the wallet address via the user’s FID with an API call, however, lots of people have created wallets for frames that are not connected to their Farcaster account so it's best to ask. Once they submit we’ll direct them to /coupon.

app.frame("/coupon", async (c) => {
  const supply = await remainingSupply();
  const address = c.inputText;
  const balance = await checkBalance(address);

  if (
    typeof balance === "number" &&
    balance < 1 &&
    typeof supply === "number" &&
    supply > 0
  ) {
    const { request: mint } = await publicClient.simulateContract({
      account,
      address: CONTRACT,
      abi: abi.abi,
      functionName: "mint",
      args: [address],
    });
    const mintTransaction = await walletClient.writeContract(mint);
    console.log(mintTransaction);

    const mintReceipt = await publicClient.waitForTransactionReceipt({
      hash: mintTransaction,
    });
    console.log("Mint Status:", mintReceipt.status);
  }

  return c.res({
    action: "/finish",
    image:
      "https://dweb.mypinata.cloud/ipfs/QmeUmBtAMBfwcFRLdoaCVJUNSXeAPzEy3dDGomL32X8HuP",
    imageAspectRatio: "1:1",
    intents: [
      <Button.Transaction target="/buy/0.0025">
        Buy for 0.0025 ETH
      </Button.Transaction>,
    ],
    title: "Pinta Hat Store",
  });
});

In this one, we definitely have a little more going on. First, we make some variables by checking the supply, the address they submitted, and the balance of the coupon NFT they may or may not have. If the supply isn’t zero and they don’t have the coupon NFT already, then we’ll airdrop them one with our walletClient. This is different than the transaction NFT, which we’ll get to next, because we’re using our own private key to make this mint happen. Of course now that they have the coupon, we’ll use Button.Transaction with a target of <span class="code-inline">/buy/0.0025</span> with the discounted price, and it will redirect them to /finish once the transaction is complete. Now let’s take a look at the /buy frame.

app.transaction("/buy/:price", async (c) => {
  
  const price = c.req.param('price')

  return c.contract({
    abi: abi.abi,
    // @ts-ignore
    chainId: "eip155:84532",
    functionName: "buyHat",
    args: [c.frameData?.fid],
    to: CONTRACT,
    value: parseEther(`${price}`),
  });
});

// end of the file exports

export const GET = handle(app);
export const POST = handle(app);

You’ll notice that this is different from our other frames in that its using app.transaction instead of app.frame. Here we have the path declaration of /buy/:price that can use the url path as a parameter for how much we need to send to the contract, which you see with the price constant. Then we just need to use the c.contract where we pass in our abi, chainId, functionName, args, to, and value. Let’s walk through each of those.

  • abi - This is the contract ABI we imported earlier, accessing it through the object abi.abi. That might be different depending on where you get your abi.
  • chainId - Here we declare what chain our contract is on. You’ll notice we did a // @ts-ignoreright above, this is because testnet frame transactions just came out and Base Sepolia isn’t part of Frog just yet.
  • functionName - This of course is the name of the function we’re going to use on the contract
  • args - This is an array of any arguments we need to pass into the function, which in our case is the FID of the user that will get sent to the event at the end of the smart contract function.
  • to - This would be our smart contract address
  • value - If you are having people pay or send ETH then you need to attach a value, in which case we pass price into parseEther.

That’s it! Now we can deploy our frame to Vercel, making sure to pass in our variables in .env.local into the Settings/Environment Variables, then go to the Frame Validator on Waprcast to test it out!

   

The open-source repo for this project can be found here!

Wrapping Up

This is really just the beginning of what's possible with transaction frames. For instance, we also used them also as a mechanism to buy lives in a Frame game called Froggy. If you combine some of the things you can do with the Farcaster API and smart contracts. The possibilities are endless, and we can’t wait to see what you build! Of course, share them in /pinata when you do 🙂

Happy Pinning!

Stay up to date

Join our newsletter for the latest stories & product updates from the Pinata community.

No spam, notifications only about new products, updates and freebies. You can always unsubscribe.