1
2
3
4
5
6
7
8import { Actor, log } from "apify";
9import { ethers } from "ethers";
10
11
12
13interface Input {
14 tokens: (string | { address: string; chain?: string })[];
15 chain: string;
16 includeHolders: boolean;
17 holderLimit: number;
18 includeTransfers: boolean;
19 transferLimit: number;
20 includePrice: boolean;
21 includeLiquidity: boolean;
22 etherscanApiKey?: string;
23 rpcUrl?: string;
24}
25
26interface TokenAnalysis {
27 chain: string;
28 address: string;
29 name: string;
30 symbol: string;
31 decimals: number;
32 totalSupply: string;
33 totalSupplyFormatted: string;
34 holderCount?: number;
35 topHolders?: HolderData[];
36 recentTransfers?: TransferData[];
37 priceUsd?: number;
38 marketCap?: number;
39 volume24h?: number;
40 priceChange24h?: number;
41 liquidityPools?: PoolData[];
42 contractVerified?: boolean;
43 isProxy?: boolean;
44 analyzedAt: string;
45}
46
47interface HolderData {
48 rank: number;
49 address: string;
50 balance: string;
51 percentage: number;
52 label?: string;
53}
54
55interface TransferData {
56 txHash: string;
57 from: string;
58 to: string;
59 value: string;
60 blockNumber: number;
61 timestamp?: number;
62}
63
64interface PoolData {
65 dex: string;
66 pairAddress: string;
67 token0: string;
68 token1: string;
69 reserve0?: string;
70 reserve1?: string;
71}
72
73
74
75interface ChainConfig {
76 rpc: string;
77 explorerApi: string;
78 explorerApiKey?: string;
79 chainId: number;
80 coingeckoPlatform: string;
81}
82
83const CHAIN_CONFIGS: Record<string, ChainConfig> = {
84 ethereum: {
85 rpc: "https://eth.llamarpc.com",
86 explorerApi: "https://api.etherscan.io/api",
87 chainId: 1,
88 coingeckoPlatform: "ethereum",
89 },
90 polygon: {
91 rpc: "https://polygon.llamarpc.com",
92 explorerApi: "https://api.polygonscan.com/api",
93 chainId: 137,
94 coingeckoPlatform: "polygon-pos",
95 },
96 arbitrum: {
97 rpc: "https://arbitrum.llamarpc.com",
98 explorerApi: "https://api.arbiscan.io/api",
99 chainId: 42161,
100 coingeckoPlatform: "arbitrum-one",
101 },
102 optimism: {
103 rpc: "https://optimism.llamarpc.com",
104 explorerApi: "https://api-optimistic.etherscan.io/api",
105 chainId: 10,
106 coingeckoPlatform: "optimistic-ethereum",
107 },
108 base: {
109 rpc: "https://base.llamarpc.com",
110 explorerApi: "https://api.basescan.org/api",
111 chainId: 8453,
112 coingeckoPlatform: "base",
113 },
114 worldchain: {
115 rpc: "https://worldchain-mainnet.g.alchemy.com/public",
116 explorerApi: "https://worldchain-mainnet.explorer.alchemy.com/api",
117 chainId: 480,
118 coingeckoPlatform: "world-chain",
119 },
120};
121
122
123
124const ERC20_ABI = [
125 "function name() view returns (string)",
126 "function symbol() view returns (string)",
127 "function decimals() view returns (uint8)",
128 "function totalSupply() view returns (uint256)",
129 "function balanceOf(address) view returns (uint256)",
130 "event Transfer(address indexed from, address indexed to, uint256 value)",
131];
132
133
134
135function parseTokenInput(input: string | { address: string; chain?: string }, defaultChain: string): { chain: string; address: string } {
136
137 if (typeof input === "object" && input !== null) {
138 return { chain: (input.chain || defaultChain).toLowerCase(), address: input.address };
139 }
140
141 if (typeof input === "string" && input.includes(":")) {
142 const [chain, address] = input.split(":", 2);
143 return { chain: chain.toLowerCase(), address };
144 }
145 return { chain: defaultChain, address: String(input) };
146}
147
148async function fetchJson(url: string): Promise<any> {
149 const res = await fetch(url, {
150 headers: { "User-Agent": "apify-web3-token-analyzer/1.0" },
151 });
152 if (!res.ok) throw new Error(`HTTP ${res.status}: ${url}`);
153 return res.json();
154}
155
156async function sleep(ms: number): Promise<void> {
157 return new Promise((r) => setTimeout(r, ms));
158}
159
160
161
162async function getTokenBasicInfo(
163 contract: ethers.Contract,
164 address: string,
165): Promise<Omit<TokenAnalysis, "chain" | "analyzedAt">> {
166 const [name, symbol, decimals, totalSupply] = await Promise.all([
167 contract.name().catch(() => "Unknown"),
168 contract.symbol().catch(() => "???"),
169 contract.decimals().catch(() => 18),
170 contract.totalSupply().catch(() => BigInt(0)),
171 ]);
172
173 return {
174 address,
175 name,
176 symbol,
177 decimals: Number(decimals),
178 totalSupply: totalSupply.toString(),
179 totalSupplyFormatted: ethers.formatUnits(totalSupply, decimals),
180 };
181}
182
183async function getTopHolders(
184 explorerApi: string,
185 address: string,
186 apiKey: string | undefined,
187 limit: number,
188): Promise<{ holders: HolderData[]; count: number } | null> {
189 try {
190 const keyParam = apiKey ? `&apikey=${apiKey}` : "";
191 const url = `${explorerApi}?module=token&action=tokenholderlist&contractaddress=${address}&page=1&offset=${limit}${keyParam}`;
192 const data = await fetchJson(url);
193
194 if (data.status !== "1" || !Array.isArray(data.result)) {
195
196 const infoUrl = `${explorerApi}?module=token&action=tokeninfo&contractaddress=${address}${keyParam}`;
197 const info = await fetchJson(infoUrl);
198 const holderCount = info.result?.[0]?.holdersCount
199 ? parseInt(info.result[0].holdersCount, 10)
200 : undefined;
201 return holderCount ? { holders: [], count: holderCount } : null;
202 }
203
204 const holders: HolderData[] = data.result.map((h: any, i: number) => ({
205 rank: i + 1,
206 address: h.TokenHolderAddress,
207 balance: h.TokenHolderQuantity,
208 percentage: 0,
209 }));
210
211 return { holders, count: holders.length };
212 } catch (err) {
213 log.warning(`Failed to fetch holders: ${err}`);
214 return null;
215 }
216}
217
218async function getRecentTransfers(
219 explorerApi: string,
220 address: string,
221 apiKey: string | undefined,
222 limit: number,
223): Promise<TransferData[]> {
224 try {
225 const keyParam = apiKey ? `&apikey=${apiKey}` : "";
226 const url = `${explorerApi}?module=account&action=tokentx&contractaddress=${address}&page=1&offset=${limit}&sort=desc${keyParam}`;
227 const data = await fetchJson(url);
228
229 if (data.status !== "1" || !Array.isArray(data.result)) return [];
230
231 return data.result.map((tx: any) => ({
232 txHash: tx.hash,
233 from: tx.from,
234 to: tx.to,
235 value: tx.value,
236 blockNumber: parseInt(tx.blockNumber, 10),
237 timestamp: tx.timeStamp ? parseInt(tx.timeStamp, 10) : undefined,
238 }));
239 } catch (err) {
240 log.warning(`Failed to fetch transfers: ${err}`);
241 return [];
242 }
243}
244
245async function getPriceData(
246 platform: string,
247 address: string,
248): Promise<{ priceUsd: number; marketCap: number; volume24h: number; priceChange24h: number } | null> {
249 try {
250 const url = `https://api.coingecko.com/api/v3/coins/${platform}/contract/${address.toLowerCase()}`;
251 const data = await fetchJson(url);
252
253 return {
254 priceUsd: data.market_data?.current_price?.usd ?? 0,
255 marketCap: data.market_data?.market_cap?.usd ?? 0,
256 volume24h: data.market_data?.total_volume?.usd ?? 0,
257 priceChange24h: data.market_data?.price_change_percentage_24h ?? 0,
258 };
259 } catch {
260
261 return null;
262 }
263}
264
265async function checkContractVerification(
266 explorerApi: string,
267 address: string,
268 apiKey: string | undefined,
269): Promise<boolean> {
270 try {
271 const keyParam = apiKey ? `&apikey=${apiKey}` : "";
272 const url = `${explorerApi}?module=contract&action=getabi&address=${address}${keyParam}`;
273 const data = await fetchJson(url);
274 return data.status === "1";
275 } catch {
276 return false;
277 }
278}
279
280
281
282await Actor.init();
283
284const input = (await Actor.getInput<Input>()) ?? ({} as Input);
285
286const {
287 tokens = [],
288 chain: defaultChain = "ethereum",
289 includeHolders = true,
290 holderLimit = 20,
291 includeTransfers = false,
292 transferLimit = 50,
293 includePrice = true,
294 includeLiquidity = false,
295 etherscanApiKey,
296 rpcUrl,
297} = input;
298
299if (tokens.length === 0) {
300 throw new Error("tokens is required. Provide at least one token address.");
301}
302
303log.info(`Analyzing ${tokens.length} tokens. Default chain: ${defaultChain}`);
304
305for (const tokenInput of tokens) {
306 try {
307 const { chain, address } = parseTokenInput(tokenInput, defaultChain);
308 const chainConfig = CHAIN_CONFIGS[chain];
309
310 if (!chainConfig) {
311 log.error(`Unknown chain: ${chain}. Supported: ${Object.keys(CHAIN_CONFIGS).join(", ")}`);
312 await Actor.pushData({ address, chain, error: `Unknown chain: ${chain}` });
313 continue;
314 }
315
316 log.info(`Analyzing ${address} on ${chain}...`);
317
318
319 const rpcEndpoint = rpcUrl || chainConfig.rpc;
320 const provider = new ethers.JsonRpcProvider(rpcEndpoint);
321 const contract = new ethers.Contract(address, ERC20_ABI, provider);
322 const apiKey = etherscanApiKey || chainConfig.explorerApiKey;
323
324
325 const basicInfo = await getTokenBasicInfo(contract, address);
326
327 const analysis: TokenAnalysis = {
328 chain,
329 ...basicInfo,
330 analyzedAt: new Date().toISOString(),
331 };
332
333
334 const tasks: Promise<void>[] = [];
335
336
337 if (includeHolders) {
338 tasks.push(
339 getTopHolders(chainConfig.explorerApi, address, apiKey, holderLimit).then(
340 (result) => {
341 if (result) {
342
343 const totalSupply = BigInt(analysis.totalSupply);
344 analysis.topHolders = result.holders.map((h) => ({
345 ...h,
346 percentage:
347 totalSupply > BigInt(0)
348 ? Number((BigInt(h.balance) * BigInt(10000)) / totalSupply) / 100
349 : 0,
350 }));
351 analysis.holderCount = result.count;
352 }
353 },
354 ),
355 );
356 }
357
358
359 if (includeTransfers) {
360 tasks.push(
361 getRecentTransfers(chainConfig.explorerApi, address, apiKey, transferLimit).then(
362 (transfers) => {
363 analysis.recentTransfers = transfers;
364 },
365 ),
366 );
367 }
368
369
370 if (includePrice) {
371 tasks.push(
372 getPriceData(chainConfig.coingeckoPlatform, address).then((price) => {
373 if (price) {
374 analysis.priceUsd = price.priceUsd;
375 analysis.marketCap = price.marketCap;
376 analysis.volume24h = price.volume24h;
377 analysis.priceChange24h = price.priceChange24h;
378 }
379 }),
380 );
381 }
382
383
384 tasks.push(
385 checkContractVerification(chainConfig.explorerApi, address, apiKey).then(
386 (verified) => {
387 analysis.contractVerified = verified;
388 },
389 ),
390 );
391
392 await Promise.all(tasks);
393
394 await Actor.pushData(analysis);
395 log.info(
396 `✅ ${analysis.symbol} (${chain}): Supply ${analysis.totalSupplyFormatted}, ${analysis.holderCount ?? "?"} holders${analysis.priceUsd ? `, $${analysis.priceUsd}` : ""}`,
397 );
398
399
400 await sleep(2000);
401 } catch (err) {
402 log.error(`❌ Failed to analyze ${tokenInput}: ${err}`);
403 await Actor.pushData({
404 address: tokenInput,
405 error: err instanceof Error ? err.message : String(err),
406 analyzedAt: new Date().toISOString(),
407 });
408 }
409}
410
411log.info("Token analysis complete.");
412await Actor.exit();