Tutorial

Building a Token Fee Simulator as a Base Mini App

A comprehensive guide to building a cross-chain token fee calculator that works as a native Base mini app, featuring real-time volume data from Solana and Base tokens.

Javery
··12 min read
Building a Token Fee Simulator as a Base Mini App

Building a Token Fee Simulator as a Base Mini App

The explosion of meme coins across Solana and Base has created a new paradigm for token launches. Platforms like Flaunch have pioneered fair launch mechanisms where trading fees are split between creators and the community—a stark contrast to traditional token models where developers extract value through presales and unlocks.

In this tutorial, we'll build a complete Token Fee Simulator that shows users how much in trading fees they could have earned if their favorite tokens had launched on a fee-sharing platform. We'll cover:

  • Setting up a Cloudflare Workers + React application
  • Fetching real-time token data from GeckoTerminal
  • Calculating historical trading volume across multiple timeframes
  • Building an app-like responsive UI with CSS Grid
  • Integrating with Base's mini app SDK for native mobile experiences
  • Generating dynamic Open Graph images for social sharing

By the end, you'll have a production-ready application that works seamlessly in browsers, Farcaster, and the Base App.

Why Token Fees Matter

Traditional token launches often leave communities holding the bag. Developers mint tokens, sell into liquidity, and disappear. The creator fee model flips this script—instead of extracting value at launch, creators earn ongoing revenue from trading activity.

Here's how it typically works:

Fee ComponentPercentageRecipient
Trading Fee1%Protocol
Creator Share50%Token Developer
Community Share50%Holders/Treasury

For high-volume tokens, these fees add up quickly. A token with $10M in weekly trading volume generates:

  • $100,000 in total fees
  • $50,000 to the creator
  • $50,000 to the community

This aligns incentives—creators benefit when their community thrives, not when they dump.

Architecture Overview

Our application uses a modern edge-first architecture:

Cloudflare Workers

Router/api/*
D1 (SQLite)Tokens & Pools
KV CacheVolume & OG Images
⚛️

React SPA (Vite)

TokenSearchSearch & Filter
FeeDisplayCalculations
Mini App SDKFarcaster Integration

Why Cloudflare Workers?

  • Global edge deployment: Sub-50ms latency worldwide
  • D1 Database: SQLite at the edge for token metadata
  • KV Storage: Distributed caching for volume calculations
  • Zero cold starts: Always-on performance
  • Cost effective: Free tier handles significant traffic

Project Setup

Let's start with a fresh Vite + React project configured for Cloudflare Workers:

npm create vite@latest token-fee-simulator -- --template react-ts
cd token-fee-simulator
npm install

Install the required dependencies:

# Core dependencies
npm install wouter @tanstack/react-query drizzle-orm
 
# Cloudflare Workers
npm install wrangler -D
npm install @cloudflare/workers-types -D
 
# Mini App SDK
npm install @farcaster/frame-sdk
 
# Build tools
npm install vite-plugin-cloudflare -D

Configuring Wrangler

Create wrangler.toml for Cloudflare Workers configuration:

name = "token-fee-simulator"
main = "dist/worker/index.js"
compatibility_date = "2024-01-01"
 
[vars]
PUBLIC_APP_URL = "https://your-app.pages.dev"
 
[[d1_databases]]
binding = "DB"
database_name = "token-simulator-db"
database_id = "your-database-id"
 
[[kv_namespaces]]
binding = "CACHE"
id = "your-kv-namespace-id"
 
[triggers]
crons = ["*/15 * * * *"]  # Sync tokens every 15 minutes

Database Schema

We'll use Drizzle ORM with D1 (SQLite). Create db/schema.ts:

import { sqliteTable, text, integer, real, unique } from "drizzle-orm/sqlite-core";
 
export const tokens = sqliteTable(
  "tokens",
  {
    id: integer("id").primaryKey({ autoIncrement: true }),
    address: text("address").notNull(),
    pool_id: text("pool_id"),
    gecko_pool_id: text("gecko_pool_id"),
    name: text("name").notNull(),
    symbol: text("symbol").notNull(),
    network: text("network", { enum: ["solana", "base"] }).notNull(),
    image_url: text("image_url"),
    price_usd: real("price_usd"),
    volume_usd_24h: real("volume_usd_24h"),
    market_cap_usd: real("market_cap_usd"),
    last_updated: text("last_updated").notNull(),
    created_at: text("created_at").default("CURRENT_TIMESTAMP"),
  },
  (table) => ({
    addressUnique: unique("tokens_address_unique").on(table.address),
  })
);
 
export type Token = typeof tokens.$inferSelect;

This schema supports both Solana tokens and Base tokens—crucial for users exploring cross-chain opportunities or considering Solana token migration to Base.

Fetching Token Data from GeckoTerminal

GeckoTerminal provides comprehensive DEX data across multiple chains. Here's our token sync service:

// src/worker/services/token-sync.ts
 
interface GeckoPool {
  id: string;
  attributes: {
    name: string;
    address: string;
    base_token_price_usd: string;
    volume_usd: { h24: string };
    market_cap_usd: string;
  };
  relationships: {
    base_token: {
      data: { id: string };
    };
  };
}
 
async function fetchTrendingPools(network: "solana" | "base"): Promise<GeckoPool[]> {
  const url = `https://api.geckoterminal.com/api/v2/networks/${network}/trending_pools`;
 
  const response = await fetch(url, {
    headers: {
      "Accept": "application/json",
      "User-Agent": "TokenFeeSimulator/1.0",
    },
  });
 
  if (!response.ok) {
    throw new Error(`GeckoTerminal API error: ${response.status}`);
  }
 
  const data = await response.json();
  return data.data || [];
}
 
export async function syncTokens(db: Database): Promise<void> {
  // Fetch from both networks in parallel
  const [solanaPools, basePools] = await Promise.all([
    fetchTrendingPools("solana"),
    fetchTrendingPools("base"),
  ]);
 
  const allPools = [
    ...solanaPools.map(p => ({ ...p, network: "solana" as const })),
    ...basePools.map(p => ({ ...p, network: "base" as const })),
  ];
 
  for (const pool of allPools) {
    const tokenAddress = pool.relationships.base_token.data.id.split("_")[1];
 
    await db
      .insert(tokens)
      .values({
        address: tokenAddress,
        pool_id: pool.id,
        gecko_pool_id: pool.id,
        name: pool.attributes.name.split(" / ")[0],
        symbol: pool.attributes.name.split(" / ")[0],
        network: pool.network,
        price_usd: parseFloat(pool.attributes.base_token_price_usd) || null,
        volume_usd_24h: parseFloat(pool.attributes.volume_usd.h24) || null,
        market_cap_usd: parseFloat(pool.attributes.market_cap_usd) || null,
        last_updated: new Date().toISOString(),
      })
      .onConflictDoUpdate({
        target: tokens.address,
        set: {
          price_usd: parseFloat(pool.attributes.base_token_price_usd) || null,
          volume_usd_24h: parseFloat(pool.attributes.volume_usd.h24) || null,
          last_updated: new Date().toISOString(),
        },
      });
  }
}

Calculating Historical Volume

The heart of our simulator is calculating trading volume across different timeframes. We use GeckoTerminal's OHLCV (Open-High-Low-Close-Volume) API:

// src/worker/routes/volume.ts
 
const timeframeConfig: Record<string, { period: string; aggregate: number; limit: number }> = {
  "24h": { period: "minute", aggregate: 30, limit: 48 },
  "1w": { period: "hour", aggregate: 4, limit: 42 },
  "1m": { period: "day", aggregate: 1, limit: 30 },
  "1y": { period: "day", aggregate: 1, limit: 365 },
  "all": { period: "day", aggregate: 1, limit: 730 },
};
 
async function fetchOHLCVData(
  network: string,
  poolId: string,
  timeframe: string
): Promise<number[][]> {
  const config = timeframeConfig[timeframe];
 
  const url = `https://api.geckoterminal.com/api/v2/networks/${network}/pools/${poolId}/ohlcv/${config.period}?aggregate=${config.aggregate}&limit=${config.limit}&currency=usd`;
 
  const response = await fetch(url, {
    headers: { "Accept": "application/json" },
  });
 
  const data = await response.json();
  return data.data?.attributes?.ohlcv_list || [];
}
 
function calculateTotalVolume(ohlcvList: number[][]): number {
  return ohlcvList.reduce((total, candle) => {
    const volume = candle[5] || 0; // Volume is index 5
    return total + volume;
  }, 0);
}
 
export async function handleGetTokenVolume(
  db: Database,
  cache: KVNamespace,
  address: string,
  params: { timeframe?: string; refresh?: boolean }
): Promise<VolumeResponse> {
  const { timeframe = "24h", refresh = false } = params;
  const cacheKey = `volume:${address}:${timeframe}`;
 
  // Check cache first (unless refresh requested)
  if (!refresh) {
    const cached = await cache.get(cacheKey);
    if (cached) {
      const parsed = JSON.parse(cached);
      const age = Date.now() - new Date(parsed.cachedAt).getTime();
      if (age < 12 * 60 * 60 * 1000) { // 12 hour TTL
        return parsed;
      }
    }
  }
 
  // Fetch token from database
  const token = await db
    .select()
    .from(tokens)
    .where(eq(tokens.address, address))
    .get();
 
  if (!token) {
    return { success: false, error: "Token not found" };
  }
 
  // Fetch OHLCV data
  const poolAddress = token.gecko_pool_id?.split("_")[1];
  const ohlcvData = await fetchOHLCVData(token.network, poolAddress, timeframe);
  const volume = calculateTotalVolume(ohlcvData);
 
  // Calculate fees (1% of volume, split 50/50)
  const fees = volume * 0.01;
  const devShare = fees * 0.5;
  const communityShare = fees * 0.5;
 
  const result = {
    success: true,
    volume,
    fees,
    devShare,
    communityShare,
    timeframe,
    calculatedAt: new Date().toISOString(),
    cachedAt: new Date().toISOString(),
  };
 
  // Cache the result
  await cache.put(cacheKey, JSON.stringify(result), {
    expirationTtl: 43200, // 12 hours
  });
 
  return result;
}

Handling New Tokens

A critical edge case: newly launched tokens don't have historical data. If we naively multiply 24h volume by 365 days for a 3-day-old token, we get wildly inflated numbers. Here's our solution:

// Fallback for when OHLCV fails
if (volume === null) {
  // Cap "all" timeframe at 30 days to avoid wild estimates
  const days = {
    "24h": 1,
    "1w": 7,
    "1m": 30,
    "1y": 365,
    "all": 30  // Capped for new tokens!
  }[timeframe] || 1;
 
  volume = (token.volume_usd_24h || 0) * days;
}

This ensures users see realistic projections, even for the hottest new base tokens that launched yesterday.

Building the Frontend

Our React frontend uses a component architecture optimized for mobile-first experiences:

Token Search Component

// src/client/components/TokenSearch.tsx
 
import { useState, useCallback } from "react";
import { useDebounce } from "../hooks/useDebounce";
import { useSearchTokens, useTokens } from "../hooks/useTokens";
import { TokenCard } from "./TokenCard";
 
export function TokenSearch({ onSelect, selectedAddress }) {
  const [query, setQuery] = useState("");
  const [network, setNetwork] = useState<"all" | "solana" | "base">("all");
  const debouncedQuery = useDebounce(query, 300);
 
  const { data: trendingData, isLoading: trendingLoading } = useTokens({
    network: network === "all" ? undefined : network,
    limit: 50
  });
 
  const { data: searchData, isLoading: searchLoading } = useSearchTokens(
    debouncedQuery,
    debouncedQuery.length > 0
  );
 
  const isSearching = debouncedQuery.length > 0;
  const tokens = (isSearching ? searchData : trendingData)?.tokens || [];
 
  return (
    <div className="flex flex-col flex-1 min-h-0 gap-4">
      {/* Search Input */}
      <div className="relative">
        <input
          type="text"
          placeholder="Search tokens by name, symbol, or address..."
          value={query}
          onChange={(e) => setQuery(e.target.value)}
          className="w-full px-4 py-3 pl-10 bg-white/5 border border-white/10
            rounded-lg text-white placeholder:text-white/40 focus:outline-none
            focus:ring-2 focus:ring-purple-500"
        />
        <SearchIcon className="absolute left-3 top-1/2 -translate-y-1/2" />
      </div>
 
      {/* Network Filter - Essential for Solana token migration users */}
      <div className="flex gap-2">
        {(["all", "solana", "base"] as const).map((n) => (
          <button
            key={n}
            onClick={() => setNetwork(n)}
            className={`px-4 py-2 rounded-lg text-sm font-medium transition-all
              ${network === n
                ? "bg-purple-500 text-white"
                : "bg-white/5 text-white/60 hover:bg-white/10"
              }`}
          >
            {n === "all" ? "All Networks" : n === "solana" ? "Solana" : "Base"}
          </button>
        ))}
      </div>
 
      {/* Token List - Stretches to fill available space */}
      <div className="flex flex-col flex-1 min-h-0 gap-2">
        <div className="flex flex-col gap-2 flex-1 min-h-0 overflow-y-auto">
          {tokens.map((token) => (
            <TokenCard
              key={token.address}
              token={token}
              onClick={onSelect}
              selected={token.address === selectedAddress}
            />
          ))}
        </div>
      </div>
    </div>
  );
}

App-Like Layout with CSS Grid

For a native app feel, we use CSS Grid with fixed header/footer and scrolling content:

/* src/client/index.css */
 
:root {
  /* Safe area insets for mini apps */
  --safe-area-top: env(safe-area-inset-top, 0px);
  --safe-area-bottom: env(safe-area-inset-bottom, 0px);
}
 
.page-grid {
  display: grid;
  grid-template-rows: auto 1fr auto;
  height: 100vh;
  height: 100dvh; /* Dynamic viewport for mobile */
  overflow: hidden;
 
  /* Apply safe areas for notched phones */
  padding-top: var(--safe-area-top);
  padding-bottom: var(--safe-area-bottom);
}
 
.page-grid > main {
  overflow-y: auto;
  min-height: 0;
}

This creates the classic app layout:

  • Header: Fixed at top (auto height)
  • Content: Scrollable middle section (1fr)
  • Footer: Fixed at bottom (auto height)

Integrating Base Mini App SDK

The magic happens when we integrate with Farcaster's mini app SDK. This enables our app to run natively inside the Base App and Warpcast.

The useMiniApp Hook

// src/client/hooks/useMiniApp.ts
 
import { useEffect, useState } from "react";
import sdk from "@farcaster/miniapp-sdk";
 
interface MiniAppContext {
  isReady: boolean;
  isInMiniApp: boolean;
  safeAreaInsets: {
    top: number;
    bottom: number;
    left: number;
    right: number;
  };
}
 
export function useMiniApp(): MiniAppContext {
  const [isReady, setIsReady] = useState(false);
  const [isInMiniApp, setIsInMiniApp] = useState(false);
  const [safeAreaInsets, setSafeAreaInsets] = useState({
    top: 0, bottom: 0, left: 0, right: 0,
  });
 
  useEffect(() => {
    async function initMiniApp() {
      try {
        if (typeof window !== "undefined" && sdk) {
          const context = await sdk.context;
 
          if (context) {
            setIsInMiniApp(true);
 
            // Get safe area insets from the host app
            if (context.client?.safeAreaInsets) {
              setSafeAreaInsets(context.client.safeAreaInsets);
            }
 
            // Signal that our app is ready to display
            await sdk.actions.ready();
            console.log("[MiniApp] SDK initialized");
          }
        }
      } catch (error) {
        console.log("[MiniApp] Not in mini app context");
      } finally {
        setIsReady(true);
      }
    }
 
    initMiniApp();
  }, []);
 
  return { isReady, isInMiniApp, safeAreaInsets };
}

Applying Safe Areas Dynamically

In our main App component, we apply the SDK-provided safe areas:

// src/client/App.tsx
 
export default function App() {
  const { isReady, isInMiniApp, safeAreaInsets } = useMiniApp();
 
  useEffect(() => {
    if (isInMiniApp) {
      document.documentElement.style.setProperty(
        '--safe-area-top',
        `${safeAreaInsets.top}px`
      );
      document.documentElement.style.setProperty(
        '--safe-area-bottom',
        `${safeAreaInsets.bottom}px`
      );
    }
  }, [isInMiniApp, safeAreaInsets]);
 
  return (
    <Router>
      <Switch>
        <Route path="/" component={HomePage} />
        <Route path="/token/:address">
          {(params) => <TokenPage address={params.address} />}
        </Route>
      </Switch>
    </Router>
  );
}

The Farcaster Manifest

Create public/.well-known/farcaster.json:

{
  "accountAssociation": {
    "header": "...",
    "payload": "eyJkb21haW4iOiJ5b3VyLWRvbWFpbi5jb20ifQ",
    "signature": "..."
  },
  "frame": {
    "version": "1",
    "name": "Token Fee Simulator",
    "iconUrl": "https://your-domain.com/icon.png",
    "homeUrl": "https://your-domain.com",
    "splashImageUrl": "https://your-domain.com/api/og/default",
    "splashBackgroundColor": "#0a0a0b",
    "webhookUrl": "https://your-domain.com/api/webhook"
  }
}

And add frame meta tags to index.html:

<meta name="fc:frame" content="vNext" />
<meta property="fc:frame:image" content="https://your-domain.com/api/og/default" />
<meta property="fc:frame:image:aspect_ratio" content="1.91:1" />
<meta property="fc:frame:button:1" content="Launch App" />
<meta property="fc:frame:button:1:action" content="launch_frame" />
<meta property="fc:frame:button:1:target" content="https://your-domain.com" />

Dynamic OG Image Generation

For viral sharing, we generate custom Open Graph images showing each token's potential fees:

// src/worker/routes/og.ts
 
import { Resvg, initWasm } from "@resvg/resvg-wasm";
 
export async function generateTokenOGImage(
  token: Token,
  fees: number,
  assets: Fetcher
): Promise<ArrayBuffer> {
  // Load embedded font
  const fontResponse = await assets.fetch(
    new Request("https://placeholder/assets/Inter.ttf")
  );
  const fontBuffer = new Uint8Array(await fontResponse.arrayBuffer());
 
  const svg = `
    <svg width="1200" height="630" xmlns="http://www.w3.org/2000/svg">
      <rect width="100%" height="100%" fill="#0a0a0b"/>
 
      <text x="600" y="200"
        font-family="Inter" font-size="72" font-weight="bold"
        fill="white" text-anchor="middle">
        ${token.symbol}
      </text>
 
      <text x="600" y="320"
        font-family="Inter" font-size="48"
        fill="#a855f7" text-anchor="middle">
        Could have earned
      </text>
 
      <text x="600" y="420"
        font-family="Inter" font-size="96" font-weight="bold"
        fill="#22c55e" text-anchor="middle">
        $${formatNumber(fees)}
      </text>
 
      <text x="600" y="520"
        font-family="Inter" font-size="36"
        fill="rgba(255,255,255,0.6)" text-anchor="middle">
        in creator fees on Flaunch
      </text>
    </svg>
  `;
 
  const resvg = new Resvg(svg, {
    fitTo: { mode: "width", value: 1200 },
    font: {
      fontBuffers: [fontBuffer],
      defaultFontFamily: "Inter",
    },
  });
 
  return resvg.render().asPng().buffer;
}

Deployment

Deploy to Cloudflare Workers:

# Build the application
npm run build
 
# Deploy to Cloudflare
npx wrangler deploy
 
# Push database schema
npx wrangler d1 execute your-db --remote --file=./schema.sql

Your app is now live at your Workers URL and custom domain!

Conclusion

We've built a production-ready Token Fee Simulator that:

  1. Fetches real-time data from both Solana and Base ecosystems
  2. Calculates accurate historical volume across multiple timeframes
  3. Provides an app-like experience with fixed navigation and smooth scrolling
  4. Integrates natively with Base App and Farcaster as a mini app
  5. Generates viral OG images for social sharing

This architecture is perfect for token launchpad analytics, creator fee dashboards, or any application comparing cross-chain token performance. For users considering Solana token migration to Base, it provides concrete data on what their trading fees could look like.

Next Steps

  • Sign your manifest: Use the Farcaster developer tools to properly sign your farcaster.json
  • Submit to Base App: Register your mini app in the Base App directory
  • Add wallet connection: Enable users to connect wallets and see personalized projections
  • Implement notifications: Alert users when their tracked tokens hit volume milestones

The full source code is available on GitHub.


Building on Base? Join the conversation in the Base Discord and follow @BuildOnBase for the latest updates.

Share:

Related Posts