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.
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 Component | Percentage | Recipient |
|---|---|---|
| Trading Fee | 1% | Protocol |
| Creator Share | 50% | Token Developer |
| Community Share | 50% | 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
React SPA (Vite)
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 installInstall 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 -DConfiguring 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 minutesDatabase 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}¤cy=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.sqlYour app is now live at your Workers URL and custom domain!
Conclusion
We've built a production-ready Token Fee Simulator that:
- Fetches real-time data from both Solana and Base ecosystems
- Calculates accurate historical volume across multiple timeframes
- Provides an app-like experience with fixed navigation and smooth scrolling
- Integrates natively with Base App and Farcaster as a mini app
- 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.
Related Posts

Running an LLM on Orange Pi for Base L2 Development
Learn how to set up and run a private large language model on Orange Pi hardware to build AI-powered dApps on Base L2 with complete data privacy.
Setup your own dedicated Base RPC
Learn how to deploy your own dedicated Base RPC from buying the server to running your wallet or app

Building Subscription Services with Base Recurring Payments
Learn how to implement onchain recurring payments using Base's Spend Permissions feature for subscription-based revenue without merchant fees.