Fee Scanner
Fee Intelligence
The fee scanner is Seshat's core data pipeline. It tracks every Clanker-created token on Base, reads unclaimed creator fee balances every 10 minutes, and detects the moment a first-ever claim is made.
Factory contracts
Clanker has deployed five factory versions on Base. Each version has a slightly different ABI for reading fee state.
// Clanker factory addresses (Base mainnet)
const FACTORIES = {
V0: "0x250c9FB2", // earliest, creatorReward() not exposed
V1: "0x9B84fcE5", // unclaimedFees / totalFeesClaimed / feeRecipient
V2: "0x732560fa", // creatorReward / claimedFees / creator
V3: "0x375C15db", // same ABI as V2
V4: "0xE85A59c6", // FeeLocker pattern
} as const;
// V4 fee locker — emits ClaimTokens / ClaimTokensPermissioned
const FEE_LOCKER_V4 = "0xF3622742";ABI fallback strategy
Rather than tracking which factory minted each token, the scanner tries the V2 ABI first (most common), then falls back to V1 if the call reverts. This covers all historical tokens in a single pass.
// baseChain.ts
async function getCreatorReward(lpAddress: string): Promise<bigint> {
try {
// V2 / V3: creatorReward(address) → uint256
return await publicClient.readContract({
address: lpAddress as `0x${string}`,
abi: CLANKER_V2_ABI,
functionName: "creatorReward",
});
} catch {
// V1: unclaimedFees() → uint256
return await publicClient.readContract({
address: lpAddress as `0x${string}`,
abi: CLANKER_V1_ABI,
functionName: "unclaimedFees",
});
}
}Multicall batching
Reading 2,800+ contracts individually would require 2,800+ RPC calls per scan cycle. Instead, the scanner uses Uniswap V3's Multicall3 contract to batch 20 reads per RPC call — reducing 2,800 calls to 140.
// 20 tokens per multicall batch
const BATCH_SIZE = 20;
async function batchGetCreatorRewards(
lpAddresses: string[]
): Promise<Map<string, bigint>> {
const results = new Map<string, bigint>();
for (let i = 0; i < lpAddresses.length; i += BATCH_SIZE) {
const batch = lpAddresses.slice(i, i + BATCH_SIZE);
const calls = batch.map(addr => ({
address: addr as `0x${string}`,
abi: CLANKER_V2_ABI,
functionName: "creatorReward",
}));
const raw = await publicClient.multicall({ contracts: calls, allowFailure: true });
for (let j = 0; j < batch.length; j++) {
const res = raw[j];
if (res.status === "success") {
results.set(batch[j], res.result as bigint);
} else {
// V2 failed — try V1 individually
const v1 = await getCreatorRewardV1(batch[j]);
results.set(batch[j], v1);
}
}
}
return results;
}Real-time notifications (Alchemy Webhooks)
The 10-minute polling cadence is the fallback. For first-ever fee claims, Seshat uses Alchemy's Notify API to deliver real-time webhook callbacks — typically within 1 second of the transaction being confirmed on Base.
This works without any polling and uses zero Claude tokens. The webhook handler is a plain Cloudflare Worker endpoint that verifies the Alchemy HMAC signature, parses the event, and dispatches the alert.
// POST /api/webhook/alchemy
export async function handleAlchemyWebhook(
request: Request,
env: Env
): Promise<Response> {
// 1. Verify HMAC-SHA256 signature
const sig = request.headers.get("x-alchemy-signature") ?? "";
const body = await request.text();
const key = await crypto.subtle.importKey(
"raw", new TextEncoder().encode(env.ALCHEMY_WEBHOOK_SIGNING_KEY),
{ name: "HMAC", hash: "SHA-256" }, false, ["verify"]
);
const valid = await crypto.subtle.verify(
"HMAC", key,
hexToBytes(sig),
new TextEncoder().encode(body)
);
if (!valid) return new Response("Unauthorized", { status: 401 });
// 2. Parse the activity logs
const payload = JSON.parse(body);
const logs = payload?.event?.data?.block?.logs ?? [];
for (const log of logs) {
const topic0 = log.topics[0];
// ClaimTokens (FeeLocker V4)
if (topic0 === CLAIM_TOKENS_TOPIC) {
await handleFirstEverClaim(log, env);
}
// Legacy: Uniswap V3 Collect event
if (topic0 === COLLECT_TOPIC) {
await handleLegacyClaim(log, env);
}
}
return new Response("ok");
}Setting up the Alchemy webhook
In your Alchemy dashboard, create a Custom Webhook with the following GraphQL filter. This fires for every ClaimTokens event on the FeeLocker contract with zero polling overhead.
{
block {
logs(
filter: {
addresses: ["0xF3622742..."],
topics: ["0x...ClaimTokens_topic_hash"]
}
) {
account { address }
topics
data
transaction { hash }
}
}
}First-ever claim detection
A first-ever claim is particularly significant: it means a creator just discovered (or was alerted to) their unclaimed fees for the first time. Seshat detects this by checking thefee_claim_eventstable — if no prior claim exists for an LP contract, the current claim is flagged as first-ever and a priority alert is sent to all subscribers tracking that address.
async function isFirstEverClaim(lpAddress: string, db: D1Database): Promise<boolean> {
const existing = await db
.prepare("SELECT id FROM fee_claim_events WHERE lp_address = ? LIMIT 1")
.bind(lpAddress)
.first();
return existing === null;
}Database schema
-- tokens: one row per Clanker token CREATE TABLE tokens ( id TEXT PRIMARY KEY, -- token address symbol TEXT NOT NULL, name TEXT, lp_address TEXT NOT NULL, -- Uniswap V3 LP contract factory_version TEXT NOT NULL, -- V0 | V1 | V2 | V3 | V4 creator_address TEXT, created_at INTEGER NOT NULL, last_scanned_at INTEGER ); -- fee_snapshots: current unclaimed fee balance CREATE TABLE fee_snapshots ( token_id TEXT NOT NULL, unclaimed_eth TEXT NOT NULL, -- stored as string (bigint) claimed_eth TEXT NOT NULL, fee_usd REAL, scanned_at INTEGER NOT NULL, PRIMARY KEY (token_id, scanned_at) ); -- fee_claim_events: on-chain claim log CREATE TABLE fee_claim_events ( id TEXT PRIMARY KEY, lp_address TEXT NOT NULL, token_address TEXT, claimer TEXT NOT NULL, amount_token0 TEXT NOT NULL, amount_token1 TEXT, tx_hash TEXT NOT NULL, block_number INTEGER NOT NULL, is_first_ever_claim INTEGER DEFAULT 0, claimed_at INTEGER NOT NULL );
Bitquery token discovery
Bitquery GraphQL is used as an independent token discovery mechanism — catching tokens created by all five factory versions even before they appear in Clanker's own API. The sync cursor is stored inbitquery_syncso each run processes only new blocks.
query DiscoverNewTokens($sinceBlock: Int!, $limit: Int!) {
ethereum(network: base) {
smartContractEvents(
smartContractAddress: {
in: [
"0x250c9FB2", "0x9B84fcE5",
"0x732560fa", "0x375C15db", "0xE85A59c6"
]
}
smartContractEvent: { is: "TokenCreated" }
height: { gt: $sinceBlock }
limit: { count: $limit }
orderBy: { ascending: false, field: "block.height" }
) {
block { height timestamp { unixtime } }
transaction { hash }
arguments {
argument
value
}
}
}
}