@keelage/mcp 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,67 @@
1
+ // holders.mjs - the holder map for one token: top wallets with pools and contracts labeled, the burn share,
2
+ // and the share held by smart accounts (EIP-7702 delegated EOAs, which is how the Robinhood app wallet reads).
3
+ import { bsGet, BS_PACE_MS, sleep, getJson, rpcCall, isAddress, CHAIN, goplusRead } from './gate.mjs';
4
+ import { blockscoutTop10 } from './calc.mjs';
5
+ import { DISCLAIMER } from './render.mjs';
6
+
7
+ const BURN = new Set(['0x0000000000000000000000000000000000000000', '0x000000000000000000000000000000000000dead', '0x0000000000000000000000000000000000000001']);
8
+ const TOP_N = 20;
9
+
10
+ // EIP-7702: an EOA whose code is the 23-byte delegation designator 0xef0100 || address.
11
+ async function isSmartAccount(address) {
12
+ const code = String(await rpcCall('eth_getCode', [address, 'latest']) || '');
13
+ if (!/^0x[0-9a-fA-F]*$/.test(code)) return null;
14
+ return code.toLowerCase().startsWith('0xef0100');
15
+ }
16
+
17
+ export async function holdersFor(address, { withSmartAccounts = true } = {}) {
18
+ const raw = String(address || '').trim();
19
+ if (!isAddress(raw)) return { ok: false, address: raw, error: 'not a contract address (0x + 40 hex characters)', disclaimer: DISCLAIMER };
20
+ const a = raw.toLowerCase();
21
+ const tk = await bsGet(`/tokens/${a}`); await sleep(BS_PACE_MS);
22
+ if (tk?.__err) return { ok: false, address: a, error: `blockscout tokens: ${tk.__err}`, disclaimer: DISCLAIMER };
23
+ const hl = await bsGet(`/tokens/${a}/holders`); await sleep(BS_PACE_MS);
24
+ if (hl?.__err) return { ok: false, address: a, error: `blockscout holders: ${hl.__err}`, disclaimer: DISCLAIMER };
25
+ const supply = Number(tk.total_supply || 0);
26
+ const decimals = Number(tk.decimals || 18);
27
+ // Pool addresses: every DexScreener pair where this token is base or quote, plus GoPlus dex pairs.
28
+ const pairs = await getJson(`https://api.dexscreener.com/tokens/v1/${CHAIN}/${a}`);
29
+ const pools = new Set((Array.isArray(pairs) ? pairs : []).map((p) => String(p.pairAddress || '').toLowerCase()).filter(Boolean));
30
+ const gp = await goplusRead({ address: a, chain: CHAIN });
31
+ for (const p of gp?.dexPairs || []) pools.add(String(p).toLowerCase());
32
+ const items = Array.isArray(hl.items) ? hl.items : [];
33
+ const t10 = blockscoutTop10(items, [...pools], supply);
34
+ const rows = [];
35
+ let burnPct = 0;
36
+ for (const h of items.slice(0, TOP_N)) {
37
+ const addr = String(h.address?.hash || '').toLowerCase();
38
+ const pctSupply = supply ? +((Number(h.value || 0) / supply) * 100).toFixed(2) : null;
39
+ const isPool = pools.has(addr);
40
+ const isBurn = BURN.has(addr);
41
+ if (isBurn && pctSupply) burnPct += pctSupply;
42
+ rows.push({
43
+ address: addr, pctSupply, balance: supply ? +(Number(h.value || 0) / 10 ** decimals).toFixed(2) : null,
44
+ isContract: !!h.address?.is_contract, isPool, isBurn, name: h.address?.name || null, smartAccount: null,
45
+ });
46
+ }
47
+ // Smart-account check on the wallets only (contracts and pools are already labeled).
48
+ let smartPct = null, smartCount = null;
49
+ if (withSmartAccounts) {
50
+ smartPct = 0; smartCount = 0;
51
+ for (const r of rows) {
52
+ if (r.isContract || r.isPool || r.isBurn) continue;
53
+ r.smartAccount = await isSmartAccount(r.address);
54
+ if (r.smartAccount) { smartCount++; smartPct += r.pctSupply || 0; }
55
+ }
56
+ smartPct = +smartPct.toFixed(2);
57
+ }
58
+ return {
59
+ ok: true, address: a, chain: CHAIN, symbol: tk.symbol || null, name: tk.name || null,
60
+ holderCount: tk.holders_count != null ? Number(tk.holders_count) : null, totalSupply: supply ? +(supply / 10 ** decimals).toFixed(2) : null,
61
+ top10: { pctExPoolsAndContracts: t10.top10Pct, pctInclPools: t10.top10InclPct, largestWalletPct: t10.maxSinglePct, contracts: t10.contractsInTop10, pools: t10.poolsInTop10 },
62
+ burnPct: +burnPct.toFixed(2),
63
+ smartAccounts: withSmartAccounts ? { count: smartCount, pctSupply: smartPct, sampled: rows.filter((r) => !r.isContract && !r.isPool && !r.isBurn).length, note: 'EIP-7702 delegated accounts among the listed wallets; the Robinhood app wallet reads this way' } : null,
64
+ poolsKnown: [...pools], creator: gp?.creator || null, creatorPct: gp?.creatorPct ?? null,
65
+ holders: rows, listed: rows.length, readAt: new Date().toISOString(), disclaimer: DISCLAIMER,
66
+ };
67
+ }
package/lib/io.mjs ADDED
@@ -0,0 +1,43 @@
1
+ // io.mjs - the one place the library touches storage. Node reads and writes files. Another runtime (a hosted
2
+ // function) installs its own store with the data files embedded and caches kept in memory.
3
+ import { readFileSync, writeFileSync, readdirSync, mkdirSync } from 'node:fs';
4
+
5
+ let impl = {
6
+ read: (p) => readFileSync(p, 'utf8'),
7
+ write: (p, text) => writeFileSync(p, text),
8
+ list: (dir) => readdirSync(dir),
9
+ mkdir: (dir) => mkdirSync(dir, { recursive: true }),
10
+ };
11
+ export const io = {
12
+ read: (p) => impl.read(p),
13
+ write: (p, text) => impl.write(p, text),
14
+ list: (dir) => impl.list(dir),
15
+ mkdir: (dir) => impl.mkdir(dir),
16
+ };
17
+ export function setIO(next) { impl = { ...impl, ...next }; }
18
+
19
+ // An in-memory store. `files` maps a path suffix ('data/thresholds.json') to its text or parsed JSON; a read matches
20
+ // on that suffix, a write lands in memory, a listing names the embedded and written entries of that directory.
21
+ export function memoryIO(files = {}) {
22
+ const norm = (p) => String(p).replace(/\\/g, '/');
23
+ const mem = new Map();
24
+ const text = (v) => (typeof v === 'string' ? v : JSON.stringify(v));
25
+ return {
26
+ read: (p) => {
27
+ const n = norm(p);
28
+ if (mem.has(n)) return mem.get(n);
29
+ for (const k of Object.keys(files)) if (n === k || n.endsWith('/' + k)) return text(files[k]);
30
+ const e = new Error(`ENOENT: no such file, ${p}`); e.code = 'ENOENT'; throw e;
31
+ },
32
+ write: (p, t) => { mem.set(norm(p), String(t)); },
33
+ list: (dir) => {
34
+ const d = norm(dir).replace(/\/$/, '');
35
+ const out = new Set();
36
+ const consider = (k) => { const i = k.lastIndexOf('/'); const kd = i < 0 ? '' : k.slice(0, i); if (d === kd || d.endsWith('/' + kd)) out.add(k.slice(i + 1)); };
37
+ Object.keys(files).forEach(consider);
38
+ for (const k of mem.keys()) consider(k);
39
+ return [...out];
40
+ },
41
+ mkdir: () => {},
42
+ };
43
+ }
package/lib/jev.mjs ADDED
@@ -0,0 +1,104 @@
1
+ // jev.mjs - Jev (TypeSafe AI) probabilities as CONTEXT on a verdict.
2
+ //
3
+ // Display only. It never admits, blocks, sizes or orders a token. Jev answers a typed question about a
4
+ // state with a calibrated probability; the three answers ride on the verdict so they can be tested against
5
+ // outcomes like every other stored fact.
6
+ //
7
+ // jevJudge(c) -> { up7d, halve7d, organicX, model, ms, tokens, at } | null (no key) ; never throws
8
+ // jevState(c) -> the state sent: the token's facts at scan time only. No outcome.
9
+ // jevLine(j) -> the text line for the card (pure)
10
+ //
11
+ // Key: TYPESAFE_API_KEY. Cache: <state>/jev-cache.json per token, JEV_TTL_H (6h); failures are never cached.
12
+ // Import-safe: no network at import, no top-level await, no argv reads.
13
+ import { io } from './io.mjs';
14
+ import { statePath } from './state.mjs';
15
+ import { line } from './calc.mjs';
16
+
17
+ export const JEV_TTL_H = Number(process.env.JEV_TTL_H || 6);
18
+ const URL_ = 'https://api.typesafe.ai/v1/systemone';
19
+ const MODEL = process.env.JEV_MODEL || 'jev-latest';
20
+
21
+ const keyOf = (c) => `${c.chain}:${String(c.address || '').toLowerCase()}`;
22
+ const readCache = () => { try { return JSON.parse(io.read(statePath('jev-cache.json'))); } catch { return {}; } };
23
+ const writeCache = (v) => { try { io.write(statePath('jev-cache.json'), JSON.stringify(v, null, 1)); } catch {} };
24
+ const num = (v, d = 2) => (v == null || !Number.isFinite(Number(v)) ? null : +Number(v).toFixed(d));
25
+ const clip = (s, n) => (s == null ? null : String(s).slice(0, n));
26
+
27
+ /** What Jev judges: the token as the scan saw it. Unknown stays null, never a placeholder zero. */
28
+ export function jevState(c) {
29
+ const g = c.gates || {}, x = c.xMeta || null;
30
+ return {
31
+ coin: { symbol: clip(c.symbol, 20), chain: c.chain ?? null, ageDays: num(c.ageDays, 1), tier: c.tier ?? null },
32
+ market: { priceUsd: c.priceUsd ?? null, mcapUsd: num(c.mcapUsd, 0), liquidityUsd: num(c.liqUsd, 0), volume24hUsd: num(c.vol24, 0),
33
+ turnover24h: c.vol24 != null && c.mcapUsd > 0 ? num(c.vol24 / c.mcapUsd, 3) : null, volumeLastHourUsd: num(c.volH1, 0),
34
+ priceChange1hPct: num(c.priceChange1), priceChange6hPct: num(c.priceChange6), priceChange24hPct: num(c.priceChange24),
35
+ buysLastHour: c.h1Buys ?? null, sellsLastHour: c.h1Sells ?? null, medianDailyRange14dPct: num(c.adr14, 1), daysMoving14d: c.moveDays14 ?? null },
36
+ location: { pctBelowAllTimeHigh: num(c.athDrawdown, 1), pctAbove30dLow: num(c.distFromLow30Pct, 1), baseBand: c.baseEntry?.band ?? c.baseEntry ?? null, entryBlock: c.entryBlock || null },
37
+ structure: { structuralGatePass: g.pass ?? null, top10HoldersPct: num(g.top10Pct, 1), holderCount: g.holderCount ?? null, creatorPct: num(g.creatorPct, 1),
38
+ lpLockedOrBurned: g.lpLockedOrBurned ?? null, ownerRenounced: g.ownerRenounced ?? null, sellsClearing: g.sellsClear ?? null, honeypot: g.honeypot ?? null, liquidityDrop30dPct: num(g.liqDropPct, 1), failReasons: (g.reasons || []).slice(0, 5) },
39
+ communityTakeoverClaims: c.takeoverClaim ? { count: c.takeoverClaim.count ?? null, latest: c.takeoverClaim.latest ?? null } : null,
40
+ xLast24h: x && x.posts24 != null ? { posts: x.posts24, accounts: x.accounts24 ?? null, accountsOver10kFollowers: x.accountsBig ?? null, views: x.views24 ?? null,
41
+ copyPasteSharePct: x.copyPasteShare ?? null, tone: x.tone ?? null, postsNamingThisChain: x.chainMentions ?? null, postsNamingAnotherChain: x.otherChainMentions ?? null,
42
+ largestAccounts: (x.bigAccounts || []).slice(0, 5).map((b) => ({ handle: b.handle, followers: b.followers, aboutAnotherChain: b.otherChain ?? null })), meta: clip(x.meta, 120) } : null,
43
+ note: 'A post-abandonment memecoin with an existing community, being judged for a swing entry. Unknown = not readable, not zero.',
44
+ };
45
+ }
46
+
47
+ const QUESTIONS = {
48
+ up7d: { type: 'noul', instructions: 'Seven days from now, will this coin trade ABOVE its current price?',
49
+ criteria: { true: 'Attention is building on real accounts, volume and buyers are rising from a base well below the ATH, the structure is clean and liquidity holds.',
50
+ false: 'A stale or coordinated campaign, sellers outnumber buyers, mid-dump or at the base with nothing moving, structural flags, liquidity thinning.' } },
51
+ halve7d: { type: 'noul', instructions: 'Within the next seven days, will this coin lose HALF or more of its current price?',
52
+ criteria: { true: 'Fragile: thin liquidity for the volume, a top-heavy holder base, a fading or paid X campaign, a recent pump that is unwinding.',
53
+ false: 'Deep liquidity, spread holders, steady organic attention, price already sitting at a long base.' } },
54
+ organicX: { type: 'noul', instructions: 'Is the X attention on this coin organic rather than a paid or coordinated campaign?',
55
+ criteria: { true: 'Many distinct accounts writing in their own words, larger profiles engaging, a low copy-paste share, posts about this chain\'s token.',
56
+ false: 'A high share of repeated templates, a few accounts producing most posts, engagement farming, posts mostly about another chain\'s token of the same name.' } },
57
+ };
58
+
59
+ /** The fresh cache entry for a token, or null. */
60
+ export function cachedJev(c) { const hit = readCache()[keyOf(c)]; return hit && hit.v === 1 && Date.now() - Date.parse(hit.at || 0) < JEV_TTL_H * 3600_000 ? hit : null; }
61
+
62
+ /** One Jev call for a token, cached JEV_TTL_H. null without a key. Never throws. */
63
+ export async function jevJudge(c, { log = console.error, force = false, timeoutMs = Number(process.env.JEV_TIMEOUT_MS || 10_000) } = {}) {
64
+ const key = process.env.TYPESAFE_API_KEY; if (!key) return null;
65
+ const k = keyOf(c), cache = readCache(), hit = cache[k];
66
+ if (!force && hit && hit.v === 1 && Date.now() - Date.parse(hit.at || 0) < JEV_TTL_H * 3600_000) return hit;
67
+ const t0 = Date.now(); let r, j;
68
+ for (let attempt = 0; attempt < 2; attempt++) {
69
+ try {
70
+ r = await fetch(URL_, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${key}` }, body: JSON.stringify({ model: MODEL, state: jevState(c), questions: QUESTIONS }), signal: AbortSignal.timeout(timeoutMs) });
71
+ if (r.status === 429 || r.status === 529) { await new Promise((res) => setTimeout(res, 2000 * (attempt + 1))); continue; }
72
+ j = await r.json(); break;
73
+ } catch (e) { log(`[jev] ${c.symbol}: ${e.message}`); return hit || null; }
74
+ }
75
+ if (!r?.ok || !j?.answers) { log(`[jev] ${c.symbol}: status ${r?.status}${j?.error ? ' ' + JSON.stringify(j.error).slice(0, 120) : ''}`); return hit || null; } // provider error, never cached
76
+ const p = (q) => (typeof j.answers[q]?.noul === 'number' ? +j.answers[q].noul.toFixed(3) : null);
77
+ const out = { v: 1, at: new Date().toISOString(), up7d: p('up7d'), halve7d: p('halve7d'), organicX: c.xMeta && c.xMeta.posts24 != null ? p('organicX') : null, model: j.model || MODEL, ms: Date.now() - t0, tokens: j.usage?.input_tokens ?? null };
78
+ const fresh = readCache(); fresh[k] = out; writeCache(fresh);
79
+ return out;
80
+ }
81
+
82
+ // Calibration from a backtest of 423 past alerts against their actual 7-day return (24 Sep 2026): halve 7d is
83
+ // calibrated and separates (Spearman -0.47, monotone bands); up 7d does not (0.09-0.11), so no words are put on
84
+ // it. Bands are frozen with their n. Observation only.
85
+ const CUTS = line('jev.halveBands');
86
+ export const HALVE_BANDS = [ // [lo, hi, label, n, halvedPct, medianPct]
87
+ [0, CUTS[0], 'holding up', 29, 0, -4], [CUTS[0], CUTS[1], 'leans safe', 67, 1, -7], [CUTS[1], CUTS[2], 'coin flip', 171, 32, -26], [CUTS[2], CUTS[3], 'likely to bleed', 120, 52, -51], [CUTS[3], 1.01, 'likely dead', 36, 83, -83],
88
+ ];
89
+ export function jevRead(j) {
90
+ if (!j || j.halve7d == null) return null;
91
+ const b = HALVE_BANDS.find(([lo, hi]) => j.halve7d >= lo && j.halve7d < hi); if (!b) return null;
92
+ const [, , label, n, halved, median] = b;
93
+ return { label, n, halved, median, words: `${label} - at this level ${halved}% of ${n} past alerts halved within 7d (median ${median >= 0 ? '+' : ''}${median}%)` };
94
+ }
95
+
96
+ /** The card lines. Pure; `esc` is an optional output escaper (identity for plain text). */
97
+ export function jevLine(j, esc = (s) => String(s ?? '')) {
98
+ if (!j || (j.up7d == null && j.halve7d == null)) return null;
99
+ const pc = (v) => (v == null ? 'n/a' : `${Math.round(100 * v)}%`);
100
+ const parts = [`Jev: up 7d ${pc(j.up7d)}`, `halve 7d ${pc(j.halve7d)}`];
101
+ if (j.organicX != null) parts.push(`X organic ${pc(j.organicX)}`);
102
+ const r = jevRead(j);
103
+ return esc(parts.join(' | ')) + (r ? '\n' + esc(`Jev read: ${r.words}. Observation only, not a gate.`) : esc(' (context only)'));
104
+ }
package/lib/record.mjs ADDED
@@ -0,0 +1,19 @@
1
+ // record.mjs - the published track record, as data. Every figure carries its date and n.
2
+ import { io } from './io.mjs';
3
+ import { dirname, join } from 'path';
4
+ import { fileURLToPath } from 'url';
5
+ import { DISCLAIMER } from './render.mjs';
6
+ import { THRESHOLDS } from './calc.mjs';
7
+
8
+ // A bundled runtime has no import.meta.url; paths then only serve as keys for the embedded store.
9
+ const HERE = (() => { try { return dirname(fileURLToPath(import.meta.url)); } catch { return '/keelage/' + 'lib'; } })();
10
+ let _rec;
11
+ export function trackRecord({ study = null } = {}) {
12
+ if (_rec === undefined) _rec = JSON.parse(io.read(join(HERE, '..', 'data', 'track-record.json')));
13
+ const out = { ...(_rec), rules: THRESHOLDS, disclaimer: DISCLAIMER };
14
+ if (study) {
15
+ const s = (out.studies || []).find((x) => x.key === study);
16
+ return s ? { ...s, disclaimer: DISCLAIMER } : { ok: false, error: `no study "${study}"`, studies: (out.studies || []).map((x) => x.key), disclaimer: DISCLAIMER };
17
+ }
18
+ return out;
19
+ }
package/lib/render.mjs ADDED
@@ -0,0 +1,56 @@
1
+ // render.mjs - the JSON shape of a scan. One object, fixed keys, null for unknown, never a placeholder zero.
2
+ import { baseBand, rulesetOf } from './calc.mjs';
3
+ import { links } from './format.mjs';
4
+ import { sizeFor, verdictFor } from './gate.mjs';
5
+ import { jevRead } from './jev.mjs';
6
+
7
+ export const DISCLAIMER = 'Research only, not financial advice.';
8
+ const n = (v) => (v === undefined ? null : v);
9
+
10
+ export function renderScanJson(res) {
11
+ if (!res?.ok) {
12
+ return { ok: false, address: n(res?.address), chain: 'robinhood', error: n(res?.err) || 'unknown error', verdict: null, scannedAt: new Date().toISOString(), disclaimer: DISCLAIMER };
13
+ }
14
+ const c = res.c, g = c.gates || {}, v = c.verdict || verdictFor(c), s = sizeFor(c), rs = rulesetOf(c);
15
+ const dist = c.baseEntry?.distPct ?? c.distFromLow30Pct ?? null;
16
+ return {
17
+ ok: true,
18
+ address: c.address, chain: c.chain, symbol: n(c.symbol), name: n(c.name), scannedAt: c.scannedAt,
19
+ // The verdict is a policy under a named, dated ruleset: `overrides` lists the lines the caller changed (empty for a preset).
20
+ verdict: { key: v.key, head: v.head, why: v.why, status: 'beta', ruleset: { name: rs.name, date: rs.date, overrides: [...rs.overrides], version: rs.date, handSetLines: rs.handSetLines, derivedLines: rs.derivedLines, note: 'The facts in this answer are measured and timestamped. The verdict is a policy over them under this ruleset and is in beta.' } },
21
+ tier: n(c.tier), actionable: !!c.actionable, entryBlock: n(c.entryBlock), score: n(c.score),
22
+ size: { usd: s.sizeUsd, onePctOfPoolUsd: s.onePctOfPoolUsd, tierCapUsd: s.tierCapUsd, rule: 'lower of 1% of pool and the tier cap, only when entry-eligible' },
23
+ market: {
24
+ priceUsd: n(c.priceUsd), liquidityUsd: n(c.liqUsd), marketCapUsd: n(c.mcapUsd), volume24hUsd: n(c.vol24),
25
+ buys24h: n(c.buys24), sells24h: n(c.sells24), priceChange24hPct: n(c.priceChange24), ageDays: n(c.ageDays),
26
+ pairAddress: n(c.pairAddress), dexId: n(c.dexId), quoteSymbol: n(c.quoteSymbol),
27
+ },
28
+ location: {
29
+ pctBelowAth: n(c.athDrawdown), pctAbove30dLow: dist, baseBand: baseBand(dist, rs).label,
30
+ ath: n(c.ath), low30: n(c.low30), medianDailyRange14dPct: n(c.adr14), daysMoving14d: n(c.moveDays14),
31
+ },
32
+ structure: {
33
+ pass: n(g.pass), unverified: !!g.gateErr, providerError: n(g.gateErr), reasons: g.reasons || [], caps: g.caps || [],
34
+ source: n(g.source), verifiedSource: n(g.verified), contractName: n(g.contractName), implementationName: n(g.implName), proxyType: n(g.proxyType),
35
+ creator: n(g.creator), template: g.template ? { key: g.template, verdict: n(g.templateVerdict), matchedBy: n(g.templateBy) } : null,
36
+ privileges: Object.keys(g.privileges || {}).filter((k) => k !== 'live'), livePrivilege: !!g.privileges?.live,
37
+ ownerRenounced: n(g.ownerRenounced), ownerAddress: n(g.ownerAddress),
38
+ transferTaxCode: n(g.taxCode), taxIdentifierHits: n(g.taxHits), reflectionIdentifierHits: n(g.reflectHits),
39
+ sellsClearing24h: n(g.sellsClear), liquidityDropPct: n(g.liqDropPct), liquidityHistoryDays: n(g.liqPoints),
40
+ },
41
+ holders: {
42
+ count: n(g.holderCount), top10Pct: n(g.top10Pct), top10Source: n(g.top10Source), top10InclPoolsPct: n(g.top10RawPct),
43
+ largestWalletPct: n(g.maxSinglePct), contractsInTop10: n(g.contractsInTop10), poolsInTop10: n(g.poolAcctsInTop10), creatorPct: n(g.creatorPct),
44
+ },
45
+ goplus: g.goplusErr ? { record: false, note: g.goplusErr } : {
46
+ record: true, honeypot: n(g.honeypot), buyTax: n(g.buyTax), sellTax: n(g.sellTax), mintable: n(g.mintable), pausable: n(g.pausable),
47
+ lpMode: n(g.lpMode), lpHolders: n(g.lpHolders), topLpPct: n(g.topLpPct), creatorLpPct: n(g.creatorLpPct), lpLockedOrBurned: n(g.lpLockedOrBurned),
48
+ },
49
+ takeoverClaim: c.takeoverClaim ? { count: c.takeoverClaim.count, latest: c.takeoverClaim.latest, note: 'caution: historically the worst-performing bucket' } : null,
50
+ discoveryShape: n(c.shapeNote),
51
+ x: n(c.xMeta),
52
+ jev: c.jev ? { halve7d: n(c.jev.halve7d), up7d: n(c.jev.up7d), organicX: n(c.jev.organicX), read: jevRead(c.jev), model: n(c.jev.model), at: n(c.jev.at), note: 'Context only. Never a gate, never a rank key. The halve-7d band is calibrated (423 alerts, 24 Sep 2026); up-7d is not and carries no words.' } : null,
53
+ links: links(c.address),
54
+ disclaimer: DISCLAIMER,
55
+ };
56
+ }
package/lib/state.mjs ADDED
@@ -0,0 +1,17 @@
1
+ // state.mjs - where caches live. One directory, created on first use, overridable with KEELAGE_STATE_DIR.
2
+ import { io } from './io.mjs';
3
+ import { homedir, tmpdir } from 'os';
4
+ import { join } from 'path';
5
+
6
+ function pick() {
7
+ if (process.env.KEELAGE_STATE_DIR) return process.env.KEELAGE_STATE_DIR;
8
+ const home = homedir();
9
+ return home ? join(home, '.cache', 'keelage') : join(tmpdir(), 'keelage');
10
+ }
11
+ export const STATE_DIR = pick();
12
+ let made = false;
13
+ export function stateDir() {
14
+ if (!made) { try { io.mkdir(STATE_DIR); } catch {} made = true; }
15
+ return STATE_DIR;
16
+ }
17
+ export const statePath = (name) => join(stateDir(), name);
@@ -0,0 +1,22 @@
1
+ // template.mjs - which launcher template a contract came from, and what the ABI and source say on their own.
2
+ import { contractFacts, isAddress, CHAIN, templates } from './gate.mjs';
3
+ import { DISCLAIMER } from './render.mjs';
4
+
5
+ export async function templateFor(address) {
6
+ const raw = String(address || '').trim();
7
+ if (!isAddress(raw)) return { ok: false, address: raw, error: 'not a contract address (0x + 40 hex characters)', disclaimer: DISCLAIMER };
8
+ const a = raw.toLowerCase();
9
+ const f = await contractFacts(a);
10
+ if (f.err) return { ok: false, address: a, error: f.err, disclaimer: DISCLAIMER };
11
+ const reg = templates();
12
+ const t = (reg?.templates || []).find((x) => x.key === f.template.key) || null;
13
+ return {
14
+ ok: true, address: a, chain: CHAIN, symbol: f.tokenSymbol, name: f.tokenName,
15
+ template: { key: f.template.key, verdict: f.template.verdict, matchedBy: f.template.by || null, feeModel: f.template.feeModel || null, note: t?.note || f.template.note || null },
16
+ contract: { verifiedSource: f.verified, verifiedAt: f.verifiedAt, compiler: f.compiler, contractName: f.contractName, implementationName: f.implName, implementationAddress: f.implAddr, proxyType: f.proxyType, creator: f.creator, functionCount: f.fns.length },
17
+ privileges: Object.entries(f.priv).filter(([k, v]) => v && k !== 'live').map(([k]) => k), livePrivilege: !!f.priv.live,
18
+ taxCode: { taxIdentifierHits: f.tax.taxHits, reflectionIdentifierHits: f.tax.reflectHits, present: f.tax.taxCode, countsAgainst: f.priv.feeSetter || (f.tax.taxCode && f.template.feeModel !== 'uniswap-pool-fee') },
19
+ registry: { version: reg?.version || null, templates: (reg?.templates || []).map((x) => ({ key: x.key, verdict: x.verdict })) },
20
+ readAt: new Date().toISOString(), disclaimer: DISCLAIMER,
21
+ };
22
+ }
@@ -0,0 +1,100 @@
1
+ {
2
+ "version": "2026-09-09",
3
+ "note": "Robinhood Chain (4663) launcher templates, judged once from the ABI and verified source of a 384-contract census. A token inherits its template's verdict; an unregistered template is judged by ABI and source alone and rendered as unregistered. verdict: clean = no live privilege, no transfer tax (fee words are Uniswap pool-fee reads); exclude = not a memecoin candidate; review = resolve the implementation and judge its ABI at run time.",
4
+ "templates": [
5
+ {
6
+ "key": "pons-launcher",
7
+ "verdict": "clean",
8
+ "feeModel": "uniswap-pool-fee",
9
+ "match": {
10
+ "creators": [
11
+ "0x0c37a24F5D23A486FA692d1500881d698B1F77a4",
12
+ "0xA5aAb3F0c6EeadF30Ef1D3Eb997108E976351feB",
13
+ "0x3711ceA4feaDE896C913C68F01Eda97Cb06D1A42",
14
+ "0xdD89f26beA3916233d002D1189F973B78D38aA70",
15
+ "0x2bA793Fd69Bf251Fd1Af90b576bE8B9fA6Be46DB"
16
+ ],
17
+ "contractNames": [
18
+ "PonsLauncherToken",
19
+ "PonsV2LauncherToken",
20
+ "LemonLauncherToken"
21
+ ]
22
+ },
23
+ "note": "PonsLaunchFactory / PonsV2LaunchDeployer / Lemon fork. No owner, no setters; poolFee/fee() are Uniswap V3 pool-tier reads, not a transfer tax; maxTxBps/maxWalletBps anti-whale limits for restrictionBlocks after launch. 141 tokens in the census, -57% median since first sight: population quality, not a rug vector."
24
+ },
25
+ {
26
+ "key": "launchtoken",
27
+ "verdict": "clean",
28
+ "feeModel": "uniswap-pool-fee",
29
+ "match": {
30
+ "creators": [
31
+ "0x98De89729dea2Df7739802C2D367D07dE926985d",
32
+ "0xD09639274b0E0A1a9586F7756ccFe134Cc525d09",
33
+ "0xD9eC2db5f3D1b236843925949fe5bd8a3836FCcB"
34
+ ],
35
+ "contractNames": [
36
+ "LaunchToken"
37
+ ]
38
+ },
39
+ "note": "Factory 0xD9eC2db5 (unnamed on Blockscout). Same ABI shape as Pons minus setInitialBuyRecipient. 61 tokens, -39% median."
40
+ },
41
+ {
42
+ "key": "uerc20-pools-trade",
43
+ "verdict": "clean",
44
+ "match": {
45
+ "creators": [
46
+ "0x000000e200088D55C39a11F609E5F667729ad49b"
47
+ ],
48
+ "contractNames": [
49
+ "UERC20"
50
+ ]
51
+ },
52
+ "note": "UERC20Factory (Pools Trade). No owner, no mint, permit + tokenURI only. 16 tokens."
53
+ },
54
+ {
55
+ "key": "robinhood-stock",
56
+ "verdict": "exclude",
57
+ "match": {
58
+ "creators": [
59
+ "0x4783C67b63dE2B358Ac5951a7D41F47A38F3C046"
60
+ ],
61
+ "implNames": [
62
+ "Stock"
63
+ ],
64
+ "contractNames": [
65
+ "BeaconProxy"
66
+ ]
67
+ },
68
+ "note": "Robinhood's tokenized equities (NVDA, SPY, ...): eip1967 beacon proxies to the Stock implementation with registry-controlled mint/burn/pause. Not a memecoin candidate: excluded as a class, never gated."
69
+ },
70
+ {
71
+ "key": "doppler",
72
+ "verdict": "clean",
73
+ "match": {
74
+ "creators": [
75
+ "0x1B37D3a72082029c44B35B604Ea473617580b69a"
76
+ ],
77
+ "implNames": [
78
+ "DopplerERC20V1"
79
+ ]
80
+ },
81
+ "note": "DopplerERC20V1Factory: eip1167 minimal proxies (token unverified) to a verified DopplerERC20V1 implementation. Owner exists (ownership handover) but no mint, pause, blacklist or fee setter; balance limit + lockPool + vesting. Judged from the implementation ABI. 32 tokens, -29% median."
82
+ },
83
+ {
84
+ "key": "party",
85
+ "verdict": "clean",
86
+ "match": {
87
+ "creators": [
88
+ "0x626C3d09B65bF5d1D40E0D5F25e19fa49783B3D4"
89
+ ],
90
+ "contractNames": [
91
+ "PartyToken"
92
+ ]
93
+ },
94
+ "note": "3 tokens, 11 functions, no privileges."
95
+ }
96
+ ],
97
+ "excludeImplNames": [
98
+ "Stock"
99
+ ]
100
+ }