// TokenVerdict engine: Base (eip155:8453) ERC-20 safety check from free public data only.
// On-chain (public Base RPC): bytecode, owner, EIP-1967 / beacon / EIP-1167 proxy slots, supply, LP burn,
// and a buy -> wallet transfer -> sell simulation in a single eth_call (see sim.js; nothing is sent).
// Sourcify: verified source ABI. On-chain: factory pools, pool balances, Chainlink ETH/USD, creation block, v4 logs.
// DexScreener / GeckoTerminal: pools, liquidity,
// holder distribution.
// Every source is best-effort with its own deadline: a missing source lowers confidence, it never fails the call.
import { makeRpc, ARCHIVE_RPCS, LOGS_RPCS, encAddr, asAddr, asUint, asString } from './rpc.js';
import { probePools, simulate, resolveV4Key, WETH, NATIVE } from './sim.js';
import { factoryPools, creationBlock, v4PoolsFromLogs, v4Liquidity } from './discover.js';
import { SELECTORS } from './selectors.js';
import { RECOGNIZED, BURN_ADDRS } from './known.js';
import { withDeadline } from './deadline.js';
export const ENGINE_VERSION = 'tokenverdict-0.1';
export const DISCLAIMER = 'Automated, informational risk signals from public on-chain and market data at the time of the check. Not financial advice, not an audit, no guarantee: a token that passes can still lose value or be changed later by whoever controls it.';
export const LIMITS = [
'Sell simulation runs in one block from a contract address: tokens that block contracts, enforce cooldowns or change rules later can behave differently for a real wallet.',
'Uniswap v4 hooks can charge fees or change behavior per swap; the simulation reflects the hook at the time of the check.',
'Owner powers come from the verified ABI or, if unverified, from function selectors in the bytecode: renamed or hidden functions can be missed.',
'LP lock status is only verified for burned LP tokens on v2-style pools; third-party lockers and concentrated-liquidity positions are not checked.',
'Holder concentration comes from GeckoTerminal snapshots and can include pools, lockers and exchanges.',
];
export class InputError extends Error { constructor(msg, status = 400, code = 'bad_request') { super(msg); this.status = status; this.code = code; } }
const ADDR_RE = /^0x[0-9a-fA-F]{40}$/;
export function normalizeToken(raw) {
const s = String(raw ?? '').trim();
if (!s) throw new InputError('"token" is required: a Base ERC-20 contract address, e.g. 0x532f27101965dd16442E59d40670FaF5eBB142E4');
if (!ADDR_RE.test(s)) throw new InputError('"token" must be a 0x-prefixed 40-hex-character Base contract address');
const a = s.toLowerCase();
if (/^0x0{40}$/.test(a)) throw new InputError('"token" cannot be the zero address');
return a;
}
const S = { name: '0x06fdde03', symbol: '0x95d89b41', decimals: '0x313ce567', totalSupply: '0x18160ddd', owner: '0x8da5cb5b', getOwner: '0x893d20e8', balanceOf: '0x70a08231', implementation: '0x5c60da1b', hasRole: '91d14854', grantRole: '2f2ff15d' };
const SLOT = {
impl: '0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc',
admin: '0xb53127684a568b3173ae13b9f8a6016e243e63b6e8ee1178d6a717850b5d6103',
beacon: '0xa3f0ad74e5423aebfd80d3ef4346578335a9a72aeaee59ff6cb3582b35133d50',
ozLegacy: '0x7050c9e0f4ca769c69bd3a8ef740bc37934f8e2c036e5a723fd8ee048ed3f8c3',
};
const ZERO = '0x0000000000000000000000000000000000000000';
const isBurn = (a) => !a || BURN_ADDRS.includes(String(a).toLowerCase());
const ec = (to, data) => ({ method: 'eth_call', params: [{ to, data }, 'latest'] });
const ok = (r) => (r && !r.error ? r.result : null);
async function jget(fetchImpl, url, ms = 5000) {
try {
const res = await withDeadline(fetchImpl(url, { headers: { accept: 'application/json', 'user-agent': 'TokenVerdict/0.1 (+https://api.youraitools.co/tokenverdict)' } }), ms, url);
if (!res.ok) return null;
return await res.json();
} catch { return null; }
}
/** Function-name based power detection from a verified ABI. */
const NAME_RULES = {
mint: /^(mint|mintTo|mintFor|issue|configureMinter|increaseSupply|createTokens|mintTokens)$/i,
blacklist: /(black|bot|sniper|frozen|freeze|block(ed)?Address|setBlocked|denylist)/i,
pause: /^(pause|unpause|setPaused|togglePause)$/i,
fees: /^(set|update|change|reduce)\w*(fee|tax)/i,
trading: /^(enableTrading|openTrading|startTrading|setTrading\w*|launch)$/i,
limits: /(maxTx|maxWallet|maxTxn|removeLimits|setCooldown|maxTransaction)/i,
balance: /^(setBalance|setBalances|adminBurn|destroyBlackFunds)$/i,
upgrade: /^(upgradeTo|upgradeToAndCall)$/i,
};
function powersFromAbi(abi) {
const fns = (abi || []).filter((x) => x?.type === 'function' && x.stateMutability !== 'view' && x.stateMutability !== 'pure');
const out = {};
for (const [k, re] of Object.entries(NAME_RULES)) out[k] = fns.filter((f) => re.test(f.name)).map((f) => `${f.name}(${(f.inputs || []).map((i) => i.type).join(',')})`).slice(0, 8);
out.access_control = (abi || []).some((x) => x?.type === 'function' && x.name === 'grantRole');
return out;
}
function powersFromBytecode(code) {
const h = (code || '').toLowerCase();
const out = {};
for (const [k, list] of Object.entries(SELECTORS)) {
if (k === 'ownership') continue;
out[k] = list.filter(([sel]) => h.includes(`63${sel}`)).map(([, sig]) => sig).slice(0, 8);
}
out.access_control = h.includes(`63${S.grantRole}`) && h.includes(`63${S.hasRole}`);
return out;
}
const merge = (a, b) => { if (!b) return a; const o = { ...a }; for (const k of Object.keys(b)) o[k] = Array.isArray(a[k]) ? [...new Set([...(a[k] || []), ...(b[k] || [])])] : (a[k] || b[k]); return o; };
function minimalProxyTarget(code) {
const m = /^0x363d3d373d3d3d363d73([0-9a-f]{40})5af43d82803e903d91602b57fd5bf3/i.exec(code || '');
return m ? `0x${m[1].toLowerCase()}` : null;
}
/**
* Run the analysis.
* @param {string} token normalized lowercase address
* @param {{tier?: 'preview'|'lite'|'quick'|'deep', fetchImpl?: Function, rpc?: any, now?: number}} [o]
*/
export async function analyzeToken(token, o = {}) {
token = String(token).trim().toLowerCase();
const tier = o.tier || 'quick';
const deep = tier === 'deep';
const fetchImpl = o.fetchImpl || ((...a) => globalThis.fetch(...a));
const rpc = o.rpc || makeRpc({ fetchImpl });
const rpcArchive = o.rpcArchive || (o.rpc ? o.rpc : makeRpc({ fetchImpl, urls: ARCHIVE_RPCS }));
const rpcLogs = o.rpcLogs || (o.rpc ? o.rpc : makeRpc({ fetchImpl, urls: LOGS_RPCS }));
const now = o.now || Date.now();
const sources = { rpc: false, dexscreener: false, geckoterminal: false, sourcify: false, onchain_pools: false };
// ---- round 1: chain basics + market + verification, in parallel ----
const chainP = rpc.batch([
{ method: 'eth_getCode', params: [token, 'latest'] },
ec(token, S.name), ec(token, S.symbol), ec(token, S.decimals), ec(token, S.totalSupply), ec(token, S.owner), ec(token, S.getOwner),
{ method: 'eth_getStorageAt', params: [token, SLOT.impl, 'latest'] }, { method: 'eth_getStorageAt', params: [token, SLOT.admin, 'latest'] },
{ method: 'eth_getStorageAt', params: [token, SLOT.beacon, 'latest'] }, { method: 'eth_getStorageAt', params: [token, SLOT.ozLegacy, 'latest'] },
ec(token, S.balanceOf + encAddr(ZERO)), ec(token, S.balanceOf + encAddr('0x000000000000000000000000000000000000dead')),
{ method: 'eth_blockNumber', params: [] },
]);
const dexP = jget(fetchImpl, `https://api.dexscreener.com/tokens/v1/base/${token}`).then((d) => d || jget(fetchImpl, `https://api.dexscreener.com/latest/dex/tokens/${token}`).then((x) => (Array.isArray(x?.pairs) ? x.pairs : null)));
const factoryP = factoryPools(rpc, token).catch((e) => { console.log(JSON.stringify({ ev: 'factory_failed', token, err: String(e?.message || e).slice(0, 160) })); return null; });
const geckoP = jget(fetchImpl, `https://api.geckoterminal.com/api/v2/networks/base/tokens/${token}/info`);
const srcP = jget(fetchImpl, `https://sourcify.dev/server/v2/contract/8453/${token}?fields=abi`);
let chain;
try { chain = await chainP; } catch (e) { const err = new InputError(`Base RPC unavailable (${e.message}). Not charged; retry shortly.`, 503, 'rpc_unavailable'); throw err; }
sources.rpc = true;
const [code, nameR, symR, decR, tsR, ownR, gownR, implS, adminS, beaconS, legacyS, bal0R, balDeadR, headR] = chain.map(ok);
if (!code || code === '0x') throw new InputError('No contract at this address on Base mainnet (EOA or wrong chain). Not charged.', 422, 'not_a_contract');
const head = headR ? Number(BigInt(headR)) : null;
// creation block (age, and the start of the Uniswap v4 Initialize log search); archive RPC, k-ary search, best effort
const createdP = head ? creationBlock(rpcArchive, token, head).then(async (b) => {
if (b == null) return null;
const blk = await rpcArchive.call('eth_getBlockByNumber', ['0x' + b.toString(16), false]).catch(() => null);
return { block: b, ts: blk?.timestamp ? Number(BigInt(blk.timestamp)) * 1000 : null };
}).catch(() => null) : Promise.resolve(null);
const decimals = asUint(decR); const totalSupply = asUint(tsR);
if (decimals == null || totalSupply == null || decimals > 255n) throw new InputError('This contract does not answer decimals() / totalSupply(), so it is not an ERC-20 token. Not charged.', 422, 'not_erc20');
const name = asString(nameR); const symbol = asString(symR);
// ---- proxy detection ----
const slotAddr = (v) => { const a = asAddr(v); return a && !/^0x0{40}$/.test(a) ? a.toLowerCase() : null; };
let proxy = { upgradeable: false, type: null, implementation: null, admin: null, admin_owner: null };
const impl1967 = slotAddr(implS); const beacon = slotAddr(beaconS); const legacy = slotAddr(legacyS); const minimal = minimalProxyTarget(code);
if (impl1967) proxy = { ...proxy, upgradeable: true, type: 'EIP-1967', implementation: impl1967, admin: slotAddr(adminS) };
else if (beacon) proxy = { ...proxy, upgradeable: true, type: 'EIP-1967 beacon', beacon };
else if (legacy) proxy = { ...proxy, upgradeable: true, type: 'OpenZeppelin legacy proxy', implementation: legacy };
else if (minimal) proxy = { ...proxy, upgradeable: false, type: 'EIP-1167 minimal proxy (not upgradeable)', implementation: minimal };
const burnedSupplyPctEarly = totalSupply > 0n ? Number(((asUint(bal0R) || 0n) + (asUint(balDeadR) || 0n)) * 10000n / totalSupply) / 100 : 0;
const ownerRaw = asAddr(ownR) || asAddr(gownR);
const owner = ownerRaw ? ownerRaw.toLowerCase() : null;
// ---- market data: DexScreener (+ GeckoTerminal) merged with on-chain factory pools and v4 Initialize logs ----
const [dex, gecko, src, fac, created] = await Promise.all([dexP, geckoP, srcP, factoryP, createdP]);
const pairs = (Array.isArray(dex) ? dex : []).filter((p) => p?.chainId === 'base');
if (Array.isArray(dex)) sources.dexscreener = true;
const ginfo = gecko?.data?.attributes || null; if (ginfo) sources.geckoterminal = true;
if (src?.abi) sources.sourcify = true;
if (fac) sources.onchain_pools = true;
let pools = pairs.map((p) => ({ address: String(p.pairAddress || ''), dex: p.dexId, labels: p.labels || [], liquidity_usd: Number(p.liquidity?.usd || 0), liquidity_quote: Number(p.liquidity?.quote || 0), quote: String(p.quoteToken?.address || '').toLowerCase(), quote_symbol: p.quoteToken?.symbol, base: String(p.baseToken?.address || '').toLowerCase(), created_at: p.pairCreatedAt ? new Date(p.pairCreatedAt).toISOString() : null, txns_h24: p.txns?.h24 || null, volume_h24: Number(p.volume?.h24 || 0), source: 'DexScreener' }));
const known4 = new Set(pools.map((p) => p.address.toLowerCase()));
for (const fp of fac?.pools || []) {
const ex = pools.find((p) => p.address.toLowerCase() === fp.address);
if (ex) { if (!ex.liquidity_usd) { ex.liquidity_usd = fp.liquidity_usd; ex.source = 'on-chain estimate'; } }
else pools.push({ address: fp.address, dex: fp.dex, labels: [fp.kind], liquidity_usd: fp.liquidity_usd, liquidity_quote: 0, quote: fp.quote, quote_symbol: fp.quote_symbol, base: token, created_at: null, txns_h24: null, volume_h24: null, source: 'on-chain estimate' });
}
// Uniswap v4 pools: DexScreener lists them by PoolId; otherwise look for Initialize logs right after creation
let v4Logged = []; let v4SearchFailed = false;
const hasV4 = pools.some((p) => /^0x[0-9a-fA-F]{64}$/.test(p.address));
if (!hasV4 && created?.block && head && head - created.block < 400000) {
try { v4Logged = await v4PoolsFromLogs(rpcLogs, token, created.block, head); }
catch { // publicnode rate-limited (or old logs): most launchpad v4 pools are initialized within a few blocks of creation
try { v4Logged = await v4PoolsFromLogs(rpcArchive, token, created.block, head, 9); } catch { v4SearchFailed = true; }
}
if (v4Logged.length) {
try { v4Logged = await v4Liquidity(rpc, v4Logged, fac?.ethUsd); } catch { /* keep */ }
for (const v of v4Logged) if (!known4.has(v.address.toLowerCase())) pools.push({ address: v.address, dex: 'uniswap', labels: ['v4'], liquidity_usd: v.liquidity_usd || 0, liquidity_quote: 0, quote: v.quote, quote_symbol: v.quote_symbol || null, base: token, created_at: created.ts ? new Date(created.ts).toISOString() : null, txns_h24: null, volume_h24: null, source: v.source || 'on-chain v4', key: v.key });
}
}
pools = pools.sort((a, b) => b.liquidity_usd - a.liquidity_usd);
const marketKnown = sources.dexscreener || sources.onchain_pools;
const evmPools = pools.filter((p) => /^0x[0-9a-fA-F]{40}$/.test(p.address));
const probeList = evmPools.slice(0, deep ? 4 : 3).map((p) => p.address);
const r2 = [];
if (proxy.implementation) r2.push({ k: 'implCode', c: { method: 'eth_getCode', params: [proxy.implementation, 'latest'] } });
if (proxy.beacon) r2.push({ k: 'beaconImpl', c: ec(proxy.beacon, S.implementation) });
if (proxy.admin) r2.push({ k: 'adminCode', c: { method: 'eth_getCode', params: [proxy.admin, 'latest'] } }, { k: 'adminOwner', c: ec(proxy.admin, S.owner) });
if (owner && !isBurn(owner)) r2.push({ k: 'ownerCode', c: { method: 'eth_getCode', params: [owner, 'latest'] } });
// token balances held by pools (v2/v3 pool contracts, and the v4 PoolManager singleton) to adjust holder concentration
const poolHolders = evmPools.slice(0, 6).map((p) => p.address.toLowerCase());
if (pools.some((p) => /^0x[0-9a-fA-F]{64}$/.test(p.address))) poolHolders.push('0x498581ff718922c3f8e6a244956af099b2652b2b');
poolHolders.forEach((a, i) => r2.push({ k: `pb${i}`, c: ec(token, S.balanceOf + encAddr(a)) }));
let r2res = {}; let poolInfo = [];
try {
const [res, pi] = await Promise.all([rpc.batch(r2.map((x) => x.c)), probeList.length ? probePools(rpc, probeList) : []]);
r2.forEach((x, i) => { r2res[x.k] = ok(res[i]); });
poolInfo = pi;
} catch (e) { console.log(JSON.stringify({ ev: "round2_failed", token, err: String(e?.message || e).slice(0, 160) })); /* partial data */ }
if (proxy.beacon && r2res.beaconImpl) {
proxy.implementation = slotAddr(r2res.beaconImpl);
if (proxy.implementation) { try { r2res.implCode = await rpc.call('eth_getCode', [proxy.implementation, 'latest']); } catch { /* keep */ } }
}
if (proxy.admin) proxy.admin_owner = slotAddr(r2res.adminOwner) || null;
const ownerIsContract = !!(r2res.ownerCode && r2res.ownerCode !== '0x');
// ---- verification + powers ----
let abiSrc = src?.abi ? 'sourcify' : null; let abi = src?.abi || null;
let verified = !!src?.abi;
let verifiedBy = src?.abi ? 'Sourcify' : null;
let implAbi = null;
if (proxy.implementation) {
const ia = await jget(fetchImpl, `https://sourcify.dev/server/v2/contract/8453/${proxy.implementation}?fields=abi`, 4000);
if (ia?.abi) implAbi = ia.abi;
}
let powers = abi ? powersFromAbi(abi) : powersFromBytecode(code);
let powersSource = abi ? `verified ABI (${abiSrc})` : 'bytecode selectors (unverified)';
if (proxy.implementation) {
const ip = implAbi ? powersFromAbi(implAbi) : powersFromBytecode(r2res.implCode || '');
powers = merge(powers, ip);
powersSource += implAbi ? ' + implementation ABI' : r2res.implCode ? ' + implementation bytecode' : '';
if (implAbi && !verified) { verified = true; verifiedBy = 'Sourcify (implementation)'; }
}
// ---- pick pools and simulate (WETH / native ETH quoted pools preferred) ----
const known = RECOGNIZED[token] || null;
const metaOf = (addr) => pools.find((x) => x.address.toLowerCase() === String(addr).toLowerCase());
if (o.debug) console.error("probe", probeList, JSON.stringify(poolInfo));
const simulatable = poolInfo.filter((p) => p.kind && (p.token0 === token || p.token1 === token));
const v4Pools = pools.filter((p) => /^0x[0-9a-fA-F]{64}$/.test(p.address));
const weight = (m, quote) => (m?.liquidity_usd || 0) * (quote === WETH || quote === NATIVE ? 3 : 1) + 1;
const cands = [
...simulatable.map((p) => ({ p, m: metaOf(p.address), w: weight(metaOf(p.address), p.token0 === token ? p.token1 : p.token0) })),
...v4Pools.slice(0, 2).map((m) => ({ v4: true, m, w: weight(m, m.quote) })),
].sort((a, b) => b.w - a.w).slice(0, deep ? 2 : 1);
const sims = [];
for (const c of cands) {
const m = c.m;
try {
let p = c.p; let amt;
if (c.v4) {
const key = m.key || await resolveV4Key(rpc, m.address, m.created_at ? Date.parse(m.created_at) : null, now);
if (!key || (key.currency0 !== token && key.currency1 !== token)) { sims.push({ pool: m.address, pool_type: 'v4', simulated: false, reason: 'Uniswap v4 pool key not found' }); continue; }
p = { address: m.address, kind: 'v4', key, token0: key.currency0, token1: key.currency1 };
const quote = key.currency0 === token ? key.currency1 : key.currency0;
const liqQ = Number(m.liquidity_quote || 0) || ((quote === NATIVE || quote === WETH) && fac?.ethUsd && m.liquidity_usd ? m.liquidity_usd / 2 / fac.ethUsd : 0);
if (quote === NATIVE || quote === WETH) {
const eth = Math.min(Math.max(liqQ * 0.002, 0.0001), 0.05);
amt = BigInt(Math.round(eth * 1e6)) * 10n ** 12n;
} else {
const dec = Number(asUint(await rpc.call('eth_call', [{ to: quote, data: S.decimals }, 'latest'])) ?? 18n);
const units = liqQ * 0.002 > 0 ? liqQ * 0.002 : 0.001;
amt = BigInt(Math.max(1, Math.round(units * 1e6))) * 10n ** BigInt(Math.max(0, dec - 6));
}
} else {
const quote = p.token0 === token ? p.token1 : p.token0;
const bal = asUint(await rpc.call('eth_call', [{ to: quote, data: S.balanceOf + encAddr(p.address) }, 'latest']));
if (!bal || bal === 0n) { sims.push({ pool: p.address, pool_type: p.kind, simulated: false, reason: 'pool holds no quote token' }); continue; }
amt = bal / 500n; // 0.2% of the pool's quote balance
if (quote === WETH) { const cap = 5n * 10n ** 16n, min = 10n ** 13n; if (amt > cap) amt = cap; if (amt < min) amt = bal / 50n; }
}
if (!amt || amt === 0n) { sims.push({ pool: p.address, pool_type: p.kind, simulated: false, reason: 'pool too small to simulate' }); continue; }
let s; try { s = await simulate(rpc, p, token, amt); } catch (e1) { await new Promise((r) => setTimeout(r, 400)); s = await simulate(rpc, p, token, amt); } // one retry: public RPCs rate-limit in bursts
sims.push({ ...s, pool_type: p.kind, dex: m?.dex || null, liquidity_usd: m?.liquidity_usd ?? null });
} catch (e) { sims.push({ pool: c.p?.address || m?.address, simulated: false, reason: `simulation unavailable: ${String(e?.message || e).slice(0, 100)}` }); }
}
if (o.debug) console.error("sims", cands.length, JSON.stringify(sims).slice(0, 600));
if (!sims.some((s) => s.simulated)) console.log(JSON.stringify({ ev: "sim_unavailable", token, cands: cands.length, probed: poolInfo.length, pools: pools.length, sims: sims.map((s) => s.reason || (s.sell && s.sell.error) || null) }));
const sim = sims.find((s) => s.simulated) || sims[0] || null;
// ---- LP burn (v2-style main pool) ----
let lp = null;
const v2main = simulatable.find((p) => p.kind === 'v2' || p.kind === 'solidly');
if (v2main) {
try {
const res = await rpc.batch([ec(v2main.address, S.totalSupply), ec(v2main.address, S.balanceOf + encAddr(ZERO)), ec(v2main.address, S.balanceOf + encAddr('0x000000000000000000000000000000000000dead'))]);
const [ts, b0, bd] = res.map((r) => asUint(ok(r)));
if (ts && ts > 0n) lp = { pool: v2main.address, pool_type: v2main.kind, burned_pct: Number(((b0 || 0n) + (bd || 0n)) * 10000n / ts) / 100, lock_checked: false, note: 'Only burned LP (sent to 0x0 / 0xdEaD) is verified; third-party lockers are not checked.' };
} catch { /* skip */ }
}
// ---- holders ----
const gh = ginfo?.holders;
const rawTop10 = gh?.distribution_percentage?.top_10 != null ? Number(gh.distribution_percentage.top_10) : null;
const holders = { count: gh?.count ?? null, top10_pct: rawTop10 != null && rawTop10 <= 100 ? Math.round(rawTop10 * 10) / 10 : null, source: gh ? 'GeckoTerminal' : null, includes_pools: true };
let poolHeld = 0n; poolHolders.forEach((_, i) => { poolHeld += asUint(r2res[`pb${i}`]) || 0n; });
holders.in_pools_pct = totalSupply > 0n ? Number(poolHeld * 10000n / totalSupply) / 100 : null;
if (holders.top10_pct != null && holders.in_pools_pct != null) holders.top10_ex_pools_est_pct = Math.max(0, Math.round((holders.top10_pct - holders.in_pools_pct - Math.min(burnedSupplyPctEarly, holders.top10_pct)) * 10) / 10);
// ---- age ----
let age = null;
if (created?.ts) age = { created_at: new Date(created.ts).toISOString(), block: created.block, source: 'contract creation (on-chain)' };
if (!age) {
const firstPool = pools.map((p) => p.created_at).filter(Boolean).sort()[0];
if (firstPool) age = { created_at: firstPool, source: 'first DEX pool (DexScreener)' };
}
if (age) age.days = Math.max(0, Math.round((now - Date.parse(age.created_at)) / 864e3) / 100);
// ---- liquidity ----
const liquidityUsd = pools.reduce((s, p) => s + (p.liquidity_usd || 0), 0);
const burnedSupplyPct = totalSupply > 0n ? Number(((asUint(bal0R) || 0n) + (asUint(balDeadR) || 0n)) * 10000n / totalSupply) / 100 : null;
// ---- verdict ----
const reasons = [];
let penalty = 0; let hard = false;
const add = (severity, code, text, p = 0, isHard = false) => { reasons.push({ severity, code, text }); penalty += p; if (isHard) hard = true; };
const ownerLive = !!owner && !isBurn(owner);
const controller = ownerLive || powers.access_control || (proxy.upgradeable && !isBurn(proxy.admin_owner || proxy.admin));
const ctl = (list) => list && list.length > 0;
if (known) add('info', 'recognized_asset', `${known.symbol} is a recognized Base asset (${known.issuer}); issuer controls below are by design.`);
// sellability
if (sim?.simulated) {
const sellTax = sim.sell?.tax_pct ?? null; const buyTax = sim.buy?.tax_pct ?? null; const xferTax = sim.transfer?.tax_pct ?? null;
if (!sim.buy.ok) add('warn', 'buy_failed', `A simulated buy on the ${sim.dex || sim.pool_type} pool failed (${sim.buy.error}). Trading may be disabled or restricted.`, 20);
else if (sim.sell && !sim.sell.ok) {
if (sim.pool_type === 'v3' && sim.transfer?.ok && (xferTax ?? 0) > 0) add('warn', 'fot_v3', `Selling on the concentrated-liquidity pool reverted because the token takes a ${xferTax}% transfer fee (such pools can't handle fee-on-transfer tokens).`, 15);
else add('danger', 'honeypot', `HONEYPOT SIGNAL: a simulated buy worked but selling back failed (${sim.sell.error}).`, 60, !known);
} else {
const t = Math.max(buyTax || 0, sellTax || 0);
if (t >= 30) add('danger', 'extreme_tax', `Extreme tax: buy ${buyTax}% / sell ${sellTax}%.`, 50, true);
else if (t >= 10) add('warn', 'high_tax', `High tax: buy ${buyTax}% / sell ${sellTax}%.`, 25);
else if (t >= 5) add('warn', 'tax', `Tax: buy ${buyTax}% / sell ${sellTax}%.`, 10);
else if (t > 0.5) add('info', 'small_tax', `Small tax: buy ${buyTax}% / sell ${sellTax}%.`, 3);
else add('good', 'sellable', `Simulated buy and sell both worked with no token tax (round trip lost ${sim.round_trip_loss_pct ?? '?'}% to pool/hook fees and price impact).`);
const rt = sim.round_trip_loss_pct;
if (rt != null && t < 10) {
if (rt >= 30) add('danger', 'round_trip_loss', `Buying then selling right away returned ${Math.round(100 - rt)}% of the money (${rt}% lost to fees, hooks or slippage).`, 40, true);
else if (rt >= 10) add('warn', 'round_trip_loss', `Buying then selling right away loses ${rt}% (fees, hooks or slippage).`, 12);
}
}
if (sim.transfer && !sim.transfer.ok) add('danger', 'transfer_blocked', 'A simulated wallet-to-wallet transfer failed.', 40, !known);
else if ((xferTax ?? 0) >= 10) add('warn', 'transfer_tax', `Wallet transfers lose ${xferTax}%.`, 10);
} else if (!known) {
add('warn', 'not_simulated', `Sellability not verified: ${sim?.reason || (pools.length ? 'no supported pool (Uniswap v2/v3/v4, Aerodrome, Pancake) with this token' : 'no DEX pool found')}.`, 10);
}
if (ginfo?.is_honeypot === true && !known) add('danger', 'gt_honeypot', 'GeckoTerminal flags this token as a honeypot.', 30);
// owner powers
if (!known) {
if (ctl(powers.balance) && controller) add('danger', 'balance_control', `Privileged balance function present: ${powers.balance.join(', ')}.`, 40, true);
if (ctl(powers.mint) && controller) add('warn', 'mintable', `Supply can be increased: ${powers.mint.join(', ')}${ownerLive ? ` (owner ${short(owner)})` : ''}.`, 25);
if (ctl(powers.blacklist) && controller) add('warn', 'blacklist', `Addresses can be blocked: ${powers.blacklist.slice(0, 3).join(', ')}.`, 20);
if (ctl(powers.pause) && controller) add('warn', 'pausable', 'Transfers can be paused.', 15);
if (ctl(powers.fees) && controller) add('warn', 'mutable_tax', `Taxes can be changed: ${powers.fees.slice(0, 3).join(', ')}.`, 15);
if (ctl(powers.trading) && controller) add('info', 'trading_switch', 'Owner controls a trading on/off switch.', 8);
if (ctl(powers.limits) && controller) add('info', 'limits', 'Owner controls max-transaction / max-wallet limits.', 4);
if (proxy.upgradeable) {
const adm = proxy.admin_owner || proxy.admin;
if (adm && isBurn(adm)) add('info', 'proxy_frozen', 'Upgradeable proxy, but its admin is renounced.', 3);
else add('warn', 'upgradeable', `Upgradeable proxy (${proxy.type}): the code can be replaced${adm ? ` by ${short(adm)}` : ''}.`, 25);
}
if (owner && isBurn(owner)) add('good', 'renounced', 'Ownership renounced.');
else if (ownerLive && !reasons.some((r) => ['mintable', 'blacklist', 'pausable', 'mutable_tax', 'balance_control'].includes(r.code))) add('info', 'owner_active', `Owner ${short(owner)}${ownerIsContract ? ' (a contract: multisig/timelock?)' : ''} is active but no major owner powers were found.`, 5);
else if (!owner && !powers.access_control) add('good', 'no_owner', 'No owner function found.');
if (powers.access_control) add('info', 'roles', 'Uses role-based access control; role holders are not enumerated.', 5);
if (!verified) add('warn', 'unverified', 'Contract source is not verified (Sourcify): hidden logic cannot be ruled out.', 20);
else add('good', 'verified', `Source verified (${verifiedBy}).`);
} else {
const ctrlNames = ['mint', 'blacklist', 'pause'].filter((k) => ctl(powers[k]));
if (ctrlNames.length || proxy.upgradeable) add('info', 'issuer_controls', `Issuer controls present: ${[...ctrlNames, ...(proxy.upgradeable ? ['upgradeable'] : [])].join(', ')}.`);
}
// liquidity
if (!known) {
if (!marketKnown) add('warn', 'liquidity_unknown', 'Liquidity could not be measured right now (market data and on-chain pool lookups unavailable). Re-check before trading.', 15);
else if (!pools.length && sources.dexscreener) add('danger', 'no_liquidity', 'No DEX liquidity found on Base.', 40, true);
else if (!pools.length && v4SearchFailed) add('warn', 'liquidity_unknown', 'Liquidity could not be measured right now (market data unavailable and the Uniswap v4 pool lookup failed). Re-check before trading.', 15);
else if (!pools.length) add('danger', 'no_liquidity_onchain', 'No pool found on the main Base DEXes (Uniswap v2/v3/v4, Aerodrome, PancakeSwap v3) against WETH/USDC; market API unavailable, so other venues were not checked.', 30);
else if (liquidityUsd < 1000 && sources.dexscreener) add('danger', 'tiny_liquidity', `Liquidity is only $${fmt(liquidityUsd)}.`, 35, true);
else if (liquidityUsd < 1000) add('danger', 'tiny_liquidity_est', `On-chain liquidity estimate is only $${fmt(liquidityUsd)} (market API unavailable; other venues not checked).`, 30, liquidityUsd < 100);
else if (liquidityUsd < 10000) add('warn', 'low_liquidity', `Low liquidity: $${fmt(liquidityUsd)}.`, 20);
else if (liquidityUsd < 50000) add('info', 'modest_liquidity', `Modest liquidity: $${fmt(liquidityUsd)}.`, 8);
else add('good', 'liquidity', `Liquidity $${fmt(liquidityUsd)} across ${pools.length} pool${pools.length === 1 ? '' : 's'}.`);
if (lp) {
if (lp.burned_pct >= 95) add('good', 'lp_burned', `${lp.burned_pct}% of the main v2 LP is burned.`);
else if (lp.burned_pct < 50) add('info', 'lp_not_burned', `Only ${lp.burned_pct}% of the main v2 LP is burned (locks not checked).`, 5);
}
// holders
const t10 = holders.top10_ex_pools_pct ?? holders.top10_ex_pools_est_pct ?? holders.top10_pct;
const t10label = holders.top10_ex_pools_pct != null ? 'excluding pools and burn' : holders.top10_ex_pools_est_pct != null ? 'estimate excluding pools and burn' : 'may include pools';
if (t10 != null) {
if (t10 >= 80) add('warn', 'concentrated', `Top 10 holders own ${t10}% (${t10label}).`, 20);
else if (t10 >= 50) add('warn', 'top_heavy', `Top 10 holders own ${t10}% (${t10label}).`, 10);
else if (t10 >= 30) add('info', 'top10', `Top 10 holders own ${t10}% (${t10label}).`, 3);
else add('good', 'distributed', `Top 10 holders own ${t10}% (${t10label}).`);
} else add('info', 'holders_unknown', 'Holder concentration is unavailable right now (the GeckoTerminal snapshot did not answer); re-check before trading.', 5);
// age
if (age) {
if (age.days < 1) add('warn', 'brand_new', `Brand new: ${age.source} ${Math.round(age.days * 24)} h ago.`, 15);
else if (age.days < 7) add('info', 'new', `New: ${age.source} ${Math.round(age.days)} days ago.`, 7);
else if (age.days < 30) add('info', 'young', `${age.source} ${Math.round(age.days)} days ago.`, 3);
}
}
let score = Math.max(0, Math.min(100, 100 - penalty));
if (hard) score = Math.min(score, 20);
if (known && !hard) score = Math.max(score, 85);
const verdict = hard || score < 45 ? 'DANGER' : score >= 75 ? 'SAFE' : 'CAUTION';
const missing = [...(!marketKnown || (!sources.dexscreener && v4SearchFailed) ? ['market'] : []), ...(pools.length && !sim?.simulated ? ['simulation'] : []), ...(holders.top10_pct == null && !known ? ['holders'] : [])];
const degraded = missing.length > 0;
let confidence = sim?.simulated && verified ? 'high' : sim?.simulated || verified ? 'medium' : 'low';
if (degraded && confidence === 'high') confidence = 'medium';
const sevOrder = { danger: 0, warn: 1, info: 2, good: 3 };
reasons.sort((a, b) => sevOrder[a.severity] - sevOrder[b.severity]);
return {
token, chain: 'base', chain_id: 8453, name, symbol, decimals: Number(decimals), total_supply: totalSupply.toString(),
verdict, score, confidence, degraded, ...(degraded ? { missing_data: missing } : {}), reasons,
checks: {
sellability: sim ? summarizeSim(sim) : { simulated: false, reason: 'no pool to simulate' },
owner: { address: owner, renounced: owner ? isBurn(owner) : null, is_contract: ownerIsContract, has_owner_function: !!ownerRaw },
powers: { ...powers, source: powersSource },
proxy,
contract: { verified, verified_by: verifiedBy, recognized: known ? known.issuer : null, burned_supply_pct: burnedSupplyPct },
liquidity: { usd: Math.round(liquidityUsd), pools: pools.length, main_pool: pools[0] ? { address: pools[0].address, dex: pools[0].dex, quote: pools[0].quote_symbol, liquidity_usd: Math.round(pools[0].liquidity_usd), volume_h24_usd: Math.round(pools[0].volume_h24), txns_h24: pools[0].txns_h24 } : null, lp },
holders,
age,
},
...(deep ? { deep: { simulations: sims, pools: pools.slice(0, 10), geckoterminal: ginfo ? { gt_score: ginfo.gt_score ?? null, is_honeypot: ginfo.is_honeypot ?? null, developer_address: ginfo.developer_address ?? null, developer_holding_pct: ginfo.developer_holding_percentage != null ? Number(ginfo.developer_holding_percentage) : null } : null, creation_block: created?.block ?? null } } : {}),
sources, limits: LIMITS, disclaimer: DISCLAIMER, engine: ENGINE_VERSION, tier, checked_at: new Date(now).toISOString(),
};
}
function summarizeSim(s) {
if (!s.simulated) return { simulated: false, reason: s.reason };
return {
simulated: true, pool: s.pool, dex: s.dex, pool_type: s.pool_type, quote: s.quote, amount_in: s.amount_in,
buy_ok: s.buy.ok, sell_ok: s.sell ? s.sell.ok : null, transfer_ok: s.transfer ? s.transfer.ok : null,
buy_tax_pct: s.buy.tax_pct, sell_tax_pct: s.sell?.tax_pct ?? null, transfer_tax_pct: s.transfer?.tax_pct ?? null,
round_trip_loss_pct: s.round_trip_loss_pct ?? null, buy_error: s.buy.error, sell_error: s.sell?.error ?? null,
buy_gas: s.buy.gas, sell_gas: s.sell?.gas ?? null,
};
}
const short = (a) => (a ? `${a.slice(0, 6)}…${a.slice(-4)}` : '');
const fmt = (n) => (n >= 1e6 ? `${(n / 1e6).toFixed(2)}M` : n >= 1e3 ? `${(n / 1e3).toFixed(1)}k` : String(Math.round(n)));
/** Views per tier. */
export function previewView(r) {
const c = { danger: 0, warn: 0 }; for (const x of r.reasons) if (c[x.severity] != null) c[x.severity]++;
return { token: r.token, chain: r.chain, name: r.name, symbol: r.symbol, verdict: r.verdict, score: r.score, confidence: r.confidence, degraded: !!r.degraded, flags: c, liquidity_usd: r.checks.liquidity.usd, sellable: r.checks.sellability.simulated ? !!(r.checks.sellability.buy_ok && r.checks.sellability.sell_ok) : null, engine: r.engine, checked_at: r.checked_at, tier: 'preview', disclaimer: r.disclaimer };
}
export function liteView(r) {
const s = r.checks.sellability;
return {
token: r.token, chain: r.chain, name: r.name, symbol: r.symbol, verdict: r.verdict, score: r.score, confidence: r.confidence, degraded: !!r.degraded, ...(r.missing_data ? { missing_data: r.missing_data } : {}),
reasons: r.reasons.map((x) => ({ severity: x.severity, code: x.code, text: x.text })),
key: { sellable: s.simulated ? !!(s.buy_ok && s.sell_ok) : null, buy_tax_pct: s.buy_tax_pct ?? null, sell_tax_pct: s.sell_tax_pct ?? null, owner_renounced: r.checks.owner.renounced, upgradeable: r.checks.proxy.upgradeable, verified: r.checks.contract.verified, liquidity_usd: r.checks.liquidity.usd, top10_pct: r.checks.holders.top10_pct, age_days: r.checks.age?.days ?? null },
engine: r.engine, checked_at: r.checked_at, tier: 'lite', disclaimer: r.disclaimer,
};
}
agentgate-source: code mirror on Cloudflare Pages. All repos