solana-risk-engine 0.0.0-stage → 0.2.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 +21 -0
- package/README.es.md +30 -0
- package/README.md +142 -2
- package/bin/solana-risk.js +65 -0
- package/package.json +45 -4
- package/src/changes.js +105 -0
- package/src/client.js +143 -0
- package/src/confluence.js +120 -0
- package/src/constants.js +55 -0
- package/src/curve.js +34 -0
- package/src/errors.js +33 -0
- package/src/holders.js +189 -0
- package/src/i18n/en.js +106 -0
- package/src/i18n/es.js +109 -0
- package/src/i18n/index.js +77 -0
- package/src/index.js +55 -0
- package/src/markets.js +58 -0
- package/src/pnl.js +148 -0
- package/src/risk.js +234 -0
- package/src/rugcheck.js +66 -0
- package/src/tokens.js +103 -0
- package/src/util.js +29 -0
- package/src/wallet.js +80 -0
package/src/i18n/es.js
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// Spanish texts: the same wording the original single-file scanner used
|
|
2
|
+
function usd(n) {
|
|
3
|
+
if (!n) return "0 $";
|
|
4
|
+
if (n < 0.01) return "<0,01 $";
|
|
5
|
+
// de-DE instead of es-ES: same format (dot thousands, comma decimals), but es-ES does not group
|
|
6
|
+
// 4-digit numbers and "6494 $" showed next to "22.505 $"
|
|
7
|
+
return n.toLocaleString("de-DE", { minimumFractionDigits: n < 1000 ? 2 : 0, maximumFractionDigits: n < 1000 ? 2 : 0 }) + " $";
|
|
8
|
+
}
|
|
9
|
+
function pct(n) { return Math.round(n) + "%"; }
|
|
10
|
+
function short(a) { return a.slice(0, 4) + "…" + a.slice(-4); }
|
|
11
|
+
function prog(d) { return d && d.prog >= 20 ? ". Los pools y programas guardan el " + pct(d.prog) : ""; }
|
|
12
|
+
|
|
13
|
+
const EXT = {
|
|
14
|
+
permanentDelegate: "El emisor puede mover o quemar tus tokens (delegado permanente). Es normal en acciones tokenizadas, pero hay que confiar en el emisor",
|
|
15
|
+
transferHook: "Tiene transfer hook: ejecuta código externo en cada transferencia",
|
|
16
|
+
transferFeeConfig: "Cobra una comisión en cada transferencia",
|
|
17
|
+
nonTransferable: "No se puede transferir",
|
|
18
|
+
defaultAccountState: "Las cuentas nuevas pueden crearse congeladas"
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export default {
|
|
22
|
+
fmt: { usd: usd, pct: pct, short: short },
|
|
23
|
+
signals: {
|
|
24
|
+
NO_INFO: () => "No se pudo leer la información del token",
|
|
25
|
+
MINT_BY_PROGRAM: () => "El mint lo controla un programa (suele ser un puente o un protocolo): hay que confiar en ese código",
|
|
26
|
+
MINT_ACTIVE: (d) => "El creador todavía puede crear más tokens (mint activo" + (d && d.byWallet ? ", en manos de una wallet" : "") + ")",
|
|
27
|
+
FREEZE_BY_PROGRAM: () => "El freeze lo controla un programa: puede congelar cuentas según su código",
|
|
28
|
+
FREEZE_ACTIVE: (d) => "El creador puede congelar cuentas (freeze activo" + (d && d.byWallet ? ", en manos de una wallet" : "") + ")",
|
|
29
|
+
EXTENSION: (d) => EXT[d.ext] || ("Extensión " + d.ext),
|
|
30
|
+
NO_MARKET: () => "No hay mercado en DexScreener (sin precio ni liquidez)",
|
|
31
|
+
MARKET_UNCHECKED: () => "No se pudo consultar el mercado de este token (fallo de red al conectar con DexScreener): vuelve a analizarlo para comprobarlo",
|
|
32
|
+
KNOWN_TOKEN: () => "Token muy conocido",
|
|
33
|
+
|
|
34
|
+
CTL_MINT_ACTIVE: () => "El creador puede crear más tokens (mint activo)",
|
|
35
|
+
CTL_FREEZE_ACTIVE: () => "El creador puede congelar cuentas (freeze activo)",
|
|
36
|
+
CTL_TRANSFER_HOOK: () => "Ejecuta código externo en cada transferencia",
|
|
37
|
+
CTL_TRANSFER_FEE: () => "Cobra una comisión en cada transferencia",
|
|
38
|
+
CTL_NON_TRANSFERABLE: () => "No se puede transferir",
|
|
39
|
+
CTL_DEFAULT_FROZEN: () => "Las cuentas nuevas pueden crearse congeladas",
|
|
40
|
+
CTL_ONLY_PROGRAMS: () => "Solo hay autoridades controladas por un programa (ver dependencia)",
|
|
41
|
+
CTL_NONE: () => "Sin autoridades activas: nadie puede crear más ni congelar",
|
|
42
|
+
IMPOSTOR: (d) => "Se hace pasar por " + d.symbol + ": el " + d.symbol + " de verdad tiene otra dirección (" + short(d.real) + "). Es una copia, casi seguro una estafa",
|
|
43
|
+
|
|
44
|
+
CON_SKIPPED: () => "Sin comprobar la concentración de holders",
|
|
45
|
+
CON_UNAVAILABLE: () => "No se pudo comprobar la concentración de holders (el servicio no respondió; prueba otra vez en un rato)",
|
|
46
|
+
CON_ALL_POOLS: (d) => "Casi todo el supply está en pools o programas" + prog(d),
|
|
47
|
+
CON_OK: (d) => "Sin concentración preocupante (mayor wallet individual: " + pct(d.w1) + ")" + prog(d),
|
|
48
|
+
CON_HIGH: (d) => "La mayor wallet individual tiene el " + pct(d.w1) + " y las 10 mayores el " + pct(d.w10) + " del supply" + prog(d),
|
|
49
|
+
CON_UNSPLIT: (d) => "No se pudo separar pools de personas. La mayor cuenta tiene el " + pct(d.top1) + " del supply",
|
|
50
|
+
|
|
51
|
+
MKT_LIQ_INFLATED: (d) => "Liquidez inflada: DexScreener dice " + usd(d.reported) + ", pero el pool solo tiene unos " + usd(d.real) + " en tokens de verdad (USDC, USDT o SOL). Es lo máximo que podrías sacar al vender",
|
|
52
|
+
MKT_LIQ_VERY_LOW: (d) => "Liquidez muy baja (" + usd(d.liq) + ")",
|
|
53
|
+
MKT_LIQ_LOW: (d) => "Liquidez baja (" + usd(d.liq) + ")",
|
|
54
|
+
MKT_VOLUME_LOW: (d) => "Volumen de 24 h casi nulo (" + usd(d.vol) + ")",
|
|
55
|
+
MKT_NEW: (d) => "Mercado muy nuevo (" + (d.days < 1 ? "menos de un día" : d.days + (d.days === 1 ? " día" : " días")) + ")",
|
|
56
|
+
MKT_POSITION_HEAVY: (d) => "La posición de esta wallet equivale al " + pct(d.pct) + " de la liquidez: vender de golpe movería mucho el precio",
|
|
57
|
+
MKT_LP_UNLOCKED: (d) => "Solo el " + pct(d.pct) + " de la liquidez está bloqueada o quemada (según RugCheck): el resto se puede retirar de golpe",
|
|
58
|
+
MKT_LP_PARTLY_LOCKED: (d) => "Solo el " + pct(d.pct) + " de la liquidez está bloqueada o quemada (según RugCheck)",
|
|
59
|
+
MKT_OK: (d) => "Liquidez " + usd(d.liq) + " · volumen 24 h " + usd(d.vol),
|
|
60
|
+
|
|
61
|
+
DEP_PROGRAM_AUTHORITY: () => "Un programa controla el mint o el freeze (puente o protocolo): hay que confiar en su código",
|
|
62
|
+
DEP_PERMANENT_DELEGATE: () => "El emisor puede mover o quemar tus tokens (delegado permanente): confías en el emisor",
|
|
63
|
+
DEP_STABLE_ISSUER: () => "Su valor depende de quien la emite y la respalda",
|
|
64
|
+
DEP_NONE: () => "No depende de un emisor ni de un puente"
|
|
65
|
+
},
|
|
66
|
+
offSolana: " y su mercado principal puede estar fuera de Solana",
|
|
67
|
+
levels: {
|
|
68
|
+
HIGH: "Riesgo alto", MEDIUM: "Riesgo medio", LOW: "Riesgo bajo", SPAM: "Probable spam",
|
|
69
|
+
NO_DATA: "Sin comprobar", NO_MARKET: "Sin mercado", MEDIUM_DATA_ONLY: "Medio por falta de datos"
|
|
70
|
+
},
|
|
71
|
+
types: {
|
|
72
|
+
STABLE: { label: "Stablecoin", note: "Se juzga por su liquidez y por la confianza en quien la emite; la concentración pesa menos." },
|
|
73
|
+
TOKENIZED: { label: "Tokenizado o regulado", note: "Lo normal es que el emisor tenga control (delegado permanente). El riesgo real es confiar en el emisor y que haya liquidez para salir." },
|
|
74
|
+
BRIDGED: { label: "Puenteado o de protocolo", note: "Lo normal es que un programa controle el mint. El riesgo real es confiar en ese puente y que su mercado en Solana sea pequeño, porque el principal puede estar en otra red." },
|
|
75
|
+
CREATOR: { label: "Con control del creador", note: "Una wallet puede crear más tokens o congelar cuentas: es el patrón típico de estafa, así que se juzga con más dureza." },
|
|
76
|
+
OPEN: { label: "Token abierto", note: "Sin autoridades del creador, típico de memecoins y proyectos nuevos. Pesan sobre todo la concentración en personas y la liquidez." }
|
|
77
|
+
},
|
|
78
|
+
lights: { control: "Control del creador", concentration: "Concentración", market: "Mercado y salida", dependency: "Dependencia" },
|
|
79
|
+
missing: { control: "quién controla el token", concentration: "la concentración de holders", market: "el mercado", dependency: "la dependencia de un emisor" },
|
|
80
|
+
steps: {
|
|
81
|
+
address: "Comprobando la dirección…", wallet: "Leyendo la wallet…", markets: "Consultando precios y liquidez…",
|
|
82
|
+
tokens: "Revisando tokens…", token: "Leyendo el token…", owners: "Buscando a quién pertenece cada cuenta…",
|
|
83
|
+
kinds: "Distinguiendo wallets de pools y programas…", bots: "Separando personas de bots y exchanges…",
|
|
84
|
+
funders: "Buscando wallets con el mismo origen de fondos…", exchangeSources: "Comprobando si el origen de cada grupo es un exchange…",
|
|
85
|
+
creator: "Mirando quién creó el token (RugCheck)…", creatorTokens: "Revisando otros tokens del creador…",
|
|
86
|
+
networks: "Comprobando cuánto tienen hoy las redes de wallets…", wallets: "Leyendo wallets…",
|
|
87
|
+
pnl: "Estimando el resultado de cada wallet…", done: "Listo"
|
|
88
|
+
},
|
|
89
|
+
notes: {
|
|
90
|
+
PNL_NO_HELIUS: "No se estimó el resultado de las wallets porque falta una clave de Helius.",
|
|
91
|
+
PNL_PARTIAL: "No se pudo leer el historial de algunas wallets.",
|
|
92
|
+
PNL_APPROX: "No se pudo obtener el histórico de precio de SOL: se usó el precio de hoy y los resultados pueden estar muy alejados de la realidad."
|
|
93
|
+
},
|
|
94
|
+
errors: {
|
|
95
|
+
CANCELLED: () => "Cancelado.",
|
|
96
|
+
INVALID_ADDRESS: () => "Esa dirección no parece de Solana. Son de 32 a 44 letras y números y no empiezan por 0x.",
|
|
97
|
+
NOT_A_TOKEN: () => "Esa dirección no es un token.",
|
|
98
|
+
IS_NFT: () => "Esa dirección es un NFT, no un token. El escáner no revisa NFTs.",
|
|
99
|
+
NO_HOLDERS: () => "El RPC no devolvió datos de holders.",
|
|
100
|
+
HELIUS_REQUIRED: () => "Para estimar el resultado hace falta una clave de Helius (o una URL de RPC de Helius con la clave).",
|
|
101
|
+
SOL_PRICE: () => "No se pudo obtener el precio de SOL para calcular el resultado. Prueba otra vez dentro de un rato.",
|
|
102
|
+
DEX_UNREACHABLE: () => "No se pudo conectar con DexScreener. Revisa tu conexión a internet y prueba otra vez.",
|
|
103
|
+
TOO_FEW_WALLETS: (d) => d && d.filter ? "Solo " + d.n + (d.n === 1 ? " wallet cumple" : " wallets cumplen") + " el filtro de resultado (de " + d.total + " leídas)." : "Hacen falta al menos 2 wallets que se puedan leer.",
|
|
104
|
+
TOO_FEW_TOKENS: () => "Pega al menos 2 direcciones de tokens de Solana.",
|
|
105
|
+
NETWORK_PUBLIC: () => "Los servicios gratuitos de Solana no han respondido (a veces se saturan). Espera un minuto y prueba otra vez, o usa tu propia URL de RPC (Helius tiene plan gratuito).",
|
|
106
|
+
NETWORK_OWN: () => "No se pudo conectar con tu URL de RPC. Revisa que esté bien copiada (con la clave al final).",
|
|
107
|
+
UNKNOWN: (d) => "Algo ha fallado: " + d.message + ". Prueba otra vez o usa otro RPC."
|
|
108
|
+
}
|
|
109
|
+
};
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import es from "./es.js";
|
|
2
|
+
import en from "./en.js";
|
|
3
|
+
import { dataOnly } from "../risk.js";
|
|
4
|
+
|
|
5
|
+
const LANGS = { es: es, en: en };
|
|
6
|
+
|
|
7
|
+
function pack(lang) {
|
|
8
|
+
return LANGS[lang] || LANGS.en;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Adds or replaces a language: addLanguage("pt", {...same shape as en.js}) */
|
|
12
|
+
export function addLanguage(code, texts) {
|
|
13
|
+
LANGS[code] = texts;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** One signal ({code, data}) as a sentence */
|
|
17
|
+
export function describe(signal, lang) {
|
|
18
|
+
const L = pack(lang), f = L.signals[signal.code];
|
|
19
|
+
let t = f ? f(signal.data || {}) : signal.code;
|
|
20
|
+
if (signal.data && signal.data.offSolana) t += L.offSolana;
|
|
21
|
+
return t;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** A whole traffic light as one sentence, its signals joined like the original scanner did */
|
|
25
|
+
export function explainLight(light, lang) {
|
|
26
|
+
return light.items.map(function (x) { return describe(x, lang); }).join(". ");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function lightLabel(k, lang) {
|
|
30
|
+
return pack(lang).lights[k] || k;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Label of a result's level; says "medium due to missing data" when that is the only reason */
|
|
34
|
+
export function levelLabel(result, lang) {
|
|
35
|
+
const L = pack(lang);
|
|
36
|
+
if (typeof result === "string") return L.levels[result] || result;
|
|
37
|
+
return dataOnly(result) ? L.levels.MEDIUM_DATA_ONLY : (L.levels[result.lvl] || result.lvl);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** What could not be checked, e.g. "la concentración de holders y el mercado" */
|
|
41
|
+
export function missingText(result, lang) {
|
|
42
|
+
const L = pack(lang), and = lang === "es" ? " y " : " and ";
|
|
43
|
+
return (result.lights || []).filter(function (l) { return l.s === "na"; }).map(function (l) { return L.missing[l.k]; }).join(and);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function typeInfo(type, lang) {
|
|
47
|
+
return type ? pack(lang).types[type] || null : null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Progress object from onProgress as text, e.g. "Revisando tokens… 3/10" */
|
|
51
|
+
export function describeStep(p, lang) {
|
|
52
|
+
const L = pack(lang);
|
|
53
|
+
let t = L.steps[p.step] || p.step;
|
|
54
|
+
if (p.done !== undefined && p.total) t += " " + p.done + "/" + p.total;
|
|
55
|
+
return t;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function describeNote(code, lang) {
|
|
59
|
+
return pack(lang).notes[code] || code;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* A friendly message for any error the engine throws.
|
|
64
|
+
* @param [ctx.ownRpc] whether the user configured their own RPC (changes the network advice)
|
|
65
|
+
*/
|
|
66
|
+
export function describeError(e, lang, ctx) {
|
|
67
|
+
const L = pack(lang), code = e && e.code;
|
|
68
|
+
if (code === "NETWORK" || code === "HTTP" || code === "RPC") return L.errors[ctx && ctx.ownRpc ? "NETWORK_OWN" : "NETWORK_PUBLIC"]();
|
|
69
|
+
const f = L.errors[code];
|
|
70
|
+
if (f) return f(e.data || {});
|
|
71
|
+
return L.errors.UNKNOWN({ message: (e && e.message) || String(e) });
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Formatting helpers of each language: format(lang).usd(1234) */
|
|
75
|
+
export function format(lang) {
|
|
76
|
+
return pack(lang).fmt;
|
|
77
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { createClient } from "./client.js";
|
|
2
|
+
import { analyzeToken, analyzeWallet, analyzeAddress } from "./wallet.js";
|
|
3
|
+
import { analyzeHolders } from "./holders.js";
|
|
4
|
+
import { estimatePnl } from "./pnl.js";
|
|
5
|
+
import { takeSnapshots } from "./changes.js";
|
|
6
|
+
import { confluence, commonHolders } from "./confluence.js";
|
|
7
|
+
import { getTokens } from "./tokens.js";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The easy entry point: one object with every high-level analysis bound to one network client.
|
|
11
|
+
*
|
|
12
|
+
* const scanner = createScanner({ rpcUrl: "https://mainnet.helius-rpc.com/?api-key=..." });
|
|
13
|
+
* const r = await scanner.analyzeToken(mint);
|
|
14
|
+
*
|
|
15
|
+
* Options are those of createClient (rpcUrl, heliusApiKey, signal, fetch...). Each method also
|
|
16
|
+
* accepts its own opts (onProgress, ...), see the module docs.
|
|
17
|
+
*/
|
|
18
|
+
export function createScanner(opts) {
|
|
19
|
+
const client = createClient(opts);
|
|
20
|
+
return {
|
|
21
|
+
client: client,
|
|
22
|
+
analyzeToken: (mint, o) => analyzeToken(client, mint, o),
|
|
23
|
+
analyzeWallet: (wallet, o) => analyzeWallet(client, wallet, o),
|
|
24
|
+
analyzeAddress: (addr, o) => analyzeAddress(client, addr, o),
|
|
25
|
+
analyzeHolders: (mint, o) => analyzeHolders(client, mint, o),
|
|
26
|
+
estimatePnl: async (wallets, o) => {
|
|
27
|
+
// Current balances trim tokens the wallet moved away; read them if the caller did not pass them
|
|
28
|
+
o = o || {};
|
|
29
|
+
let bal = o.balances;
|
|
30
|
+
if (!bal) {
|
|
31
|
+
bal = {};
|
|
32
|
+
for (const w of wallets) bal[w] = (await getTokens(client, w)).tokens;
|
|
33
|
+
}
|
|
34
|
+
return estimatePnl(client, wallets, bal, o);
|
|
35
|
+
},
|
|
36
|
+
takeSnapshots: (wallets, o) => takeSnapshots(client, wallets, o),
|
|
37
|
+
confluence: (wallets, o) => confluence(client, wallets, o),
|
|
38
|
+
commonHolders: (mints, o) => commonHolders(client, mints, o)
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export { createClient };
|
|
43
|
+
export { ScanError, isCancel } from "./errors.js";
|
|
44
|
+
export * from "./constants.js";
|
|
45
|
+
export { isOnCurve, onCurve, b58bytes } from "./curve.js";
|
|
46
|
+
export { getTokens, getMint, getConc, getAuthKinds, isMint, accountKinds, kindOf } from "./tokens.js";
|
|
47
|
+
export { getMarkets, realLiq, impostorOf, INFLATED_RATIO } from "./markets.js";
|
|
48
|
+
export { rugcheckSummary, rugcheckReport, rugcheckGraph, netBalances } from "./rugcheck.js";
|
|
49
|
+
export { score, tokenType, buildLights, levelFrom, dataOnly, reasonsFrom, assess } from "./risk.js";
|
|
50
|
+
export { analyzeToken, analyzeWallet, analyzeAddress, sortResults } from "./wallet.js";
|
|
51
|
+
export { holderBase, markBusy, findFunder, findFunders, clusterByFunder, holderRows, creatorInfo, analyzeHolders } from "./holders.js";
|
|
52
|
+
export { fetchSwaps, solHistory, priceSeries, parseTrades, pnlFromTrades, pnlStatus, estimatePnl } from "./pnl.js";
|
|
53
|
+
export { capSnap, chgDiff, chgAgg, takeSnapshots, diffSnapshots } from "./changes.js";
|
|
54
|
+
export { confluence, commonHolders } from "./confluence.js";
|
|
55
|
+
export { describe, explainLight, lightLabel, levelLabel, missingText, typeInfo, describeStep, describeNote, describeError, format, addLanguage } from "./i18n/index.js";
|
package/src/markets.js
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { DEX, USDC, USDT, WSOL, FAMOUS } from "./constants.js";
|
|
2
|
+
import { ScanError, isCancel } from "./errors.js";
|
|
3
|
+
import { sleep } from "./util.js";
|
|
4
|
+
|
|
5
|
+
// DexScreener values both sides of a pool at the pool's own price. A scammer creates a pool with
|
|
6
|
+
// 4 billion of their token against 2 real USDC and it shows "liquidity $4,000M". What you can actually
|
|
7
|
+
// take out when selling is the real-token side (USDC, USDT or SOL): if that is much smaller, it wins.
|
|
8
|
+
// Concentrated-liquidity pools (Orca Whirlpools, Raydium CLMM, Meteora DLMM) are lopsided by nature,
|
|
9
|
+
// often 10-20% real side, so only an extreme gap counts: real side under 5% of the reported value.
|
|
10
|
+
// The scams this targets sit far below that (2 USDC against "$4,000M" is 0.00000005%).
|
|
11
|
+
// Mutates the pair: keeps the reported figure in liqRaw and replaces liquidity.usd with the real one.
|
|
12
|
+
export const INFLATED_RATIO = 0.05;
|
|
13
|
+
|
|
14
|
+
export function realLiq(p) {
|
|
15
|
+
const l = p.liquidity, qa = p.quoteToken && p.quoteToken.address;
|
|
16
|
+
if (!l || !l.usd || (qa !== USDC && qa !== USDT && qa !== WSOL)) return;
|
|
17
|
+
const pn = Number(p.priceNative), pu = Number(p.priceUsd), q = Number(l.quote);
|
|
18
|
+
if (!(pn > 0 && pu > 0 && q >= 0)) return;
|
|
19
|
+
const real = 2 * q * pu / pn; // pu/pn = USD price of the real token
|
|
20
|
+
if (real < INFLATED_RATIO * l.usd) { p.liqRaw = l.usd; p.liquidity = Object.assign({}, l, { usd: real }); }
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// A token using the symbol (or name) of a famous one but with another address is a copy
|
|
24
|
+
export function impostorOf(mint, pair) {
|
|
25
|
+
const b = (pair && pair.baseToken) || {};
|
|
26
|
+
let s = String(b.symbol || "").replace(/[\s$]/g, "").toUpperCase();
|
|
27
|
+
const n = String(b.name || "").trim().toLowerCase();
|
|
28
|
+
if (!FAMOUS[s]) { if (n === "usd coin") s = "USDC"; else if (n === "tether usd" || n === "tether") s = "USDT"; }
|
|
29
|
+
return (FAMOUS[s] && FAMOUS[s] !== mint) ? { sym: s, real: FAMOUS[s] } : null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Best (most liquid) DexScreener pair per mint, 30 mints per request.
|
|
33
|
+
// `failed` lists mints whose batch failed: unknown, which is not the same as "no market".
|
|
34
|
+
export async function getMarkets(client, mints, onStep) {
|
|
35
|
+
const best = {};
|
|
36
|
+
let failed = [];
|
|
37
|
+
for (let i = 0; i < mints.length; i += 30) {
|
|
38
|
+
const lote = mints.slice(i, i + 30);
|
|
39
|
+
try {
|
|
40
|
+
const pares = await client.jfetch(DEX + lote.join(","));
|
|
41
|
+
if (Array.isArray(pares)) pares.forEach(function (p) {
|
|
42
|
+
const m = p.baseToken && p.baseToken.address;
|
|
43
|
+
if (lote.indexOf(m) < 0) return;
|
|
44
|
+
realLiq(p);
|
|
45
|
+
const liq = (p.liquidity && p.liquidity.usd) || 0;
|
|
46
|
+
const cur = best[m] && best[m].liquidity && best[m].liquidity.usd || 0;
|
|
47
|
+
if (!best[m] || liq > cur) best[m] = p;
|
|
48
|
+
});
|
|
49
|
+
} catch (e) {
|
|
50
|
+
if (isCancel(e)) throw e;
|
|
51
|
+
if (i === 0 && e.code === "NETWORK") throw new ScanError("DEX_UNREACHABLE", "could not reach DexScreener");
|
|
52
|
+
failed = failed.concat(lote);
|
|
53
|
+
}
|
|
54
|
+
if (onStep) onStep(Math.min(i + 30, mints.length), mints.length);
|
|
55
|
+
if (i + 30 < mints.length) await sleep(250);
|
|
56
|
+
}
|
|
57
|
+
return { data: best, failed: failed };
|
|
58
|
+
}
|
package/src/pnl.js
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { HELIUS_API, COINGECKO_SOL, USDC, USDT, WSOL } from "./constants.js";
|
|
2
|
+
import { ScanError, isCancel } from "./errors.js";
|
|
3
|
+
import { pool, short } from "./util.js";
|
|
4
|
+
import { getMarkets } from "./markets.js";
|
|
5
|
+
|
|
6
|
+
export async function fetchSwaps(client, addr, max) {
|
|
7
|
+
const key = client.heliusKey();
|
|
8
|
+
if (!key) throw new ScanError("HELIUS_REQUIRED", "PnL needs a Helius API key");
|
|
9
|
+
let out = [], before = null;
|
|
10
|
+
while (out.length < max) {
|
|
11
|
+
const url = HELIUS_API + addr + "/transactions?api-key=" + encodeURIComponent(key) + "&type=SWAP&limit=100" + (before ? "&before=" + before : "");
|
|
12
|
+
const page = await client.jfetch(url);
|
|
13
|
+
if (!Array.isArray(page) || !page.length) break;
|
|
14
|
+
out = out.concat(page); before = page[page.length - 1].signature;
|
|
15
|
+
if (page.length < 100) break;
|
|
16
|
+
}
|
|
17
|
+
return out;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// SOL price over the last year (CoinGecko) with today's price as fallback. at(ts) gives the price at a unix time.
|
|
21
|
+
export async function solHistory(client) {
|
|
22
|
+
let cur = 0, pts = null;
|
|
23
|
+
try { const mkr = await getMarkets(client, [WSOL]); cur = Number((mkr.data[WSOL] && mkr.data[WSOL].priceUsd) || 0); } catch (e) { if (isCancel(e)) throw e; }
|
|
24
|
+
try { const j = await client.jfetch(COINGECKO_SOL); pts = j && j.prices; } catch (e) { if (isCancel(e)) throw e; }
|
|
25
|
+
return priceSeries(pts, cur);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function priceSeries(pts, cur) {
|
|
29
|
+
const approx = !(pts && pts.length);
|
|
30
|
+
return {
|
|
31
|
+
approx: approx, now: cur, at: function (ts) {
|
|
32
|
+
if (approx) return cur;
|
|
33
|
+
const ms = ts * 1000;
|
|
34
|
+
let lo = 0, hi = pts.length - 1;
|
|
35
|
+
while (lo < hi) { const mid = (lo + hi) >> 1; if (pts[mid][0] < ms) lo = mid + 1; else hi = mid; }
|
|
36
|
+
return pts[lo][1];
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Real net balance change of the wallet in each swap (balances are the most reliable source)
|
|
42
|
+
export function parseTrades(addr, txs, sol) {
|
|
43
|
+
const out = [];
|
|
44
|
+
txs.forEach(function (tx) {
|
|
45
|
+
if (!tx || tx.transactionError || !tx.accountData) return;
|
|
46
|
+
const d = {};
|
|
47
|
+
let nat = 0;
|
|
48
|
+
tx.accountData.forEach(function (ad) {
|
|
49
|
+
if (ad.account === addr) nat += Number(ad.nativeBalanceChange || 0) / 1e9;
|
|
50
|
+
(ad.tokenBalanceChanges || []).forEach(function (c) {
|
|
51
|
+
if (c.userAccount !== addr || !c.mint || !c.rawTokenAmount) return;
|
|
52
|
+
const q = Number(c.rawTokenAmount.tokenAmount || 0) / Math.pow(10, Number(c.rawTokenAmount.decimals || 0));
|
|
53
|
+
d[c.mint] = (d[c.mint] || 0) + q;
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
const solDelta = nat + (d[WSOL] || 0), usdDelta = (d[USDC] || 0) + (d[USDT] || 0);
|
|
57
|
+
delete d[WSOL]; delete d[USDC]; delete d[USDT];
|
|
58
|
+
const bases = Object.keys(d).filter(function (m) { return Math.abs(d[m]) > 0; });
|
|
59
|
+
if (bases.length !== 1) return; // swaps with several tokens at once are ignored
|
|
60
|
+
const m = bases[0], q = d[m], flow = usdDelta + solDelta * sol.at(tx.timestamp); // net dollars in (+) or out (-) of the wallet
|
|
61
|
+
if (Math.abs(flow) < 5) return; // tiny trades: noise
|
|
62
|
+
if (q > 0 && flow < 0) out.push({ t: tx.timestamp, mint: m, side: "buy", qty: q, usd: -flow });
|
|
63
|
+
else if (q < 0 && flow > 0) out.push({ t: tx.timestamp, mint: m, side: "sell", qty: -q, usd: flow });
|
|
64
|
+
});
|
|
65
|
+
out.sort(function (a, b) { return a.t - b.t; });
|
|
66
|
+
return out;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// FIFO PnL. Only what has a known entry price counts as realized; open lots are valued at today's price.
|
|
70
|
+
export function pnlFromTrades(trades, bal, price, sym, approx) {
|
|
71
|
+
const lots = {};
|
|
72
|
+
let realized = 0, costClosed = 0;
|
|
73
|
+
trades.forEach(function (t) {
|
|
74
|
+
const L = lots[t.mint] || (lots[t.mint] = []);
|
|
75
|
+
if (t.side === "buy") { L.push({ q: t.qty, c: t.usd }); return; }
|
|
76
|
+
let need = t.qty, cost = 0, cov = 0;
|
|
77
|
+
while (need > 1e-12 && L.length) {
|
|
78
|
+
const l = L[0], take = Math.min(l.q, need), part = l.c * (take / l.q);
|
|
79
|
+
cost += part; l.c -= part; l.q -= take; need -= take; cov += take;
|
|
80
|
+
if (l.q <= 1e-12) L.shift();
|
|
81
|
+
}
|
|
82
|
+
if (cov > 0) { realized += t.usd * (cov / t.qty) - cost; costClosed += cost; }
|
|
83
|
+
});
|
|
84
|
+
const open = [];
|
|
85
|
+
let unreal = 0, costOpen = 0, noPrice = 0;
|
|
86
|
+
Object.keys(lots).forEach(function (m) {
|
|
87
|
+
const L = lots[m];
|
|
88
|
+
let q = 0, c = 0;
|
|
89
|
+
L.forEach(function (l) { q += l.q; c += l.c; });
|
|
90
|
+
if (q <= 0) return;
|
|
91
|
+
const have = (bal && bal[m]) || 0;
|
|
92
|
+
if (have < q) { c = c * (have / q); q = have; } // if it no longer has that many tokens (moved them), trim
|
|
93
|
+
if (q <= 1e-9) return;
|
|
94
|
+
const p = price[m] || 0, v = q * p;
|
|
95
|
+
if (!p) noPrice++;
|
|
96
|
+
unreal += v - c; costOpen += c;
|
|
97
|
+
open.push({ mint: m, sym: (sym && sym[m]) || short(m), q: q, cost: c, entry: c / q, price: p, value: v, ret: c > 0 ? (v - c) / c : null });
|
|
98
|
+
});
|
|
99
|
+
open.sort(function (a, b) { return b.value - a.value; });
|
|
100
|
+
const base = costClosed + costOpen, total = realized + unreal;
|
|
101
|
+
const p = { n: trades.length, since: trades.length ? trades[0].t : 0, realized: realized, unreal: unreal, total: total, base: base, ret: base > 0 ? total / base : null, open: open.slice(0, 5), noPrice: noPrice, ts: Date.now() };
|
|
102
|
+
p.st = pnlStatus(p, approx);
|
|
103
|
+
return p;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// win | lose | flat | unk
|
|
107
|
+
export function pnlStatus(p, approx) {
|
|
108
|
+
if (!p || p.n < 3 || !(p.base >= 100)) return "unk";
|
|
109
|
+
if (approx) return "unk"; // without real SOL price history there is no reliable base to say win or lose
|
|
110
|
+
if (p.ret >= 0.05) return "win";
|
|
111
|
+
if (p.ret <= -0.05) return "lose";
|
|
112
|
+
return "flat";
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Estimated result of each wallet from its last ~300 swaps (needs a Helius key).
|
|
117
|
+
*
|
|
118
|
+
* @param addrs wallets
|
|
119
|
+
* @param balances {wallet: {mint: amount}} current holdings (from getTokens), to trim moved tokens
|
|
120
|
+
* @returns {pnl: {wallet: result|null}, down, approx}
|
|
121
|
+
*/
|
|
122
|
+
export async function estimatePnl(client, addrs, balances, opts) {
|
|
123
|
+
opts = opts || {};
|
|
124
|
+
if (!client.heliusKey()) throw new ScanError("HELIUS_REQUIRED", "PnL needs a Helius API key");
|
|
125
|
+
const sol = await solHistory(client);
|
|
126
|
+
if (sol.approx && !(sol.now > 0)) throw new ScanError("SOL_PRICE", "no SOL price available");
|
|
127
|
+
const tb = {};
|
|
128
|
+
let done = 0, down = false;
|
|
129
|
+
await pool(addrs, 3, async function (a) {
|
|
130
|
+
if (down) { tb[a] = null; return; }
|
|
131
|
+
try { tb[a] = parseTrades(a, await fetchSwaps(client, a, opts.maxSwaps || 300), sol); }
|
|
132
|
+
catch (e) {
|
|
133
|
+
if (isCancel(e)) throw e;
|
|
134
|
+
tb[a] = null;
|
|
135
|
+
if (e.code === "NETWORK" || (e.code === "HTTP" && (e.data.status === 401 || e.data.status === 403))) down = true;
|
|
136
|
+
}
|
|
137
|
+
done++; if (opts.onStep) opts.onStep(done, addrs.length);
|
|
138
|
+
});
|
|
139
|
+
const set = {};
|
|
140
|
+
addrs.forEach(function (a) { (tb[a] || []).forEach(function (t) { set[t.mint] = 1; }); });
|
|
141
|
+
const ms = Object.keys(set);
|
|
142
|
+
const mk = ms.length ? (await getMarkets(client, ms)).data : {};
|
|
143
|
+
const price = {}, sym = {};
|
|
144
|
+
Object.keys(mk).forEach(function (m) { price[m] = Number(mk[m].priceUsd || 0); sym[m] = (mk[m].baseToken && mk[m].baseToken.symbol) || short(m); });
|
|
145
|
+
const out = {};
|
|
146
|
+
addrs.forEach(function (a) { out[a] = tb[a] ? pnlFromTrades(tb[a], (balances && balances[a]) || {}, price, sym, sol.approx) : null; });
|
|
147
|
+
return { pnl: out, down: down, approx: sol.approx };
|
|
148
|
+
}
|