@potswap_io/mcp 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.
Files changed (45) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +114 -3
  3. package/dist/api.d.ts +41 -0
  4. package/dist/api.js +91 -0
  5. package/dist/config.d.ts +22 -0
  6. package/dist/config.js +24 -0
  7. package/dist/format.d.ts +3 -0
  8. package/dist/format.js +7 -0
  9. package/dist/index.d.ts +2 -0
  10. package/dist/index.js +29 -0
  11. package/dist/limits.d.ts +13 -0
  12. package/dist/limits.js +23 -0
  13. package/dist/redact.d.ts +5 -0
  14. package/dist/redact.js +23 -0
  15. package/dist/runtime.d.ts +15 -0
  16. package/dist/runtime.js +39 -0
  17. package/dist/server.d.ts +20 -0
  18. package/dist/server.js +201 -0
  19. package/dist/shared/errors.d.ts +5 -0
  20. package/dist/shared/errors.js +24 -0
  21. package/dist/shared/fee.d.ts +24 -0
  22. package/dist/shared/fee.js +42 -0
  23. package/dist/shared/lib-types.d.ts +42 -0
  24. package/dist/shared/lib-types.js +1 -0
  25. package/dist/shared/risk.d.ts +15 -0
  26. package/dist/shared/risk.js +35 -0
  27. package/dist/shared/types.d.ts +50 -0
  28. package/dist/shared/types.js +1 -0
  29. package/dist/strategies/engine.d.ts +40 -0
  30. package/dist/strategies/engine.js +100 -0
  31. package/dist/strategies/lock.d.ts +7 -0
  32. package/dist/strategies/lock.js +30 -0
  33. package/dist/strategies/scheduler.d.ts +38 -0
  34. package/dist/strategies/scheduler.js +219 -0
  35. package/dist/strategies/store.d.ts +26 -0
  36. package/dist/strategies/store.js +53 -0
  37. package/dist/strategies/tools.d.ts +10 -0
  38. package/dist/strategies/tools.js +143 -0
  39. package/dist/strategies/types.d.ts +68 -0
  40. package/dist/strategies/types.js +2 -0
  41. package/dist/trade.d.ts +86 -0
  42. package/dist/trade.js +83 -0
  43. package/dist/wallet.d.ts +10 -0
  44. package/dist/wallet.js +48 -0
  45. package/package.json +58 -6
package/dist/server.js ADDED
@@ -0,0 +1,201 @@
1
+ // PRD P10 — the Potswap MCP server. Tools are thin, safe wrappers over the Potswap API.
2
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
3
+ import { Connection, PublicKey } from '@solana/web3.js';
4
+ import { z } from 'zod';
5
+ import { PotswapApi, ApiError } from './api.js';
6
+ import { loadConfig, NO_WALLET, SOL_MINT, USDC_MINT } from './config.js';
7
+ import { buildQuote, executeSwap } from './trade.js';
8
+ import { redact, redactValue } from './redact.js';
9
+ import { loadSigner } from './wallet.js';
10
+ import { riskBadges, riskLevel } from './shared/risk.js';
11
+ import { registerStrategyTools } from './strategies/tools.js';
12
+ export const VERSION = '0.2.0';
13
+ const ok = (data, summary) => ({
14
+ content: [{ type: 'text', text: redact(summary ?? JSON.stringify(data, null, 2)) }], structuredContent: redactValue(data),
15
+ });
16
+ const fail = (message, extra = {}) => ({
17
+ content: [{ type: 'text', text: redact(message) }], structuredContent: redactValue({ error: message, ...extra }), isError: true,
18
+ });
19
+ export function createServer(deps = {}) {
20
+ const config = deps.config ?? loadConfig();
21
+ const signer = deps.signer !== undefined ? deps.signer : loadSigner(config);
22
+ const api = new PotswapApi(config.apiBase, deps.fetchFn);
23
+ const readBalances = deps.balances ?? rpcBalances(config.rpcUrl);
24
+ const trade = { api, config, signer, balances: readBalances };
25
+ const server = new McpServer({ name: 'potswap', version: VERSION });
26
+ const walletOr = (w) => w ?? signer?.publicKey ?? null;
27
+ server.registerTool('search_tokens', {
28
+ title: 'Search Solana tokens',
29
+ description: 'Find tokens by name, symbol or mint address. Returns up to 20 with price, market cap and liquidity.',
30
+ inputSchema: { query: z.string().min(1).max(64) },
31
+ }, async ({ query }) => {
32
+ try {
33
+ const list = (await api.searchTokens(query)).slice(0, 20);
34
+ const tokens = list.map(slimToken);
35
+ return ok({ tokens }, tokens.map((t) => `${t.symbol} (${t.name}) ${t.mint} price=${t.usdPrice ?? '?'} mcap=${t.mcap ?? '?'}${t.isVerified ? ' ✓' : ''}`).join('\n') || 'No tokens found');
36
+ }
37
+ catch (e) {
38
+ return fail(msg(e));
39
+ }
40
+ });
41
+ server.registerTool('get_token', {
42
+ title: 'Token details and risk flags',
43
+ description: 'Full token record: price, mcap, liquidity, holders, 5m/1h/6h/24h stats, audit data and risk badges (riskLevel 0 safe … 3 danger).',
44
+ inputSchema: { mint: z.string().regex(/^[1-9A-HJ-NP-Za-km-z]{32,44}$/) },
45
+ }, async ({ mint }) => {
46
+ try {
47
+ const t = await api.terminalToken(mint);
48
+ const badges = riskBadges(t);
49
+ return ok({ token: t, riskBadges: badges, riskLevel: riskLevel(badges) }, `${t.symbol} (${t.name})\nprice=${t.usdPrice} mcap=${t.mcap} liquidity=${t.liquidity} holders=${t.holderCount}\nrisk ${riskLevel(badges)}/3: ${badges.map((b) => b.label).join(', ')}`);
50
+ }
51
+ catch (e) {
52
+ return fail(msg(e));
53
+ }
54
+ });
55
+ server.registerTool('get_trending', {
56
+ title: 'Trending / new / top / graduating tokens',
57
+ description: 'Discovery lists from Potswap (edge-cached, refreshed every 15 s).',
58
+ inputSchema: { kind: z.enum(['new', 'trending', 'top', 'graduating']).default('trending'), limit: z.number().int().min(1).max(50).default(20) },
59
+ }, async ({ kind, limit }) => {
60
+ try {
61
+ const list = (await api.list(kind)).slice(0, limit);
62
+ const rows = list.map((t) => ({ mint: t.id, symbol: t.symbol, name: t.name, usdPrice: t.usdPrice, mcap: t.mcap, liquidity: t.liquidity, holders: t.holderCount, change5m: t.stats5m?.priceChange, change1h: t.stats1h?.priceChange, bondingProgress: t.bondingProgress, riskLevel: riskLevel(riskBadges(t)) }));
63
+ return ok({ kind, tokens: rows }, rows.map((r) => `${r.symbol} ${r.mint} mcap=${r.mcap} 5m=${r.change5m} risk=${r.riskLevel}`).join('\n'));
64
+ }
65
+ catch (e) {
66
+ return fail(msg(e));
67
+ }
68
+ });
69
+ server.registerTool('get_quote', {
70
+ title: 'Get a swap quote',
71
+ description: 'Best route across Metis, JupiterZ, DFlow and OKX. Amount is in UI units of the input token (1 = 1 SOL). Includes the Potswap fee (returned to the wallet as POT points).',
72
+ inputSchema: quoteShape,
73
+ }, async (a) => {
74
+ try {
75
+ const q = await buildQuote(api, a);
76
+ return ok({ ...q }, `${a.amount} ${q.inputSymbol} → ${q.outAmount} ${q.outputSymbol} via ${q.router} (fee ${q.feeLabel ?? `${q.feeBps} bps`}, impact ${q.priceImpactPct}%)`);
77
+ }
78
+ catch (e) {
79
+ return fail(msg(e));
80
+ }
81
+ });
82
+ server.registerTool('swap', {
83
+ title: 'Swap tokens (signs with the agent wallet)',
84
+ description: 'Executes a swap at the best route. Requires confirm=true; otherwise returns the quote only. Protected by POTSWAP_MAX_TRADE_SOL and POTSWAP_MAX_SLIPPAGE_BPS. Every error states whether funds were debited.',
85
+ inputSchema: { ...quoteShape, confirm: z.boolean().default(false), maxSlippageBps: z.number().int().min(1).max(5000).optional() },
86
+ }, async (a) => {
87
+ const out = await executeSwap(trade, { inputMint: a.inputMint, outputMint: a.outputMint, amount: a.amount, slippageBps: a.slippageBps ?? a.maxSlippageBps, confirm: a.confirm });
88
+ switch (out.kind) {
89
+ case 'no-wallet': return fail(out.message);
90
+ case 'refused': return fail(out.message, { quote: out.quote });
91
+ case 'quote-only': return ok({ confirmRequired: true, quote: out.quote }, `Quote only (confirm=true to execute): ${a.amount} ${out.quote.inputSymbol} → ${out.quote.outAmount} ${out.quote.outputSymbol} via ${out.quote.router}`);
92
+ case 'dry-run': return ok({ dryRun: true, quote: out.quote }, `DRY RUN, nothing signed: ${a.amount} ${out.quote.inputSymbol} → ${out.quote.outAmount} ${out.quote.outputSymbol}`);
93
+ case 'failed': return fail(out.message, { signature: out.signature, explorerUrl: out.explorerUrl, quote: out.quote });
94
+ case 'ok': return ok({ ...out.result }, `Swapped ${a.amount} ${out.result.inputSymbol} → ${out.result.outAmount} ${out.result.outputSymbol} via ${out.result.router}. ${out.result.explorerUrl}`);
95
+ }
96
+ });
97
+ server.registerTool('get_balances', {
98
+ title: 'Wallet balances',
99
+ description: 'SOL and SPL token balances with USD values. Defaults to the agent wallet.',
100
+ inputSchema: { wallet: z.string().regex(/^[1-9A-HJ-NP-Za-km-z]{32,44}$/).optional() },
101
+ }, async ({ wallet }) => {
102
+ const w = walletOr(wallet);
103
+ if (!w)
104
+ return fail(NO_WALLET);
105
+ try {
106
+ const bal = await readBalances(w);
107
+ const mints = Object.keys(bal);
108
+ const prices = await api.prices(mints.slice(0, 50)).catch(() => ({}));
109
+ const rows = mints.map((m) => ({ mint: m, amount: bal[m], usd: prices[m] ? bal[m] * prices[m] : undefined })).sort((x, y) => (y.usd ?? 0) - (x.usd ?? 0));
110
+ const total = rows.reduce((s, r) => s + (r.usd ?? 0), 0);
111
+ return ok({ wallet: w, totalUsd: total, balances: rows }, `${w}\ntotal ≈ $${total.toFixed(2)}\n` + rows.map((r) => `${r.mint === SOL_MINT ? 'SOL' : r.mint}: ${r.amount}${r.usd !== undefined ? ` ($${r.usd.toFixed(2)})` : ''}`).join('\n'));
112
+ }
113
+ catch (e) {
114
+ return fail(msg(e));
115
+ }
116
+ });
117
+ server.registerTool('get_positions', {
118
+ title: 'Positions',
119
+ description: 'Token holdings with 24h price change (PnL arrives in a later version).',
120
+ inputSchema: { wallet: z.string().regex(/^[1-9A-HJ-NP-Za-km-z]{32,44}$/).optional() },
121
+ }, async ({ wallet }) => {
122
+ const w = walletOr(wallet);
123
+ if (!w)
124
+ return fail(NO_WALLET);
125
+ try {
126
+ const bal = await readBalances(w);
127
+ const mints = Object.keys(bal).filter((m) => m !== SOL_MINT).slice(0, 20);
128
+ const infos = mints.length ? await api.searchTokens(mints.join(',')).catch(() => []) : [];
129
+ const prices = await api.prices([SOL_MINT, ...mints]).catch(() => ({}));
130
+ const positions = mints.map((m) => { const t = infos.find((x) => x.id === m); return { mint: m, symbol: t?.symbol ?? m.slice(0, 4), amount: bal[m], usd: prices[m] ? bal[m] * prices[m] : undefined }; });
131
+ return ok({ wallet: w, sol: bal[SOL_MINT] ?? 0, solUsd: prices[SOL_MINT] ? (bal[SOL_MINT] ?? 0) * prices[SOL_MINT] : undefined, positions });
132
+ }
133
+ catch (e) {
134
+ return fail(msg(e));
135
+ }
136
+ });
137
+ server.registerTool('get_points', {
138
+ title: 'POT points',
139
+ description: "Season 1 points for a wallet: this epoch, all-time, POT allocated and claimable, next unlock. Defaults to the agent wallet.",
140
+ inputSchema: { wallet: z.string().regex(/^[1-9A-HJ-NP-Za-km-z]{32,44}$/).optional() },
141
+ }, async ({ wallet }) => {
142
+ const w = walletOr(wallet);
143
+ if (!w)
144
+ return fail(NO_WALLET);
145
+ try {
146
+ return ok({ enabled: true, ...(await api.points(w)) });
147
+ }
148
+ catch (e) {
149
+ if (e instanceof ApiError && e.status === 503)
150
+ return ok({ enabled: false, wallet: w, note: 'The points engine is not enabled yet. Swaps made now still pay the Potswap fee; points start with Season 1.' });
151
+ return fail(msg(e));
152
+ }
153
+ });
154
+ server.registerTool('get_leaderboard', {
155
+ title: 'Leaderboard',
156
+ description: 'Top wallets by Potswap fee paid, with POT allocated.',
157
+ inputSchema: {},
158
+ }, async () => {
159
+ try {
160
+ return ok({ enabled: true, ...(await api.leaderboard()) });
161
+ }
162
+ catch (e) {
163
+ if (e instanceof ApiError && e.status === 503)
164
+ return ok({ enabled: false, note: 'The points engine is not enabled yet.' });
165
+ return fail(msg(e));
166
+ }
167
+ });
168
+ registerStrategyTools(server, { trade, store: deps.strategyStore, scheduler: deps.scheduler });
169
+ return server;
170
+ }
171
+ const quoteShape = {
172
+ inputMint: z.string().regex(/^[1-9A-HJ-NP-Za-km-z]{32,44}$/).describe('Mint of the token to sell (SOL = So11111111111111111111111111111111111111112)'),
173
+ outputMint: z.string().regex(/^[1-9A-HJ-NP-Za-km-z]{32,44}$/).describe('Mint of the token to buy'),
174
+ amount: z.number().positive().describe('Amount in UI units of the input token (1 = 1 SOL)'),
175
+ slippageBps: z.number().int().min(1).max(5000).optional().describe('Fixed slippage in bps; omit for auto'),
176
+ };
177
+ function slimToken(t) {
178
+ return { mint: t.id, symbol: t.symbol, name: t.name, decimals: t.decimals, usdPrice: t.usdPrice, mcap: t.mcap, liquidity: t.liquidity, isVerified: t.isVerified ?? false };
179
+ }
180
+ function msg(e) { return e instanceof Error ? e.message : String(e); }
181
+ function rpcBalances(rpcUrl) {
182
+ return async (wallet) => {
183
+ const conn = new Connection(rpcUrl, 'confirmed');
184
+ const owner = new PublicKey(wallet);
185
+ const out = {};
186
+ const [lamports, spl, t22] = await Promise.all([
187
+ conn.getBalance(owner),
188
+ conn.getParsedTokenAccountsByOwner(owner, { programId: new PublicKey('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA') }),
189
+ conn.getParsedTokenAccountsByOwner(owner, { programId: new PublicKey('TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb') }).catch(() => ({ value: [] })),
190
+ ]);
191
+ out[SOL_MINT] = lamports / 1e9;
192
+ for (const { account } of [...spl.value, ...t22.value]) {
193
+ const info = account.data.parsed?.info;
194
+ const ui = Number(info?.tokenAmount?.uiAmount ?? 0);
195
+ if (info && ui > 0)
196
+ out[info.mint] = (out[info.mint] ?? 0) + ui;
197
+ }
198
+ return out;
199
+ };
200
+ }
201
+ export { SOL_MINT, USDC_MINT };
@@ -0,0 +1,5 @@
1
+ import type { OrderResponse } from './lib-types.js';
2
+ /** Maps /order error codes (transaction empty) to user copy. */
3
+ export declare function describeOrderError(o: Pick<OrderResponse, 'errorCode' | 'errorMessage'>): string;
4
+ /** User-facing error copy. Every message states whether funds were debited. */
5
+ export declare function friendlyError(msg: string): string;
@@ -0,0 +1,24 @@
1
+ /** Maps /order error codes (transaction empty) to user copy. */
2
+ export function describeOrderError(o) {
3
+ const map = {
4
+ 1: 'Insufficient balance for this swap.',
5
+ 2: 'Not enough SOL to pay network fees. Keep at least 0.01 SOL.',
6
+ 3: 'Amount too small for this route. Try a larger amount.',
7
+ };
8
+ return (o.errorCode && map[o.errorCode]) || o.errorMessage || 'Could not build the swap. Try again.';
9
+ }
10
+ /** User-facing error copy. Every message states whether funds were debited. */
11
+ export function friendlyError(msg) {
12
+ const m = msg.toLowerCase();
13
+ if (m.includes('user rejected') || m.includes('rejected the request'))
14
+ return 'You rejected the transaction in your wallet. No funds were debited.';
15
+ if (m.includes('slippage') || m.includes('0x1771') || m.includes('6001'))
16
+ return 'Price moved beyond your slippage tolerance. No funds were debited. Try again or raise slippage in settings.';
17
+ if (m.includes('insufficient'))
18
+ return 'Insufficient balance for this swap, including network fees. No funds were debited.';
19
+ if (m.includes('expired') || m.includes('-1005') || m.includes('block height'))
20
+ return 'The quote expired before the transaction landed. No funds were debited. Please try again.';
21
+ if (m.includes('failed to land') || m.includes('-1000'))
22
+ return 'The network was congested and the transaction did not land. No funds were debited. Please try again.';
23
+ return `${msg} If the transaction did not land, no funds were debited.`;
24
+ }
@@ -0,0 +1,24 @@
1
+ export interface FeeSchedule {
2
+ standardBps: number;
3
+ sizeBps: number;
4
+ sizeThresholdUsd: number;
5
+ stakerDiscountBps: number;
6
+ /** POT that must be staked for the discount; null until TGE (discount disabled). */
7
+ stakerMinPot: number | null;
8
+ }
9
+ export declare const JUP_MIN_BPS = 50;
10
+ export declare const JUP_MAX_BPS = 255;
11
+ export declare const DEFAULT_FEE_SCHEDULE: FeeSchedule;
12
+ export type FeeTier = 'standard' | 'size' | 'staker';
13
+ export interface FeeDecision {
14
+ bps: number;
15
+ tier: FeeTier;
16
+ label: string;
17
+ }
18
+ /**
19
+ * @param inputUsd USD value of the input amount at quote time; undefined when no price is known (→ standard tier)
20
+ * @param stakedPot POT staked by the wallet; 0 / undefined when unknown
21
+ */
22
+ export declare function feeBpsFor(schedule: FeeSchedule, inputUsd?: number, stakedPot?: number): FeeDecision;
23
+ /** Parses the schedule from /api/config, falling back to defaults field by field. */
24
+ export declare function parseFeeSchedule(raw: unknown): FeeSchedule;
@@ -0,0 +1,42 @@
1
+ // GENERATED by scripts/sync-shared.mjs from app/src/lib/fee.ts. Do not edit here.
2
+ // PRD P8.12–P8.15 — the fee schedule. One pure function shared by the web app and the MCP
3
+ // (mcp/src/shared/fee.ts is a generated copy). Jupiter allows 50–255 bps.
4
+ export const JUP_MIN_BPS = 50;
5
+ export const JUP_MAX_BPS = 255;
6
+ export const DEFAULT_FEE_SCHEDULE = {
7
+ standardBps: 250,
8
+ sizeBps: 100,
9
+ sizeThresholdUsd: 1000,
10
+ stakerDiscountBps: 100,
11
+ stakerMinPot: null,
12
+ };
13
+ const clamp = (bps) => Math.min(JUP_MAX_BPS, Math.max(JUP_MIN_BPS, Math.round(bps)));
14
+ /**
15
+ * @param inputUsd USD value of the input amount at quote time; undefined when no price is known (→ standard tier)
16
+ * @param stakedPot POT staked by the wallet; 0 / undefined when unknown
17
+ */
18
+ export function feeBpsFor(schedule, inputUsd, stakedPot = 0) {
19
+ const size = inputUsd !== undefined && inputUsd >= schedule.sizeThresholdUsd;
20
+ let bps = size ? schedule.sizeBps : schedule.standardBps;
21
+ let tier = size ? 'size' : 'standard';
22
+ if (schedule.stakerMinPot !== null && stakedPot >= schedule.stakerMinPot) {
23
+ bps -= schedule.stakerDiscountBps;
24
+ tier = 'staker';
25
+ }
26
+ bps = clamp(bps);
27
+ const pct = `${bps / 100}%`;
28
+ const label = tier === 'standard' ? pct : tier === 'size' ? `${pct} (size tier)` : `${pct} (staker)`;
29
+ return { bps, tier, label };
30
+ }
31
+ /** Parses the schedule from /api/config, falling back to defaults field by field. */
32
+ export function parseFeeSchedule(raw) {
33
+ const r = (raw ?? {});
34
+ const num = (v, d) => (typeof v === 'number' && Number.isFinite(v) ? v : d);
35
+ return {
36
+ standardBps: clamp(num(r.standardBps, DEFAULT_FEE_SCHEDULE.standardBps)),
37
+ sizeBps: clamp(num(r.sizeBps, DEFAULT_FEE_SCHEDULE.sizeBps)),
38
+ sizeThresholdUsd: num(r.sizeThresholdUsd, DEFAULT_FEE_SCHEDULE.sizeThresholdUsd),
39
+ stakerDiscountBps: num(r.stakerDiscountBps, DEFAULT_FEE_SCHEDULE.stakerDiscountBps),
40
+ stakerMinPot: typeof r.stakerMinPot === 'number' && r.stakerMinPot > 0 ? r.stakerMinPot : null,
41
+ };
42
+ }
@@ -0,0 +1,42 @@
1
+ export interface Token {
2
+ id: string;
3
+ symbol: string;
4
+ name: string;
5
+ icon?: string;
6
+ decimals: number;
7
+ isVerified?: boolean;
8
+ usdPrice?: number;
9
+ }
10
+ export interface OrderResponse {
11
+ router: 'metis' | 'jupiterz' | 'dflow' | 'okx' | string;
12
+ inAmount: string;
13
+ outAmount: string;
14
+ inUsdValue?: number;
15
+ outUsdValue?: number;
16
+ priceImpactPct?: string | number;
17
+ slippageBps?: number;
18
+ feeBps?: number;
19
+ feeMint?: string;
20
+ platformFee?: {
21
+ amount: string;
22
+ feeBps: number;
23
+ feeMint: string;
24
+ };
25
+ transaction: string | null;
26
+ requestId: string;
27
+ lastValidBlockHeight?: number;
28
+ expireAt?: string;
29
+ errorCode?: number;
30
+ errorMessage?: string;
31
+ mode?: string;
32
+ transactionVersion?: number;
33
+ }
34
+ export interface ExecuteResponse {
35
+ status: 'Success' | 'Failed';
36
+ signature?: string;
37
+ code: number;
38
+ error?: string;
39
+ totalInputAmount?: string;
40
+ totalOutputAmount?: string;
41
+ }
42
+ export type SwapStage = 'idle' | 'signing' | 'sending' | 'confirming' | 'success' | 'failed';
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,15 @@
1
+ import type { TerminalToken } from './types.js';
2
+ export type Tone = 'good' | 'warn' | 'bad' | 'muted';
3
+ export interface Badge {
4
+ key: string;
5
+ icon: string;
6
+ label: string;
7
+ tone: Tone;
8
+ tip: string;
9
+ }
10
+ export declare const TOP_HOLDER_WARN = 30;
11
+ export declare const DEV_BALANCE_WARN = 10;
12
+ export declare const DEV_MINTS_WARN = 20;
13
+ export declare function riskBadges(t: Pick<TerminalToken, 'audit' | 'isVerified'>): Badge[];
14
+ /** 0 (safe) … 3 (danger) summary used for row colouring. */
15
+ export declare function riskLevel(badges: Badge[]): number;
@@ -0,0 +1,35 @@
1
+ export const TOP_HOLDER_WARN = 30; // %
2
+ export const DEV_BALANCE_WARN = 10; // %
3
+ export const DEV_MINTS_WARN = 20;
4
+ export function riskBadges(t) {
5
+ const a = t.audit;
6
+ if (!a)
7
+ return [{ key: 'unaudited', icon: '?', label: 'Unaudited', tone: 'muted', tip: 'No audit data for this token yet.' }];
8
+ const out = [];
9
+ out.push(a.mintAuthorityDisabled
10
+ ? { key: 'mint', icon: '🔒', label: 'Mint revoked', tone: 'good', tip: 'Nobody can mint more supply.' }
11
+ : { key: 'mint', icon: '🖨️', label: 'Mintable', tone: 'bad', tip: 'The creator can still mint new supply.' });
12
+ out.push(a.freezeAuthorityDisabled
13
+ ? { key: 'freeze', icon: '🧊', label: 'Freeze revoked', tone: 'good', tip: 'Token accounts cannot be frozen.' }
14
+ : { key: 'freeze', icon: '❄️', label: 'Freezable', tone: 'bad', tip: 'The creator can freeze your tokens.' });
15
+ if (a.topHoldersPercentage !== undefined && a.topHoldersPercentage > TOP_HOLDER_WARN) {
16
+ out.push({ key: 'top', icon: '⚠', label: `Top holders ${a.topHoldersPercentage.toFixed(0)}%`, tone: 'warn', tip: 'A few wallets hold a large share of the supply.' });
17
+ }
18
+ if (a.devBalancePercentage !== undefined && a.devBalancePercentage > DEV_BALANCE_WARN) {
19
+ out.push({ key: 'dev', icon: '🧑‍💻', label: `Dev holds ${a.devBalancePercentage.toFixed(0)}%`, tone: 'warn', tip: 'The deployer wallet still holds a large share.' });
20
+ }
21
+ if (a.devMints !== undefined && a.devMints > DEV_MINTS_WARN) {
22
+ out.push({ key: 'serial', icon: '🔁', label: `Dev launched ${a.devMints.toLocaleString()} tokens`, tone: 'bad', tip: 'This deployer has launched many tokens before.' });
23
+ }
24
+ if (t.isVerified)
25
+ out.push({ key: 'verified', icon: '✓', label: 'Verified', tone: 'good', tip: 'On Jupiter\'s verified list.' });
26
+ return out;
27
+ }
28
+ /** 0 (safe) … 3 (danger) summary used for row colouring. */
29
+ export function riskLevel(badges) {
30
+ const bad = badges.filter((b) => b.tone === 'bad').length;
31
+ const warn = badges.filter((b) => b.tone === 'warn').length;
32
+ if (badges.some((b) => b.key === 'unaudited'))
33
+ return 1;
34
+ return Math.min(3, bad * 2 + warn);
35
+ }
@@ -0,0 +1,50 @@
1
+ export interface TokenStats {
2
+ priceChange?: number;
3
+ buyVolume?: number;
4
+ sellVolume?: number;
5
+ numBuys?: number;
6
+ numSells?: number;
7
+ numTraders?: number;
8
+ numNetBuyers?: number;
9
+ }
10
+ export interface TokenAudit {
11
+ mintAuthorityDisabled?: boolean;
12
+ freezeAuthorityDisabled?: boolean;
13
+ topHoldersPercentage?: number;
14
+ devBalancePercentage?: number;
15
+ devMigrations?: number;
16
+ devMints?: number;
17
+ }
18
+ export interface TerminalToken {
19
+ id: string;
20
+ symbol: string;
21
+ name: string;
22
+ icon?: string;
23
+ decimals: number;
24
+ usdPrice?: number;
25
+ mcap?: number;
26
+ fdv?: number;
27
+ liquidity?: number;
28
+ holderCount?: number;
29
+ organicScore?: number;
30
+ organicScoreLabel?: string;
31
+ launchpad?: string;
32
+ graduatedPool?: string;
33
+ graduatedAt?: string;
34
+ firstPool?: {
35
+ id: string;
36
+ createdAt: string;
37
+ };
38
+ createdAt?: string;
39
+ tags?: string[];
40
+ audit?: TokenAudit;
41
+ stats5m?: TokenStats;
42
+ stats1h?: TokenStats;
43
+ stats6h?: TokenStats;
44
+ stats24h?: TokenStats;
45
+ isVerified?: boolean;
46
+ /** Added by the graduating list: estimated bonding-curve progress 0–1. */
47
+ bondingProgress?: number;
48
+ }
49
+ export type ListKind = 'new' | 'trending' | 'top' | 'graduating';
50
+ export declare const LIST_KINDS: ListKind[];
@@ -0,0 +1 @@
1
+ export const LIST_KINDS = ['new', 'trending', 'top', 'graduating'];
@@ -0,0 +1,40 @@
1
+ import type { DcaStrategy, RebalanceStrategy, Run, Strategy, TpSlStrategy } from './types.js';
2
+ export declare const SOL_MINT = "So11111111111111111111111111111111111111112";
3
+ /** True when a time-based strategy should run at `now`. */
4
+ export declare function isDue(s: Strategy, now: number): boolean;
5
+ /**
6
+ * Next run after a run at `now`. A missed window runs once; the next slot is
7
+ * always in the future (P11.2), never a catch-up burst.
8
+ */
9
+ export declare function nextRunAfter(s: DcaStrategy | RebalanceStrategy, now: number): string;
10
+ export declare function dcaIsComplete(s: DcaStrategy): boolean;
11
+ export interface Leg {
12
+ inputMint: string;
13
+ outputMint: string;
14
+ amount: number;
15
+ reason: string;
16
+ }
17
+ /**
18
+ * Rebalance plan (P11.4): weights from balances × prices; assets drifting more than
19
+ * `driftPct` points are brought back via SOL. Returns [] when nothing drifts.
20
+ * Unpriced targets are excluded and reported in `warnings`.
21
+ */
22
+ export declare function rebalancePlan(s: RebalanceStrategy, balances: Record<string, number>, prices: Record<string, number>): {
23
+ legs: Leg[];
24
+ warnings: string[];
25
+ weights: Record<string, number>;
26
+ };
27
+ export type TpSlTrigger = {
28
+ trigger: 'take-profit' | 'stop-loss' | 'trailing-stop';
29
+ threshold: number;
30
+ } | null;
31
+ /** TP/SL decision (P11.5). `price` undefined = no decision. Returns the updated high-water mark too. */
32
+ export declare function tpslDecision(s: TpSlStrategy, price: number | undefined): {
33
+ trigger: TpSlTrigger;
34
+ highWater: number;
35
+ };
36
+ /** SOL-equivalent spent by all strategies in the UTC day containing `now` (P11.6). */
37
+ export declare function solSpentToday(runs: Run[], now: number): number;
38
+ export declare function dcaLegFor(s: DcaStrategy): Leg;
39
+ export declare const MAX_CONSECUTIVE_FAILURES = 3;
40
+ export declare function newId(prefix: string, now?: number): string;
@@ -0,0 +1,100 @@
1
+ export const SOL_MINT = 'So11111111111111111111111111111111111111112';
2
+ const MIN = 60_000;
3
+ /** True when a time-based strategy should run at `now`. */
4
+ export function isDue(s, now) {
5
+ if (s.status !== 'active' || !s.nextRunAt)
6
+ return false;
7
+ return Date.parse(s.nextRunAt) <= now;
8
+ }
9
+ /**
10
+ * Next run after a run at `now`. A missed window runs once; the next slot is
11
+ * always in the future (P11.2), never a catch-up burst.
12
+ */
13
+ export function nextRunAfter(s, now) {
14
+ const interval = (s.kind === 'dca' ? s.intervalMinutes : s.checkIntervalMinutes) * MIN;
15
+ return new Date(now + interval).toISOString();
16
+ }
17
+ export function dcaIsComplete(s) {
18
+ return s.totalRuns !== undefined && s.runsDone >= s.totalRuns;
19
+ }
20
+ /**
21
+ * Rebalance plan (P11.4): weights from balances × prices; assets drifting more than
22
+ * `driftPct` points are brought back via SOL. Returns [] when nothing drifts.
23
+ * Unpriced targets are excluded and reported in `warnings`.
24
+ */
25
+ export function rebalancePlan(s, balances, prices) {
26
+ const warnings = [];
27
+ const mints = Object.keys(s.targets);
28
+ const priced = mints.filter((m) => prices[m] > 0);
29
+ for (const m of mints)
30
+ if (!(prices[m] > 0))
31
+ warnings.push(`No price for ${m}; it is excluded until one is available.`);
32
+ const values = Object.fromEntries(priced.map((m) => [m, (balances[m] ?? 0) * prices[m]]));
33
+ const total = Object.values(values).reduce((a, b) => a + b, 0);
34
+ const weights = {};
35
+ if (total <= 0)
36
+ return { legs: [], warnings, weights };
37
+ for (const m of priced)
38
+ weights[m] = (values[m] / total) * 100;
39
+ // Re-normalise targets over priced assets only.
40
+ const targetSum = priced.reduce((a, m) => a + s.targets[m], 0);
41
+ const targets = Object.fromEntries(priced.map((m) => [m, (s.targets[m] / targetSum) * 100]));
42
+ const drifted = priced.some((m) => Math.abs(weights[m] - targets[m]) > s.driftPct);
43
+ if (!drifted)
44
+ return { legs: [], warnings, weights };
45
+ const legs = [];
46
+ const deltaUsd = Object.fromEntries(priced.map((m) => [m, (targets[m] / 100) * total - values[m]])); // + = buy, − = sell
47
+ // Sells first (overweight non-SOL → SOL), then buys (SOL → underweight).
48
+ for (const m of priced) {
49
+ if (m === SOL_MINT)
50
+ continue;
51
+ const d = deltaUsd[m];
52
+ if (d < 0 && -d >= s.minTradeUsd)
53
+ legs.push({ inputMint: m, outputMint: SOL_MINT, amount: round(-d / prices[m]), reason: `sell overweight ${pct(weights[m])}% → ${pct(targets[m])}%` });
54
+ }
55
+ for (const m of priced) {
56
+ if (m === SOL_MINT)
57
+ continue;
58
+ const d = deltaUsd[m];
59
+ if (d > 0 && d >= s.minTradeUsd)
60
+ legs.push({ inputMint: SOL_MINT, outputMint: m, amount: round(d / prices[SOL_MINT]), reason: `buy underweight ${pct(weights[m])}% → ${pct(targets[m])}%` });
61
+ }
62
+ return { legs, warnings, weights };
63
+ }
64
+ /** TP/SL decision (P11.5). `price` undefined = no decision. Returns the updated high-water mark too. */
65
+ export function tpslDecision(s, price) {
66
+ if (price === undefined || !(price > 0))
67
+ return { trigger: null, highWater: s.highWater };
68
+ const highWater = Math.max(s.highWater, price);
69
+ if (s.takeProfitPct !== undefined) {
70
+ const t = s.entryPrice * (1 + s.takeProfitPct / 100);
71
+ if (price >= t)
72
+ return { trigger: { trigger: 'take-profit', threshold: t }, highWater };
73
+ }
74
+ if (s.trailingPct !== undefined) {
75
+ const t = highWater * (1 - s.trailingPct / 100);
76
+ if (price <= t && highWater > s.entryPrice)
77
+ return { trigger: { trigger: 'trailing-stop', threshold: t }, highWater };
78
+ }
79
+ if (s.stopLossPct !== undefined) {
80
+ const t = s.entryPrice * (1 - s.stopLossPct / 100);
81
+ if (price <= t)
82
+ return { trigger: { trigger: 'stop-loss', threshold: t }, highWater };
83
+ }
84
+ return { trigger: null, highWater };
85
+ }
86
+ /** SOL-equivalent spent by all strategies in the UTC day containing `now` (P11.6). */
87
+ export function solSpentToday(runs, now) {
88
+ const dayStart = new Date(now);
89
+ dayStart.setUTCHours(0, 0, 0, 0);
90
+ return runs.filter((r) => r.outcome === 'ok' && Date.parse(r.at) >= dayStart.getTime()).reduce((a, r) => a + (r.solEquivalent ?? 0), 0);
91
+ }
92
+ export function dcaLegFor(s) {
93
+ return { inputMint: s.inputMint, outputMint: s.outputMint, amount: s.amountPerRun, reason: `DCA run ${s.runsDone + 1}${s.totalRuns ? `/${s.totalRuns}` : ''}` };
94
+ }
95
+ export const MAX_CONSECUTIVE_FAILURES = 3;
96
+ const round = (n) => Math.floor(n * 1e9) / 1e9;
97
+ const pct = (n) => n.toFixed(1);
98
+ export function newId(prefix, now = Date.now()) {
99
+ return `${prefix}_${now.toString(36)}${Math.random().toString(36).slice(2, 6)}`;
100
+ }
@@ -0,0 +1,7 @@
1
+ export interface Lock {
2
+ acquired: boolean;
3
+ ownerPid: number | null;
4
+ release(): void;
5
+ }
6
+ export declare function pidAlive(pid: number, kill?: (pid: number, sig: 0) => void): boolean;
7
+ export declare function acquireLock(dir: string, pid?: number, alive?: (pid: number) => boolean): Lock;
@@ -0,0 +1,30 @@
1
+ // PRD P11.11/P11.12 — one scheduler per state directory. The lock is a file holding the owner PID;
2
+ // a lock whose PID is no longer alive is stale and may be reclaimed.
3
+ import { existsSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
4
+ import { join } from 'node:path';
5
+ export function pidAlive(pid, kill = process.kill) {
6
+ try {
7
+ kill(pid, 0);
8
+ return true;
9
+ }
10
+ catch (e) {
11
+ return e.code === 'EPERM';
12
+ }
13
+ }
14
+ export function acquireLock(dir, pid = process.pid, alive = pidAlive) {
15
+ const file = join(dir, 'scheduler.lock');
16
+ if (existsSync(file)) {
17
+ const owner = Number(readFileSync(file, 'utf8').trim());
18
+ if (owner && owner !== pid && alive(owner))
19
+ return { acquired: false, ownerPid: owner, release() { } };
20
+ }
21
+ writeFileSync(file, String(pid));
22
+ return {
23
+ acquired: true, ownerPid: pid,
24
+ release() { try {
25
+ if (Number(readFileSync(file, 'utf8').trim()) === pid)
26
+ unlinkSync(file);
27
+ }
28
+ catch { /* already gone */ } },
29
+ };
30
+ }