How to Launch a Memecoin on Farcaster

Using Pinata, Supabase, and Cloudflare

Justin Hunter
Pinata
13 min readMay 17, 2024

--

In crypto, the new cultural zeitgeist is forming around memecoins. What is a memecoin? It’s exactly what it sounds like — a cryptocurrency built around a single meme (or multiple memes in some cases). The earliest and most successful example of a memecoin is Dogecoin. It’s been around for years, showing a staying power normally reserved for more “serious” coins. Today, we’re seeing an entirely new push around memecoins. After years of bubbles focused around the utility of various blockchain initiatives, memecoins offer no promise. Just memes.

And much of the drive around memecoins is happening on Farcaster. The decentralized social protocol saw the launch of the $DEGEN memecoin earlier this year, which seems to have sparked interest from other communities and developers in launching their own coins. A few examples native to Farcaster include Higher, Ham, 747 Airlines, and Bows. There are many more currently active, and there are many more under active development.

But what does the process of building, launching, and supporting a memecoin look like, especially when it lives natively within one ecosystem? We’ll explore that in today’s tutorial.

Disclaimer: Memecoins can and often do have real financial impact. As such, be sure to ensure you remain regulatory complaint for your region. Pinata does not endorse any specific memecoins nor does it offer financial or legal advice.

Getting Started

For this tutorial, we’re going to focus very little on the smart contract. I recommend taking a look at any of the available ERC20 tutorials out there as that’s the token type you’ll be using for your memecoin. However, in the real world, you’ll probably want to customize your contract quite a bit. Keep that in mind.

Instead, we’re going to focus on the launch and management dynamics which will be a combination of onchain transactions and offchain data handling. As such, you’re going to need a few things:

  • A Pinata account. Our free Farcaster Read API will make it easy to handle queries of the Farcaster network that will help power your memecoin distributions.
  • A free Cloudflare account. We’re going to make this very fast and scalable by using Cloudflare workers.
  • A free Supabase account. That’s right, we’re going to use a database to manage our decentralized currency. Actually, we’re just using a database to track allocations of the memecoin.

You’ll need a good code editor and some experience with the terminal, but that should be it for the accounts you’ll need to sign up for.

Memecoin Manager

Before we start writing code, we should think about what we need to launch and manage our memecoin. When the coin is first launched, there are two paths you can take:

  • Preminting
  • Minting when people claim

When preminting, in most cases, a project will allocate tokens for airdrops, claims, and community in general. They may allocate tokens for other purposes as well, such as tipping. When people claim tokens, the tokens are transferred from one wallet to another.

In contrast, when minting at the time of claim, there is no premint, so users who claim are minting and paying gas fees for the claim.

There are benefits and drawbacks to each approach, but for our tutorial, we’re going to take the latter–minting when people claim.

So, we will need to track:

  1. How much each person can claim
  2. Offchain transactions that might adjust that claim amount (tipping, for example)

For this, we’ll use a Supabase PostgreSQL database and a Cloudflare worker script. We are going to be leveraging Farcaster for distribution, so tipping will be key. So we will need to make use of Pinata’s Farcaster APIs to determine if tips have been allocated via comments on posts. We will then need to have some logic that takes tips from one user and applies them to another, assuming the tipper has enough tipping allocation.

Let’s write some code and see how this looks in practice. First, we’ll need to create a table in Supabase. Here are the columns you’ll need:

create table
public.memecoin (
id bigint generated by default as identity,
created_at timestamp with time zone not null default now(),
updated_at timestamp with time zone not null default now(),
fid bigint not null,
airdrop_claim bigint not null default '0'::bigint,
tipping_balance bigint not null default '0'::bigint,
tipped bigint not null default '0'::bigint,
address text null,
username text null,
display_name text null,
pfp_url text null,
constraint memecoin_pkey primary key (id),
constraint memecoin_fid_key unique (fid)
) tablespace pg_default;

You can either manually create your table in Supabase or you can paste this query into their SQL query editor. Here’s a guide on doing so.

Once your table is created, we can spin up our Cloudflare project. This is incredibly easy with the Cloudflare’s Wrangler CLI, so let’s get to it.

The CLI is going to create a directory for your project for you, so let’s run the command to initialize the project.

npx wrangler init

This process will walk you through creating your project, starting with naming the directory where your project will be housed. In this process, you will also need to decide what template to use. Pick the scheduled worker template:

This is going to give us a nice starting point for the cron job we’ll use for our memecoin manager. Now, if you open your project in your favorite code editor, you’re going to find a project src folder with an index.js or index.ts file, depending on whether or not you chose to work in Typescript. I’m going to assume Typescript, so adjust accordingly.

If you take a look at your index.ts file, you’ll see Cloudflare’s Wrangler CLI was nice enough to document a lot of the usage patterns for workers in comments. There’s a problem, though. This template assumes a single cron job running on a single cadence. We’re going to run multiple cron jobs that call different functions. Let’s take a look at what our cron trigger functions are going to be and what frequency they will be executed.

We want to find all the casts that match what we’re looking for. We should do this pretty frequently because this is how people will receive tips that have been sent to them. For this tutorial, we’ll run this cron job every two minutes.

The other function we need to run is a tipping allocation reset. For simplicity, at the end of every day, we’re going to reset everyone’s tipping allocation to 1000. From here, you can add custom rules that control how much each person gets, all while using the same function and same frequency. But we’ll keep it simple.

So, let’s set up our index.ts file. In that file, replace everything with:

export default {
async scheduled(event: ScheduledEvent, env: Env, ctx: ExecutionContext): Promise {
switch (event.cron) {
case "*/2 * * * *":
// Every two minutes
console.log("Find casts")
await findCasts(env);
break;
case "0 0 * * *":
// Every day
console.log("Updating tipping balance")
await updateDailyTipBalance(env);
break;
}
},
};

As you can tell, this is creating two scheduled events, one that runs daily and one that runs every two minutes. It’s worth pointing out that you must pass the environment variables that are exposed as arguments to our scheduled function to any functions that we use within our cron flow.

You can add as many of these cron functions as you want, but you’ll want to make sure to update your wrangler.toml file to support each cron cadence you add. Open that file and either uncomment the cron section and change it or simply add this:

[triggers]
crons = ["*/2 * * * *", "0 0 * * *"]

This file is used when deploying your project to Cloudflare, so if you don’t set this in the wrangler.toml file, then your cron functions will never be executed.

Of course, we have set up the triggers, but we haven’t written our functions. Let’s start with the findCasts function. In your index.ts file, add the following:

const findCasts = async (env: Env) => {
const supabase = createClient(env.SUPABASE_URL, env.SUPABASE_KEY)
// First get the timestamp to query after
let { data: timestamp, error: timestampError } = await supabase
.from('query_timestamp')
.select('timestamp')
.eq("id", 1)
if (timestampError) {
console.log("Timestamp error")
throw timestampError
}
const timestamp1 = timestamp![0].timestamp
let resp: any = await fetch('https://api.pinata.cloud/v3/farcaster/casts?pageSize=2000&reverse=true', {
headers: {
'Authorization': `Bearer ${env.PINATA_JWT}`
}
});
const data: CastResponse = await resp.json()
// Get the most recent timestamp
const newestTimestamp = data.casts[0].timestamp
const lastTimestamp = data.casts[data.casts.length - 1].timestamp
const casts: Cast[] = data.casts;
await handleTippingUpdates(env, casts);
if (data.next?.cursor && timestamp && new Date(timestamp[0].timestamp) <= new Date(lastTimestamp)) {
console.log("timestamp is old")
let hasMore = true;
let token = data.next.cursor
while (hasMore) {
try {
let resp: any = await fetch(`https://api.pinata.cloud/v3/farcaster/casts?pageSize=2000&reverse=true&pageToken=${token}`, {
headers: {
'Authorization': `Bearer ${env.PINATA_JWT}`
}
});
const newData: CastResponse = await resp.json()
const newCasts: Cast[] = newData.casts;
await handleTippingUpdates(env, newCasts);
const timestamp2 = newData.casts[newData.casts.length - 1].timestamp
if (timestamp2 < timestamp1) {
console.log("Timestamps are caught up")
hasMore = false
await updateTimestamp(env, newestTimestamp)
} else if (!newData.next?.cursor) {
console.log("No more page tokens")
hasMore = false
await updateTimestamp(env, newestTimestamp)
}
} catch (error) {
console.log("Error in the while loop")
console.log(error);
throw error;
}
}
}
}

This is relatively straightforward. We are looping. The easiest, safest thing in programming. Just kidding. You can mess up a lot here, so let’s make sure we understand what’s going on.

Note that, as I mentioned before, we’re passing environment variables in as an argument to the function we’re using for the cron job here. Unlike other env systems in Node.js, using envs in Cloudflare workers means simply references them like this:

env.NAME_OF_ENV

To set your envs, you can create a file called .dev.vars. Make sure to add this to your .gitignore. The format of this file is the same as any other env file you’ve created.

Now, let’s check out the code. We are using the query_timestamp table from Supabase to make sure we have a central source of truth for the timestamp we’ll use to find casts that have happened since. With this timestamp, we know when to stop looping, but let’s explore how.

Using the Pinata Farcaster Read API, we can get all casts in reverse chronological order. We can set our page size to make sure we get the most casts in a single request that is allowed. The trick is we need to use the next.cursor portion of the API response to continue fetching casts until we find casts that is before or equal to the timestamp stored in our database.

We need to break out of the loop when we have found a matching timestamp, or one that happens before our database timestamp, OR we need to break out of the loop if there happens to not be a next.cursor in the response. But, to be honest, if we get into a situation where we don’t have a next.cursor, then it probably means that we have made a mistake and just accidentally queried all casts in the history of the Farcaster network.

If you notice, we call a function called handleTippingUpdates in the loop. This function is important because it’s going to do all the lifting of finding casts that represent tips. Let’s write that function now.

const handleTippingUpdates = async (env: Env, casts: Cast[]) => {
console.log("Searching casts");
const supabase = createClient(env.SUPABASE_URL, env.SUPABASE_KEY)
try {
for (const cast of casts.reverse()) {
if (cast.content.includes("$memecoin")) {
let textRegex = /(\d+)\s+\$memecoin/;
let textMatch = cast.content.match(textRegex);
let tipAmount;
if (textMatch) {
console.log("Text match: ", textMatch)
tipAmount = parseInt(textMatch[1]);
}
let { data: balanceForTipper, error } = await supabase
.from('balance')
.select('tipping_balance')
.eq("fid", cast.fid)
if (balanceForTipper && balanceForTipper[0]?.tipping_balance) {
const tippingBalance = balanceForTipper && balanceForTipper[0] ? balanceForTipper[0].tipping_balance : 0
await updateTippingBalances(env, tipAmount || 0, tippingBalance, cast)
}
}
}
} catch (error) {
console.log("Tipping updates error")
console.log(error)
throw error;
}
}

This function checks the cast text to see if there is a mention of the memecoin name. For the sake of this tutorial, the name of the coin is simply “memecoin” but you would check for mentions of your coin’s name. If there is a mention found, we need a regex so that we can find how many of the coin is being tipped.

If there is a regex match, we then need to figure out who sent the cast and make sure they have a tipping balance available. We use the database for this and simply query the balance table we created earlier. Next, assuming the tipping balance is sufficient, we will decrease the tipping balance available for the person sending the tip and increase the tipped column for the person receiving the tip. This happens through another function called updateTippingBalances. Let’s write that function now. In the same file, add this:

const updateTippingBalances = async (env: Env, tipAmount: number, tippingBalance: number, cast: Cast) => {
const supabase = createClient(env.SUPABASE_URL, env.SUPABASE_KEY)
try {
// Get user to allocate tip to
const parent = cast.parentAuthor ? cast.parentAuthor.fid : null
if (tipAmount && parent && tippingBalance >= tipAmount) {
console.log("Tipping!")
// Allocated tipped amount
let { data: balanceForReceiver, error } = await supabase
.from('balance')
.select('tipped')
.eq("fid", parent)
if (error) {
console.log("Error getting balance for recipient")
throw error
}
let isRecipientInDB = balanceForReceiver && balanceForReceiver[0] ? true : false
let tippedbalance = balanceForReceiver && balanceForReceiver[0] ? balanceForReceiver[0].tipped : 0
const updated = tippedbalance + tipAmount;
if (isRecipientInDB) {
const { data: updatedData, error: upsertError } = await supabase
.from('balance')
.upsert({ tipped: updated, fid: parent }, { onConflict: "fid" })
.select()
if (upsertError) {
console.log("Updating recipient balance error")
console.log(upsertError)
throw error;
}
} else {
// Need to get the user's info and add them to the db
await getUserDataAndAddToDB(env, parent, false, updated)
}
// Reduce tipper's balance
const newTippingBalance = tippingBalance - tipAmount
const { data: updatedTipperBalance, error: updatedTipperError } = await supabase
.from('balance')
.upsert({ tipping_balance: newTippingBalance, fid: cast.author.fid }, { onConflict: "fid" })
.select()
}
return;
} catch (error) {
console.log("Update tipping balances error")
console.log(error);
throw error;
}
}

There’s quite a bit going on in this function, so let’s spend some time here. First, we use it to figure out who should receive the tipped memecoins. This is very simple, thanks to the Pinata API. We just need to grab the parent author FID, which tells us who the person sending the tip is responding to. Now, we need to find that user in the database. This is where we have some branching logic because, depending on how you populate your balance table initially, it’s not guaranteed that everyone receiving tips will be in the database. Let’s check out the logic.

If the recipient is already in the database, we simply update the values appropriately with an upsert. Very straightforward. Not much to say here.

Now, if the user is not in the database, we need to add them, but we need to add them with values that are defined by how our memecoin project works. For example, does this person get an initial airdrop claim outside of the tip? If not, you need to make sure to add them with a 0 in that column. If they do get an airdrop claim, how much and from what criteria? These are questions you have to answer as part of your question, but the template above is flexible enough to handle any decision. We add the user to the database using a dedicated function. Go ahead and add this function above in the same file:

const getUserDataAndAddToDB = async (env: Env, fid: number, addAirdrop: boolean, updatedTippedAmount?: number) => {
const supabase = createClient(env.SUPABASE_URL, env.SUPABASE_KEY)
try {
const userRes = await fetch(`https://api.pinata.cloud/v3/farcaster/users/${fid}`, {
headers: {
'Authorization': `Bearer ${env.PINATA_JWT}`
}
})
const data: any = await userRes.json();
const { user } = data;
const newRecord = {
fid: fid,
username: user.username,
display_name: user.display_name,
pfp_url: user.pfp_url,
airdrop_claim: addAirdrop ? AIRDROP : 0,
tipping_balance: addAirdrop ? TIPPING_ALLOTMENT : 0,
tipped: updatedTippedAmount ? updatedTippedAmount : 0,
daily_tipping_balance: 0,
claimed: false,
address: data.verifications && data.verifications.length > 0 ? data.verifications[0] : data.custody_address
}
const { data: insertData, error: insertError } = await supabase
.from('balance')
.insert([newRecord])
.select()
if (insertError) {
console.log("Insert error")
console.log(insertError)
throw insertError
}
} catch (error) {
console.log("Get user data and add to db error")
console.log(error);
throw error
}
}

I have some variables that represent adding an airdrop balance and how much, as well as an initial tipping allotment and whether it should be updated. This is configurability you can choose to ignore or extend. But, ultimately, this function gets the user data from the Pinata Farcaster API based on the user’s FID and inserts it into the database.

Let’s return to our updateTippingBalances function. We need to reduce the tipper’s balance. We know they are in the database because we already checked early to make sure they had a tipping balance to use. So, this is a little simpler. We just reduce the tipped amount from their allowance.

And that’s it… For finding casts that tip your memecoin and making adjustments. Now we need to write a cron function that updates the daily tipping allowance. This will be much simpler. Add the following function in your index.ts file:

const updateDailyTipBalance = async (env: Env) => {
const supabase = createClient(env.SUPABASE_URL, env.SUPABASE_KEY)
const { data, error } = await supabase
.from('balance')
.update({ tipping_balance: 1000 })
.neq("tipping_balance", 1000)
if (error) {
console.error('Error updating tip_balance:', error);
return;
}
console.log('Updated tip_balance for all rows:', data);
}

Remember, this function will run once a day and, to keep things simple, we are setting the daily tipping balance for everyone to 1000. You will want to create your own logic for this, but you can generally follow the same pattern of running this function daily or weekly or whatever you need.

Conclusion

You’ve made it. You have the cron job infrastructure to manage the entire backend processing of your memecoin’s economy. This was the hard part, young padawan. Your smart contract can be as complex or as simple as you need, but here are a few things to think about.

  1. Will you be airdropping claims to your users or will they be minting them into their wallets?
  2. If they are minting them, how will you manage the allowlist? You’ll have a database of users and the balances they receive. A merkle root system would work well here.
  3. How often will you have people claim their tokens?
  4. Will your token have liquidity initially or organically?

Creating a memecoin is not as simple as writing a smart contract. You have to manage the “back office” with queries that span the Farcaster network. Luckily, Pinata makes this easy with the Farcaster API. Combine that with easy-to-use tools like Cloudflare workers and Supabase, and you have a winning recipe.

Happy Memeing!

--

--

Justin Hunter
Pinata

Writer. Lead Product Manager, ClickUp. Tinkerer.