@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/LICENSE +21 -0
- package/README.md +100 -0
- package/bin/keelage.mjs +54 -0
- package/data/thresholds.json +26 -0
- package/data/track-record.json +61 -0
- package/lib/calc.mjs +338 -0
- package/lib/format.mjs +166 -0
- package/lib/gate.mjs +638 -0
- package/lib/holders.mjs +67 -0
- package/lib/io.mjs +43 -0
- package/lib/jev.mjs +104 -0
- package/lib/record.mjs +19 -0
- package/lib/render.mjs +56 -0
- package/lib/state.mjs +17 -0
- package/lib/template.mjs +22 -0
- package/lib/templates.json +100 -0
- package/lib/x-meta.mjs +214 -0
- package/package.json +45 -0
- package/server/core.mjs +114 -0
- package/server/hosted-io.mjs +9 -0
- package/server/mcp.mjs +25 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Keelage
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# @keelage/mcp
|
|
2
|
+
|
|
3
|
+
Token intelligence for Robinhood Chain, as an MCP server. It reads a token from the chain itself, scores it against a published record of what happened next, and answers an agent in one call.
|
|
4
|
+
|
|
5
|
+
Research only, not financial advice. Read-only: no wallet, no key, nothing here can spend.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
Node 22 or newer. No runtime dependencies.
|
|
10
|
+
|
|
11
|
+
```json
|
|
12
|
+
{
|
|
13
|
+
"mcpServers": {
|
|
14
|
+
"keelage": { "command": "npx", "args": ["-y", "@keelage/mcp"] }
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Or from a checkout: `node core/bin/keelage.mjs` starts the stdio server.
|
|
20
|
+
|
|
21
|
+
## Tools
|
|
22
|
+
|
|
23
|
+
| Tool | What it answers |
|
|
24
|
+
|---|---|
|
|
25
|
+
| `keelage_scan` | The structural verdict for one token: verified source, launcher template, owner privileges, transfer-tax code, honeypot flags, sells clearing, holder concentration tier, ATH drawdown and 30-day-low band, entry test, size rule. Four states: `ELIGIBLE`, `NOT_ELIGIBLE`, `FAIL`, `UNVERIFIED`. |
|
|
26
|
+
| `keelage_holders` | Top wallets with pools, contracts and burn addresses labeled; top-10 share excluding pools and contracts; burn share; share held by EIP-7702 smart accounts. |
|
|
27
|
+
| `keelage_template` | Which launcher template the contract came from, with the template verdict, the ABI privileges and the transfer-tax identifier count. |
|
|
28
|
+
| `keelage_record` | The dated studies, with sample sizes, behind every rule. |
|
|
29
|
+
|
|
30
|
+
Rules, fixed: the score ranks, it never admits. Unknown facts cap size. Only evidence blocks. Unknown is `null`, never zero.
|
|
31
|
+
|
|
32
|
+
### The `ruleset` argument
|
|
33
|
+
|
|
34
|
+
`keelage_scan` takes an optional `ruleset`. The facts don't move; the lines the verdict policy reads can.
|
|
35
|
+
|
|
36
|
+
- Omitted, or `"keelage-default"` (short form `"default"`): the 18 lines in `data/thresholds.json`, each with its origin, date and n, dated 2026-10-04. The only preset today.
|
|
37
|
+
- `{ "lines": [{ "key": "pool.deep", "value": 100000 }] }`: keelage-default with those lines replaced. A key is one of the 18 below; a value is a number in that line's unit. An unknown key, a value of the wrong shape or a key given twice comes back as an `ok:false` result with the reason, never a verdict. One line can't be moved, `jev.halveBands`: the words on each band were measured at exactly those cuts.
|
|
38
|
+
|
|
39
|
+
Every answer says what it ran under, in `verdict.ruleset`: `name` (`keelage-default`, or `custom` for your own lines), `date` (the preset's date, or today for your own lines) and `overrides` (the keys whose value differs from the default; empty for a preset).
|
|
40
|
+
|
|
41
|
+
| Key | Unit | Default | What it decides |
|
|
42
|
+
|---|---|---|---|
|
|
43
|
+
| `top10.conviction` | % of supply | 30 | Conviction tier: the 10 largest wallets hold this much or less |
|
|
44
|
+
| `top10.scout` | % of supply | 45 | Above this the token is watch-only and never sized |
|
|
45
|
+
| `pool.deep` | USD in the pool | 50,000 | Conviction needs a pool at least this deep; thinner pools are scout size |
|
|
46
|
+
| `size.cap.conviction` | USD | 1,000 | Largest size we would suggest for a conviction token |
|
|
47
|
+
| `size.cap.scout` | USD | 100 | Largest size we would suggest for a scout token |
|
|
48
|
+
| `size.poolShare` | % of the pool | 1 | Never more than this share of the pool, whatever the tier |
|
|
49
|
+
| `deployer.max` | % of supply | 5 | A deployer still holding more than this fails the token |
|
|
50
|
+
| `holders.min` | wallets | 100 | Fewer holders than this fails the entry test |
|
|
51
|
+
| `entry.midPump` | % price change in 24h | 30 | Up more than this today fails the entry test |
|
|
52
|
+
| `entry.minVolume24h` | USD traded in 24h | 10,000 | Quieter than this fails the entry test |
|
|
53
|
+
| `entry.minTurnoverOfMcap` | % of market cap traded in 24h | 5 | Less turnover than this fails the entry test |
|
|
54
|
+
| `entry.deepPoolTurnover` | % of the pool traded in 24h | 0.2 | A pool over $100,000 doing less than this reads as fabricated or dead |
|
|
55
|
+
| `entry.convictionBelowPeak` | % below all-time high | 60 | Conviction needs at least this much decline from peak |
|
|
56
|
+
| `liquidity.maxDrawdown` | % below its recorded peak | 70 | A pool drained more than this fails the token |
|
|
57
|
+
| `base.atBase` | % above the 30-day low | 30 | At base band ends here |
|
|
58
|
+
| `base.justOff` | % above the 30-day low | 100 | Just off base band ends here |
|
|
59
|
+
| `base.wellOff` | % above the 30-day low | 500 | Well off base band ends here; beyond is already ran |
|
|
60
|
+
| `jev.halveBands` | probability | 0.4, 0.5, 0.6, 0.7 | Bands for the halve-in-7-days read |
|
|
61
|
+
|
|
62
|
+
A stricter preset ships only when every line it moves has a documented source: an earlier value stated in that line's own origin, or a dated study with its n. Today 1 line has one (`pool.deep` was 100,000 before 2026-08-01). The other 17 are set by hand or measured at their current value, and we don't invent the stricter number.
|
|
63
|
+
|
|
64
|
+
## Hosted
|
|
65
|
+
|
|
66
|
+
The same server runs at `https://mcp.keelage.ai/mcp` over Streamable HTTP (POST JSON-RPC; GET answers 405). No key and no sign-in. Per caller, 20 counted calls a day across `keelage_scan` and `keelage_holders`; `keelage_record` and `keelage_template` are never counted. The caller is the `X-Wallet` header when you send one (a 0x address), otherwise a salted hash of the network address that changes daily. Past the quota a priced tool answers with its price in USDG on Robinhood Chain and `x402.status: "not open yet"`; settlement opens when the merchant address is published. The hosted code is this package plus one transport file, so the two give the same answers.
|
|
67
|
+
|
|
68
|
+
## Command line
|
|
69
|
+
|
|
70
|
+
```
|
|
71
|
+
keelage scan <0x> [--text] [--ruleset <name|json>]
|
|
72
|
+
keelage holders <0x> [--no-smart]
|
|
73
|
+
keelage template <0x>
|
|
74
|
+
keelage record [study-key]
|
|
75
|
+
keelage tools
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## Environment (all optional)
|
|
79
|
+
|
|
80
|
+
| Variable | Purpose |
|
|
81
|
+
|---|---|
|
|
82
|
+
| `ROBINHOOD_RPC_URL` | JSON-RPC endpoint. Default: the public Robinhood Chain RPC. |
|
|
83
|
+
| `KEELAGE_STATE_DIR` | Cache directory. Default: `~/.cache/keelage`. |
|
|
84
|
+
| `BLOCKSCOUT_IP` | Pin the explorer's A record where a resolver misdirects it. |
|
|
85
|
+
| `CG_PRO_API_KEY` | CoinGecko Pro routing for GeckoTerminal reads. |
|
|
86
|
+
| `TYPESAFE_API_KEY` | Jev probabilities as context on a verdict. |
|
|
87
|
+
| `SOCIALDATA_API_KEY`, `XAI_API_KEY` | What X is saying, as context on a verdict. |
|
|
88
|
+
|
|
89
|
+
## Upstreams
|
|
90
|
+
|
|
91
|
+
DexScreener (pairs, takeover orders), Blockscout for Robinhood Chain (contract, source, ABI, holders), GoPlus (corroboration only), GeckoTerminal (price history, holder count), the chain RPC (`owner()`, `eth_getCode`).
|
|
92
|
+
|
|
93
|
+
## Tests
|
|
94
|
+
|
|
95
|
+
```
|
|
96
|
+
npm test # pure: measurements, verdict states, JSON shape, MCP handshake
|
|
97
|
+
npm run test:live # five reference tokens against the live chain
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
Research, timestamped.
|
package/bin/keelage.mjs
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// keelage - token intelligence for Robinhood Chain.
|
|
3
|
+
//
|
|
4
|
+
// keelage stdio MCP server (what an agent client launches)
|
|
5
|
+
// keelage serve same
|
|
6
|
+
// keelage scan <0x> [--text] [--ruleset <name|json>] verdict as JSON (or the text card)
|
|
7
|
+
// keelage holders <0x> [--no-smart]
|
|
8
|
+
// keelage template <0x>
|
|
9
|
+
// keelage record [study-key]
|
|
10
|
+
// keelage tools the MCP tool list
|
|
11
|
+
import { scanOne, renderScanText } from '../lib/gate.mjs';
|
|
12
|
+
import { renderScanJson } from '../lib/render.mjs';
|
|
13
|
+
import { holdersFor } from '../lib/holders.mjs';
|
|
14
|
+
import { templateFor } from '../lib/template.mjs';
|
|
15
|
+
import { trackRecord } from '../lib/record.mjs';
|
|
16
|
+
import { serve, TOOLS } from '../server/mcp.mjs';
|
|
17
|
+
|
|
18
|
+
const argv = process.argv.slice(2);
|
|
19
|
+
const cmd = argv[0];
|
|
20
|
+
// --ruleset takes a value: a preset name or a JSON object of overrides, as `--ruleset x` or `--ruleset=x`.
|
|
21
|
+
let ruleset = 'default';
|
|
22
|
+
const rest = [];
|
|
23
|
+
for (let i = 1; i < argv.length; i++) {
|
|
24
|
+
const a = argv[i];
|
|
25
|
+
if (a === '--ruleset') { ruleset = argv[++i]; if (ruleset === undefined) { console.error('usage: --ruleset <name|json>'); process.exit(2); } }
|
|
26
|
+
else if (a.startsWith('--ruleset=')) ruleset = a.slice('--ruleset='.length);
|
|
27
|
+
else rest.push(a);
|
|
28
|
+
}
|
|
29
|
+
if (typeof ruleset === 'string' && ruleset.trim().startsWith('{')) {
|
|
30
|
+
try { ruleset = JSON.parse(ruleset); } catch { console.error('--ruleset: not valid JSON'); process.exit(2); }
|
|
31
|
+
}
|
|
32
|
+
const flags = new Set(rest.filter((a) => a.startsWith('--')));
|
|
33
|
+
const arg = rest.find((a) => !a.startsWith('--'));
|
|
34
|
+
const out = (o) => process.stdout.write(JSON.stringify(o, null, 2) + '\n');
|
|
35
|
+
const log = (...a) => console.error(...a);
|
|
36
|
+
|
|
37
|
+
if (!cmd || cmd === 'serve') { serve(); }
|
|
38
|
+
else if (cmd === 'scan') {
|
|
39
|
+
if (!arg) { log('usage: keelage scan <0x address> [--text] [--ruleset <name|json>]'); process.exit(2); }
|
|
40
|
+
const t0 = Date.now();
|
|
41
|
+
const res = await scanOne(arg, { log, ruleset });
|
|
42
|
+
if (flags.has('--text')) process.stdout.write(renderScanText(res) + '\n'); else out(renderScanJson(res));
|
|
43
|
+
log(`[keelage] scan ${((Date.now() - t0) / 1000).toFixed(1)}s`);
|
|
44
|
+
} else if (cmd === 'holders') {
|
|
45
|
+
if (!arg) { log('usage: keelage holders <0x address> [--no-smart]'); process.exit(2); }
|
|
46
|
+
out(await holdersFor(arg, { withSmartAccounts: !flags.has('--no-smart') }));
|
|
47
|
+
} else if (cmd === 'template') {
|
|
48
|
+
if (!arg) { log('usage: keelage template <0x address>'); process.exit(2); }
|
|
49
|
+
out(await templateFor(arg));
|
|
50
|
+
} else if (cmd === 'record') { out(trackRecord({ study: arg || null })); }
|
|
51
|
+
else if (cmd === 'tools') { out(TOOLS); }
|
|
52
|
+
else if (cmd === '--help' || cmd === '-h' || cmd === 'help') {
|
|
53
|
+
process.stdout.write('keelage [serve] | scan <0x> [--text] [--ruleset <name|json>] | holders <0x> [--no-smart] | template <0x> | record [study] | tools\n');
|
|
54
|
+
} else { log(`unknown command ${cmd}`); process.exit(2); }
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"generatedAt": "2026-10-04",
|
|
3
|
+
"status": "hand-set",
|
|
4
|
+
"note": "Every line the rules use, with where it came from. Lines marked derived came out of a dated study on our own outcome rows. The others were set by hand and are stated as such until the nightly re-cut runs on our own store of outcome rows.",
|
|
5
|
+
"recut": { "cadence": "every 24 hours", "needs": "outcome rows with the raw facts at alert time (top-10 share, volume, market cap, distance from the 30-day low, drawdown from peak) flowing into Keelage's own store", "status": "not yet running" },
|
|
6
|
+
"lines": [
|
|
7
|
+
{ "key": "top10.conviction", "value": 30, "unit": "% of supply", "usedFor": "Conviction tier: the 10 largest wallets hold this much or less", "origin": "A Midsummer Meme's Dream: Investigating Market Manipulations in the Meme Coin Ecosystem, arXiv 2507.01963 (posted 16 Apr 2025, revised 2 Jan 2026), section 5.1.3: the 10 largest holders owning over 30% of the supply was the most common anomaly, present in 87.14% of the anomalies detected among high-return tokens. Read July 2026. https://arxiv.org/abs/2507.01963", "setOn": "2026-07-23", "n": null, "derived": false },
|
|
8
|
+
{ "key": "top10.scout", "value": 45, "unit": "% of supply", "usedFor": "Above this the token is watch-only and never sized", "origin": "Set by hand as a buffer above the 30% line", "setOn": "2026-07-23", "n": null, "derived": false },
|
|
9
|
+
{ "key": "pool.deep", "value": 50000, "unit": "USD in the pool", "usedFor": "Conviction needs a pool at least this deep; thinner pools are scout size", "origin": "Set by hand, lowered from 100,000", "setOn": "2026-08-01", "n": null, "derived": false },
|
|
10
|
+
{ "key": "size.cap.conviction", "value": 1000, "unit": "USD", "usedFor": "Largest size we would suggest for a conviction token", "origin": "Set by hand", "setOn": "2026-07-23", "n": null, "derived": false },
|
|
11
|
+
{ "key": "size.cap.scout", "value": 100, "unit": "USD", "usedFor": "Largest size we would suggest for a scout token", "origin": "Set by hand", "setOn": "2026-07-23", "n": null, "derived": false },
|
|
12
|
+
{ "key": "size.poolShare", "value": 1, "unit": "% of the pool", "usedFor": "Never more than this share of the pool, whatever the tier", "origin": "Set by hand", "setOn": "2026-07-23", "n": null, "derived": false },
|
|
13
|
+
{ "key": "deployer.max", "value": 5, "unit": "% of supply", "usedFor": "A deployer still holding more than this fails the token", "origin": "Published research on token risk read in July 2026", "setOn": "2026-07-23", "n": null, "derived": false },
|
|
14
|
+
{ "key": "holders.min", "value": 100, "unit": "wallets", "usedFor": "Fewer holders than this fails the entry test", "origin": "The one holder figure in the July 2026 research", "setOn": "2026-07-28", "n": null, "derived": false },
|
|
15
|
+
{ "key": "entry.midPump", "value": 30, "unit": "% price change in 24h", "usedFor": "Up more than this today fails the entry test", "origin": "Set by hand", "setOn": "2026-07-28", "n": null, "derived": false },
|
|
16
|
+
{ "key": "entry.minVolume24h", "value": 10000, "unit": "USD traded in 24h", "usedFor": "Quieter than this fails the entry test", "origin": "Board review: 105 passing tokens, 42 under this floor; 39 survived both floors", "setOn": "2026-09-22", "n": 105, "derived": true },
|
|
17
|
+
{ "key": "entry.minTurnoverOfMcap", "value": 5, "unit": "% of market cap traded in 24h", "usedFor": "Less turnover than this fails the entry test", "origin": "Board review: median turnover of passing tokens was 4% of market cap", "setOn": "2026-09-22", "n": 105, "derived": true },
|
|
18
|
+
{ "key": "entry.deepPoolTurnover", "value": 0.2, "unit": "% of the pool traded in 24h", "usedFor": "A pool over $100,000 doing less than this reads as fabricated or dead", "origin": "Set by hand", "setOn": "2026-07-28", "n": null, "derived": false },
|
|
19
|
+
{ "key": "entry.convictionBelowPeak", "value": 60, "unit": "% below all-time high", "usedFor": "Conviction needs at least this much decline from peak", "origin": "Set by hand, replacing the base-proximity block", "setOn": "2026-09-09", "n": null, "derived": false },
|
|
20
|
+
{ "key": "liquidity.maxDrawdown", "value": 70, "unit": "% below its recorded peak", "usedFor": "A pool drained more than this fails the token", "origin": "Set by hand", "setOn": "2026-09-09", "n": null, "derived": false },
|
|
21
|
+
{ "key": "base.atBase", "value": 30, "unit": "% above the 30-day low", "usedFor": "At base band ends here", "origin": "First-alert study: within 30% of the low read +8% median, 61% positive", "setOn": "2026-09-04", "n": 1013, "derived": true },
|
|
22
|
+
{ "key": "base.justOff", "value": 100, "unit": "% above the 30-day low", "usedFor": "Just off base band ends here", "origin": "First-alert study: 30 to 100% read -1% median, 50% positive", "setOn": "2026-09-04", "n": 1013, "derived": true },
|
|
23
|
+
{ "key": "base.wellOff", "value": 500, "unit": "% above the 30-day low", "usedFor": "Well off base band ends here; beyond is already ran", "origin": "First-alert study: over 500% read -70% median, 13% positive, 31% down 80%+", "setOn": "2026-09-04", "n": 1013, "derived": true },
|
|
24
|
+
{ "key": "jev.halveBands", "value": [0.4, 0.5, 0.6, 0.7], "unit": "probability", "usedFor": "Bands for the halve-in-7-days read", "origin": "Backtest of 423 alerts against realized 7-day return, rank correlation -0.47", "setOn": "2026-09-24", "n": 423, "derived": true }
|
|
25
|
+
]
|
|
26
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
{
|
|
2
|
+
"title": "Track record",
|
|
3
|
+
"note": "Every figure carries its measurement date and sample size. Published as measured, including the uncomfortable rows.",
|
|
4
|
+
"operatingSince": { "hourlyRuns": "2026-07", "robinhoodChainOnly": "2026-09-09" },
|
|
5
|
+
"studies": [
|
|
6
|
+
{
|
|
7
|
+
"key": "gate-policy-review",
|
|
8
|
+
"date": "2026-09-04",
|
|
9
|
+
"title": "Gate policy review, 7-day outcomes on alert rows",
|
|
10
|
+
"findings": [
|
|
11
|
+
{ "metric": "Rows that passed the base-entry gate, 7 days", "n": 27, "medianPct": 3.8, "positivePct": 63 },
|
|
12
|
+
{ "metric": "Rows the gate blocked, 7 days", "n": 204, "medianPct": -29.6, "positivePct": 24 },
|
|
13
|
+
{ "metric": "Rows that passed the base-entry gate, 24 hours", "n": 27, "medianPct": 0.1, "positivePct": 52 },
|
|
14
|
+
{ "metric": "Rows the gate blocked, 24 hours", "n": 204, "medianPct": -11.1, "positivePct": 33 },
|
|
15
|
+
{ "metric": "First alert, 30-day-low distance 30% or less", "n": 1013, "medianPct": 8, "positivePct": 61, "down80Pct": 0, "cohort": "subset of 1,013 first-alert rows" },
|
|
16
|
+
{ "metric": "First alert, 30-day-low distance over 500%", "n": 1013, "medianPct": -70, "positivePct": 13, "down80Pct": 31, "cohort": "subset of 1,013 first-alert rows" },
|
|
17
|
+
{ "metric": "Community takeover claim present", "n": 155, "medianPct": -58, "positivePct": 11 },
|
|
18
|
+
{ "metric": "Community takeover claim absent", "n": 858, "medianPct": -13, "positivePct": 37 },
|
|
19
|
+
{ "metric": "Third-party-score rules on Robinhood Chain, admitted", "n": 49, "medianPct": -25, "positivePct": 24 },
|
|
20
|
+
{ "metric": "Third-party-score rules on Robinhood Chain, rejected", "n": 108, "medianPct": -2, "positivePct": 30 }
|
|
21
|
+
]
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
"key": "template-census",
|
|
25
|
+
"date": "2026-09-09",
|
|
26
|
+
"title": "Launcher template census",
|
|
27
|
+
"findings": [
|
|
28
|
+
{ "metric": "Robinhood Chain token contracts read", "n": 384 },
|
|
29
|
+
{ "metric": "Verified source", "n": 384, "sharePct": 85 },
|
|
30
|
+
{ "metric": "Deployed by a launcher factory", "n": 384, "sharePct": 92 },
|
|
31
|
+
{ "metric": "Covered by the 5 registered templates", "n": 384, "sharePct": 68, "breakdown": { "pons-launcher": 141, "launchtoken": 61, "robinhood-stock": 34, "doppler": 32, "uerc20-pools-trade": 16 } }
|
|
32
|
+
]
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
"key": "base-band-outcomes",
|
|
36
|
+
"date": "2026-09-22",
|
|
37
|
+
"title": "Robinhood-only era, alerts 7 days or older, by base band",
|
|
38
|
+
"caveat": "n between 10 and 30 per band. Small. Shown with that caveat.",
|
|
39
|
+
"findings": [
|
|
40
|
+
{ "metric": "Already ran", "medianPct": 2.1, "reached2xPct": 37 },
|
|
41
|
+
{ "metric": "Well off base", "medianPct": -51.8, "reached2xPct": 21 },
|
|
42
|
+
{ "metric": "Just off base", "medianPct": -33.4, "reached2xPct": 6 },
|
|
43
|
+
{ "metric": "At base", "medianPct": -77.4, "reached2xPct": 10 }
|
|
44
|
+
]
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
"key": "jev-halve-calibration",
|
|
48
|
+
"date": "2026-09-24",
|
|
49
|
+
"title": "Jev halve-in-7-days probability against realized 7-day return",
|
|
50
|
+
"findings": [
|
|
51
|
+
{ "metric": "Spearman, halve 7d vs realized return", "n": 423, "value": -0.47 },
|
|
52
|
+
{ "metric": "Spearman, up 7d vs realized return", "n": 423, "value": 0.1 },
|
|
53
|
+
{ "metric": "Band 0 to 0.4", "n": 29, "halvedPct": 0, "medianPct": -4 },
|
|
54
|
+
{ "metric": "Band 0.4 to 0.5", "n": 67, "halvedPct": 1, "medianPct": -7 },
|
|
55
|
+
{ "metric": "Band 0.5 to 0.6", "n": 171, "halvedPct": 32, "medianPct": -26 },
|
|
56
|
+
{ "metric": "Band 0.6 to 0.7", "n": 120, "halvedPct": 52, "medianPct": -51 },
|
|
57
|
+
{ "metric": "Band 0.7 to 1.0", "n": 36, "halvedPct": 83, "medianPct": -83 }
|
|
58
|
+
]
|
|
59
|
+
}
|
|
60
|
+
]
|
|
61
|
+
}
|
package/lib/calc.mjs
ADDED
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
// calc.mjs - pure measurement functions over provider payloads. No network, no state.
|
|
2
|
+
// Every numeric line comes from data/thresholds.json, which carries each line's origin, date and n.
|
|
3
|
+
// A ruleset is that file resolved into a frozen object with its derived tables. The default is resolved
|
|
4
|
+
// once at import; a preset (data/rulesets/<name>.json) or a set of overrides is resolved per call.
|
|
5
|
+
import { io } from './io.mjs';
|
|
6
|
+
import { dirname, join } from 'path';
|
|
7
|
+
import { fileURLToPath } from 'url';
|
|
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
|
+
const DATA = join(HERE, '..', 'data');
|
|
11
|
+
const RULESETS_DIR = join(DATA, 'rulesets');
|
|
12
|
+
export const THRESHOLDS = JSON.parse(io.read(join(DATA, 'thresholds.json')));
|
|
13
|
+
|
|
14
|
+
// ---------- Rulesets ----------
|
|
15
|
+
|
|
16
|
+
// A bad ruleset argument is a caller error with a message the caller can act on, never a crash.
|
|
17
|
+
export class RulesetError extends Error { constructor(message) { super(message); this.name = 'RulesetError'; this.code = 'RULESET'; } }
|
|
18
|
+
|
|
19
|
+
const RESOLVED = Symbol('keelage.ruleset');
|
|
20
|
+
const PRESET_NAME = /^[a-z][a-z0-9-]{0,31}$/;
|
|
21
|
+
// jev.halveBands is a calibration, not a policy line: each band carries the n, halved share and median
|
|
22
|
+
// measured at exactly those cuts (jev.mjs), so moving the cuts would print figures measured elsewhere.
|
|
23
|
+
const NOT_OVERRIDABLE = new Set(['jev.halveBands']);
|
|
24
|
+
const todayUtc = () => new Date().toISOString().slice(0, 10);
|
|
25
|
+
const deepFreeze = (o) => {
|
|
26
|
+
if (o && typeof o === 'object' && !Object.isFrozen(o) && !(o instanceof Set)) { Object.freeze(o); for (const v of Object.values(o)) deepFreeze(v); }
|
|
27
|
+
return o;
|
|
28
|
+
};
|
|
29
|
+
const isNum = (v) => typeof v === 'number' && Number.isFinite(v);
|
|
30
|
+
const sameShape = (ref, v) => Array.isArray(ref) ? Array.isArray(v) && v.length === ref.length && v.every(isNum) : isNum(v);
|
|
31
|
+
const show = (v) => JSON.stringify(v);
|
|
32
|
+
|
|
33
|
+
// Preset names on disk, without the default. An absent directory means no presets.
|
|
34
|
+
export function presetNames() {
|
|
35
|
+
try { return io.list(RULESETS_DIR).filter((f) => f.endsWith('.json')).map((f) => f.slice(0, -5)).filter((n) => PRESET_NAME.test(n)).sort(); } catch { return []; }
|
|
36
|
+
}
|
|
37
|
+
// The default's public name is "keelage-default" (what the site and every verdict print); "default" is its short form.
|
|
38
|
+
export const DEFAULT_NAME = 'keelage-default';
|
|
39
|
+
const isDefaultName = (s) => s === 'default' || s === DEFAULT_NAME;
|
|
40
|
+
export const knownRulesets = () => [DEFAULT_NAME, ...presetNames()];
|
|
41
|
+
|
|
42
|
+
// The tables every rule reads, computed from one set of lines. `label` names the source in a miss.
|
|
43
|
+
function tablesFrom(lines, label) {
|
|
44
|
+
const values = Object.fromEntries(lines.map((l) => [l.key, l.value]));
|
|
45
|
+
const line = (key) => { if (!(key in values)) throw new Error(`${label} has no line ${key}`); return values[key]; };
|
|
46
|
+
// Concentration is a sizing TIER, never a gate. The deep-liquidity bar only decides whether a
|
|
47
|
+
// well-distributed token is capped at scout size; the 1%-of-pool rule throttles it independently.
|
|
48
|
+
const TIER_BARS = { conviction: line('top10.conviction'), scout: line('top10.scout'), deepLiqUsd: line('pool.deep') };
|
|
49
|
+
// Tier names are constants. The scout label embeds the liquidity bar, and that exact string is also a
|
|
50
|
+
// key in ACTIONABLE_TIERS and TIER_CAP, so it is derived once and never typed out.
|
|
51
|
+
const TIERS = {
|
|
52
|
+
CONVICTION: 'CONVICTION',
|
|
53
|
+
SCOUT_DIST_OK: `scout (dist ok, liq under ${Math.round(TIER_BARS.deepLiqUsd / 1000)}k)`,
|
|
54
|
+
SCOUT_CLUSTER: 'scout + cluster-check',
|
|
55
|
+
WATCH_ONLY: 'watch-only',
|
|
56
|
+
DIST_UNKNOWN: 'distribution-unknown',
|
|
57
|
+
};
|
|
58
|
+
// Size caps per tier, in USD. Keyed off TIERS so the scout label can never go stale here.
|
|
59
|
+
const TIER_CAP = {
|
|
60
|
+
[TIERS.CONVICTION]: line('size.cap.conviction'),
|
|
61
|
+
[TIERS.SCOUT_CLUSTER]: line('size.cap.scout'),
|
|
62
|
+
[TIERS.SCOUT_DIST_OK]: line('size.cap.scout'),
|
|
63
|
+
};
|
|
64
|
+
// The only tiers that may be presented as entry-eligible. A whitelist: a tier added later is non-actionable
|
|
65
|
+
// until deliberately admitted here.
|
|
66
|
+
const ACTIONABLE_TIERS = new Set([TIERS.CONVICTION, TIERS.SCOUT_CLUSTER, TIERS.SCOUT_DIST_OK]);
|
|
67
|
+
// What predicted 7-day outcome over 1,013 first-alert rows, in order of strength: distance above the
|
|
68
|
+
// 30-day low (<=30%: +8% median, 61% positive; >500%: -70%, 13%), then market-cap band (<$500k -38%
|
|
69
|
+
// median vs $2M+ -1%). The score never predicted outcome, so it is the LAST tiebreak. Ranking orders
|
|
70
|
+
// what is shown; it admits nothing and blocks nothing.
|
|
71
|
+
const BASE_BANDS = [
|
|
72
|
+
{ max: line('base.atBase'), key: 0, label: 'AT BASE' },
|
|
73
|
+
{ max: line('base.justOff'), key: 1, label: 'just off base' },
|
|
74
|
+
{ max: line('base.wellOff'), key: 2, label: 'well off base' },
|
|
75
|
+
{ max: Infinity, key: 3, label: 'ALREADY RAN' },
|
|
76
|
+
];
|
|
77
|
+
return { values, line, TIER_BARS, TIERS, TIER_CAP, ACTIONABLE_TIERS, BASE_BANDS };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function buildRuleset({ name, date, overrides, lines, label }) {
|
|
81
|
+
const rs = {
|
|
82
|
+
name, date, overrides, lines,
|
|
83
|
+
handSetLines: lines.filter((l) => !l.derived).length, derivedLines: lines.filter((l) => l.derived).length,
|
|
84
|
+
...tablesFrom(lines, label),
|
|
85
|
+
[RESOLVED]: true,
|
|
86
|
+
};
|
|
87
|
+
return deepFreeze(rs);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export const DEFAULT_RULESET = buildRuleset({ name: DEFAULT_NAME, date: THRESHOLDS.generatedAt, overrides: [], lines: THRESHOLDS.lines, label: 'thresholds.json' });
|
|
91
|
+
const defaultLine = (key) => DEFAULT_RULESET.lines.find((l) => l.key === key);
|
|
92
|
+
|
|
93
|
+
const presetCache = new Map();
|
|
94
|
+
function presetRuleset(name) {
|
|
95
|
+
if (!PRESET_NAME.test(name) || !presetNames().includes(name)) throw new RulesetError(`unknown ruleset "${name}" - known rulesets: ${knownRulesets().join(', ')} ("default" is short for ${DEFAULT_NAME})`);
|
|
96
|
+
if (presetCache.has(name)) return presetCache.get(name);
|
|
97
|
+
const file = JSON.parse(io.read(join(RULESETS_DIR, `${name}.json`)));
|
|
98
|
+
const given = new Map((file.lines || []).map((l) => [l.key, l]));
|
|
99
|
+
for (const key of given.keys()) if (!defaultLine(key)) throw new RulesetError(`ruleset "${name}" has a line thresholds.json does not: ${key}`);
|
|
100
|
+
// A preset states every line it changes; a line it leaves out inherits the default unchanged.
|
|
101
|
+
const lines = DEFAULT_RULESET.lines.map((l) => given.get(l.key) || l);
|
|
102
|
+
const rs = buildRuleset({ name, date: file.generatedAt || THRESHOLDS.generatedAt, overrides: [], lines, label: `ruleset ${name}` });
|
|
103
|
+
presetCache.set(name, rs);
|
|
104
|
+
return rs;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function overrideRuleset(spec) {
|
|
108
|
+
const list = spec.lines;
|
|
109
|
+
if (!Array.isArray(list) || !list.length) throw new RulesetError('ruleset overrides need lines: [{ key, value }], at least one');
|
|
110
|
+
const seen = new Map();
|
|
111
|
+
for (const o of list) {
|
|
112
|
+
const key = o && typeof o === 'object' ? o.key : undefined;
|
|
113
|
+
if (typeof key !== 'string') throw new RulesetError(`ruleset override ${show(o)} has no key`);
|
|
114
|
+
const ref = defaultLine(key);
|
|
115
|
+
if (!ref) throw new RulesetError(`unknown ruleset line "${key}" - known lines: ${DEFAULT_RULESET.lines.map((l) => l.key).join(', ')}`);
|
|
116
|
+
if (NOT_OVERRIDABLE.has(key)) throw new RulesetError(`ruleset line "${key}" is a calibration with its own n and cannot be overridden`);
|
|
117
|
+
if (seen.has(key)) throw new RulesetError(`ruleset line "${key}" is given twice`);
|
|
118
|
+
if (!sameShape(ref.value, o.value)) throw new RulesetError(`ruleset line "${key}" wants ${Array.isArray(ref.value) ? `${ref.value.length} numbers` : 'a number'} (${ref.unit}), got ${show(o.value)}`);
|
|
119
|
+
seen.set(key, o.value);
|
|
120
|
+
}
|
|
121
|
+
// Only a line whose value differs is an override. Nothing different means the default itself.
|
|
122
|
+
for (const [key, value] of [...seen]) if (show(value) === show(defaultLine(key).value)) seen.delete(key);
|
|
123
|
+
if (!seen.size) return DEFAULT_RULESET;
|
|
124
|
+
const date = todayUtc();
|
|
125
|
+
const lines = DEFAULT_RULESET.lines.map((l) => seen.has(l.key)
|
|
126
|
+
? { ...l, value: seen.get(l.key), origin: `Override passed with the call, replacing ${show(l.value)}`, setOn: date, n: null, derived: false }
|
|
127
|
+
: l);
|
|
128
|
+
return buildRuleset({ name: 'custom', date, overrides: [...seen.keys()], lines, label: 'ruleset custom' });
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// "keelage-default" (or "default"), a preset name, { lines: [{ key, value }] }, or an already resolved ruleset -> a frozen ruleset.
|
|
132
|
+
export function resolveRuleset(spec = 'default') {
|
|
133
|
+
if (spec === undefined || spec === null || isDefaultName(spec)) return DEFAULT_RULESET;
|
|
134
|
+
if (spec && typeof spec === 'object' && spec[RESOLVED]) return spec;
|
|
135
|
+
if (typeof spec === 'string') return presetRuleset(spec);
|
|
136
|
+
if (spec && typeof spec === 'object' && !Array.isArray(spec)) return overrideRuleset(spec);
|
|
137
|
+
throw new RulesetError(`ruleset must be "${DEFAULT_NAME}", a preset name, or { lines: [{ key, value }] }, got ${show(spec)}`);
|
|
138
|
+
}
|
|
139
|
+
// The ruleset a candidate was scanned under; a candidate built without one reads the default.
|
|
140
|
+
export const rulesetOf = (c) => (c?.ruleset && c.ruleset[RESOLVED] ? c.ruleset : DEFAULT_RULESET);
|
|
141
|
+
|
|
142
|
+
// Module-level views of the default, kept for every caller that reads thresholds without a ruleset in hand.
|
|
143
|
+
export const line = (key) => { if (!(key in DEFAULT_RULESET.values)) throw new Error(`thresholds.json has no line ${key}`); return DEFAULT_RULESET.values[key]; };
|
|
144
|
+
//
|
|
145
|
+
// Everything here MEASURES. The pass rule that consumes these measurements lives in gate.mjs.
|
|
146
|
+
// Keeping the maths separate means every number the verdict rests on can be unit-tested against a
|
|
147
|
+
// saved payload without touching a provider.
|
|
148
|
+
|
|
149
|
+
// ---------- GoPlus token_security record ----------
|
|
150
|
+
|
|
151
|
+
// GoPlus has no real record for this token: blank honeypot AND no holders AND no LP supply.
|
|
152
|
+
// That shape is "provider has no data", never a structural fail. GoPlus also indexes Robinhood Chain
|
|
153
|
+
// lazily: the first single-address call can return an empty result and a call seconds later returns
|
|
154
|
+
// the record, so the caller re-asks once before giving up.
|
|
155
|
+
export function goplusUnindexed(r) {
|
|
156
|
+
if (!r) return true;
|
|
157
|
+
const hp = r.is_honeypot === undefined || r.is_honeypot === null || r.is_honeypot === '';
|
|
158
|
+
const noHolders = !(r.holders || []).length;
|
|
159
|
+
const noLp = !r.lp_total_supply || Number(r.lp_total_supply) === 0;
|
|
160
|
+
return hp && noHolders && noLp;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Top-10 from a GoPlus holders list with the pool and pair addresses removed. A pool is liquidity,
|
|
164
|
+
// not a holder: counting it measured liquidity depth instead of concentration. The uncorrected figure
|
|
165
|
+
// is kept alongside so the correction is auditable and a provider change is visible rather than silent.
|
|
166
|
+
export function evmTop10(r) {
|
|
167
|
+
const holders = r?.holders || [];
|
|
168
|
+
const lpAddrs = new Set([
|
|
169
|
+
...(r?.lp_holders || []).map((h) => String(h.address || '').toLowerCase()),
|
|
170
|
+
...(r?.dex || []).map((x) => String(x.pair || '').toLowerCase()),
|
|
171
|
+
].filter(Boolean));
|
|
172
|
+
const isPool = (h) => lpAddrs.has(String(h.address || '').toLowerCase());
|
|
173
|
+
const nonPool = holders.filter((h) => !isPool(h));
|
|
174
|
+
const sum = (list) => +list.slice(0, 10).reduce((a, h) => a + Number(h.percent || 0) * 100, 0).toFixed(1);
|
|
175
|
+
return {
|
|
176
|
+
// Absent holder data reads as UNKNOWN, never as perfect distribution.
|
|
177
|
+
top10Pct: holders.length ? sum(nonPool) : null,
|
|
178
|
+
top10RawPct: holders.length ? sum(holders) : null,
|
|
179
|
+
poolAcctsInTop10: holders.slice(0, 10).filter(isPool).length,
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Pool type from GoPlus. 'cl' = concentrated liquidity (Uniswap V3/V4 NFT positions, where lock/burn
|
|
184
|
+
// is undefined), 'v2' = fungible LP tokens (lock/burn measurable), 'unknown' = no dex entries at all.
|
|
185
|
+
export function lpModeOf(r) {
|
|
186
|
+
const types = (r?.dex || []).map((d) => String(d.liquidity_type || ''));
|
|
187
|
+
const nft = (r?.lp_holders || []).some((h) => Array.isArray(h.NFT_list) && h.NFT_list.length);
|
|
188
|
+
if (nft || types.some((t) => /v3|v4|cl|concentrated/i.test(t))) return 'cl';
|
|
189
|
+
if (types.some((t) => /v2/i.test(t))) return 'v2';
|
|
190
|
+
return types.length ? 'other' : 'unknown';
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// LP-holder statistics. creatorLpPct = share of LP positions held by the creator or an un-renounced
|
|
194
|
+
// owner: "the deployer is the pool" is the concentrated-liquidity form of "the deployer can pull the pool".
|
|
195
|
+
export function lpStats(r) {
|
|
196
|
+
const lp = r?.lp_holders || [];
|
|
197
|
+
const real = lp.filter((h) => Number(h.percent || 0) > 0);
|
|
198
|
+
const zero = '0x0000000000000000000000000000000000000000';
|
|
199
|
+
const insiders = new Set([r?.creator_address, r?.owner_address].map((a) => String(a || '').toLowerCase()).filter((a) => a && a !== zero));
|
|
200
|
+
const pct = (h) => Number(h.percent || 0) * 100;
|
|
201
|
+
return {
|
|
202
|
+
lpHolders: real.length,
|
|
203
|
+
topLpPct: +Math.max(0, ...real.map(pct)).toFixed(1),
|
|
204
|
+
creatorLpPct: +real.filter((h) => insiders.has(String(h.address || '').toLowerCase())).reduce((s, h) => s + pct(h), 0).toFixed(1),
|
|
205
|
+
lockedOrBurned: lp.some((h) => (h.is_locked === 1 || /dead|null|burn/i.test(h.address || '')) && Number(h.percent || 0) > 0.9),
|
|
206
|
+
lpTotal: r?.lp_total_supply === undefined || r?.lp_total_supply === null || r?.lp_total_supply === '' ? null : Number(r.lp_total_supply),
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// ---------- Blockscout holders page ----------
|
|
211
|
+
|
|
212
|
+
// Top-10 from a Blockscout /tokens/{addr}/holders page with pool and contract addresses removed.
|
|
213
|
+
// `supply` is the raw total_supply from /tokens/{addr}. A V4 pool sits inside one PoolManager singleton
|
|
214
|
+
// and shows as a contract, so contracts are counted and reported rather than silently dropped.
|
|
215
|
+
// Unknown supply or an empty page -> null, never 0.
|
|
216
|
+
export function blockscoutTop10(items, poolAddrs, supply) {
|
|
217
|
+
const it = Array.isArray(items) ? items : [];
|
|
218
|
+
const sup = Number(supply || 0);
|
|
219
|
+
if (!it.length || !sup) return { top10Pct: null, top10InclPct: null, maxSinglePct: null, contractsInTop10: null, poolsInTop10: null };
|
|
220
|
+
const pools = new Set((poolAddrs || []).filter(Boolean).map((a) => String(a).toLowerCase()));
|
|
221
|
+
let incl = 0, ex = 0, max = 0, contracts = 0, poolsSeen = 0;
|
|
222
|
+
for (const h of it.slice(0, 10)) {
|
|
223
|
+
const v = (Number(h.value || 0) / sup) * 100;
|
|
224
|
+
const isPool = pools.has(String(h.address?.hash || '').toLowerCase());
|
|
225
|
+
const isContract = !!h.address?.is_contract;
|
|
226
|
+
incl += v;
|
|
227
|
+
if (isPool) poolsSeen++;
|
|
228
|
+
if (isContract) contracts++;
|
|
229
|
+
if (isPool || isContract) continue;
|
|
230
|
+
ex += v; if (v > max) max = v;
|
|
231
|
+
}
|
|
232
|
+
return { top10Pct: +ex.toFixed(1), top10InclPct: +incl.toFixed(1), maxSinglePct: +max.toFixed(1), contractsInTop10: contracts, poolsInTop10: poolsSeen };
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// ---------- Contract structure from the ABI and verified source ----------
|
|
236
|
+
|
|
237
|
+
// Owner privileges from ABI function names. A function's presence is the fact; whether the owner has
|
|
238
|
+
// renounced is a separate on-chain read. Deterministic, no regex over prose.
|
|
239
|
+
export const ABI_PRIVILEGES = {
|
|
240
|
+
owner: /^(owner|getOwner)$/i, mint: /^(mint|mintTo|mintFor)$/i, pause: /^(pause|unpause|setPaused)$/i,
|
|
241
|
+
blacklist: /blacklist|blocklist|denylist|setBot|isBot|antiBot/i, feeSetter: /^(set|update)(Fee|Fees|Tax|Taxes|BuyTax|SellTax|TransferTax)/i,
|
|
242
|
+
tradingToggle: /^(enableTrading|openTrading|setTradingEnabled|startTrading)$/i, upgrade: /^(upgradeTo|upgradeToAndCall)$/i,
|
|
243
|
+
maxLimits: /^(set|update)(MaxTx|MaxWallet|MaxTransaction)/i, // setters only - maxTxAmount / maxWalletBps getters are launch constants
|
|
244
|
+
};
|
|
245
|
+
export function abiPrivileges(fnNames) {
|
|
246
|
+
const fns = Array.isArray(fnNames) ? fnNames : [];
|
|
247
|
+
const out = Object.fromEntries(Object.entries(ABI_PRIVILEGES).map(([k, re]) => [k, fns.some((f) => re.test(String(f)))]));
|
|
248
|
+
// A live privilege can change what holders are able to do. `owner` alone (no setter) and anti-whale limits cannot.
|
|
249
|
+
out.live = out.mint || out.pause || out.blacklist || out.feeSetter || out.tradingToggle || out.upgrade;
|
|
250
|
+
return out;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// Transfer-tax markers in verified source. The identifiers are tax-specific on purpose: a bare /fee/
|
|
254
|
+
// matches Uniswap `poolFee` reads (every launcher token) and ERC404 mint-fee errors. A template can
|
|
255
|
+
// declare feeModel 'uniswap-pool-fee' to say its fee words are pool-tier reads.
|
|
256
|
+
export const TAX_RE = /\b(_?(buy|sell|transfer|marketing|liquidity|dev|team|treasury|burn|reflection)(Tax|Fee|Fees|Taxes)\w*|_?tax(Fee|Wallet|Rate|Percent|Amount)\w*|excludeFromFee\w*|_?isExcludedFromFee\w*|swapBack\w*|swapAndLiquify\w*|feeWallet\w*|feeReceiver\w*|taxWallet\w*|_taxFee|_liquidityFee|totalFees?)\b/g;
|
|
257
|
+
export const REFLECT_RE = /\b(ReflectionToken|_rTotal|tokenFromReflection|reflectionFromToken|_getRate)\b/g;
|
|
258
|
+
export function taxCodeScan(src) {
|
|
259
|
+
const text = String(src || '');
|
|
260
|
+
const taxHits = (text.match(TAX_RE) || []).length, reflectHits = (text.match(REFLECT_RE) || []).length;
|
|
261
|
+
return { taxHits, reflectHits, taxCode: taxHits > 0 || reflectHits > 0 };
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Template lookup against templates.json. Match order: implementation name (proxies) -> creator
|
|
265
|
+
// address -> contract name. An unregistered template is judged by ABI and source alone.
|
|
266
|
+
export function resolveTemplate(registry, facts) {
|
|
267
|
+
const tpls = registry?.templates || [];
|
|
268
|
+
const lc = (x) => String(x || '').toLowerCase();
|
|
269
|
+
const creator = lc(facts?.creator), name = String(facts?.contractName || ''), impl = String(facts?.implName || '');
|
|
270
|
+
const excludeImpl = (registry?.excludeImplNames || []).includes(impl);
|
|
271
|
+
if (excludeImpl) return { key: 'excluded-implementation', verdict: 'exclude', note: `implementation ${impl}` };
|
|
272
|
+
for (const t of tpls) {
|
|
273
|
+
const m = t.match || {};
|
|
274
|
+
if (impl && (m.implNames || []).includes(impl)) return { key: t.key, verdict: t.verdict, feeModel: t.feeModel || null, note: t.note || null, by: 'impl' };
|
|
275
|
+
}
|
|
276
|
+
for (const t of tpls) {
|
|
277
|
+
const m = t.match || {};
|
|
278
|
+
if (creator && (m.creators || []).map(lc).includes(creator)) return { key: t.key, verdict: t.verdict, feeModel: t.feeModel || null, note: t.note || null, by: 'creator' };
|
|
279
|
+
}
|
|
280
|
+
for (const t of tpls) {
|
|
281
|
+
const m = t.match || {};
|
|
282
|
+
if (name && (m.contractNames || []).includes(name)) return { key: t.key, verdict: t.verdict, feeModel: t.feeModel || null, note: t.note || null, by: 'name' };
|
|
283
|
+
}
|
|
284
|
+
return { key: 'unregistered', verdict: 'unregistered', feeModel: null, note: name || impl || null, by: null };
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// Liquidity drawdown from a history of [[day, liqUsd], ...] points against the current liquidity.
|
|
288
|
+
// null until there are at least 3 points: a new token has no history to judge. A pool pulled or
|
|
289
|
+
// drained shows here, where a V3/V4 lock flag cannot exist.
|
|
290
|
+
export function liqDrawdown(liqHist, liqNow) {
|
|
291
|
+
const pts = (Array.isArray(liqHist) ? liqHist : []).map((p) => Number(p?.[1])).filter((n) => Number.isFinite(n) && n > 0);
|
|
292
|
+
if (pts.length < 3 || !Number.isFinite(+liqNow)) return { liqDropPct: null, liqPeak: pts.length ? Math.max(...pts) : null, points: pts.length };
|
|
293
|
+
const peak = Math.max(...pts);
|
|
294
|
+
return { liqDropPct: +Math.max(0, (1 - +liqNow / peak) * 100).toFixed(1), liqPeak: peak, points: pts.length };
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// ---------- Sizing tier ----------
|
|
298
|
+
// The default's tables, as module constants. The tables themselves are built in tablesFrom() above, once
|
|
299
|
+
// per ruleset; these are the default ruleset's and are what every caller without a ruleset reads.
|
|
300
|
+
export const TIER_BARS = DEFAULT_RULESET.TIER_BARS;
|
|
301
|
+
export const TIERS = DEFAULT_RULESET.TIERS;
|
|
302
|
+
export const TIER_CAP = DEFAULT_RULESET.TIER_CAP;
|
|
303
|
+
|
|
304
|
+
export function tierFromTop10(top10Pct, liqUsd, rs = DEFAULT_RULESET) {
|
|
305
|
+
const { TIERS: T, TIER_BARS: B } = rs;
|
|
306
|
+
// Unknown distribution is its own tier and is not actionable.
|
|
307
|
+
if (top10Pct === null || top10Pct === undefined) return T.DIST_UNKNOWN;
|
|
308
|
+
if (top10Pct <= B.conviction) {
|
|
309
|
+
return (liqUsd || 0) >= B.deepLiqUsd ? T.CONVICTION : T.SCOUT_DIST_OK;
|
|
310
|
+
}
|
|
311
|
+
if (top10Pct <= B.scout) return T.SCOUT_CLUSTER;
|
|
312
|
+
return T.WATCH_ONLY;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// ---------- Ranking ----------
|
|
316
|
+
// The base bands and what they predicted are described at tablesFrom(). Ranking orders what is shown;
|
|
317
|
+
// it admits nothing and blocks nothing.
|
|
318
|
+
export const BASE_BANDS = DEFAULT_RULESET.BASE_BANDS;
|
|
319
|
+
export function baseBand(distPct, rs = DEFAULT_RULESET) {
|
|
320
|
+
if (distPct === null || distPct === undefined || !Number.isFinite(+distPct)) return { key: 4, label: 'base unknown' };
|
|
321
|
+
const d = +distPct;
|
|
322
|
+
return rs.BASE_BANDS.find((b) => d <= b.max);
|
|
323
|
+
}
|
|
324
|
+
export function mcapBand(mcapUsd) {
|
|
325
|
+
const m = +mcapUsd || 0;
|
|
326
|
+
if (m >= 2_000_000) return 0;
|
|
327
|
+
if (m >= 500_000) return 1;
|
|
328
|
+
return 2;
|
|
329
|
+
}
|
|
330
|
+
export function rankKey(c) {
|
|
331
|
+
const dist = c.baseEntry?.distPct ?? c.distFromLow30Pct ?? null;
|
|
332
|
+
return [baseBand(dist, rulesetOf(c)).key, mcapBand(c.mcapUsd), -(c.score || 0)];
|
|
333
|
+
}
|
|
334
|
+
export function rankCompare(a, b) {
|
|
335
|
+
const ka = rankKey(a), kb = rankKey(b);
|
|
336
|
+
for (let i = 0; i < ka.length; i++) if (ka[i] !== kb[i]) return ka[i] - kb[i];
|
|
337
|
+
return 0;
|
|
338
|
+
}
|