@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.
package/lib/gate.mjs ADDED
@@ -0,0 +1,638 @@
1
+ // gate.mjs - the Robinhood Chain structural pass, sizing tier, entry test, score and verdict for one token.
2
+ //
3
+ // Import-safe: no network at import, no top-level await, no argv reads. State it touches: the OHLCV cache
4
+ // (<state>/ohlcv-cache.json, 6h TTL for ATH and 30-day low). Liquidity history for the "liquidity intact"
5
+ // clause is injected through setLiquidityHistory(); without it the clause reads "not yet judgeable".
6
+ //
7
+ // Vocabulary, fixed: score ranks, it never admits; unknown facts cap size (caps[]); only evidence blocks (reasons[]).
8
+
9
+ import { io } from './io.mjs';
10
+ import { dirname, join } from 'path';
11
+ import { fileURLToPath } from 'url';
12
+ import { evmTop10, tierFromTop10, goplusUnindexed, lpModeOf, lpStats, abiPrivileges, taxCodeScan, resolveTemplate, liqDrawdown, blockscoutTop10, line, THRESHOLDS, DEFAULT_RULESET, resolveRuleset, rulesetOf, RulesetError } from './calc.mjs';
13
+ export { THRESHOLDS, DEFAULT_RULESET, resolveRuleset, rulesetOf, RulesetError };
14
+ import { statePath } from './state.mjs';
15
+ import { tokenCard, keyNorm } from './format.mjs';
16
+ import { xMeta } from './x-meta.mjs';
17
+ import { jevJudge } from './jev.mjs';
18
+
19
+ // A bundled runtime has no import.meta.url; paths then only serve as keys for the embedded store.
20
+ const HERE = (() => { try { return dirname(fileURLToPath(import.meta.url)); } catch { return '/keelage/' + 'lib'; } })();
21
+ export const CHAIN = 'robinhood';
22
+ export const CHAIN_ID = '4663';
23
+
24
+ // ---------- Config ----------
25
+ // Market shape of the post-abandonment revival thesis: what discovery goes looking for. Informational on
26
+ // a single scan; a token outside the shape is still judged.
27
+ export const DISCOVERY_SHAPE = {
28
+ minAgeDays: 14, // paid attention on a token this old = revival-shaped, not launch hype
29
+ minAgeDaysClaimed: 5, // an explicit paid takeover claim replaces age as proof
30
+ maxAgeDays: 400,
31
+ minLiqUsd: 5_000, // below = untradeable
32
+ maxLiqUsd: 500_000, // above = not an early revival
33
+ minMcapUsd: 10_000,
34
+ maxMcapUsd: 5_000_000, // beyond = the revival already happened
35
+ };
36
+ export const GATE_CFG = {
37
+ creatorMaxPct: line('deployer.max'), // deployer still holding more than this = not exited
38
+ goplusChainId: CHAIN_ID,
39
+ };
40
+ export const GT_NETWORK = 'robinhood';
41
+
42
+ // 0x + 40 hex = a contract address on Robinhood Chain. Anything else is unjudgeable, never a verdict.
43
+ export function isAddress(a) { return /^0x[0-9a-fA-F]{40}$/.test(String(a || '').trim()); }
44
+
45
+ export const OHLCV_CACHE_PATH = () => statePath('ohlcv-cache.json');
46
+ export const readJsonSafe = (p) => { try { return JSON.parse(io.read(p)); } catch { return {}; } };
47
+ export const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
48
+ export { keyNorm };
49
+
50
+ // Optional CoinGecko Pro routing. With CG_PRO_API_KEY unset the URL and headers are untouched.
51
+ const CG_PRO_API_KEY = process.env.CG_PRO_API_KEY || '';
52
+ function proRoute(url) {
53
+ if (!CG_PRO_API_KEY) return { url, headers: { accept: 'application/json' } };
54
+ const headers = { accept: 'application/json', 'x-cg-pro-api-key': CG_PRO_API_KEY };
55
+ if (url.startsWith('https://api.geckoterminal.com/api/v2/')) {
56
+ return { url: url.replace('https://api.geckoterminal.com/api/v2/', 'https://pro-api.coingecko.com/api/v3/onchain/'), headers };
57
+ }
58
+ if (url.startsWith('https://api.coingecko.com/api/v3/')) {
59
+ return { url: url.replace('https://api.coingecko.com/api/v3/', 'https://pro-api.coingecko.com/api/v3/'), headers };
60
+ }
61
+ return { url, headers };
62
+ }
63
+
64
+ export async function getJson(rawUrl, tries = 3) {
65
+ const { url, headers } = proRoute(rawUrl);
66
+ for (let i = 0; i < tries; i++) {
67
+ try {
68
+ const r = await fetch(url, { headers });
69
+ if (r.status === 429) { await sleep(5000 * (i + 1)); continue; }
70
+ if (!r.ok) throw new Error(`HTTP ${r.status}`);
71
+ return await r.json();
72
+ } catch (e) {
73
+ if (i === tries - 1) return { __err: String(e.message || e) };
74
+ await sleep(1500 * (i + 1));
75
+ }
76
+ }
77
+ // A 429 on every try falls out of the loop. Fail loudly and let callers decide.
78
+ return { __err: `429 exhausted after ${tries} tries` };
79
+ }
80
+
81
+ // ---------- Paid takeover claim (DexScreener orders) ----------
82
+ // Rendered as a caution where present. It never scores: claimed tokens were the worst-performing bucket.
83
+ export async function checkClaim(c) {
84
+ const d = await getJson(`https://api.dexscreener.com/orders/v1/${c.chain}/${c.address}`);
85
+ const claims = (d?.orders || []).filter((o) => o.type === 'communityTakeover' && o.status === 'approved');
86
+ if (claims.length) {
87
+ c.takeoverClaim = { count: claims.length, latest: new Date(Math.max(...claims.map((o) => o.paymentTimestamp))).toISOString().slice(0, 10) };
88
+ }
89
+ return !!claims.length;
90
+ }
91
+
92
+ // ---------- GoPlus: corroboration only ----------
93
+ // Where it has a record it supplies honeypot, mintable, owner, creator share and LP-holder statistics.
94
+ // Where it has none, the on-chain pass still decides. GoPlus indexes this chain lazily: one re-ask after a
95
+ // pause, then "no record". The comma-separated batch form returns only already-cached tokens, so never batch.
96
+ export async function goplusRead(c) {
97
+ const url = `https://api.gopluslabs.io/api/v1/token_security/${GATE_CFG.goplusChainId}?contract_addresses=${c.address}`;
98
+ let d = await getJson(url);
99
+ let r = d?.result?.[c.address.toLowerCase()];
100
+ if (!r && !d?.__err) { await sleep(3000); d = await getJson(url); r = d?.result?.[c.address.toLowerCase()]; if (r) c._goplusLazy = true; }
101
+ if (!r) return { goplusErr: `goplus: ${d?.__err || 'no record after 2 asks (lazy index)'}` };
102
+ if (goplusUnindexed(r)) return { goplusErr: 'goplus: partial record (no honeypot/holders/LP data)' };
103
+ const num = (x) => (x === undefined || x === null || x === '' ? null : Number(x));
104
+ const { top10Pct, top10RawPct, poolAcctsInTop10 } = evmTop10(r);
105
+ const lpMode = lpModeOf(r);
106
+ const lp = lpStats(r);
107
+ const lpLockedOrBurned = lp.lockedOrBurned;
108
+ const creatorRaw = num(r.creator_percent);
109
+ const creatorPct = creatorRaw === null ? null : +(creatorRaw * 100).toFixed(2);
110
+ const buyTax = num(r.buy_tax), sellTax = num(r.sell_tax);
111
+ return {
112
+ honeypot: num(r.is_honeypot), buyTax, sellTax,
113
+ mintable: num(r.is_mintable), ownerRenounced: r.owner_address === '0x0000000000000000000000000000000000000000' || r.owner_address === '',
114
+ holderCount: num(r.holder_count),
115
+ pausable: num(r.transfer_pausable), lpLockedOrBurned,
116
+ lpMode, lpHolders: lp.lpHolders, topLpPct: lp.topLpPct, creatorLpPct: lp.creatorLpPct,
117
+ taxKnown: buyTax !== null && sellTax !== null,
118
+ slippageModifiable: num(r.slippage_modifiable), antiWhale: num(r.is_anti_whale), tradingCooldown: num(r.trading_cooldown),
119
+ goplusLazy: !!c._goplusLazy,
120
+ top10Pct, top10RawPct, poolAcctsInTop10,
121
+ creator: r.creator_address || null, creatorPct,
122
+ ownerAddress: r.owner_address || null,
123
+ dexPairs: (r.dex || []).map((x) => x.pair).filter(Boolean),
124
+ };
125
+ }
126
+
127
+ // ---------- Blockscout (robinhoodchain.blockscout.com, API v2) ----------
128
+ // The host sits behind a challenge that 403s bare API clients on most paths; a full browser header set
129
+ // gets 200. BLOCKSCOUT_IP pins the A record where a resolver misdirects this host.
130
+ export const BS_HOST = 'robinhoodchain.blockscout.com';
131
+ const BS_HEADERS = {
132
+ 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36',
133
+ Accept: 'application/json, text/plain, */*', 'Accept-Language': 'en-US,en;q=0.9',
134
+ Referer: `https://${BS_HOST}/`, Origin: `https://${BS_HOST}`,
135
+ 'sec-ch-ua': '"Chromium";v="128", "Not;A=Brand";v="24", "Google Chrome";v="128"', 'sec-ch-ua-mobile': '?0', 'sec-ch-ua-platform': '"macOS"',
136
+ 'sec-fetch-dest': 'empty', 'sec-fetch-mode': 'cors', 'sec-fetch-site': 'same-origin',
137
+ };
138
+ export const BS_PACE_MS = 600;
139
+ async function bsGetRaw(path) {
140
+ const ip = process.env.BLOCKSCOUT_IP || '';
141
+ if (ip) { const { default: https } = await import('node:https'); return bsGetPinned(path, ip, https); }
142
+ try {
143
+ const r = await fetch(`https://${BS_HOST}/api/v2${path}`, { headers: BS_HEADERS, signal: AbortSignal.timeout(20_000) });
144
+ if (r.status !== 200) return { __err: `http ${r.status}` };
145
+ try { return await r.json(); } catch { return { __err: 'bad json' }; }
146
+ } catch (e) { return { __err: (e && e.name === 'TimeoutError' ? 'timeout' : String(e && e.message || e)).slice(0, 60) }; }
147
+ }
148
+ // With BLOCKSCOUT_IP set the request is pinned to that address over node's https (a Node-only path).
149
+ function bsGetPinned(path, ip, https) {
150
+ return new Promise((resolve) => {
151
+ const req = https.request({
152
+ host: BS_HOST, servername: BS_HOST, path: `/api/v2${path}`, method: 'GET', headers: BS_HEADERS, timeout: 20_000,
153
+ ...(ip ? { lookup: (h, o, cb) => cb(null, [{ address: ip, family: 4 }]) } : {}),
154
+ }, (res) => {
155
+ let b = ''; res.on('data', (ch) => (b += ch));
156
+ res.on('end', () => { if (res.statusCode !== 200) return resolve({ __err: `http ${res.statusCode}` }); try { resolve(JSON.parse(b)); } catch { resolve({ __err: 'bad json' }); } });
157
+ });
158
+ req.on('timeout', () => req.destroy(new Error('timeout')));
159
+ req.on('error', (e) => resolve({ __err: String(e.message || e).slice(0, 60) }));
160
+ req.end();
161
+ });
162
+ }
163
+ // Circuit breaker. After BS_TRIP_AFTER consecutive 5xx / timeouts the circuit opens for BS_TRIP_OPEN_MS:
164
+ // every read answers { __err: 'blockscout down' } at once (callers file the token UNVERIFIED), no socket,
165
+ // no retry. When the window lapses the next read is a probe: a good answer closes the circuit, a bad one
166
+ // re-opens it. Matters in a long-lived server process.
167
+ const BS_TRIP_AFTER = Math.max(2, Number(process.env.BS_TRIP_AFTER || 6));
168
+ const BS_TRIP_OPEN_MS = Number(process.env.BS_TRIP_OPEN_MS || 10 * 60_000);
169
+ const bsCircuit = { fails: 0, openUntil: 0, trips: 0 };
170
+ export function bsCircuitState() { return { ...bsCircuit, open: Date.now() < bsCircuit.openUntil }; }
171
+ const bsBad = (d) => /^http 5\d\d$/.test(d?.__err || '') || d?.__err === 'hard timeout' || d?.__err === 'timeout';
172
+ export async function bsGet(path) {
173
+ if (Date.now() < bsCircuit.openUntil) return { __err: 'blockscout down' };
174
+ const probing = bsCircuit.trips > 0 && bsCircuit.fails >= BS_TRIP_AFTER;
175
+ const race = () => Promise.race([bsGetRaw(path), new Promise((r) => setTimeout(() => r({ __err: 'hard timeout' }), 20_000))]);
176
+ let d = await race();
177
+ if (d?.__err === 'http 429') { await sleep(4000); d = await race(); }
178
+ else if (/^http 5\d\d$/.test(d?.__err || '') && !probing && bsCircuit.fails + 1 < BS_TRIP_AFTER) { await sleep(2500); d = await race(); }
179
+ if (bsBad(d)) {
180
+ bsCircuit.fails++;
181
+ if (bsCircuit.fails >= BS_TRIP_AFTER) {
182
+ bsCircuit.openUntil = Date.now() + BS_TRIP_OPEN_MS; bsCircuit.trips++;
183
+ console.error(`[blockscout] circuit OPEN after ${bsCircuit.fails} consecutive 5xx/timeouts (last: ${d.__err} on ${path.split('/').slice(0, 2).join('/')}) - reads answer 'blockscout down' for ${Math.round(BS_TRIP_OPEN_MS / 60_000)} min`);
184
+ }
185
+ } else if (!d?.__err || d.__err === 'http 404') {
186
+ if (bsCircuit.trips > 0 && bsCircuit.fails >= BS_TRIP_AFTER) console.error('[blockscout] circuit CLOSED - probe answered');
187
+ bsCircuit.fails = 0;
188
+ }
189
+ return d;
190
+ }
191
+
192
+ // ---------- RPC ----------
193
+ // owner() via eth_call. ROBINHOOD_RPC_URL overrides the public endpoint. A failed read is null (unknown)
194
+ // and the caller fails closed on live privileges.
195
+ export const RPC_URL = () => process.env.ROBINHOOD_RPC_URL || 'https://rpc.mainnet.chain.robinhood.com';
196
+ export async function rpcCall(method, params, { timeoutMs = 10_000 } = {}) {
197
+ try {
198
+ const r = await fetch(RPC_URL(), { method: 'POST', headers: { 'Content-Type': 'application/json' }, signal: AbortSignal.timeout(timeoutMs),
199
+ body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }) });
200
+ const j = await r.json();
201
+ return j?.result ?? null;
202
+ } catch { return null; }
203
+ }
204
+ async function ownerOf(address) {
205
+ const hex = String(await rpcCall('eth_call', [{ to: address, data: '0x8da5cb5b' }, 'latest']) || '');
206
+ if (!/^0x[0-9a-fA-F]{64}$/.test(hex)) return null;
207
+ return '0x' + hex.slice(-40).toLowerCase();
208
+ }
209
+
210
+ let _templates;
211
+ export function templates() {
212
+ if (_templates === undefined) { try { _templates = JSON.parse(io.read(join(HERE, 'templates.json'))); } catch (e) { console.error(`[gate] templates.json unreadable: ${e.message} - every template reads unregistered`); _templates = null; } }
213
+ return _templates;
214
+ }
215
+
216
+ // Liquidity history hook: fn(chain, address) -> [[day, liqUsd], ...] | null. Absent = no history.
217
+ let _liqHistory = null;
218
+ export function setLiquidityHistory(fn) { _liqHistory = typeof fn === 'function' ? fn : null; }
219
+ const liqHistoryFor = (chain, address) => (_liqHistory ? _liqHistory(chain, address) || null : null);
220
+
221
+ // ---------- Contract facts from Blockscout (shared by the scan and the template tool) ----------
222
+ export async function contractFacts(address) {
223
+ const a = address;
224
+ const ad = await bsGet(`/addresses/${a}`); await sleep(BS_PACE_MS);
225
+ if (ad?.__err) return { err: `blockscout addresses: ${ad.__err}` };
226
+ if (ad.is_contract === false) return { err: 'blockscout: address is not a contract' };
227
+ const creator = ad.creator_address_hash || null;
228
+ const proxyType = ad.proxy_type || null;
229
+ const impl = (ad.implementations || [])[0];
230
+ const implAddr = impl ? (impl.address || impl.address_hash || null) : null;
231
+ const codeAddr = implAddr || a;
232
+ const sc = await bsGet(`/smart-contracts/${codeAddr}`); await sleep(BS_PACE_MS);
233
+ if (sc?.__err && sc.__err !== 'http 404') return { err: `blockscout smart-contracts: ${sc.__err}` };
234
+ const verified = !!sc?.is_verified;
235
+ const contractName = implAddr ? (ad.name || null) : (sc?.name || null);
236
+ const implName = implAddr ? (sc?.name || impl.name || null) : null;
237
+ const fns = (sc?.abi || []).filter((f) => f.type === 'function').map((f) => f.name);
238
+ const priv = abiPrivileges(fns);
239
+ const src = (sc?.source_code || '') + (sc?.additional_sources || []).map((x) => x.source_code || '').join('');
240
+ const tax = taxCodeScan(src);
241
+ const template = resolveTemplate(templates(), { creator, contractName: sc?.name || contractName, implName, proxyType });
242
+ return { creator, proxyType, implAddr, codeAddr, verified, contractName, implName, fns, priv, tax, template, verifiedAt: sc?.verified_at || null, compiler: sc?.compiler_version || null, tokenName: ad.token?.name || null, tokenSymbol: ad.token?.symbol || null };
243
+ }
244
+
245
+ // ---------- Structural pass: facts from the chain, GoPlus as corroboration ----------
246
+ // The pass: verified source (following a proxy to its implementation), a template whose verdict is not
247
+ // `exclude`, no live owner privilege (mint / pause / blacklist / fee setter / trading toggle / upgrade in the
248
+ // ABI unless owner() is the zero address), no transfer-tax code, sells clearing on DexScreener in the last 24h,
249
+ // liquidity not drained more than 70% from its recorded peak, and no honeypot flag from GoPlus or GeckoTerminal.
250
+ // Concentration stays a sizing TIER (Blockscout holders, pools and contracts removed).
251
+ export async function gateRobinhood(c) {
252
+ const a = c.address, rs = rulesetOf(c), creatorMaxPct = rs.line('deployer.max');
253
+ const f = await contractFacts(a);
254
+ if (f.err) return { gateErr: f.err };
255
+ const { creator, proxyType, implAddr, codeAddr, verified, contractName, implName, priv, tax, template } = f;
256
+ const [hl, tk] = [await bsGet(`/tokens/${a}/holders`), await (sleep(BS_PACE_MS), bsGet(`/tokens/${a}`))]; await sleep(BS_PACE_MS);
257
+ const gp = await goplusRead(c);
258
+ const poolAddrs = [c.pairAddress, ...((gp?.dexPairs) || [])].filter(Boolean);
259
+ const bs = hl?.__err || tk?.__err ? null : blockscoutTop10(hl?.items, poolAddrs, tk?.total_supply);
260
+ const gt = await gtTokenInfo(c);
261
+ const top10Pct = bs?.top10Pct ?? gt?.top10 ?? gp?.top10Pct ?? null;
262
+ const top10Source = bs?.top10Pct != null ? 'blockscout ex-pool' : gt?.top10 != null ? 'geckoterminal incl. pools' : gp?.top10Pct != null ? 'goplus (partial list)' : null;
263
+ const holderCount = (tk && !tk.__err && tk.holders_count != null) ? Number(tk.holders_count) : (gp?.holderCount ?? gt?.count ?? null);
264
+ // owner: zero address = renounced; no owner function = nothing to renounce
265
+ let ownerRenounced = null, ownerAddress = null;
266
+ if (priv.owner) {
267
+ ownerAddress = await ownerOf(a) ?? (gp?.ownerAddress ? String(gp.ownerAddress).toLowerCase() : null);
268
+ ownerRenounced = ownerAddress === null ? null : /^0x0{40}$/.test(ownerAddress);
269
+ } else ownerRenounced = true;
270
+ const sellsClear = c.sells24 == null ? null : c.sells24 > 0;
271
+ const liq = liqDrawdown(liqHistoryFor(c.chain, a), c.liqUsd);
272
+ const creatorPct = gp?.creatorPct ?? null;
273
+ const taxCode = priv.feeSetter || (tax.taxCode && template.feeModel !== 'uniswap-pool-fee');
274
+ const reasons = [], caps = [];
275
+ if (template.verdict === 'exclude') reasons.push(`excluded template: ${template.note || template.key}`);
276
+ if (!verified) reasons.push(implAddr ? `implementation ${implName || codeAddr.slice(0, 10)} unverified` : 'source unverified');
277
+ if (priv.live && ownerRenounced !== true) reasons.push(`live privilege (${['mint', 'pause', 'blacklist', 'feeSetter', 'tradingToggle', 'upgrade'].filter((k) => priv[k]).join('+')}) with owner ${ownerRenounced === false ? 'active' : 'unverified'}`);
278
+ if (taxCode) reasons.push(`transfer-tax code in source (${tax.taxHits} tax, ${tax.reflectHits} reflection ids${priv.feeSetter ? ', fee setter in ABI' : ''})`);
279
+ if (gp?.honeypot === 1) reasons.push('GoPlus honeypot flag');
280
+ if (gt?.isHoneypot === true) reasons.push('GeckoTerminal honeypot flag');
281
+ if (sellsClear === false && (c.buys24 || 0) > 0) reasons.push(`no sells in 24h against ${c.buys24} buys - sells not clearing`);
282
+ if (liq.liqDropPct !== null && liq.liqDropPct > rs.line('liquidity.maxDrawdown')) reasons.push(`liquidity down ${liq.liqDropPct}% from its recorded peak $${Math.round(liq.liqPeak).toLocaleString('en-US')}`);
283
+ if (creatorPct !== null && creatorPct > creatorMaxPct) reasons.push(`creator holds ${creatorPct}% (max ${creatorMaxPct}%)`);
284
+ if (creatorPct === null) caps.push('creator balance unknown (no GoPlus record)');
285
+ if (sellsClear === null) caps.push('sell flow unknown');
286
+ if (liq.liqDropPct === null) caps.push(`liq history ${liq.points} day(s) - intact clause not yet judgeable`);
287
+ if (template.verdict === 'unregistered') caps.push(`unregistered template ${template.note || ''}`.trim());
288
+ return {
289
+ pass: reasons.length === 0, reasons, caps, source: 'blockscout',
290
+ verified, contractName, implName, proxyType, creator, template: template.key, templateVerdict: template.verdict, templateBy: template.by || null,
291
+ privileges: Object.fromEntries(Object.entries(priv).filter(([, v]) => v === true).map(([k]) => [k, true])),
292
+ ownerRenounced, ownerAddress, taxHits: tax.taxHits, reflectHits: tax.reflectHits, taxCode,
293
+ sellsClear, buys24: c.buys24 ?? null, sells24: c.sells24 ?? null, liqDropPct: liq.liqDropPct, liqPeak: liq.liqPeak, liqPoints: liq.points,
294
+ top10Pct, top10RawPct: bs?.top10InclPct ?? gp?.top10RawPct ?? null, poolAcctsInTop10: bs?.poolsInTop10 ?? gp?.poolAcctsInTop10 ?? null,
295
+ contractsInTop10: bs?.contractsInTop10 ?? null, maxSinglePct: bs?.maxSinglePct ?? null, top10Source,
296
+ holderCount, creatorPct,
297
+ honeypot: gp?.honeypot ?? null, buyTax: gp?.buyTax ?? null, sellTax: gp?.sellTax ?? null, mintable: gp?.mintable ?? null, pausable: gp?.pausable ?? null,
298
+ lpLockedOrBurned: gp?.lpLockedOrBurned ?? null, lpMode: gp?.lpMode ?? null, lpHolders: gp?.lpHolders ?? null, topLpPct: gp?.topLpPct ?? null, creatorLpPct: gp?.creatorLpPct ?? null,
299
+ taxKnown: gp?.taxKnown ?? null, goplusLazy: gp?.goplusLazy ?? null, goplusErr: gp?.goplusErr ?? null,
300
+ };
301
+ }
302
+
303
+ export async function gateFor(c) {
304
+ if (c.chain === CHAIN) return gateRobinhood(c);
305
+ return { gateErr: `no gate provider for chain ${c.chain}` };
306
+ }
307
+
308
+ // ---------- Sizing tier ----------
309
+ export function tierOf(c) {
310
+ const g = c.gates;
311
+ if (!g || g.gateErr) return 'unknown';
312
+ if (!g.pass) return 'structural-fail';
313
+ return tierFromTop10(g.top10Pct, c.liqUsd, rulesetOf(c));
314
+ }
315
+
316
+ // The only tiers that may be presented as entry-eligible. A whitelist: a tier added later is non-actionable
317
+ // until deliberately admitted (in tablesFrom, calc.mjs). This is the default ruleset's set.
318
+ export const ACTIONABLE_TIERS = DEFAULT_RULESET.ACTIONABLE_TIERS;
319
+
320
+ // ---------- Score: ranks, never admits ----------
321
+ export function score(c) {
322
+ const rs = rulesetOf(c), E = entryFor(rs), bars = rs.TIER_BARS;
323
+ let s = 0;
324
+ if (c.gates?.pass) s += 30; // structural pass
325
+ // A paid takeover claim scores 0: measured over 1,013 first-alert rows it was the strongest negative signal.
326
+ const t = c.gates?.top10Pct;
327
+ const tKnown = t !== null && t !== undefined; // unknown earns nothing, either way
328
+ if (c.gates?.pass && tKnown && t <= bars.conviction) s += 15;
329
+ else if (c.gates?.pass && tKnown && t <= bars.scout) s += 5;
330
+ else if (tKnown && t > bars.scout) s -= 10;
331
+ if (c.types.includes('profile')) s += 10; // paid profile on an aged token
332
+ if (c.types.includes('boost') || c.types.includes('boost-top')) s += 10;
333
+ if ((c.gates?.creatorPct ?? 100) === 0) s += 10; // deployer fully exited
334
+ // A living remnant is HOLDERS, not transactions: a transaction count is earnable by one wallet round-tripping.
335
+ const holders = c.gates?.holderCount;
336
+ if (holders != null && holders >= 1_000) s += 10;
337
+ else if (holders != null && holders >= E.minHolders) s += 5;
338
+ if (c.ageDays >= 30) s += 5; // survived a full cycle
339
+ const liqRatio = c.liqUsd / Math.max(c.mcapUsd, 1);
340
+ const turnover = (c.vol24 || 0) / Math.max(c.liqUsd || 1, 1);
341
+ // Turnover is required too, or this term rewards fabricated liquidity.
342
+ if (liqRatio >= 0.05 && turnover >= E.minTurnover24) s += 10;
343
+ // Entry at base: a candidate that already ran is a missed trade. A missing 24h change earns nothing.
344
+ if (c.priceChange24 != null) {
345
+ if (c.priceChange24 > 30) s -= 20;
346
+ else if (c.priceChange24 < 10) s += 10;
347
+ }
348
+ c.tier = tierOf(c);
349
+ c.convictionEligible = c.tier === rs.TIERS.CONVICTION;
350
+ return s;
351
+ }
352
+
353
+ // ---------- Price history: ATH drawdown and the 30-day low ----------
354
+ // "How far from the top" and "are we at a base" are different questions; both come from one OHLCV pull,
355
+ // memoized per token and cached 6h per pair. Failures are not cached.
356
+ export async function poolHistory(c) {
357
+ if (c._hist !== undefined) return c._hist;
358
+ if (!c.pairAddress) { c._hist = null; return null; }
359
+ const ck = keyNorm(c.chain, c.pairAddress);
360
+ const cachePath = OHLCV_CACHE_PATH();
361
+ const ohlcvCache = readJsonSafe(cachePath);
362
+ const cached = ohlcvCache[ck];
363
+ if (cached && Date.now() - cached.t < 6 * 3600_000) { c._hist = cached.hist; return c._hist; }
364
+ const gtGap = Date.now() - poolHistory._lastGt;
365
+ if (gtGap < 2500) await sleep(2500 - gtGap);
366
+ poolHistory._lastGt = Date.now();
367
+ const ohlcvUrl = `https://api.geckoterminal.com/api/v2/networks/${GT_NETWORK}/pools/${c.pairAddress}/ohlcv/day?aggregate=1&limit=365`;
368
+ let d = await getJson(ohlcvUrl);
369
+ if (d?.__err && /429/.test(d.__err)) { await sleep(20_000); d = await getJson(ohlcvUrl); }
370
+ // Candles price the pool's BASE token. When this token is the QUOTE, ask for the quote side.
371
+ const baseAddr = String(d?.meta?.base?.address || '').toLowerCase();
372
+ const quoteAddr = String(d?.meta?.quote?.address || '').toLowerCase();
373
+ if (baseAddr && quoteAddr && quoteAddr === String(c.address).toLowerCase() && baseAddr !== quoteAddr) {
374
+ await sleep(600);
375
+ const dq = await getJson(`${ohlcvUrl}&token=quote`);
376
+ if (dq && !dq.__err) { d = dq; c._histSide = 'quote'; }
377
+ else console.error(`[history] ${c.symbol} is the QUOTE token of its pool and the quote-side read failed (${dq?.__err}) - treated as no history`), d = { __err: 'quote side unavailable' };
378
+ }
379
+ if (d?.__err) console.error(`[history] ${c.symbol} ohlcv failed: ${d.__err} - treated as no history`);
380
+ const list = d?.data?.attributes?.ohlcv_list;
381
+ if (!Array.isArray(list) || !list.length) { c._hist = null; return null; }
382
+ const ath = Math.max(...list.map((r) => +r[2] || 0));
383
+ const recent = [...list].sort((a, b) => (+b[0] || 0) - (+a[0] || 0)).slice(0, 30);
384
+ const lows = recent.map((r) => +r[3]).filter((n) => n > 0);
385
+ const low30 = lows.length ? Math.min(...lows) : null;
386
+ // Daily movement: adr14 = median daily range over the newest 14 days; moveDays14 = days that closed 10%+ from the open.
387
+ const last14 = recent.slice(0, 14);
388
+ const ranges = last14.map((r) => (+r[1] > 0 ? ((+r[2] - +r[3]) / +r[1]) * 100 : null)).filter((n) => n != null && Number.isFinite(n));
389
+ const closes = last14.map((r) => (+r[1] > 0 ? (Math.abs(+r[4] - +r[1]) / +r[1]) * 100 : null)).filter((n) => n != null && Number.isFinite(n));
390
+ const median = (a) => { const v = [...a].sort((x, y) => x - y); return v.length ? v[Math.floor((v.length - 1) / 2)] : null; };
391
+ c._hist = {
392
+ ath, low30,
393
+ adr14: ranges.length >= 3 ? +median(ranges).toFixed(1) : null,
394
+ moveDays14: closes.length ? closes.filter((n) => n >= 10).length : null,
395
+ candleDays: last14.length,
396
+ // 99.5%+ below ATH on a token that trades today is a unit mismatch between candles and the live price: unknown, never a verdict.
397
+ athDrawdown: ath && c.priceUsd ? ((1 - c.priceUsd / ath) * 100 >= 99.5 && (c.sells24 || 0) > 0 ? null : +((1 - c.priceUsd / ath) * 100).toFixed(1)) : null,
398
+ distFromLow30Pct: low30 && c.priceUsd ? +(((c.priceUsd - low30) / low30) * 100).toFixed(1) : null,
399
+ };
400
+ ohlcvCache[ck] = { t: Date.now(), hist: c._hist };
401
+ try { io.write(cachePath, JSON.stringify(ohlcvCache, null, 1)); } catch {}
402
+ return c._hist;
403
+ }
404
+ poolHistory._lastGt = 0;
405
+ export async function athDrawdown(c) { const h = await poolHistory(c); return h ? h.athDrawdown : null; }
406
+
407
+ // ---------- Entry test ----------
408
+ // The entry lines under one ruleset. The two environment knobs still win over the default; a line the
409
+ // caller overrode in the ruleset is what the caller asked for, so it is not quietly replaced by the env.
410
+ export function entryFor(rs = DEFAULT_RULESET) {
411
+ const env = (name, key, v) => (rs.overrides.includes(key) ? v : Number(process.env[name] || v));
412
+ return {
413
+ minHolders: rs.line('holders.min'),
414
+ maxDistFromLow30Pct: rs.line('base.atBase'), // label only: the "at the base" band
415
+ minAthDrawdownConviction: rs.line('entry.convictionBelowPeak'),
416
+ maxPriceChange24Pct: rs.line('entry.midPump'),
417
+ minTurnover24: rs.line('entry.deepPoolTurnover') / 100, // share of the pool
418
+ deepLiqUsd: 100_000, // only judge pool turnover where the pool is this deep
419
+ minVol24Usd: env('ENTRY_MIN_VOL24_USD', 'entry.minVolume24h', rs.line('entry.minVolume24h')),
420
+ minTurnoverMcap: env('ENTRY_MIN_TURNOVER_MCAP', 'entry.minTurnoverOfMcap', rs.line('entry.minTurnoverOfMcap') / 100),
421
+ };
422
+ }
423
+ export const ENTRY = entryFor(DEFAULT_RULESET);
424
+
425
+ // Second source for holder count, plus an independent top-10 figure as a cross-check.
426
+ export async function gtTokenInfo(c) {
427
+ if (c._gtInfo !== undefined) return c._gtInfo;
428
+ const d = await getJson(`https://api.geckoterminal.com/api/v2/networks/${GT_NETWORK}/tokens/${c.address}/info`);
429
+ if (d?.__err) console.error(`[holders] ${c.symbol} gt info failed: ${d.__err} - holder count stays unknown (caps size, never blocks)`);
430
+ const at = d?.data?.attributes;
431
+ const h = at?.holders;
432
+ const t10 = h?.distribution_percentage?.top_10;
433
+ c._gtInfo = h ? { count: h.count ?? null, top10: t10 == null ? null : +(+t10).toFixed(1), isHoneypot: at?.is_honeypot === true ? true : at?.is_honeypot === false ? false : null } : null;
434
+ return c._gtInfo;
435
+ }
436
+
437
+ // Populate the price-history fields on the token whatever else happens. Reporting only; no gate changes.
438
+ export async function recordHistory(c) {
439
+ const h = await poolHistory(c);
440
+ if (!h) return null;
441
+ c.low30 = h.low30;
442
+ c.ath = h.ath;
443
+ c.distFromLow30Pct = h.distFromLow30Pct;
444
+ if (c.athDrawdown == null) c.athDrawdown = h.athDrawdown;
445
+ c.adr14 = h.adr14 ?? null; c.moveDays14 = h.moveDays14 ?? null; c.candleDays = h.candleDays ?? null;
446
+ return h;
447
+ }
448
+
449
+ // Returns null when the token may be presented as entry-eligible, else the reason it may not.
450
+ // Unknown data fails CLOSED, except where absence of evidence is not evidence of absence, in which case
451
+ // it caps SIZE rather than killing the candidate (the holder-count branch).
452
+ export async function entryChecks(c) {
453
+ const rs = rulesetOf(c), ENTRY = entryFor(rs), TIERS = rs.TIERS;
454
+ const h = await recordHistory(c);
455
+ let holders = c.gates?.holderCount;
456
+ c.holderSource = holders == null ? null : 'gate-provider';
457
+ if (holders == null) {
458
+ const gi = await gtTokenInfo(c);
459
+ if (gi?.count != null) { holders = gi.count; c.holderSource = 'geckoterminal'; }
460
+ if (gi?.top10 != null) c.gtTop10Pct = gi.top10;
461
+ }
462
+ c.holderCount = holders ?? null;
463
+ if (holders != null && holders < ENTRY.minHolders) return `only ${holders} holders < ${ENTRY.minHolders} - no remnant community`;
464
+ if (holders == null && c.tier === TIERS.CONVICTION) {
465
+ c.tier = TIERS.SCOUT_CLUSTER;
466
+ c.tierDowngraded = 'holder count unavailable from both sources - capped at scout size';
467
+ }
468
+ if ((c.liqUsd || 0) >= ENTRY.deepLiqUsd) {
469
+ const turnover = (c.vol24 || 0) / Math.max(c.liqUsd, 1);
470
+ if (turnover < ENTRY.minTurnover24) {
471
+ return `$${Math.round(c.liqUsd).toLocaleString('en-US')} pool doing ${(turnover * 100).toFixed(3)}% 24h turnover - liquidity is fabricated or dead`;
472
+ }
473
+ }
474
+ const vol24 = Number(c.vol24) || 0;
475
+ if (vol24 < ENTRY.minVol24Usd) return `quiet: $${Math.round(vol24).toLocaleString('en-US')} 24h volume under the $${ENTRY.minVol24Usd.toLocaleString('en-US')} floor`;
476
+ const mcapForTurnover = Number(c.mcapUsd) || 0;
477
+ if (mcapForTurnover > 0 && vol24 / mcapForTurnover < ENTRY.minTurnoverMcap) return `quiet: 24h turnover ${(100 * vol24 / mcapForTurnover).toFixed(1)}% of mcap under the ${Math.round(100 * ENTRY.minTurnoverMcap)}% floor`;
478
+ if (c.priceChange24 == null) return 'no 24h price change data - cannot confirm it is not mid-pump';
479
+ if (c.priceChange24 > ENTRY.maxPriceChange24Pct) return `mid-pump: 24h +${c.priceChange24}% over the +${ENTRY.maxPriceChange24Pct}% ceiling - a candidate that already ran is a missed trade`;
480
+ // Base proximity ranks and renders; it does not block. The drawdown from ATH is the entry thesis.
481
+ const base = baseEntryPenalty(h, rs);
482
+ c.baseEntry = base;
483
+ c.score += base.penalty;
484
+ if (c.tier === TIERS.CONVICTION) {
485
+ if (c.athDrawdown == null) return `ATH drawdown unknown (no usable price history) - CONVICTION needs a measured >=${ENTRY.minAthDrawdownConviction}% decline from ATH`;
486
+ if (c.athDrawdown < ENTRY.minAthDrawdownConviction) return `only ${c.athDrawdown}% below ATH - CONVICTION needs >=${ENTRY.minAthDrawdownConviction}% (post-abandonment, not a chart in progress)`;
487
+ }
488
+ return null;
489
+ }
490
+
491
+ // Graduated score penalty for distance above the 30-day low.
492
+ export function baseEntryPenalty(h, rs = DEFAULT_RULESET) {
493
+ const d = h?.distFromLow30Pct;
494
+ const [atBase, justOff, wellOff] = rs.BASE_BANDS.map((b) => b.max);
495
+ if (d == null) return { distPct: null, penalty: -5, note: 'base entry unconfirmed - no usable 30d low' };
496
+ if (d <= atBase) return { distPct: d, penalty: 0, note: `at the base (${d}% above 30d low)` };
497
+ if (d <= justOff) return { distPct: d, penalty: -5, note: `${d}% above 30d low - just off the base` };
498
+ if (d <= wellOff) return { distPct: d, penalty: -10, note: `${d}% above 30d low - well off the base` };
499
+ return { distPct: d, penalty: -20, note: `${d}% above 30d low - already ran hard` };
500
+ }
501
+
502
+ // ---------- One token, the full pass ----------
503
+
504
+ // The deepest DexScreener pair where this token is the BASE. A token that only appears as the QUOTE of its
505
+ // pools has no DexScreener price of its own: null, and the caller says so.
506
+ export function candidateFromPairs(chain, address, pairs) {
507
+ const raw = String(address || '').trim();
508
+ const key = raw.toLowerCase();
509
+ let p = null;
510
+ for (const x of Array.isArray(pairs) ? pairs : []) {
511
+ if (String(x?.baseToken?.address || '').toLowerCase() !== key) continue;
512
+ if (!p || (x.liquidity?.usd || 0) > (p.liquidity?.usd || 0)) p = x;
513
+ }
514
+ if (!p || !p.pairCreatedAt) return null;
515
+ const ageDays = (Date.now() - p.pairCreatedAt) / 864e5;
516
+ const liq = p.liquidity?.usd || 0;
517
+ const mcap = p.marketCap || p.fdv || 0;
518
+ return {
519
+ chain, address: key, types: ['scan'],
520
+ buys24: p.txns?.h24?.buys || 0, sells24: p.txns?.h24?.sells || 0,
521
+ symbol: p.baseToken.symbol, name: p.baseToken.name, priceUsd: +p.priceUsd || null,
522
+ ageDays: +ageDays.toFixed(1), liqUsd: Math.round(liq), mcapUsd: Math.round(mcap),
523
+ vol24: Math.round(p.volume?.h24 || 0), tx24: (p.txns?.h24?.buys || 0) + (p.txns?.h24?.sells || 0),
524
+ priceChange24: p.priceChange?.h24 ?? null,
525
+ pairAddress: p.pairAddress, dexId: p.dexId,
526
+ url: p.url || `https://dexscreener.com/${chain}/${p.pairAddress}`,
527
+ quoteSymbol: p.quoteToken?.symbol || null,
528
+ };
529
+ }
530
+
531
+ // Where the token sits against the discovery shape. Informational only.
532
+ export function shapeNote(c) {
533
+ const out = [];
534
+ const usd = (n) => `$${Number(n).toLocaleString('en-US')}`;
535
+ if (c.ageDays != null && c.ageDays < DISCOVERY_SHAPE.minAgeDaysClaimed) out.push(`age ${c.ageDays}d under ${DISCOVERY_SHAPE.minAgeDaysClaimed}d`);
536
+ else if (c.ageDays != null && c.ageDays < DISCOVERY_SHAPE.minAgeDays && !c.takeoverClaim) out.push(`age ${c.ageDays}d under ${DISCOVERY_SHAPE.minAgeDays}d with no takeover claim`);
537
+ if (c.ageDays != null && c.ageDays > DISCOVERY_SHAPE.maxAgeDays) out.push(`age ${c.ageDays}d over ${DISCOVERY_SHAPE.maxAgeDays}d`);
538
+ if (c.liqUsd != null && c.liqUsd < DISCOVERY_SHAPE.minLiqUsd) out.push(`liq ${usd(c.liqUsd)} under ${usd(DISCOVERY_SHAPE.minLiqUsd)}`);
539
+ if (c.liqUsd != null && c.liqUsd > DISCOVERY_SHAPE.maxLiqUsd) out.push(`liq ${usd(c.liqUsd)} over ${usd(DISCOVERY_SHAPE.maxLiqUsd)}`);
540
+ if (c.mcapUsd != null && c.mcapUsd < DISCOVERY_SHAPE.minMcapUsd) out.push(`mcap ${usd(c.mcapUsd)} under ${usd(DISCOVERY_SHAPE.minMcapUsd)}`);
541
+ if (c.mcapUsd != null && c.mcapUsd > DISCOVERY_SHAPE.maxMcapUsd) out.push(`mcap ${usd(c.mcapUsd)} over ${usd(DISCOVERY_SHAPE.maxMcapUsd)}`);
542
+ return out.length ? `Discovery shape: outside (${out.join('; ')})` : null;
543
+ }
544
+
545
+ // The size rule: the lower of 1% of the pool and the cap for the tier, only on an entry-eligible token.
546
+ export function sizeFor(c) {
547
+ const onePct = Math.floor((c.liqUsd || 0) / 100);
548
+ const cap = rulesetOf(c).TIER_CAP[c.tier] ?? 100;
549
+ return { onePctOfPoolUsd: onePct, tierCapUsd: cap, sizeUsd: c.actionable ? Math.min(onePct, cap) : null };
550
+ }
551
+ export function sizeLineFor(c) {
552
+ const s = sizeFor(c), rs = rulesetOf(c);
553
+ if (c.actionable) return `size $${s.sizeUsd.toLocaleString('en-US')} (lower of 1% of pool $${s.onePctOfPoolUsd.toLocaleString('en-US')} and the ${c.tier} cap $${s.tierCapUsd.toLocaleString('en-US')})`;
554
+ return `NOT entry-eligible${c.entryBlock ? ` - ${c.entryBlock}` : (c.tier === rs.TIERS.WATCH_ONLY ? ` - the 10 largest wallets hold over ${rs.TIER_BARS.scout}% of the supply; structure passed, judge the concentration yourself` : '')}`;
555
+ }
556
+
557
+ // The one-line answer. Four states, no fifth:
558
+ // UNVERIFIED - a provider failed; structure unknown; never a verdict
559
+ // FAIL - the chain says no (the reasons)
560
+ // NOT_ELIGIBLE - structure passes, but the tier or the entry test says not now (why)
561
+ // ELIGIBLE - structure passes, tier actionable, entry test clear, with the size rule
562
+ export function verdictFor(c) {
563
+ const g = c.gates || {}, rs = rulesetOf(c), TIERS = rs.TIERS, scoutLine = rs.TIER_BARS.scout;
564
+ if (g.gateErr) return { key: 'UNVERIFIED', head: 'UNVERIFIED - no verdict', why: `A gate provider failed (${g.gateErr}). Structure unknown - do not size. Try again in a minute.` };
565
+ if (!g.pass) return { key: 'FAIL', head: 'STRUCTURAL FAIL - not a buy', why: `FAIL: ${(g.reasons || []).join(' | ') || 'gates failed'}` };
566
+ if (c.actionable) {
567
+ const conviction = c.tier === TIERS.CONVICTION;
568
+ const caps = (g.caps || []).length ? ` Unknowns cap the size: ${g.caps.join(' | ')}.` : '';
569
+ return {
570
+ key: 'ELIGIBLE', head: `ENTRY-ELIGIBLE - ${c.tier}`,
571
+ why: `Structure passes on-chain, tier ${conviction ? 'CONVICTION' : 'scout'}, entry test clear.${caps}${conviction ? ' Above scout size: your own small buy and sell first.' : ''}`,
572
+ };
573
+ }
574
+ const why = c.entryBlock
575
+ || (c.tier === TIERS.WATCH_ONLY ? `the 10 largest wallets hold ${g.top10Pct != null ? g.top10Pct + '%' : `more than ${scoutLine}%`} of the supply, over the ${scoutLine}% line - every structural check passed, judge the concentration yourself`
576
+ : c.tier === TIERS.DIST_UNKNOWN ? 'holder distribution unknown from every source - cannot size'
577
+ : `tier ${c.tier} is not entry-eligible`);
578
+ return { key: 'NOT_ELIGIBLE', head: 'NOT ENTRY-ELIGIBLE - gates pass, not now', why: `Why: ${why}` };
579
+ }
580
+
581
+ // The whole pass for one address. Returns { ok:true, c } with the verdict on c, or { ok:false, err } when
582
+ // there is nothing to judge (bad address, no pair, DexScreener down). An error is never a verdict.
583
+ // `ruleset` is "default", a preset name, or { lines: [{ key, value }] } (see resolveRuleset in calc.mjs). A bad
584
+ // one is answered before any network call, as an error result, never a verdict.
585
+ export async function scanOne(address, { log = console.error, liquidityHistory = null, withX = true, withJev = true, ruleset = 'default' } = {}) {
586
+ const raw = String(address || '').trim();
587
+ if (!isAddress(raw)) return { ok: false, address: raw, err: 'not a contract address (0x + 40 hex characters)' };
588
+ const chain = CHAIN;
589
+ const a = raw.toLowerCase();
590
+ let rs;
591
+ try { rs = resolveRuleset(ruleset); } catch (e) { if (e instanceof RulesetError) return { ok: false, address: a, err: e.message }; throw e; }
592
+ if (liquidityHistory) setLiquidityHistory(liquidityHistory);
593
+ try {
594
+ const d = await getJson(`https://api.dexscreener.com/tokens/v1/${chain}/${a}`);
595
+ if (d?.__err) return { ok: false, address: a, err: `dexscreener: ${d.__err}` };
596
+ const c = candidateFromPairs(chain, a, d);
597
+ if (c) c.ruleset = rs;
598
+ if (!c) {
599
+ const asQuote = (Array.isArray(d) ? d : []).find((x) => String(x?.quoteToken?.address || '').toLowerCase() === a);
600
+ return {
601
+ ok: false, address: a,
602
+ err: asQuote
603
+ ? `only trades as the QUOTE side of a ${asQuote.baseToken?.symbol || '?'} pool on DexScreener - no price of its own to judge`
604
+ : `no DexScreener pair on ${chain} for this address (not traded here, or a different chain)`,
605
+ };
606
+ }
607
+ await sleep(1100);
608
+ await checkClaim(c);
609
+ await sleep(1100);
610
+ c.gates = await gateFor(c);
611
+ if (c.gates?.gateErr) log(`[scan] ${c.symbol} UNVERIFIED: ${c.gates.gateErr}`);
612
+ c.score = score(c); // sets c.tier
613
+ const structuralOk = !c.gates?.gateErr && !!c.gates?.pass && rs.ACTIONABLE_TIERS.has(c.tier);
614
+ c.entryBlock = structuralOk ? await entryChecks(c) : null;
615
+ // ATH and 30-day-low context on every verdict, pass or fail.
616
+ if (c.athDrawdown == null && c.distFromLow30Pct == null) { await sleep(700); await recordHistory(c); }
617
+ c.actionable = structuralOk && !c.entryBlock;
618
+ if (withX) { try { c.xMeta = await xMeta(c, { log }); } catch (e) { log(`[scan] x-meta ${c.symbol}: ${e.message}`); c.xMeta = null; } }
619
+ // Jev probabilities ride along as context when a key is present. Never a gate, never a rank key.
620
+ if (withJev) { try { c.jev = await jevJudge(c, { log }); } catch (e) { log(`[scan] jev ${c.symbol}: ${e.message}`); c.jev = null; } }
621
+ c.shapeNote = shapeNote(c);
622
+ c.verdict = verdictFor(c);
623
+ c.scannedAt = new Date().toISOString();
624
+ delete c._hist; delete c._gtInfo;
625
+ return { ok: true, c };
626
+ } finally { if (liquidityHistory) setLiquidityHistory(null); }
627
+ }
628
+
629
+ // Plain-text body: verdict, why, context lines, then the token card with the size line.
630
+ export function renderScanText(res) {
631
+ if (!res?.ok) return [`NO VERDICT`, String(res?.err || 'unknown error'), res?.address || null].filter(Boolean).join('\n');
632
+ const c = res.c, v = c.verdict || verdictFor(c);
633
+ const lines = [v.head, v.why];
634
+ if (c.shapeNote) lines.push(c.shapeNote);
635
+ lines.push('', tokenCard({ ...c, sizeLine: sizeLineFor(c) }));
636
+ lines.push('', 'Research only, not financial advice.');
637
+ return lines.join('\n');
638
+ }