@three-ws/data-workbench-mcp 0.1.0
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 +20 -0
- package/README.md +42 -0
- package/package.json +18 -0
- package/server.json +24 -0
- package/src/index.js +233 -0
- package/src/pump.cjs +574 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
Copyright 2026 nirholas
|
|
2
|
+
|
|
3
|
+
All rights reserved.
|
|
4
|
+
|
|
5
|
+
This software is proprietary and may not be used, copied, modified, distributed,
|
|
6
|
+
translated, or made available to any third party without the express written
|
|
7
|
+
permission of the copyright owner.
|
|
8
|
+
|
|
9
|
+
No rights are granted by implication, estoppel, or otherwise. Use of this
|
|
10
|
+
software is subject to the terms of a separate license agreement with the
|
|
11
|
+
copyright owner.
|
|
12
|
+
|
|
13
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
14
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
15
|
+
FITNESS FOR A PARTICULAR PURPOSE, AND NONINFRINGEMENT.
|
|
16
|
+
|
|
17
|
+
IN NO EVENT SHALL THE COPYRIGHT OWNER BE LIABLE FOR ANY CLAIM, DAMAGES, OR
|
|
18
|
+
OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT, OR OTHERWISE,
|
|
19
|
+
ARISING FROM, OUT OF, OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
|
|
20
|
+
DEALINGS IN THE SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# @three-ws/data-workbench-mcp
|
|
2
|
+
|
|
3
|
+
MCP server exposing the [data workbench](https://data-workbench-e3s.pages.dev)'s
|
|
4
|
+
data lanes as agent tools. Live public APIs only; no keys, no state.
|
|
5
|
+
|
|
6
|
+
## Tools
|
|
7
|
+
|
|
8
|
+
| Tool | What it returns |
|
|
9
|
+
|---|---|
|
|
10
|
+
| `creator_rewards` | Pump.fun creator-fee earnings for a coin mint, wallet, pump.fun username, or linked social identity (`github:user`, `x:user`): lifetime totals, and with `breakdown: true` the per-coin figures summed from distribution timelines (reconciles with the wallet's claimed total to ~0.001%). |
|
|
11
|
+
| `search_pairs` | Every DexScreener pair matching a ticker, name, contract address, pair address, or dexscreener.com URL, with chain, dex, price, 24h change, liquidity, volume, and chart link. |
|
|
12
|
+
| `coin_news` | Headlines for a ticker over the last N days (up to 90), merged from the three.ws live feed (192 publishers) and its 660k-article historical archive, with sentiment. |
|
|
13
|
+
| `ohlc` | USD candles over 1/7/14/30/90 days: CoinGecko by id, falling back to Coinbase Exchange and Kraken public data by ticker. |
|
|
14
|
+
|
|
15
|
+
## Use
|
|
16
|
+
|
|
17
|
+
```json
|
|
18
|
+
{
|
|
19
|
+
"mcpServers": {
|
|
20
|
+
"data-workbench": {
|
|
21
|
+
"command": "npx",
|
|
22
|
+
"args": ["-y", "@three-ws/data-workbench-mcp"]
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Requires Node 18+.
|
|
29
|
+
|
|
30
|
+
## Example
|
|
31
|
+
|
|
32
|
+
Ask an agent: "How much has FeMbDoX7R1Psc4GEcvJdsbNbZA3bfztcyDCatJVJpump paid
|
|
33
|
+
its creator, per day, and what was in the news on the biggest payout day?" It
|
|
34
|
+
chains `creator_rewards` → `coin_news` and answers with sourced numbers.
|
|
35
|
+
|
|
36
|
+
## Notes
|
|
37
|
+
|
|
38
|
+
- The pump.fun totals endpoint ignores its `mint` filter upstream, so per-coin
|
|
39
|
+
earnings are derived from each coin's distribution timeline. The derivation
|
|
40
|
+
and its reconciliation check live in `src/pump.cjs`.
|
|
41
|
+
- Archive-backed `coin_news` queries share a free per-IP daily quota (60/day)
|
|
42
|
+
on the three.ws news archive.
|
package/package.json
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@three-ws/data-workbench-mcp",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "MCP server for market storytelling data: pump.fun creator rewards, DexScreener pair search, crypto headlines (live + 660k archive), and USD candles",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": { "data-workbench-mcp": "src/index.js" },
|
|
7
|
+
"files": ["src", "server.json", "README.md", "LICENSE"],
|
|
8
|
+
"mcpName": "io.github.nirholas/data-workbench-mcp",
|
|
9
|
+
"repository": { "type": "git", "url": "git+https://github.com/nirholas/data.git", "directory": "mcp" },
|
|
10
|
+
"keywords": ["mcp", "modelcontextprotocol", "pump.fun", "creator-rewards", "dexscreener", "crypto-news", "ohlc"],
|
|
11
|
+
"author": "nirholas",
|
|
12
|
+
"license": "MIT",
|
|
13
|
+
"engines": { "node": ">=18" },
|
|
14
|
+
"dependencies": {
|
|
15
|
+
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
16
|
+
"zod": "^3.23.8"
|
|
17
|
+
}
|
|
18
|
+
}
|
package/server.json
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
|
|
3
|
+
"name": "io.github.nirholas/data-workbench-mcp",
|
|
4
|
+
"title": "Data Workbench",
|
|
5
|
+
"description": "Market storytelling data: pump.fun creator rewards, DexScreener pair search, crypto headlines from a 660k-article archive, and USD candles.",
|
|
6
|
+
"websiteUrl": "https://data-workbench-e3s.pages.dev",
|
|
7
|
+
"repository": {
|
|
8
|
+
"url": "https://github.com/nirholas/data",
|
|
9
|
+
"source": "github",
|
|
10
|
+
"subfolder": "mcp"
|
|
11
|
+
},
|
|
12
|
+
"version": "0.1.0",
|
|
13
|
+
"packages": [
|
|
14
|
+
{
|
|
15
|
+
"registryType": "npm",
|
|
16
|
+
"identifier": "@three-ws/data-workbench-mcp",
|
|
17
|
+
"version": "0.1.0",
|
|
18
|
+
"runtimeHint": "npx",
|
|
19
|
+
"transport": {
|
|
20
|
+
"type": "stdio"
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
]
|
|
24
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// data-workbench MCP server: the workbench's data lanes as agent tools.
|
|
3
|
+
//
|
|
4
|
+
// creator_rewards pump.fun creator-fee earnings for a coin, wallet, or
|
|
5
|
+
// social identity (github:user, x:user), with per-day and
|
|
6
|
+
// per-coin breakdowns derived from distribution timelines
|
|
7
|
+
// search_pairs every DexScreener pair matching a ticker, name, contract
|
|
8
|
+
// address, pair address, or dexscreener.com URL
|
|
9
|
+
// coin_news headlines for a ticker over a window, from the three.ws
|
|
10
|
+
// live feed + 660k-article historical archive
|
|
11
|
+
// ohlc USD candles for any coin (CoinGecko, falling back to
|
|
12
|
+
// Coinbase Exchange and Kraken public data by ticker)
|
|
13
|
+
//
|
|
14
|
+
// Every tool hits live public APIs. No keys, no state, stdio transport.
|
|
15
|
+
|
|
16
|
+
import { createRequire } from 'node:module';
|
|
17
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
18
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
19
|
+
import { z } from 'zod';
|
|
20
|
+
|
|
21
|
+
const require = createRequire(import.meta.url);
|
|
22
|
+
const pump = require('./pump.cjs');
|
|
23
|
+
|
|
24
|
+
const UA = { 'user-agent': 'data-workbench-mcp/0.1 (+https://github.com/nirholas/data)', accept: 'application/json' };
|
|
25
|
+
|
|
26
|
+
async function getJson(url) {
|
|
27
|
+
const r = await fetch(url, { headers: UA });
|
|
28
|
+
if (!r.ok) throw new Error(`HTTP ${r.status} from ${new URL(url).host}`);
|
|
29
|
+
return r.json();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const asText = (data) => ({ content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] });
|
|
33
|
+
const asError = (message) => ({ content: [{ type: 'text', text: JSON.stringify({ error: message }) }], isError: true });
|
|
34
|
+
|
|
35
|
+
const server = new McpServer({ name: 'data-workbench', version: '0.1.0' });
|
|
36
|
+
|
|
37
|
+
// ── creator_rewards ──────────────────────────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
server.registerTool('creator_rewards', {
|
|
40
|
+
title: 'Pump.fun creator rewards',
|
|
41
|
+
description:
|
|
42
|
+
'Lifetime and per-day pump.fun creator-fee earnings for a coin mint, wallet address, ' +
|
|
43
|
+
'pump.fun username, or linked social identity (github:user, x:user). Per-coin figures ' +
|
|
44
|
+
'are summed from distribution timelines and reconcile with the wallet\'s lifetime ' +
|
|
45
|
+
'claimed total to ~0.001%.',
|
|
46
|
+
inputSchema: {
|
|
47
|
+
query: z.string().min(2).describe('Coin mint, wallet address, pump.fun username, github:user, or x:user'),
|
|
48
|
+
breakdown: z.boolean().optional().describe('Include the per-coin earnings breakdown (slower: walks every coin\'s timeline). Default false.'),
|
|
49
|
+
},
|
|
50
|
+
}, async ({ query, breakdown }) => {
|
|
51
|
+
try {
|
|
52
|
+
if (breakdown) {
|
|
53
|
+
const full = await pump.getEarningsBreakdown(query);
|
|
54
|
+
return asText(full);
|
|
55
|
+
}
|
|
56
|
+
const summary = await pump.getCreatorFees(query);
|
|
57
|
+
return asText(summary);
|
|
58
|
+
} catch (err) {
|
|
59
|
+
return asError(err.message);
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
// ── search_pairs ─────────────────────────────────────────────────────────────
|
|
64
|
+
|
|
65
|
+
const DS = 'https://api.dexscreener.com/latest/dex';
|
|
66
|
+
const MINT_RE = /^[1-9A-HJ-NP-Za-km-z]{32,44}$/;
|
|
67
|
+
|
|
68
|
+
server.registerTool('search_pairs', {
|
|
69
|
+
title: 'DexScreener pair search',
|
|
70
|
+
description:
|
|
71
|
+
'Every DexScreener trading pair matching a query: ticker, token name, contract/mint ' +
|
|
72
|
+
'address, pair address, or a dexscreener.com URL. Returns chain, dex, price, 24h ' +
|
|
73
|
+
'change, liquidity, volume, and the pair address (chartable at ' +
|
|
74
|
+
'https://dexscreener.com/<chainId>/<pairAddress>).',
|
|
75
|
+
inputSchema: {
|
|
76
|
+
query: z.string().min(2).describe('Ticker, name, CA/mint, pair address, or dexscreener.com URL'),
|
|
77
|
+
limit: z.number().int().min(1).max(30).optional().describe('Max pairs to return (default 10)'),
|
|
78
|
+
},
|
|
79
|
+
}, async ({ query, limit = 10 }) => {
|
|
80
|
+
try {
|
|
81
|
+
const raw = query.trim();
|
|
82
|
+
let pairs = [];
|
|
83
|
+
const urlMatch = raw.match(/dexscreener\.com\/([a-z0-9-]+)\/([A-Za-z0-9]+)/i);
|
|
84
|
+
if (urlMatch) {
|
|
85
|
+
const d = await getJson(`${DS}/pairs/${urlMatch[1].toLowerCase()}/${urlMatch[2]}`);
|
|
86
|
+
pairs = d.pairs || (d.pair ? [d.pair] : []);
|
|
87
|
+
} else if (MINT_RE.test(raw)) {
|
|
88
|
+
const t = await getJson(`${DS}/tokens/${raw}`).catch(() => null);
|
|
89
|
+
pairs = t?.pairs || [];
|
|
90
|
+
if (!pairs.length) {
|
|
91
|
+
const d = await getJson(`${DS}/pairs/solana/${raw}`).catch(() => null);
|
|
92
|
+
pairs = d?.pairs || (d?.pair ? [d.pair] : []);
|
|
93
|
+
}
|
|
94
|
+
} else {
|
|
95
|
+
const s = await getJson(`${DS}/search?q=${encodeURIComponent(raw)}`);
|
|
96
|
+
pairs = s.pairs || [];
|
|
97
|
+
}
|
|
98
|
+
return asText(pairs.slice(0, limit).map((p) => ({
|
|
99
|
+
chainId: p.chainId,
|
|
100
|
+
dexId: p.dexId,
|
|
101
|
+
pairAddress: p.pairAddress,
|
|
102
|
+
base: { symbol: p.baseToken?.symbol, name: p.baseToken?.name, address: p.baseToken?.address },
|
|
103
|
+
quote: p.quoteToken?.symbol,
|
|
104
|
+
priceUsd: p.priceUsd != null ? Number(p.priceUsd) : null,
|
|
105
|
+
change24hPct: p.priceChange?.h24 ?? null,
|
|
106
|
+
liquidityUsd: p.liquidity?.usd ?? null,
|
|
107
|
+
volume24hUsd: p.volume?.h24 ?? null,
|
|
108
|
+
chartUrl: `https://dexscreener.com/${p.chainId}/${p.pairAddress}`,
|
|
109
|
+
})));
|
|
110
|
+
} catch (err) {
|
|
111
|
+
return asError(err.message);
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
// ── coin_news ────────────────────────────────────────────────────────────────
|
|
116
|
+
|
|
117
|
+
const NEWS = 'https://three.ws/api/news/feed';
|
|
118
|
+
const ARCHIVE = 'https://three.ws/api/news/archive';
|
|
119
|
+
|
|
120
|
+
server.registerTool('coin_news', {
|
|
121
|
+
title: 'Coin headlines (live + archive)',
|
|
122
|
+
description:
|
|
123
|
+
'Headlines for a ticker over the last N days, merged from the three.ws live feed ' +
|
|
124
|
+
'(192 publishers) and its 660k-article historical archive. Each item carries source, ' +
|
|
125
|
+
'timestamp, sentiment, and link. Archive queries have a free per-IP daily quota.',
|
|
126
|
+
inputSchema: {
|
|
127
|
+
ticker: z.string().min(2).max(12).describe('Asset ticker, e.g. BTC, SOL, THREE'),
|
|
128
|
+
days: z.number().int().min(1).max(90).optional().describe('Lookback window in days (default 7)'),
|
|
129
|
+
limit: z.number().int().min(1).max(100).optional().describe('Max headlines (default 30)'),
|
|
130
|
+
},
|
|
131
|
+
}, async ({ ticker, days = 7, limit = 30 }) => {
|
|
132
|
+
try {
|
|
133
|
+
const t = ticker.toUpperCase();
|
|
134
|
+
const now = Date.now();
|
|
135
|
+
const dayStr = (ms) => new Date(ms).toISOString().slice(0, 10);
|
|
136
|
+
const calls = [getJson(`${NEWS}?q=${encodeURIComponent(t)}&limit=50`).catch(() => null)];
|
|
137
|
+
if (days > 2) {
|
|
138
|
+
const buckets = Math.min(5, Math.ceil(days / 7));
|
|
139
|
+
const span = days * 86400_000;
|
|
140
|
+
for (let i = 0; i < buckets; i++) {
|
|
141
|
+
const a = now - span + (i * span) / buckets;
|
|
142
|
+
const b = now - span + ((i + 1) * span) / buckets;
|
|
143
|
+
calls.push(getJson(`${ARCHIVE}?ticker=${t}&start_date=${dayStr(a)}&end_date=${dayStr(b)}&limit=20`).catch(() => null));
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
const results = await Promise.all(calls);
|
|
147
|
+
const seen = new Set();
|
|
148
|
+
const items = [];
|
|
149
|
+
for (const d of results) {
|
|
150
|
+
for (const a of d?.articles || []) {
|
|
151
|
+
const key = a.id || a.link || a.title;
|
|
152
|
+
if (seen.has(key)) continue;
|
|
153
|
+
seen.add(key);
|
|
154
|
+
const ts = Date.parse(a.pub_date);
|
|
155
|
+
if (!Number.isFinite(ts) || ts < now - days * 86400_000) continue;
|
|
156
|
+
if (a.tickers?.length && !a.tickers.includes(t)) continue;
|
|
157
|
+
items.push({
|
|
158
|
+
publishedAt: a.pub_date,
|
|
159
|
+
title: a.title,
|
|
160
|
+
source: a.source || a.source_key,
|
|
161
|
+
link: a.link,
|
|
162
|
+
sentiment: a.sentiment?.label || 'neutral',
|
|
163
|
+
tickers: a.tickers || [],
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
items.sort((a, b) => b.publishedAt.localeCompare(a.publishedAt));
|
|
168
|
+
return asText(items.slice(0, limit));
|
|
169
|
+
} catch (err) {
|
|
170
|
+
return asError(err.message);
|
|
171
|
+
}
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
// ── ohlc ─────────────────────────────────────────────────────────────────────
|
|
175
|
+
|
|
176
|
+
const CG = 'https://api.coingecko.com/api/v3';
|
|
177
|
+
const GRAN = {
|
|
178
|
+
1: { cb: 900, kr: 15, bars: 96 },
|
|
179
|
+
7: { cb: 3600, kr: 60, bars: 168 },
|
|
180
|
+
14: { cb: 21600, kr: 240, bars: 56 },
|
|
181
|
+
30: { cb: 21600, kr: 240, bars: 120 },
|
|
182
|
+
90: { cb: 86400, kr: 1440, bars: 90 },
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
server.registerTool('ohlc', {
|
|
186
|
+
title: 'USD candles',
|
|
187
|
+
description:
|
|
188
|
+
'OHLC candles in USD for a coin over 1/7/14/30/90 days. Tries CoinGecko by coin id, ' +
|
|
189
|
+
'then Coinbase Exchange and Kraken public data by ticker. Returns ' +
|
|
190
|
+
'[isoTime, open, high, low, close] ascending.',
|
|
191
|
+
inputSchema: {
|
|
192
|
+
coin: z.string().min(2).describe('CoinGecko id (bitcoin) or exchange ticker (BTC)'),
|
|
193
|
+
days: z.union([z.literal(1), z.literal(7), z.literal(14), z.literal(30), z.literal(90)]).optional()
|
|
194
|
+
.describe('Window in days (default 30)'),
|
|
195
|
+
},
|
|
196
|
+
}, async ({ coin, days = 30 }) => {
|
|
197
|
+
const g = GRAN[days];
|
|
198
|
+
const sym = coin.toUpperCase();
|
|
199
|
+
const id = coin.toLowerCase();
|
|
200
|
+
const errs = [];
|
|
201
|
+
try {
|
|
202
|
+
const raw = await getJson(`${CG}/coins/${encodeURIComponent(id)}/ohlc?vs_currency=usd&days=${days}`);
|
|
203
|
+
if (Array.isArray(raw) && raw.length) {
|
|
204
|
+
return asText(raw.map(([ms, o, h, l, c]) => [new Date(ms).toISOString(), o, h, l, c]));
|
|
205
|
+
}
|
|
206
|
+
errs.push('coingecko: empty');
|
|
207
|
+
} catch (e) { errs.push(`coingecko: ${e.message}`); }
|
|
208
|
+
try {
|
|
209
|
+
const raw = await getJson(`https://api.exchange.coinbase.com/products/${sym}-USD/candles?granularity=${g.cb}`);
|
|
210
|
+
if (Array.isArray(raw) && raw.length) {
|
|
211
|
+
return asText(raw.slice(0, g.bars)
|
|
212
|
+
.map(([t, low, high, open, close]) => [new Date(t * 1000).toISOString(), open, high, low, close])
|
|
213
|
+
.reverse());
|
|
214
|
+
}
|
|
215
|
+
errs.push('coinbase: empty');
|
|
216
|
+
} catch (e) { errs.push(`coinbase: ${e.message}`); }
|
|
217
|
+
try {
|
|
218
|
+
const d = await getJson(`https://api.kraken.com/0/public/OHLC?pair=${sym}USD&interval=${g.kr}`);
|
|
219
|
+
if (d.error?.length) throw new Error(d.error[0]);
|
|
220
|
+
const rows = Object.values(d.result || {}).find(Array.isArray);
|
|
221
|
+
if (rows?.length) {
|
|
222
|
+
return asText(rows.slice(-g.bars)
|
|
223
|
+
.map(([t, o, h, l, c]) => [new Date(t * 1000).toISOString(), Number(o), Number(h), Number(l), Number(c)]));
|
|
224
|
+
}
|
|
225
|
+
errs.push('kraken: empty');
|
|
226
|
+
} catch (e) { errs.push(`kraken: ${e.message}`); }
|
|
227
|
+
return asError(`no provider had candles for "${coin}": ${errs.join(' | ')}`);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
// ── go ───────────────────────────────────────────────────────────────────────
|
|
231
|
+
|
|
232
|
+
const transport = new StdioServerTransport();
|
|
233
|
+
await server.connect(transport);
|
package/src/pump.cjs
ADDED
|
@@ -0,0 +1,574 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Pump.fun resolver + creator-fee fetcher.
|
|
3
|
+
const { createHash } = require('node:crypto');
|
|
4
|
+
|
|
5
|
+
const FRONTEND = 'https://frontend-api-v3.pump.fun';
|
|
6
|
+
const SWAP_API = 'https://swap-api.pump.fun';
|
|
7
|
+
const SOLANA_RPC = 'https://api.mainnet-beta.solana.com';
|
|
8
|
+
|
|
9
|
+
// pfee program — social vault PDA seeds: ["social-fee-pda", numeric_id_str, [platform_byte]]
|
|
10
|
+
// Platform enum: Pump=0, X=1, GitHub=2, Google=3, Discord=4, Reddit=5,
|
|
11
|
+
// TikTok=6, YouTube=7, Twitch=8, LinkedIn=9, Facebook=10, Instagram=11, Telegram=13
|
|
12
|
+
const PFEE_PROGRAM = 'pfeeUxB6jkeY1Hxd7CsFCAjcbHA9rWtchMGdZ6VojVZ';
|
|
13
|
+
const PLATFORM = { pump: 0, x: 1, twitter: 1, github: 2, google: 3, discord: 4, reddit: 5, tiktok: 6, youtube: 7, twitch: 8, linkedin: 9, facebook: 10, instagram: 11, snapchat: 12, telegram: 13 };
|
|
14
|
+
|
|
15
|
+
// Bonding-curve account: sharing-config PDA at byte 49.
|
|
16
|
+
// Sharing-config: disc(8) + ?(32) + admin(32) + flags(4) + vec_len(4) + shares(n×36)
|
|
17
|
+
const BC_CONFIG_OFFSET = 49;
|
|
18
|
+
|
|
19
|
+
const HEADERS = {
|
|
20
|
+
'User-Agent': 'pumpfun-creator-fees/0.1',
|
|
21
|
+
'Accept': 'application/json',
|
|
22
|
+
'Origin': 'https://pump.fun',
|
|
23
|
+
'Referer': 'https://pump.fun/',
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const BASE58_RE = /^[1-9A-HJ-NP-Za-km-z]{32,44}$/;
|
|
27
|
+
const B58_ALPHA = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
|
|
28
|
+
|
|
29
|
+
function b58enc(buf) {
|
|
30
|
+
let n = 0n;
|
|
31
|
+
for (const b of buf) n = (n << 8n) | BigInt(b);
|
|
32
|
+
if (!n) return '1';
|
|
33
|
+
const r = [];
|
|
34
|
+
while (n > 0n) { const rem = n % 58n; r.push(B58_ALPHA[Number(rem)]); n = (n - rem) / 58n; }
|
|
35
|
+
return r.reverse().join('');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function b58dec(s) {
|
|
39
|
+
let n = 0n;
|
|
40
|
+
for (const c of s) n = n * 58n + BigInt(B58_ALPHA.indexOf(c));
|
|
41
|
+
const b = new Uint8Array(32);
|
|
42
|
+
for (let i = 31; i >= 0; i--) { b[i] = Number(n & 0xffn); n >>= 8n; }
|
|
43
|
+
return b;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// ── Solana PDA derivation (pure Node:crypto + BigInt) ────────────────────────
|
|
47
|
+
const ED25519_P = (1n << 255n) - 19n;
|
|
48
|
+
const ED25519_D = 37095705934669439343138083508754565189542113879843219016388785533085940283555n;
|
|
49
|
+
|
|
50
|
+
function modP(x) { return ((x % ED25519_P) + ED25519_P) % ED25519_P; }
|
|
51
|
+
function powModP(b, e) {
|
|
52
|
+
let r = 1n; b = modP(b);
|
|
53
|
+
while (e > 0n) { if (e & 1n) r = r * b % ED25519_P; b = b * b % ED25519_P; e >>= 1n; }
|
|
54
|
+
return r;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function isOnEd25519Curve(bytes) {
|
|
58
|
+
let y = 0n;
|
|
59
|
+
for (let i = 0; i < 32; i++) y |= BigInt(bytes[i]) << BigInt(8 * i);
|
|
60
|
+
y &= (1n << 255n) - 1n;
|
|
61
|
+
const y2 = y * y % ED25519_P;
|
|
62
|
+
const u = modP(y2 - 1n);
|
|
63
|
+
const v = modP(ED25519_D * y2 + 1n);
|
|
64
|
+
const x2 = u * powModP(v, ED25519_P - 2n) % ED25519_P;
|
|
65
|
+
if (x2 === 0n) return true;
|
|
66
|
+
return powModP(x2, (ED25519_P - 1n) / 2n) === 1n;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function findPDA(seeds, programIdStr) {
|
|
70
|
+
const progBytes = Buffer.from(b58dec(programIdStr));
|
|
71
|
+
const suffix = Buffer.from('ProgramDerivedAddress');
|
|
72
|
+
for (let bump = 255; bump >= 0; bump--) {
|
|
73
|
+
const data = Buffer.concat([...seeds, Buffer.from([bump]), progBytes, suffix]);
|
|
74
|
+
const hash = createHash('sha256').update(data).digest();
|
|
75
|
+
if (!isOnEd25519Curve(hash)) return b58enc(hash);
|
|
76
|
+
}
|
|
77
|
+
throw new Error('could not find PDA');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function socialVaultAddress(platformId, userId) {
|
|
81
|
+
return findPDA(
|
|
82
|
+
[Buffer.from('social-fee-pda'), Buffer.from(String(userId)), Buffer.from([platformId])],
|
|
83
|
+
PFEE_PROGRAM
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ── HTTP helpers ──────────────────────────────────────────────────────────────
|
|
88
|
+
|
|
89
|
+
async function getJson(url, { allow404 = false, allowEmpty = false } = {}) {
|
|
90
|
+
const res = await fetch(url, { headers: HEADERS });
|
|
91
|
+
if (res.status === 404 && allow404) return null;
|
|
92
|
+
if (!res.ok) {
|
|
93
|
+
const body = await res.text().catch(() => '');
|
|
94
|
+
throw new Error(`${res.status} ${res.statusText} for ${url}${body ? `: ${body.slice(0, 200)}` : ''}`);
|
|
95
|
+
}
|
|
96
|
+
const text = await res.text();
|
|
97
|
+
if (!text) { if (allowEmpty) return null; throw new Error(`empty response from ${url}`); }
|
|
98
|
+
return JSON.parse(text);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ── Concurrency + cache ───────────────────────────────────────────────────────
|
|
102
|
+
// A breakdown for a 53-coin creator fans out to ~110 upstream calls. Cap the
|
|
103
|
+
// concurrency so pump.fun doesn't rate-limit us, and cache the per-coin reads:
|
|
104
|
+
// a coin's timeline and metadata barely move, and the same coins recur across
|
|
105
|
+
// lookups of the same creator.
|
|
106
|
+
|
|
107
|
+
async function mapLimit(items, limit, fn) {
|
|
108
|
+
const out = new Array(items.length);
|
|
109
|
+
let next = 0;
|
|
110
|
+
const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
|
|
111
|
+
while (next < items.length) {
|
|
112
|
+
const i = next++;
|
|
113
|
+
out[i] = await fn(items[i], i);
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
await Promise.all(workers);
|
|
117
|
+
return out;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const CACHE = new Map();
|
|
121
|
+
|
|
122
|
+
async function cached(key, ttlMs, fn) {
|
|
123
|
+
const now = Date.now();
|
|
124
|
+
const hit = CACHE.get(key);
|
|
125
|
+
if (hit && hit.expires > now) return hit.value;
|
|
126
|
+
|
|
127
|
+
const value = await fn();
|
|
128
|
+
CACHE.set(key, { value, expires: now + ttlMs });
|
|
129
|
+
|
|
130
|
+
if (CACHE.size > 5000) {
|
|
131
|
+
for (const [k, v] of CACHE) if (v.expires <= now) CACHE.delete(k);
|
|
132
|
+
}
|
|
133
|
+
return value;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async function solanaGetAccount(address) {
|
|
137
|
+
const res = await fetch(SOLANA_RPC, {
|
|
138
|
+
method: 'POST',
|
|
139
|
+
headers: { 'Content-Type': 'application/json' },
|
|
140
|
+
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'getAccountInfo', params: [address, { encoding: 'base64' }] }),
|
|
141
|
+
});
|
|
142
|
+
const { result } = await res.json();
|
|
143
|
+
if (!result?.value) return null;
|
|
144
|
+
return { owner: result.value.owner, lamports: result.value.lamports, data: Buffer.from(result.value.data[0], 'base64') };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ── Platform ID resolvers ─────────────────────────────────────────────────────
|
|
148
|
+
|
|
149
|
+
async function resolveGitHubId(username) {
|
|
150
|
+
const res = await fetch(`https://api.github.com/users/${encodeURIComponent(username)}`, {
|
|
151
|
+
headers: { 'User-Agent': 'pumpfun-creator-fees/0.1', 'Accept': 'application/vnd.github+json' },
|
|
152
|
+
});
|
|
153
|
+
if (!res.ok) return null;
|
|
154
|
+
const data = await res.json();
|
|
155
|
+
return data.id ? String(data.id) : null;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async function resolveXId(username) {
|
|
159
|
+
// Scrape id_str from the public X profile page — no API key needed.
|
|
160
|
+
const res = await fetch(`https://x.com/${encodeURIComponent(username)}`, {
|
|
161
|
+
headers: { 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36' },
|
|
162
|
+
});
|
|
163
|
+
if (!res.ok) return null;
|
|
164
|
+
const html = await res.text();
|
|
165
|
+
const m = html.match(/"id_str":"(\d+)"/);
|
|
166
|
+
return m ? m[1] : null;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// ── Input parsing ─────────────────────────────────────────────────────────────
|
|
170
|
+
|
|
171
|
+
function parsePlatformPrefix(q) {
|
|
172
|
+
const prefixMap = { github: 'github', gh: 'github', x: 'x', twitter: 'x', tiktok: 'tiktok', tt: 'tiktok', discord: 'discord', reddit: 'reddit', youtube: 'youtube', yt: 'youtube', twitch: 'twitch', pump: 'pump' };
|
|
173
|
+
const m = q.match(/^([a-z]+):(.+)$/i);
|
|
174
|
+
if (m) {
|
|
175
|
+
const platform = prefixMap[m[1].toLowerCase()];
|
|
176
|
+
if (platform) return { platform, username: m[2].trim() };
|
|
177
|
+
}
|
|
178
|
+
return null;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// ── Pump.fun API ──────────────────────────────────────────────────────────────
|
|
182
|
+
|
|
183
|
+
async function fetchCoin(mint) {
|
|
184
|
+
return getJson(`${FRONTEND}/coins-v2/${encodeURIComponent(mint)}`, { allow404: true, allowEmpty: true });
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async function fetchUserByUsernameOrAddress(value) {
|
|
188
|
+
return getJson(`${FRONTEND}/users/${encodeURIComponent(value)}`, { allow404: true, allowEmpty: true });
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Wallet-level lifetime totals: claimed, unclaimed, total earned.
|
|
192
|
+
//
|
|
193
|
+
// This endpoint accepts a `mint` query param, but IGNORES it — filtering by
|
|
194
|
+
// mint returns byte-identical numbers to the unfiltered call (verified against
|
|
195
|
+
// several mints, including ones the wallet has no share in). So there is no
|
|
196
|
+
// such thing as "totals for one coin" from this API. Per-coin earnings come
|
|
197
|
+
// from the distribution timeline instead: see fetchCoinEarnings.
|
|
198
|
+
async function fetchTotals(wallet) {
|
|
199
|
+
return getJson(`${SWAP_API}/v1/fee-sharing/account/${encodeURIComponent(wallet)}/totals`);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Every coin sharing fees with this wallet. The API pages at 100; a creator
|
|
203
|
+
// with 53 coins used to silently show only the first 20.
|
|
204
|
+
async function fetchAllShares(wallet, { maxPages = 20 } = {}) {
|
|
205
|
+
const items = [];
|
|
206
|
+
let cursor = null;
|
|
207
|
+
|
|
208
|
+
for (let page = 0; page < maxPages; page++) {
|
|
209
|
+
const url = new URL(`${SWAP_API}/v1/fee-sharing/account/${encodeURIComponent(wallet)}/shares`);
|
|
210
|
+
url.searchParams.set('limit', '100');
|
|
211
|
+
if (cursor) url.searchParams.set('cursor', cursor);
|
|
212
|
+
|
|
213
|
+
const res = await getJson(url.toString());
|
|
214
|
+
items.push(...(res?.items || []));
|
|
215
|
+
|
|
216
|
+
if (!res?.pagination?.hasMore) return { items, truncated: false };
|
|
217
|
+
cursor = res.pagination.nextCursor;
|
|
218
|
+
}
|
|
219
|
+
// Hit the page ceiling — say so rather than pretending the list is complete.
|
|
220
|
+
return { items, truncated: true };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// How much THIS wallet actually earned from ONE coin.
|
|
224
|
+
//
|
|
225
|
+
// The coin's timeline carries `distribution` events, each listing the exact
|
|
226
|
+
// lamports paid to each recipient. Summing the entries addressed to `wallet` is
|
|
227
|
+
// the only per-coin earnings figure pump.fun exposes, and it reconciles with
|
|
228
|
+
// the wallet's lifetime `shareholderClaimed` to within ~0.0004%.
|
|
229
|
+
async function fetchCoinEarnings(mint, wallet, { maxPages = 30 } = {}) {
|
|
230
|
+
let cursor = null;
|
|
231
|
+
let lamports = 0n;
|
|
232
|
+
let distributions = 0;
|
|
233
|
+
let firstAt = null;
|
|
234
|
+
let lastAt = null;
|
|
235
|
+
const payouts = [];
|
|
236
|
+
|
|
237
|
+
for (let page = 0; page < maxPages; page++) {
|
|
238
|
+
const url = new URL(`${SWAP_API}/v1/coins/${encodeURIComponent(mint)}/timeline`);
|
|
239
|
+
url.searchParams.set('limit', '100');
|
|
240
|
+
if (cursor) url.searchParams.set('cursor', cursor);
|
|
241
|
+
|
|
242
|
+
const res = await getJson(url.toString(), { allow404: true, allowEmpty: true });
|
|
243
|
+
if (!res) break;
|
|
244
|
+
|
|
245
|
+
for (const ev of res.items || []) {
|
|
246
|
+
if (ev.eventType !== 'distribution') continue;
|
|
247
|
+
for (const r of ev.recipients || []) {
|
|
248
|
+
if (r.address !== wallet) continue;
|
|
249
|
+
const amount = BigInt(r.amount || '0');
|
|
250
|
+
if (amount <= 0n) continue;
|
|
251
|
+
|
|
252
|
+
lamports += amount;
|
|
253
|
+
distributions++;
|
|
254
|
+
payouts.push({ at: ev.timestamp, lamports: amount.toString(), tx: ev.tx || null });
|
|
255
|
+
if (!firstAt || ev.timestamp < firstAt) firstAt = ev.timestamp;
|
|
256
|
+
if (!lastAt || ev.timestamp > lastAt) lastAt = ev.timestamp;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
if (!res.pagination?.hasMore) break;
|
|
261
|
+
cursor = res.pagination.nextCursor;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
return { mint, lamports, distributions, firstAt, lastAt, payouts };
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// ── On-chain sharing-config resolution ───────────────────────────────────────
|
|
268
|
+
|
|
269
|
+
async function fetchOnChainRecipients(bondingCurve) {
|
|
270
|
+
if (!bondingCurve) return null;
|
|
271
|
+
const bcAccount = await solanaGetAccount(bondingCurve).catch(() => null);
|
|
272
|
+
if (!bcAccount || bcAccount.data.length < BC_CONFIG_OFFSET + 32) return null;
|
|
273
|
+
const configAddress = b58enc(bcAccount.data.slice(BC_CONFIG_OFFSET, BC_CONFIG_OFFSET + 32));
|
|
274
|
+
if (!BASE58_RE.test(configAddress)) return null;
|
|
275
|
+
const configAccount = await solanaGetAccount(configAddress).catch(() => null);
|
|
276
|
+
if (!configAccount || configAccount.owner !== PFEE_PROGRAM) return null;
|
|
277
|
+
const raw = configAccount.data;
|
|
278
|
+
const vecLen = raw.readUInt32LE(76);
|
|
279
|
+
if (vecLen === 0 || vecLen > 50) return null;
|
|
280
|
+
const recipients = [];
|
|
281
|
+
for (let i = 0; i < vecLen; i++) {
|
|
282
|
+
const off = 80 + i * 36;
|
|
283
|
+
if (off + 36 > raw.length) break;
|
|
284
|
+
const pubkey = b58enc(raw.slice(off, off + 32));
|
|
285
|
+
const bps = raw.readUInt32LE(off + 32);
|
|
286
|
+
if (BASE58_RE.test(pubkey) && bps > 0) recipients.push({ wallet: pubkey, bps, sharePct: bps / 100 });
|
|
287
|
+
}
|
|
288
|
+
return recipients.length ? { configAddress, recipients } : null;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// ── Social vault resolution ───────────────────────────────────────────────────
|
|
292
|
+
|
|
293
|
+
async function resolveSocialVault(platform, username) {
|
|
294
|
+
const platformId = PLATFORM[platform];
|
|
295
|
+
if (platformId === undefined) throw new Error(`unsupported platform: ${platform}`);
|
|
296
|
+
|
|
297
|
+
if (platform === 'pump') {
|
|
298
|
+
const user = await fetchUserByUsernameOrAddress(username);
|
|
299
|
+
if (!user?.address) throw new Error(`pump.fun username "${username}" not found`);
|
|
300
|
+
return { type: 'username', wallet: user.address, username: user.username || username, platform: 'pump', userMeta: user };
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
let numericId;
|
|
304
|
+
if (platform === 'github') {
|
|
305
|
+
numericId = await resolveGitHubId(username);
|
|
306
|
+
if (!numericId) throw new Error(`GitHub user "${username}" not found`);
|
|
307
|
+
} else if (platform === 'x') {
|
|
308
|
+
if (/^\d+$/.test(username)) {
|
|
309
|
+
numericId = username;
|
|
310
|
+
} else {
|
|
311
|
+
numericId = await resolveXId(username);
|
|
312
|
+
if (!numericId) throw new Error(`X user "@${username}" not found or profile is private.`);
|
|
313
|
+
}
|
|
314
|
+
} else if (/^\d+$/.test(username)) {
|
|
315
|
+
numericId = username;
|
|
316
|
+
} else {
|
|
317
|
+
throw new Error(`For ${platform}, provide the numeric user ID (e.g. ${platform}:12345678) — username lookup isn't available for this platform yet.`);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
const vaultAddress = socialVaultAddress(platformId, numericId);
|
|
321
|
+
const account = await solanaGetAccount(vaultAddress).catch(() => null);
|
|
322
|
+
if (!account || account.owner !== PFEE_PROGRAM) {
|
|
323
|
+
throw new Error(`No pump.fun creator vault found for ${platform} user "${username}". They may not have linked their ${platform} account on pump.fun yet.`);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
return { type: 'social', wallet: vaultAddress, platform, username, numericId };
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// ── Input resolution ──────────────────────────────────────────────────────────
|
|
330
|
+
|
|
331
|
+
async function resolveInput(raw) {
|
|
332
|
+
const q = String(raw || '').trim().replace(/^@/, '');
|
|
333
|
+
if (!q) throw new Error('empty input');
|
|
334
|
+
|
|
335
|
+
const prefixed = parsePlatformPrefix(q);
|
|
336
|
+
if (prefixed) return resolveSocialVault(prefixed.platform, prefixed.username);
|
|
337
|
+
|
|
338
|
+
if (BASE58_RE.test(q)) {
|
|
339
|
+
const coin = await fetchCoin(q);
|
|
340
|
+
if (coin && coin.creator) {
|
|
341
|
+
const onChain = await fetchOnChainRecipients(coin.bonding_curve).catch(() => null);
|
|
342
|
+
return {
|
|
343
|
+
type: 'mint',
|
|
344
|
+
wallet: onChain?.recipients?.[0]?.wallet ?? coin.creator,
|
|
345
|
+
mint: q,
|
|
346
|
+
coinMeta: coinMetaFrom(coin),
|
|
347
|
+
onChainRecipients: onChain?.recipients || null,
|
|
348
|
+
sharingConfigAddress: onChain?.configAddress || null,
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
const user = await fetchUserByUsernameOrAddress(q);
|
|
352
|
+
return { type: 'wallet', wallet: q, username: user?.username || null, userMeta: user || null };
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const user = await fetchUserByUsernameOrAddress(q);
|
|
356
|
+
if (user?.address) return { type: 'username', wallet: user.address, username: user.username || q, userMeta: user };
|
|
357
|
+
|
|
358
|
+
// Fallback: try as GitHub username
|
|
359
|
+
const ghResult = await resolveSocialVault('github', q).catch(() => null);
|
|
360
|
+
if (ghResult) return ghResult;
|
|
361
|
+
|
|
362
|
+
throw new Error(`"${q}" not found. Try: github:username · x:numeric_id · tiktok:numeric_id · coin mint · wallet address · pump.fun username`);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function coinMetaFrom(coin) {
|
|
366
|
+
return { mint: coin.mint, name: coin.name, symbol: coin.symbol, image: coin.image_uri || null, marketCapUsd: coin.usd_market_cap ?? null, creator: coin.creator, createdTimestamp: coin.created_timestamp ?? null, twitter: coin.twitter || null, website: coin.website || null };
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// ── Top-level entry ───────────────────────────────────────────────────────────
|
|
370
|
+
|
|
371
|
+
function resolvedPayload(resolved) {
|
|
372
|
+
return {
|
|
373
|
+
type: resolved.type,
|
|
374
|
+
wallet: resolved.wallet,
|
|
375
|
+
mint: resolved.mint || null,
|
|
376
|
+
platform: resolved.platform || null,
|
|
377
|
+
username: resolved.username || null,
|
|
378
|
+
numericId: resolved.numericId || null,
|
|
379
|
+
coinMeta: resolved.coinMeta || null,
|
|
380
|
+
sharingConfigAddress: resolved.sharingConfigAddress || null,
|
|
381
|
+
onChainRecipients: resolved.onChainRecipients || null,
|
|
382
|
+
profile: resolved.userMeta
|
|
383
|
+
? {
|
|
384
|
+
username: resolved.userMeta.username || null,
|
|
385
|
+
bio: resolved.userMeta.bio || null,
|
|
386
|
+
profileImage: resolved.userMeta.profile_image || null,
|
|
387
|
+
followers: resolved.userMeta.followers ?? null,
|
|
388
|
+
}
|
|
389
|
+
: null,
|
|
390
|
+
};
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// The API reports every total in both SOL and USD, so the SOL price it used is
|
|
394
|
+
// recoverable by division. No price oracle, no hardcoded number, and the USD we
|
|
395
|
+
// derive for a coin is consistent with the USD it reports for the wallet.
|
|
396
|
+
function deriveSolPriceUsd(totals) {
|
|
397
|
+
for (const key of ['shareholderTotalEarned', 'shareholderClaimed', 'shareholderUnclaimed']) {
|
|
398
|
+
const sol = Number(totals?.[key]?.sol);
|
|
399
|
+
const usd = Number(totals?.[key]?.usd);
|
|
400
|
+
if (sol > 0 && usd > 0) return usd / sol;
|
|
401
|
+
}
|
|
402
|
+
return null;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function money(lamports, solPriceUsd) {
|
|
406
|
+
const sol = Number(lamports) / 1e9;
|
|
407
|
+
return {
|
|
408
|
+
lamports: lamports.toString(),
|
|
409
|
+
sol,
|
|
410
|
+
usd: solPriceUsd == null ? null : sol * solPriceUsd,
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// Summary view: who the input resolves to, their lifetime totals, and every
|
|
415
|
+
// coin sharing fees with them. Fast — no per-coin timeline walk.
|
|
416
|
+
async function getCreatorFees(query) {
|
|
417
|
+
const resolved = await resolveInput(query);
|
|
418
|
+
const { wallet } = resolved;
|
|
419
|
+
|
|
420
|
+
const [totals, shares] = await Promise.all([fetchTotals(wallet), fetchAllShares(wallet)]);
|
|
421
|
+
|
|
422
|
+
const metas = await mapLimit(shares.items, 8, (s) =>
|
|
423
|
+
cached(`coin:${s.mint}`, 120_000, () => fetchCoin(s.mint).catch(() => null))
|
|
424
|
+
);
|
|
425
|
+
|
|
426
|
+
const coins = shares.items.map((s, i) => {
|
|
427
|
+
const meta = metas[i];
|
|
428
|
+
return {
|
|
429
|
+
mint: s.mint,
|
|
430
|
+
bps: s.bps,
|
|
431
|
+
sharePct: typeof s.bps === 'number' ? s.bps / 100 : null,
|
|
432
|
+
isAdmin: !!s.isAdmin,
|
|
433
|
+
name: meta?.name || null,
|
|
434
|
+
symbol: meta?.symbol || null,
|
|
435
|
+
image: meta?.image_uri || null,
|
|
436
|
+
marketCapUsd: meta?.usd_market_cap ?? null,
|
|
437
|
+
createdTimestamp: meta?.created_timestamp ?? null,
|
|
438
|
+
};
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
return {
|
|
442
|
+
query: String(query),
|
|
443
|
+
resolved: resolvedPayload(resolved),
|
|
444
|
+
totals,
|
|
445
|
+
solPriceUsd: deriveSolPriceUsd(totals),
|
|
446
|
+
coins,
|
|
447
|
+
coinsTruncated: shares.truncated,
|
|
448
|
+
fetchedAt: new Date().toISOString(),
|
|
449
|
+
};
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// Intelligence view: what each individual coin actually paid this wallet.
|
|
453
|
+
//
|
|
454
|
+
// Per-coin figures are DISTRIBUTED (i.e. paid out) amounts, summed from each
|
|
455
|
+
// coin's distribution events. Unclaimed fees are only reported at the wallet
|
|
456
|
+
// level by pump.fun — there is no per-coin unclaimed figure to be had — so the
|
|
457
|
+
// per-coin numbers here reconcile against `shareholderClaimed`, not
|
|
458
|
+
// `shareholderTotalEarned`. `reconciliation` reports that delta rather than
|
|
459
|
+
// hiding it.
|
|
460
|
+
async function getEarningsBreakdown(query) {
|
|
461
|
+
const resolved = await resolveInput(query);
|
|
462
|
+
const { wallet } = resolved;
|
|
463
|
+
|
|
464
|
+
const [totals, shares] = await Promise.all([fetchTotals(wallet), fetchAllShares(wallet)]);
|
|
465
|
+
const solPriceUsd = deriveSolPriceUsd(totals);
|
|
466
|
+
const items = shares.items;
|
|
467
|
+
|
|
468
|
+
const [earnings, metas] = await Promise.all([
|
|
469
|
+
mapLimit(items, 8, (s) =>
|
|
470
|
+
cached(`earn:${s.mint}:${wallet}`, 60_000, () => fetchCoinEarnings(s.mint, wallet))
|
|
471
|
+
),
|
|
472
|
+
mapLimit(items, 8, (s) => cached(`coin:${s.mint}`, 120_000, () => fetchCoin(s.mint).catch(() => null))),
|
|
473
|
+
]);
|
|
474
|
+
|
|
475
|
+
const distributedLamports = earnings.reduce((sum, e) => sum + e.lamports, 0n);
|
|
476
|
+
const thirtyDaysAgo = new Date(Date.now() - 30 * 86_400_000).toISOString();
|
|
477
|
+
|
|
478
|
+
const coins = items.map((s, i) => {
|
|
479
|
+
const e = earnings[i];
|
|
480
|
+
const meta = metas[i];
|
|
481
|
+
const last30 = e.payouts.reduce((sum, p) => (p.at >= thirtyDaysAgo ? sum + BigInt(p.lamports) : sum), 0n);
|
|
482
|
+
|
|
483
|
+
return {
|
|
484
|
+
mint: s.mint,
|
|
485
|
+
bps: s.bps,
|
|
486
|
+
sharePct: typeof s.bps === 'number' ? s.bps / 100 : null,
|
|
487
|
+
isAdmin: !!s.isAdmin,
|
|
488
|
+
name: meta?.name || null,
|
|
489
|
+
symbol: meta?.symbol || null,
|
|
490
|
+
image: meta?.image_uri || null,
|
|
491
|
+
marketCapUsd: meta?.usd_market_cap ?? null,
|
|
492
|
+
createdTimestamp: meta?.created_timestamp ?? null,
|
|
493
|
+
|
|
494
|
+
earned: money(e.lamports, solPriceUsd),
|
|
495
|
+
earnedLast30d: money(last30, solPriceUsd),
|
|
496
|
+
// What fraction of everything this wallet was ever paid came from this coin.
|
|
497
|
+
shareOfEarningsPct:
|
|
498
|
+
distributedLamports > 0n ? (Number(e.lamports) / Number(distributedLamports)) * 100 : 0,
|
|
499
|
+
distributions: e.distributions,
|
|
500
|
+
firstEarnedAt: e.firstAt,
|
|
501
|
+
lastEarnedAt: e.lastAt,
|
|
502
|
+
};
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
coins.sort((a, b) => Number(BigInt(b.earned.lamports) - BigInt(a.earned.lamports)));
|
|
506
|
+
|
|
507
|
+
const earners = coins.filter((c) => BigInt(c.earned.lamports) > 0n);
|
|
508
|
+
const last30dLamports = coins.reduce((sum, c) => sum + BigInt(c.earnedLast30d.lamports), 0n);
|
|
509
|
+
const lastEarnedAt = coins.reduce((max, c) => (c.lastEarnedAt > max ? c.lastEarnedAt : max), '');
|
|
510
|
+
const claimedLamports = BigInt(totals?.shareholderClaimed?.lamports || '0');
|
|
511
|
+
|
|
512
|
+
const insights = {
|
|
513
|
+
coinCount: coins.length,
|
|
514
|
+
earningCoinCount: earners.length,
|
|
515
|
+
// Coins that share fees but have never paid out a lamport.
|
|
516
|
+
silentCoinCount: coins.length - earners.length,
|
|
517
|
+
distributed: money(distributedLamports, solPriceUsd),
|
|
518
|
+
unclaimed: totals?.shareholderUnclaimed || null,
|
|
519
|
+
last30d: money(last30dLamports, solPriceUsd),
|
|
520
|
+
lastEarnedAt: lastEarnedAt || null,
|
|
521
|
+
topCoin: earners[0]
|
|
522
|
+
? { mint: earners[0].mint, name: earners[0].name, symbol: earners[0].symbol, earned: earners[0].earned }
|
|
523
|
+
: null,
|
|
524
|
+
// Concentration: how much of the creator's income rides on one coin.
|
|
525
|
+
topCoinSharePct: earners[0]?.shareOfEarningsPct ?? 0,
|
|
526
|
+
// Coins needed to account for 90% of all earnings.
|
|
527
|
+
coinsFor90Pct: (() => {
|
|
528
|
+
if (!distributedLamports) return 0;
|
|
529
|
+
let acc = 0;
|
|
530
|
+
let n = 0;
|
|
531
|
+
for (const c of earners) {
|
|
532
|
+
acc += c.shareOfEarningsPct;
|
|
533
|
+
n++;
|
|
534
|
+
if (acc >= 90) break;
|
|
535
|
+
}
|
|
536
|
+
return n;
|
|
537
|
+
})(),
|
|
538
|
+
};
|
|
539
|
+
|
|
540
|
+
// Summed distributions should equal what the API says was claimed. Publish the
|
|
541
|
+
// delta so a drift (paging gap, upstream change) is visible instead of silent.
|
|
542
|
+
const deltaLamports = distributedLamports - claimedLamports;
|
|
543
|
+
const reconciliation = {
|
|
544
|
+
summedDistributions: money(distributedLamports, solPriceUsd),
|
|
545
|
+
apiClaimed: totals?.shareholderClaimed || null,
|
|
546
|
+
deltaSol: Number(deltaLamports) / 1e9,
|
|
547
|
+
deltaPct: claimedLamports > 0n ? (Number(deltaLamports) / Number(claimedLamports)) * 100 : 0,
|
|
548
|
+
};
|
|
549
|
+
|
|
550
|
+
return {
|
|
551
|
+
query: String(query),
|
|
552
|
+
resolved: resolvedPayload(resolved),
|
|
553
|
+
totals,
|
|
554
|
+
solPriceUsd,
|
|
555
|
+
// When the input was a mint, this is that coin's own earnings — the figure
|
|
556
|
+
// the UI used to fill with the creator's lifetime total across all coins.
|
|
557
|
+
coinEarnings: resolved.mint ? coins.find((c) => c.mint === resolved.mint) || null : null,
|
|
558
|
+
coins,
|
|
559
|
+
coinsTruncated: shares.truncated,
|
|
560
|
+
insights,
|
|
561
|
+
reconciliation,
|
|
562
|
+
fetchedAt: new Date().toISOString(),
|
|
563
|
+
};
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
module.exports = {
|
|
567
|
+
getCreatorFees,
|
|
568
|
+
getEarningsBreakdown,
|
|
569
|
+
fetchCoin,
|
|
570
|
+
fetchTotals,
|
|
571
|
+
fetchAllShares,
|
|
572
|
+
fetchCoinEarnings,
|
|
573
|
+
resolveInput,
|
|
574
|
+
};
|