@potswap_io/mcp 0.2.0 → 0.3.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/README.md +22 -4
- package/dist/agentTools.d.ts +44 -0
- package/dist/agentTools.js +179 -0
- package/dist/api.d.ts +17 -0
- package/dist/api.js +2 -0
- package/dist/config.d.ts +11 -1
- package/dist/config.js +12 -1
- package/dist/history.d.ts +72 -0
- package/dist/history.js +53 -0
- package/dist/pnl.d.ts +16 -0
- package/dist/pnl.js +45 -0
- package/dist/runtime.js +3 -3
- package/dist/server.d.ts +6 -1
- package/dist/server.js +4 -22
- package/dist/strategies/engine.d.ts +4 -1
- package/dist/strategies/engine.js +17 -0
- package/dist/strategies/scheduler.d.ts +1 -0
- package/dist/strategies/scheduler.js +25 -2
- package/dist/strategies/tools.js +20 -1
- package/dist/strategies/types.d.ts +11 -1
- package/dist/trade.js +4 -1
- package/dist/transfer.d.ts +49 -0
- package/dist/transfer.js +86 -0
- package/dist/wallet.d.ts +13 -1
- package/dist/wallet.js +41 -12
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -52,12 +52,15 @@ Without a key the server still runs in read-only mode (search, quotes, trending,
|
|
|
52
52
|
|---|---|---|
|
|
53
53
|
| `SOLANA_PRIVATE_KEY` | — | base58 secret key (64 bytes) or JSON byte array, as exported by Phantom / `solana-keygen` |
|
|
54
54
|
| `SOLANA_KEYPAIR_PATH` | — | path to a `solana-keygen` JSON file; used when `SOLANA_PRIVATE_KEY` is unset |
|
|
55
|
+
| *(none)* | `~/.potswap/wallet.json` | created by `wallet_create`; loaded when neither variable is set |
|
|
55
56
|
| `SOLANA_RPC_URL` | Potswap's RPC proxy | your own RPC if you trade a lot |
|
|
56
57
|
| `POTSWAP_MAX_TRADE_SOL` | `1` | refuse any swap whose input is worth more than this many SOL |
|
|
57
58
|
| `POTSWAP_MAX_SLIPPAGE_BPS` | `300` | refuse requested slippage above this |
|
|
58
59
|
| `POTSWAP_MAX_DAILY_SOL` | `5` | daily SOL-equivalent cap across all strategies |
|
|
59
|
-
| `POTSWAP_STATE_DIR` | `~/.potswap` | where strategies and run logs are stored |
|
|
60
|
-
| `
|
|
60
|
+
| `POTSWAP_STATE_DIR` | `~/.potswap` | where the agent wallet, strategies and run logs are stored |
|
|
61
|
+
| `POTSWAP_ALLOWED_TOKENS` | — | comma-separated mints; when set, every swap, strategy leg and stake must stay inside the list |
|
|
62
|
+
| `POTSWAP_ALLOWED_RECIPIENTS` | — | comma-separated addresses; when set, `transfer` only sends to these |
|
|
63
|
+
| `POTSWAP_DRY_RUN` | `false` | `true` = `swap`, `transfer`, stakes and strategy legs never sign; they return what they would have done |
|
|
61
64
|
| `POTSWAP_API` | `https://www.potswap.io` | base URL |
|
|
62
65
|
|
|
63
66
|
Use a dedicated agent wallet with only the funds you want the agent to control. Keep the caps low until you trust the agent's behaviour.
|
|
@@ -72,13 +75,27 @@ Use a dedicated agent wallet with only the funds you want the agent to control.
|
|
|
72
75
|
| `get_quote {inputMint, outputMint, amount}` | Best route, fee, impact, router. `amount` is in UI units (1 = 1 SOL) |
|
|
73
76
|
| `swap {…, confirm: true}` | Executes at the best route and returns the signature, amounts, fee and POT points |
|
|
74
77
|
| `get_balances {wallet?}` | SOL + SPL balances with USD |
|
|
75
|
-
| `get_positions {wallet?}` | Holdings with USD
|
|
78
|
+
| `get_positions {wallet?}` | Holdings with USD value, average cost, unrealised and realised PnL (from swap history) |
|
|
76
79
|
| `get_points {wallet?}` | Season 1 points, POT allocated / claimable, next unlock |
|
|
77
80
|
| `get_leaderboard` | Top wallets by fee paid |
|
|
81
|
+
| `get_price_history {mint, timeframe, limit}` | OHLCV candles from the token's top pool (`1m`…`1d`) |
|
|
82
|
+
|
|
83
|
+
### Wallet and operations (0.3.0)
|
|
84
|
+
|
|
85
|
+
| Tool | Does |
|
|
86
|
+
|---|---|
|
|
87
|
+
| `wallet_create {confirm: true}` | Generates a keypair into `POTSWAP_STATE_DIR/wallet.json` (mode 0600) when no wallet is configured. Returns the public key only. **Back the file up**; it is the only copy. |
|
|
88
|
+
| `get_wallet` | Public key, funding instructions, active limits and allowlists, scheduler status |
|
|
89
|
+
| `transfer {to, mint?, amount, confirm: true}` | Sends SOL or any SPL / Token-2022 token, creating the recipient's token account if needed. No Potswap fee. Honours `POTSWAP_ALLOWED_RECIPIENTS`, `POTSWAP_MAX_TRADE_SOL` and the 0.01 SOL reserve |
|
|
90
|
+
| `get_transaction {signature}` | processed / confirmed / finalized / failed / not found, fee, and the wallet's SOL and token balance changes |
|
|
91
|
+
| `get_history {wallet?, limit}` | Recent parsed transactions: swaps (in/out amounts), transfers (direction, counterparty), other |
|
|
92
|
+
| `stake_sol {amount, provider, confirm: true}` | Liquid-stakes SOL into jitoSOL (default) or mSOL at the best route; normal fee and points. `unstake_sol` is the reverse |
|
|
93
|
+
|
|
94
|
+
Agents that start from nothing: `wallet_create` → fund the address → `get_quote` → `swap`. Set `POTSWAP_ALLOWED_TOKENS` and `POTSWAP_ALLOWED_RECIPIENTS` before giving an agent an unattended wallet.
|
|
78
95
|
|
|
79
96
|
Example prompt: *"Check BONK's risk flags, and if riskLevel is 0 or 1 buy 0.1 SOL worth."* The agent calls `get_token`, then `swap` with `confirm: true`; you can require that it shows you the quote first by leaving `POTSWAP_DRY_RUN=true` until you are comfortable.
|
|
80
97
|
|
|
81
|
-
## Strategies: DCA, rebalance, take-profit / stop-loss
|
|
98
|
+
## Strategies: DCA, rebalance, take-profit / stop-loss, limit orders
|
|
82
99
|
|
|
83
100
|
Standing orders that keep running without you. Every leg is an ordinary Potswap swap (fee + POT points).
|
|
84
101
|
|
|
@@ -87,6 +104,7 @@ Standing orders that keep running without you. Every leg is an ordinary Potswap
|
|
|
87
104
|
| `dca_create` | `{ inputMint: SOL, outputMint: USDC, amountPerRun: 0.1, intervalMinutes: 60, totalRuns: 24, confirm: true }` |
|
|
88
105
|
| `rebalance_create` | `{ targets: { SOL: 60, USDC: 40 }, driftPct: 5, checkIntervalMinutes: 60, confirm: true }` (mints, not symbols) |
|
|
89
106
|
| `tpsl_create` | `{ mint: BONK, sellPct: 50, takeProfitPct: 50, stopLossPct: 20, trailingPct: 15, confirm: true }` |
|
|
107
|
+
| `limit_order_create` | `{ side: 'buy', mint: BONK, amount: 0.1, triggerPriceUsd: 0.00001, condition: 'below', expiresAt?, confirm: true }` — fills once when the price crosses, then completes |
|
|
90
108
|
| `strategy_list`, `strategy_runs {id}`, `strategy_pause/resume/delete {id}` | manage and audit |
|
|
91
109
|
|
|
92
110
|
**Who runs the clock.** Strategies execute inside the MCP server while your host app is open. For 24/7 operation run the daemon on any always-on machine:
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
|
+
import { Connection } from '@solana/web3.js';
|
|
3
|
+
import { type Config } from './config.js';
|
|
4
|
+
import { type TradeContext } from './trade.js';
|
|
5
|
+
import { type Rpc } from './transfer.js';
|
|
6
|
+
/** Transaction-level RPC reads; injectable for tests. */
|
|
7
|
+
export interface TxRpc {
|
|
8
|
+
getSignatureStatus(sig: string): Promise<{
|
|
9
|
+
confirmationStatus?: string;
|
|
10
|
+
err?: unknown;
|
|
11
|
+
slot?: number;
|
|
12
|
+
} | null>;
|
|
13
|
+
getParsedTransaction(sig: string): Promise<{
|
|
14
|
+
slot: number;
|
|
15
|
+
fee: number;
|
|
16
|
+
err: unknown;
|
|
17
|
+
preBalances: number[];
|
|
18
|
+
postBalances: number[];
|
|
19
|
+
accountKeys: string[];
|
|
20
|
+
preTokenBalances: {
|
|
21
|
+
mint: string;
|
|
22
|
+
owner?: string;
|
|
23
|
+
uiTokenAmount: {
|
|
24
|
+
uiAmount: number | null;
|
|
25
|
+
};
|
|
26
|
+
}[];
|
|
27
|
+
postTokenBalances: {
|
|
28
|
+
mint: string;
|
|
29
|
+
owner?: string;
|
|
30
|
+
uiTokenAmount: {
|
|
31
|
+
uiAmount: number | null;
|
|
32
|
+
};
|
|
33
|
+
}[];
|
|
34
|
+
} | null>;
|
|
35
|
+
}
|
|
36
|
+
export declare function txRpcFromConnection(conn: Connection): TxRpc;
|
|
37
|
+
export interface AgentToolDeps {
|
|
38
|
+
trade: TradeContext;
|
|
39
|
+
config: Config;
|
|
40
|
+
rpc?: Rpc;
|
|
41
|
+
txRpc?: TxRpc;
|
|
42
|
+
schedulerActive?: () => boolean;
|
|
43
|
+
}
|
|
44
|
+
export declare function registerAgentTools(server: McpServer, deps: AgentToolDeps): void;
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { Connection } from '@solana/web3.js';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { JITOSOL_MINT, MSOL_MINT, NO_WALLET, SOL_MINT } from './config.js';
|
|
4
|
+
import { redact, redactValue } from './redact.js';
|
|
5
|
+
import { createWallet } from './wallet.js';
|
|
6
|
+
import { executeSwap } from './trade.js';
|
|
7
|
+
import { executeTransfer, rpcFromConnection } from './transfer.js';
|
|
8
|
+
import { mapSignatureStatus, normalizeHistory } from './history.js';
|
|
9
|
+
import { computePositions } from './pnl.js';
|
|
10
|
+
const ok = (data, summary) => ({ content: [{ type: 'text', text: redact(summary ?? JSON.stringify(data, null, 2)) }], structuredContent: redactValue(data) });
|
|
11
|
+
const fail = (message, extra = {}) => ({ content: [{ type: 'text', text: redact(message) }], structuredContent: redactValue({ error: message, ...extra }), isError: true });
|
|
12
|
+
const MINT = z.string().regex(/^[1-9A-HJ-NP-Za-km-z]{32,44}$/);
|
|
13
|
+
export function txRpcFromConnection(conn) {
|
|
14
|
+
return {
|
|
15
|
+
async getSignatureStatus(sig) { const r = await conn.getSignatureStatuses([sig], { searchTransactionHistory: true }); const s = r.value[0]; return s ? { confirmationStatus: s.confirmationStatus ?? undefined, err: s.err, slot: s.slot } : null; },
|
|
16
|
+
async getParsedTransaction(sig) {
|
|
17
|
+
const t = await conn.getParsedTransaction(sig, { maxSupportedTransactionVersion: 0, commitment: 'confirmed' });
|
|
18
|
+
if (!t?.meta)
|
|
19
|
+
return null;
|
|
20
|
+
return {
|
|
21
|
+
slot: t.slot, fee: t.meta.fee, err: t.meta.err, preBalances: t.meta.preBalances, postBalances: t.meta.postBalances,
|
|
22
|
+
accountKeys: t.transaction.message.accountKeys.map((k) => k.pubkey.toBase58()),
|
|
23
|
+
preTokenBalances: (t.meta.preTokenBalances ?? []).map((b) => ({ mint: b.mint, owner: b.owner, uiTokenAmount: { uiAmount: b.uiTokenAmount.uiAmount } })),
|
|
24
|
+
postTokenBalances: (t.meta.postTokenBalances ?? []).map((b) => ({ mint: b.mint, owner: b.owner, uiTokenAmount: { uiAmount: b.uiTokenAmount.uiAmount } })),
|
|
25
|
+
};
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
export function registerAgentTools(server, deps) {
|
|
30
|
+
const { trade, config } = deps;
|
|
31
|
+
const api = trade.api;
|
|
32
|
+
const conn = () => new Connection(config.rpcUrl, 'confirmed');
|
|
33
|
+
const rpc = deps.rpc ?? rpcFromConnection(conn());
|
|
34
|
+
const txRpc = deps.txRpc ?? txRpcFromConnection(conn());
|
|
35
|
+
const walletOr = (w) => w ?? trade.signer?.publicKey ?? null;
|
|
36
|
+
server.registerTool('wallet_create', {
|
|
37
|
+
title: 'Create an agent wallet',
|
|
38
|
+
description: 'Generates a new keypair and stores it in the Potswap state directory (0600). Only when no wallet is configured. Returns the public key; the secret key is never shown. Back up the file.',
|
|
39
|
+
inputSchema: { confirm: z.boolean().default(false) },
|
|
40
|
+
}, async ({ confirm }) => {
|
|
41
|
+
if (trade.signer)
|
|
42
|
+
return fail(`A wallet is already configured (${trade.signer.publicKey}). Nothing was created.`);
|
|
43
|
+
if (!confirm)
|
|
44
|
+
return fail('Set confirm=true to create a wallet. Nothing was created.');
|
|
45
|
+
try {
|
|
46
|
+
const { signer, file } = createWallet(config.stateDir);
|
|
47
|
+
trade.signer = signer;
|
|
48
|
+
return ok({ publicKey: signer.publicKey, file, backup: 'Copy this file somewhere safe; it is the only copy of the secret key.' }, `Wallet created: ${signer.publicKey}\nSecret stored at ${file} (0600). Fund it with SOL to start; keep ≥ 0.01 SOL for fees.`);
|
|
49
|
+
}
|
|
50
|
+
catch (e) {
|
|
51
|
+
return fail(e instanceof Error ? e.message : String(e));
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
server.registerTool('get_wallet', {
|
|
55
|
+
title: 'Wallet, limits and status',
|
|
56
|
+
description: 'Public key, funding instructions, configured limits and allowlists, scheduler status.',
|
|
57
|
+
inputSchema: {},
|
|
58
|
+
}, async () => ok({
|
|
59
|
+
publicKey: trade.signer?.publicKey ?? null,
|
|
60
|
+
fund: trade.signer ? `Send SOL to ${trade.signer.publicKey}. Keep at least 0.01 SOL for network fees.` : NO_WALLET,
|
|
61
|
+
limits: { maxTradeSol: config.maxTradeSol, maxDailySol: config.maxDailySol, maxSlippageBps: config.maxSlippageBps, dryRun: config.dryRun },
|
|
62
|
+
allowedTokens: [...config.allowedTokens], allowedRecipients: [...config.allowedRecipients],
|
|
63
|
+
schedulerActive: !!deps.schedulerActive?.(), stateDir: config.stateDir, api: config.apiBase, rpc: config.rpcUrl.replace(/api-key=[^&]+/, 'api-key=***'),
|
|
64
|
+
}));
|
|
65
|
+
server.registerTool('transfer', {
|
|
66
|
+
title: 'Transfer SOL or a token (no Potswap fee)',
|
|
67
|
+
description: 'Sends SOL or an SPL token from the agent wallet. Requires confirm=true. Subject to POTSWAP_ALLOWED_RECIPIENTS, the per-trade cap and the SOL reserve. Every error states whether funds were debited.',
|
|
68
|
+
inputSchema: { to: z.string().min(32).max(44), mint: MINT.optional(), amount: z.number().positive(), confirm: z.boolean().default(false) },
|
|
69
|
+
}, async (a) => {
|
|
70
|
+
const out = await executeTransfer({
|
|
71
|
+
config, signer: trade.signer, rpc, balances: trade.balances, prices: (m) => api.prices(m),
|
|
72
|
+
decimalsOf: async (m) => (await api.token(m)).decimals,
|
|
73
|
+
}, { to: a.to, mint: a.mint, amount: a.amount, confirm: a.confirm });
|
|
74
|
+
if (out.kind === 'ok')
|
|
75
|
+
return ok({ signature: out.signature, explorerUrl: out.explorerUrl }, `${out.summary}. ${out.explorerUrl}`);
|
|
76
|
+
if (out.kind === 'quote-only' || out.kind === 'dry-run')
|
|
77
|
+
return ok({ [out.kind === 'dry-run' ? 'dryRun' : 'confirmRequired']: true }, out.summary);
|
|
78
|
+
return fail(out.message);
|
|
79
|
+
});
|
|
80
|
+
server.registerTool('get_transaction', {
|
|
81
|
+
title: 'Transaction status',
|
|
82
|
+
description: 'Status (processed / confirmed / finalized / failed / not found), slot, fee and the wallet balance changes for a signature.',
|
|
83
|
+
inputSchema: { signature: z.string().min(64).max(90), wallet: MINT.optional() },
|
|
84
|
+
}, async ({ signature, wallet }) => {
|
|
85
|
+
try {
|
|
86
|
+
const st = await txRpc.getSignatureStatus(signature);
|
|
87
|
+
const status = mapSignatureStatus(st);
|
|
88
|
+
const data = { signature, status, slot: st?.slot ?? null, explorerUrl: `https://solscan.io/tx/${signature}` };
|
|
89
|
+
if (status === 'confirmed' || status === 'finalized' || status === 'failed') {
|
|
90
|
+
const t = await txRpc.getParsedTransaction(signature);
|
|
91
|
+
if (t) {
|
|
92
|
+
const w = walletOr(wallet);
|
|
93
|
+
const idx = w ? t.accountKeys.indexOf(w) : -1;
|
|
94
|
+
data.feeSol = t.fee / 1e9;
|
|
95
|
+
data.error = t.err ?? null;
|
|
96
|
+
if (idx >= 0)
|
|
97
|
+
data.solChange = (t.postBalances[idx] - t.preBalances[idx]) / 1e9;
|
|
98
|
+
if (w) {
|
|
99
|
+
const pre = Object.fromEntries(t.preTokenBalances.filter((b) => b.owner === w).map((b) => [b.mint, b.uiTokenAmount.uiAmount ?? 0]));
|
|
100
|
+
const post = Object.fromEntries(t.postTokenBalances.filter((b) => b.owner === w).map((b) => [b.mint, b.uiTokenAmount.uiAmount ?? 0]));
|
|
101
|
+
const mints = new Set([...Object.keys(pre), ...Object.keys(post)]);
|
|
102
|
+
data.tokenChanges = [...mints].map((m) => ({ mint: m, change: (post[m] ?? 0) - (pre[m] ?? 0) })).filter((c) => c.change !== 0);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return ok(data, `${signature.slice(0, 8)}… ${status}${data.solChange !== undefined ? ` · SOL ${data.solChange >= 0 ? '+' : ''}${data.solChange}` : ''}`);
|
|
107
|
+
}
|
|
108
|
+
catch (e) {
|
|
109
|
+
return fail(e instanceof Error ? e.message : String(e));
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
server.registerTool('get_history', {
|
|
113
|
+
title: 'Recent transactions',
|
|
114
|
+
description: 'Parsed recent transactions for a wallet (swaps, transfers, other), newest first.',
|
|
115
|
+
inputSchema: { wallet: MINT.optional(), limit: z.number().int().min(1).max(100).default(25) },
|
|
116
|
+
}, async ({ wallet, limit }) => {
|
|
117
|
+
const w = walletOr(wallet);
|
|
118
|
+
if (!w)
|
|
119
|
+
return fail(NO_WALLET);
|
|
120
|
+
try {
|
|
121
|
+
const raw = await api.history(w, limit);
|
|
122
|
+
const items = normalizeHistory(raw, w);
|
|
123
|
+
return ok({ wallet: w, items }, items.map((i) => `${i.time} ${i.kind}${i.failed ? ' (failed)' : ''}: ${i.kind === 'swap' ? `${i.inAmount} ${short(i.inMint)} → ${i.outAmount} ${short(i.outMint)}` : i.kind === 'transfer' ? `${i.direction} ${i.amount} ${short(i.mint)} ${i.direction === 'out' ? 'to' : 'from'} ${i.counterparty}` : i.source ?? ''} ${i.signature.slice(0, 8)}…`).join('\n') || 'No transactions yet.');
|
|
124
|
+
}
|
|
125
|
+
catch (e) {
|
|
126
|
+
return fail(e instanceof Error ? e.message : String(e));
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
server.registerTool('get_price_history', {
|
|
130
|
+
title: 'Price history (OHLCV)',
|
|
131
|
+
description: 'Candles for a token from its top pool. Timeframes: 1m, 5m, 15m, 1h, 4h, 1d.',
|
|
132
|
+
inputSchema: { mint: MINT, timeframe: z.enum(['1m', '5m', '15m', '1h', '4h', '1d']).default('15m'), limit: z.number().int().min(1).max(1000).default(100) },
|
|
133
|
+
}, async ({ mint, timeframe, limit }) => {
|
|
134
|
+
try {
|
|
135
|
+
const d = await api.ohlcv(mint, timeframe, limit);
|
|
136
|
+
const c = d.candles;
|
|
137
|
+
return ok(d, c.length ? `${c.length} ${timeframe} candles · last close ${c[c.length - 1].close} · range ${Math.min(...c.map((x) => x.low))}–${Math.max(...c.map((x) => x.high))}` : d.reason ?? 'No candles');
|
|
138
|
+
}
|
|
139
|
+
catch (e) {
|
|
140
|
+
return fail(e instanceof Error ? e.message : String(e));
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
const stakeTool = (name, title, description, from, to) => server.registerTool(name, { title, description, inputSchema: { amount: z.number().positive(), provider: z.enum(['jito', 'marinade']).default('jito'), confirm: z.boolean().default(false), maxSlippageBps: z.number().int().min(1).max(5000).optional() } }, async (a) => {
|
|
144
|
+
const out = await executeSwap(trade, { inputMint: from(a.provider), outputMint: to(a.provider), amount: a.amount, slippageBps: a.maxSlippageBps, confirm: a.confirm });
|
|
145
|
+
switch (out.kind) {
|
|
146
|
+
case 'no-wallet': return fail(out.message);
|
|
147
|
+
case 'refused': return fail(out.message, { quote: out.quote });
|
|
148
|
+
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}`);
|
|
149
|
+
case 'dry-run': return ok({ dryRun: true, quote: out.quote }, `DRY RUN: ${a.amount} ${out.quote.inputSymbol} → ${out.quote.outAmount} ${out.quote.outputSymbol}`);
|
|
150
|
+
case 'failed': return fail(out.message, { signature: out.signature });
|
|
151
|
+
case 'ok': return ok({ ...out.result }, `${name === 'stake_sol' ? 'Staked' : 'Unstaked'}: ${a.amount} ${out.result.inputSymbol} → ${out.result.outAmount} ${out.result.outputSymbol}. ${out.result.explorerUrl}`);
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
const lst = (p) => (p === 'jito' ? JITOSOL_MINT : MSOL_MINT);
|
|
155
|
+
stakeTool('stake_sol', 'Liquid-stake SOL', 'Swaps SOL into jitoSOL (Jito) or mSOL (Marinade) at the best route. Normal fee and points apply. Requires confirm=true.', () => SOL_MINT, lst);
|
|
156
|
+
stakeTool('unstake_sol', 'Unstake liquid SOL', 'Swaps jitoSOL or mSOL back into SOL at the best route. Normal fee and points apply. Requires confirm=true.', lst, () => SOL_MINT);
|
|
157
|
+
// Upgraded positions with PnL (P13.7): replaces the basic tool registered by the core server.
|
|
158
|
+
server.registerTool('get_positions', {
|
|
159
|
+
title: 'Positions with PnL',
|
|
160
|
+
description: 'Token holdings with USD value, average cost, unrealised and realised PnL from the wallet\'s swap history.',
|
|
161
|
+
inputSchema: { wallet: MINT.optional() },
|
|
162
|
+
}, async ({ wallet }) => {
|
|
163
|
+
const w = walletOr(wallet);
|
|
164
|
+
if (!w)
|
|
165
|
+
return fail(NO_WALLET);
|
|
166
|
+
try {
|
|
167
|
+
const [bal, raw] = await Promise.all([trade.balances(w), api.history(w, 100).catch(() => [])]);
|
|
168
|
+
const history = normalizeHistory(raw, w);
|
|
169
|
+
const mints = [...new Set([...Object.keys(bal), ...history.flatMap((h) => [h.inMint, h.outMint]).filter((m) => !!m)])].slice(0, 50);
|
|
170
|
+
const prices = await api.prices(mints).catch(() => ({}));
|
|
171
|
+
const positions = computePositions(history, bal, prices);
|
|
172
|
+
return ok({ wallet: w, sol: bal[SOL_MINT] ?? 0, solUsd: prices[SOL_MINT] ? (bal[SOL_MINT] ?? 0) * prices[SOL_MINT] : undefined, positions }, positions.map((p) => `${short(p.mint)}: ${p.amount}${p.valueUsd !== undefined ? ` ($${p.valueUsd.toFixed(2)})` : ''}${p.unrealisedUsd !== undefined ? ` unrealised ${p.unrealisedUsd >= 0 ? '+' : ''}$${p.unrealisedUsd.toFixed(2)}` : ''}${p.realisedUsd ? ` realised ${p.realisedUsd >= 0 ? '+' : ''}$${p.realisedUsd.toFixed(2)}` : ''}`).join('\n') || 'No positions.');
|
|
173
|
+
}
|
|
174
|
+
catch (e) {
|
|
175
|
+
return fail(e instanceof Error ? e.message : String(e));
|
|
176
|
+
}
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
const short = (m) => (!m ? '?' : m === SOL_MINT ? 'SOL' : m === JITOSOL_MINT ? 'jitoSOL' : m === MSOL_MINT ? 'mSOL' : m.slice(0, 4) + '…');
|
package/dist/api.d.ts
CHANGED
|
@@ -37,5 +37,22 @@ export declare class PotswapApi {
|
|
|
37
37
|
list(kind: string): Promise<TerminalToken[]>;
|
|
38
38
|
terminalToken(mint: string): Promise<TerminalToken>;
|
|
39
39
|
points(wallet: string): Promise<Record<string, unknown>>;
|
|
40
|
+
history(wallet: string, limit?: number): Promise<import("./history.js").HeliusTx[]>;
|
|
41
|
+
ohlcv(mint: string, tf: string, limit?: number): Promise<{
|
|
42
|
+
mint: string;
|
|
43
|
+
pool: {
|
|
44
|
+
id: string;
|
|
45
|
+
name: string;
|
|
46
|
+
} | null;
|
|
47
|
+
candles: {
|
|
48
|
+
time: number;
|
|
49
|
+
open: number;
|
|
50
|
+
high: number;
|
|
51
|
+
low: number;
|
|
52
|
+
close: number;
|
|
53
|
+
volume: number;
|
|
54
|
+
}[];
|
|
55
|
+
reason?: string;
|
|
56
|
+
}>;
|
|
40
57
|
leaderboard(): Promise<Record<string, unknown>>;
|
|
41
58
|
}
|
package/dist/api.js
CHANGED
|
@@ -87,5 +87,7 @@ export class PotswapApi {
|
|
|
87
87
|
list(kind) { return this.req(`/api/terminal/list/${kind}`); }
|
|
88
88
|
terminalToken(mint) { return this.req(`/api/terminal/token/${mint}`); }
|
|
89
89
|
points(wallet) { return this.req(`/api/points/${wallet}`); }
|
|
90
|
+
history(wallet, limit = 25) { return this.req(`/api/history/${wallet}?limit=${limit}`); }
|
|
91
|
+
ohlcv(mint, tf, limit = 100) { return this.req(`/api/terminal/ohlcv/${mint}?tf=${tf}&limit=${limit}`); }
|
|
90
92
|
leaderboard() { return this.req('/api/leaderboard'); }
|
|
91
93
|
}
|
package/dist/config.d.ts
CHANGED
|
@@ -8,6 +8,12 @@ export interface Config {
|
|
|
8
8
|
dryRun: boolean;
|
|
9
9
|
privateKey?: string;
|
|
10
10
|
keypairPath?: string;
|
|
11
|
+
/** Where strategies, run logs and a created wallet live. */
|
|
12
|
+
stateDir: string;
|
|
13
|
+
/** When non-empty, swaps/strategies/staking may only touch these mints (PRD P13.4). */
|
|
14
|
+
allowedTokens: Set<string>;
|
|
15
|
+
/** When non-empty, `transfer` may only send to these addresses (PRD P13.3). */
|
|
16
|
+
allowedRecipients: Set<string>;
|
|
11
17
|
}
|
|
12
18
|
export declare const DEFAULTS: {
|
|
13
19
|
apiBase: string;
|
|
@@ -19,4 +25,8 @@ export declare const DEFAULTS: {
|
|
|
19
25
|
export declare function loadConfig(env?: NodeJS.ProcessEnv): Config;
|
|
20
26
|
export declare const SOL_MINT = "So11111111111111111111111111111111111111112";
|
|
21
27
|
export declare const USDC_MINT = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v";
|
|
22
|
-
export declare const
|
|
28
|
+
export declare const JITOSOL_MINT = "J1toso1uCk3RLmjorhTtrVwY9HJ7X8V9yYac6Y7kGCPn";
|
|
29
|
+
export declare const MSOL_MINT = "mSoLzYCxHdYgdzU16g5QSh3i5K3z3KZK7ytfqcJm7So";
|
|
30
|
+
export declare const NO_WALLET = "Wallet not configured. Set SOLANA_PRIVATE_KEY or SOLANA_KEYPAIR_PATH, or run wallet_create. No funds were debited.";
|
|
31
|
+
export declare const TOKEN_NOT_ALLOWED = "Token not in POTSWAP_ALLOWED_TOKENS. No funds were debited.";
|
|
32
|
+
export declare const RECIPIENT_NOT_ALLOWED = "Recipient not in POTSWAP_ALLOWED_RECIPIENTS. No funds were debited.";
|
package/dist/config.js
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
// PRD P10 / P13 — configuration from environment variables.
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
1
4
|
export const DEFAULTS = {
|
|
2
5
|
apiBase: 'https://www.potswap.io',
|
|
3
6
|
maxTradeSol: 1,
|
|
@@ -5,6 +8,7 @@ export const DEFAULTS = {
|
|
|
5
8
|
maxSlippageBps: 300,
|
|
6
9
|
solReserve: 0.01,
|
|
7
10
|
};
|
|
11
|
+
const list = (v) => new Set((v ?? '').split(',').map((s) => s.trim()).filter(Boolean));
|
|
8
12
|
export function loadConfig(env = process.env) {
|
|
9
13
|
const apiBase = (env.POTSWAP_API || DEFAULTS.apiBase).replace(/\/$/, '');
|
|
10
14
|
const num = (v, d) => { const n = Number(v); return v !== undefined && v !== '' && Number.isFinite(n) && n > 0 ? n : d; };
|
|
@@ -17,8 +21,15 @@ export function loadConfig(env = process.env) {
|
|
|
17
21
|
dryRun: /^(1|true|yes)$/i.test(env.POTSWAP_DRY_RUN ?? ''),
|
|
18
22
|
privateKey: env.SOLANA_PRIVATE_KEY || undefined,
|
|
19
23
|
keypairPath: env.SOLANA_KEYPAIR_PATH || undefined,
|
|
24
|
+
stateDir: env.POTSWAP_STATE_DIR || join(homedir(), '.potswap'),
|
|
25
|
+
allowedTokens: list(env.POTSWAP_ALLOWED_TOKENS),
|
|
26
|
+
allowedRecipients: list(env.POTSWAP_ALLOWED_RECIPIENTS),
|
|
20
27
|
};
|
|
21
28
|
}
|
|
22
29
|
export const SOL_MINT = 'So11111111111111111111111111111111111111112';
|
|
23
30
|
export const USDC_MINT = 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v';
|
|
24
|
-
export const
|
|
31
|
+
export const JITOSOL_MINT = 'J1toso1uCk3RLmjorhTtrVwY9HJ7X8V9yYac6Y7kGCPn';
|
|
32
|
+
export const MSOL_MINT = 'mSoLzYCxHdYgdzU16g5QSh3i5K3z3KZK7ytfqcJm7So';
|
|
33
|
+
export const NO_WALLET = 'Wallet not configured. Set SOLANA_PRIVATE_KEY or SOLANA_KEYPAIR_PATH, or run wallet_create. No funds were debited.';
|
|
34
|
+
export const TOKEN_NOT_ALLOWED = 'Token not in POTSWAP_ALLOWED_TOKENS. No funds were debited.';
|
|
35
|
+
export const RECIPIENT_NOT_ALLOWED = 'Recipient not in POTSWAP_ALLOWED_RECIPIENTS. No funds were debited.';
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
export interface HeliusTx {
|
|
2
|
+
signature: string;
|
|
3
|
+
timestamp: number;
|
|
4
|
+
type?: string;
|
|
5
|
+
source?: string;
|
|
6
|
+
fee?: number;
|
|
7
|
+
feePayer?: string;
|
|
8
|
+
transactionError?: unknown;
|
|
9
|
+
nativeTransfers?: {
|
|
10
|
+
fromUserAccount?: string;
|
|
11
|
+
toUserAccount?: string;
|
|
12
|
+
amount?: number;
|
|
13
|
+
}[];
|
|
14
|
+
tokenTransfers?: {
|
|
15
|
+
fromUserAccount?: string;
|
|
16
|
+
toUserAccount?: string;
|
|
17
|
+
tokenAmount?: number;
|
|
18
|
+
mint?: string;
|
|
19
|
+
}[];
|
|
20
|
+
events?: {
|
|
21
|
+
swap?: {
|
|
22
|
+
nativeInput?: {
|
|
23
|
+
account: string;
|
|
24
|
+
amount: string;
|
|
25
|
+
} | null;
|
|
26
|
+
nativeOutput?: {
|
|
27
|
+
account: string;
|
|
28
|
+
amount: string;
|
|
29
|
+
} | null;
|
|
30
|
+
tokenInputs?: {
|
|
31
|
+
userAccount: string;
|
|
32
|
+
mint: string;
|
|
33
|
+
rawTokenAmount: {
|
|
34
|
+
tokenAmount: string;
|
|
35
|
+
decimals: number;
|
|
36
|
+
};
|
|
37
|
+
}[];
|
|
38
|
+
tokenOutputs?: {
|
|
39
|
+
userAccount: string;
|
|
40
|
+
mint: string;
|
|
41
|
+
rawTokenAmount: {
|
|
42
|
+
tokenAmount: string;
|
|
43
|
+
decimals: number;
|
|
44
|
+
};
|
|
45
|
+
}[];
|
|
46
|
+
};
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
export declare const SOL = "So11111111111111111111111111111111111111112";
|
|
50
|
+
export interface HistoryItem {
|
|
51
|
+
signature: string;
|
|
52
|
+
time: string;
|
|
53
|
+
kind: 'swap' | 'transfer' | 'other';
|
|
54
|
+
source?: string;
|
|
55
|
+
feeSol: number;
|
|
56
|
+
failed: boolean;
|
|
57
|
+
/** For swaps: what the wallet gave and got. For transfers: amount and direction. */
|
|
58
|
+
inMint?: string;
|
|
59
|
+
inAmount?: number;
|
|
60
|
+
outMint?: string;
|
|
61
|
+
outAmount?: number;
|
|
62
|
+
direction?: 'in' | 'out';
|
|
63
|
+
counterparty?: string;
|
|
64
|
+
mint?: string;
|
|
65
|
+
amount?: number;
|
|
66
|
+
}
|
|
67
|
+
export declare function normalizeHistory(txs: HeliusTx[], wallet: string): HistoryItem[];
|
|
68
|
+
export type TxStatus = 'not found' | 'processed' | 'confirmed' | 'finalized' | 'failed';
|
|
69
|
+
export declare function mapSignatureStatus(s: {
|
|
70
|
+
confirmationStatus?: string;
|
|
71
|
+
err?: unknown;
|
|
72
|
+
} | null): TxStatus;
|
package/dist/history.js
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
export const SOL = 'So11111111111111111111111111111111111111112';
|
|
2
|
+
export function normalizeHistory(txs, wallet) {
|
|
3
|
+
return txs.map((t) => {
|
|
4
|
+
const base = { signature: t.signature, time: new Date((t.timestamp ?? 0) * 1000).toISOString(), kind: 'other', source: t.source, feeSol: (t.fee ?? 0) / 1e9, failed: !!t.transactionError };
|
|
5
|
+
const sw = t.events?.swap;
|
|
6
|
+
if (sw) {
|
|
7
|
+
// Determine what this wallet put in and took out.
|
|
8
|
+
let inMint, inAmount = 0, outMint, outAmount = 0;
|
|
9
|
+
if (sw.nativeInput && sw.nativeInput.account === wallet) {
|
|
10
|
+
inMint = SOL;
|
|
11
|
+
inAmount = Number(sw.nativeInput.amount) / 1e9;
|
|
12
|
+
}
|
|
13
|
+
if (sw.nativeOutput && sw.nativeOutput.account === wallet) {
|
|
14
|
+
outMint = SOL;
|
|
15
|
+
outAmount = Number(sw.nativeOutput.amount) / 1e9;
|
|
16
|
+
}
|
|
17
|
+
for (const i of sw.tokenInputs ?? [])
|
|
18
|
+
if (i.userAccount === wallet) {
|
|
19
|
+
inMint = i.mint;
|
|
20
|
+
inAmount += Number(i.rawTokenAmount.tokenAmount) / 10 ** i.rawTokenAmount.decimals;
|
|
21
|
+
}
|
|
22
|
+
for (const o of sw.tokenOutputs ?? [])
|
|
23
|
+
if (o.userAccount === wallet) {
|
|
24
|
+
outMint = o.mint;
|
|
25
|
+
outAmount += Number(o.rawTokenAmount.tokenAmount) / 10 ** o.rawTokenAmount.decimals;
|
|
26
|
+
}
|
|
27
|
+
if (inMint || outMint)
|
|
28
|
+
return { ...base, kind: 'swap', inMint, inAmount, outMint, outAmount };
|
|
29
|
+
}
|
|
30
|
+
const tt = (t.tokenTransfers ?? []).find((x) => x.fromUserAccount === wallet || x.toUserAccount === wallet);
|
|
31
|
+
if (tt && tt.mint && tt.tokenAmount) {
|
|
32
|
+
const out = tt.fromUserAccount === wallet;
|
|
33
|
+
return { ...base, kind: 'transfer', direction: out ? 'out' : 'in', counterparty: out ? tt.toUserAccount : tt.fromUserAccount, mint: tt.mint, amount: tt.tokenAmount };
|
|
34
|
+
}
|
|
35
|
+
const nt = (t.nativeTransfers ?? []).find((x) => (x.fromUserAccount === wallet || x.toUserAccount === wallet) && (x.amount ?? 0) > 0);
|
|
36
|
+
if (nt) {
|
|
37
|
+
const out = nt.fromUserAccount === wallet;
|
|
38
|
+
return { ...base, kind: 'transfer', direction: out ? 'out' : 'in', counterparty: out ? nt.toUserAccount : nt.fromUserAccount, mint: SOL, amount: (nt.amount ?? 0) / 1e9 };
|
|
39
|
+
}
|
|
40
|
+
return base;
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
export function mapSignatureStatus(s) {
|
|
44
|
+
if (!s)
|
|
45
|
+
return 'not found';
|
|
46
|
+
if (s.err)
|
|
47
|
+
return 'failed';
|
|
48
|
+
if (s.confirmationStatus === 'finalized')
|
|
49
|
+
return 'finalized';
|
|
50
|
+
if (s.confirmationStatus === 'confirmed')
|
|
51
|
+
return 'confirmed';
|
|
52
|
+
return 'processed';
|
|
53
|
+
}
|
package/dist/pnl.d.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { HistoryItem } from './history.js';
|
|
2
|
+
export interface Position {
|
|
3
|
+
mint: string;
|
|
4
|
+
amount: number;
|
|
5
|
+
avgCostUsd?: number;
|
|
6
|
+
costBasisUsd?: number;
|
|
7
|
+
valueUsd?: number;
|
|
8
|
+
unrealisedUsd?: number;
|
|
9
|
+
realisedUsd: number;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Walks swaps oldest→newest. A swap "buys" its outMint at (inAmount × price(inMint at the time)) and
|
|
13
|
+
* "sells" its inMint. Price history is approximated with current prices unless the item carries `inUsd`.
|
|
14
|
+
* Good enough for an agent dashboard; exact fills come from the on-chain record.
|
|
15
|
+
*/
|
|
16
|
+
export declare function computePositions(history: HistoryItem[], balances: Record<string, number>, prices: Record<string, number>, quoteMints?: Set<string>): Position[];
|
package/dist/pnl.js
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { SOL } from './history.js';
|
|
2
|
+
/**
|
|
3
|
+
* Walks swaps oldest→newest. A swap "buys" its outMint at (inAmount × price(inMint at the time)) and
|
|
4
|
+
* "sells" its inMint. Price history is approximated with current prices unless the item carries `inUsd`.
|
|
5
|
+
* Good enough for an agent dashboard; exact fills come from the on-chain record.
|
|
6
|
+
*/
|
|
7
|
+
export function computePositions(history, balances, prices, quoteMints = new Set([SOL])) {
|
|
8
|
+
const lots = {};
|
|
9
|
+
const items = [...history].filter((h) => h.kind === 'swap' && !h.failed).sort((a, b) => a.time.localeCompare(b.time));
|
|
10
|
+
for (const h of items) {
|
|
11
|
+
const inUsd = h.inUsd ?? (h.inMint && prices[h.inMint] ? (h.inAmount ?? 0) * prices[h.inMint] : undefined);
|
|
12
|
+
// sell side
|
|
13
|
+
if (h.inMint && !quoteMints.has(h.inMint) && h.inAmount) {
|
|
14
|
+
const l = (lots[h.inMint] ??= { amount: 0, cost: 0, realised: 0 });
|
|
15
|
+
const avg = l.amount > 0 ? l.cost / l.amount : 0;
|
|
16
|
+
const sold = Math.min(h.inAmount, l.amount);
|
|
17
|
+
const proceeds = h.outMint && prices[h.outMint] ? (h.outAmount ?? 0) * prices[h.outMint] : inUsd ?? 0;
|
|
18
|
+
l.realised += proceeds - sold * avg;
|
|
19
|
+
l.amount -= sold;
|
|
20
|
+
l.cost -= sold * avg;
|
|
21
|
+
if (l.amount < 1e-12) {
|
|
22
|
+
l.amount = 0;
|
|
23
|
+
l.cost = 0;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
// buy side
|
|
27
|
+
if (h.outMint && !quoteMints.has(h.outMint) && h.outAmount && inUsd !== undefined) {
|
|
28
|
+
const l = (lots[h.outMint] ??= { amount: 0, cost: 0, realised: 0 });
|
|
29
|
+
l.amount += h.outAmount;
|
|
30
|
+
l.cost += inUsd;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
const mints = new Set([...Object.keys(balances), ...Object.keys(lots)].filter((m) => !quoteMints.has(m)));
|
|
34
|
+
return [...mints].map((mint) => {
|
|
35
|
+
const amount = balances[mint] ?? 0;
|
|
36
|
+
const l = lots[mint];
|
|
37
|
+
const avg = l && l.amount > 0 ? l.cost / l.amount : undefined;
|
|
38
|
+
const price = prices[mint];
|
|
39
|
+
const value = price !== undefined ? amount * price : undefined;
|
|
40
|
+
return {
|
|
41
|
+
mint, amount, avgCostUsd: avg, costBasisUsd: avg !== undefined ? amount * avg : undefined, valueUsd: value,
|
|
42
|
+
unrealisedUsd: value !== undefined && avg !== undefined ? value - amount * avg : undefined, realisedUsd: l?.realised ?? 0,
|
|
43
|
+
};
|
|
44
|
+
}).filter((p) => p.amount > 0 || p.realisedUsd !== 0).sort((a, b) => (b.valueUsd ?? 0) - (a.valueUsd ?? 0));
|
|
45
|
+
}
|
package/dist/runtime.js
CHANGED
|
@@ -2,15 +2,15 @@
|
|
|
2
2
|
import { Connection, PublicKey } from '@solana/web3.js';
|
|
3
3
|
import { PotswapApi } from './api.js';
|
|
4
4
|
import { loadSigner } from './wallet.js';
|
|
5
|
-
import { FileStore
|
|
5
|
+
import { FileStore } from './strategies/store.js';
|
|
6
6
|
import { acquireLock } from './strategies/lock.js';
|
|
7
7
|
import { Scheduler } from './strategies/scheduler.js';
|
|
8
8
|
export function createRuntime(config) {
|
|
9
|
-
const signer = loadSigner(config);
|
|
9
|
+
const signer = loadSigner({ privateKey: config.privateKey, keypairPath: config.keypairPath, stateDir: config.stateDir });
|
|
10
10
|
const api = new PotswapApi(config.apiBase);
|
|
11
11
|
const balances = rpcBalances(config.rpcUrl);
|
|
12
12
|
const trade = { api, config, signer, balances };
|
|
13
|
-
const dir =
|
|
13
|
+
const dir = config.stateDir;
|
|
14
14
|
const store = new FileStore(dir);
|
|
15
15
|
const lock = acquireLock(dir);
|
|
16
16
|
const scheduler = lock.acquired ? new Scheduler({ store, trade, maxDailySol: config.maxDailySol }) : null;
|
package/dist/server.d.ts
CHANGED
|
@@ -3,9 +3,11 @@ import { type FetchFn } from './api.js';
|
|
|
3
3
|
import { SOL_MINT, USDC_MINT, type Config } from './config.js';
|
|
4
4
|
import { type BalancesFn } from './trade.js';
|
|
5
5
|
import { type Signer } from './wallet.js';
|
|
6
|
+
import { type TxRpc } from './agentTools.js';
|
|
7
|
+
import type { Rpc } from './transfer.js';
|
|
6
8
|
import type { StrategyStore } from './strategies/store.js';
|
|
7
9
|
import type { Scheduler } from './strategies/scheduler.js';
|
|
8
|
-
export declare const VERSION = "0.
|
|
10
|
+
export declare const VERSION = "0.3.0";
|
|
9
11
|
export interface ServerDeps {
|
|
10
12
|
config?: Config;
|
|
11
13
|
signer?: Signer | null;
|
|
@@ -15,6 +17,9 @@ export interface ServerDeps {
|
|
|
15
17
|
/** Strategy persistence and scheduler (P11); defaults are created from config when omitted. */
|
|
16
18
|
strategyStore?: StrategyStore;
|
|
17
19
|
scheduler?: Scheduler;
|
|
20
|
+
/** Transfer / transaction RPC surfaces; injectable for tests (P13). */
|
|
21
|
+
rpc?: Rpc;
|
|
22
|
+
txRpc?: TxRpc;
|
|
18
23
|
}
|
|
19
24
|
export declare function createServer(deps?: ServerDeps): McpServer;
|
|
20
25
|
export { SOL_MINT, USDC_MINT };
|
package/dist/server.js
CHANGED
|
@@ -9,7 +9,8 @@ import { redact, redactValue } from './redact.js';
|
|
|
9
9
|
import { loadSigner } from './wallet.js';
|
|
10
10
|
import { riskBadges, riskLevel } from './shared/risk.js';
|
|
11
11
|
import { registerStrategyTools } from './strategies/tools.js';
|
|
12
|
-
|
|
12
|
+
import { registerAgentTools } from './agentTools.js';
|
|
13
|
+
export const VERSION = '0.3.0';
|
|
13
14
|
const ok = (data, summary) => ({
|
|
14
15
|
content: [{ type: 'text', text: redact(summary ?? JSON.stringify(data, null, 2)) }], structuredContent: redactValue(data),
|
|
15
16
|
});
|
|
@@ -18,7 +19,7 @@ const fail = (message, extra = {}) => ({
|
|
|
18
19
|
});
|
|
19
20
|
export function createServer(deps = {}) {
|
|
20
21
|
const config = deps.config ?? loadConfig();
|
|
21
|
-
const signer = deps.signer !== undefined ? deps.signer : loadSigner(config);
|
|
22
|
+
const signer = deps.signer !== undefined ? deps.signer : loadSigner({ privateKey: config.privateKey, keypairPath: config.keypairPath, stateDir: config.stateDir });
|
|
22
23
|
const api = new PotswapApi(config.apiBase, deps.fetchFn);
|
|
23
24
|
const readBalances = deps.balances ?? rpcBalances(config.rpcUrl);
|
|
24
25
|
const trade = { api, config, signer, balances: readBalances };
|
|
@@ -114,26 +115,6 @@ export function createServer(deps = {}) {
|
|
|
114
115
|
return fail(msg(e));
|
|
115
116
|
}
|
|
116
117
|
});
|
|
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
118
|
server.registerTool('get_points', {
|
|
138
119
|
title: 'POT points',
|
|
139
120
|
description: "Season 1 points for a wallet: this epoch, all-time, POT allocated and claimable, next unlock. Defaults to the agent wallet.",
|
|
@@ -165,6 +146,7 @@ export function createServer(deps = {}) {
|
|
|
165
146
|
return fail(msg(e));
|
|
166
147
|
}
|
|
167
148
|
});
|
|
149
|
+
registerAgentTools(server, { trade, config, rpc: deps.rpc, txRpc: deps.txRpc, schedulerActive: () => !!deps.scheduler?.active });
|
|
168
150
|
registerStrategyTools(server, { trade, store: deps.strategyStore, scheduler: deps.scheduler });
|
|
169
151
|
return server;
|
|
170
152
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { DcaStrategy, RebalanceStrategy, Run, Strategy, TpSlStrategy } from './types.js';
|
|
1
|
+
import type { DcaStrategy, LimitStrategy, RebalanceStrategy, Run, Strategy, TpSlStrategy } from './types.js';
|
|
2
2
|
export declare const SOL_MINT = "So11111111111111111111111111111111111111112";
|
|
3
3
|
/** True when a time-based strategy should run at `now`. */
|
|
4
4
|
export declare function isDue(s: Strategy, now: number): boolean;
|
|
@@ -36,5 +36,8 @@ export declare function tpslDecision(s: TpSlStrategy, price: number | undefined)
|
|
|
36
36
|
/** SOL-equivalent spent by all strategies in the UTC day containing `now` (P11.6). */
|
|
37
37
|
export declare function solSpentToday(runs: Run[], now: number): number;
|
|
38
38
|
export declare function dcaLegFor(s: DcaStrategy): Leg;
|
|
39
|
+
/** Limit order decision (P13.10): 'fill' when the condition holds, 'expired' past expiresAt, else null. */
|
|
40
|
+
export declare function limitDecision(s: LimitStrategy, price: number | undefined, now: number): 'fill' | 'expired' | null;
|
|
41
|
+
export declare function limitLegFor(s: LimitStrategy): Leg;
|
|
39
42
|
export declare const MAX_CONSECUTIVE_FAILURES = 3;
|
|
40
43
|
export declare function newId(prefix: string, now?: number): string;
|
|
@@ -92,6 +92,23 @@ export function solSpentToday(runs, now) {
|
|
|
92
92
|
export function dcaLegFor(s) {
|
|
93
93
|
return { inputMint: s.inputMint, outputMint: s.outputMint, amount: s.amountPerRun, reason: `DCA run ${s.runsDone + 1}${s.totalRuns ? `/${s.totalRuns}` : ''}` };
|
|
94
94
|
}
|
|
95
|
+
/** Limit order decision (P13.10): 'fill' when the condition holds, 'expired' past expiresAt, else null. */
|
|
96
|
+
export function limitDecision(s, price, now) {
|
|
97
|
+
if (s.expiresAt && Date.parse(s.expiresAt) <= now)
|
|
98
|
+
return 'expired';
|
|
99
|
+
if (price === undefined || !(price > 0))
|
|
100
|
+
return null;
|
|
101
|
+
if (s.condition === 'below' && price <= s.triggerPriceUsd)
|
|
102
|
+
return 'fill';
|
|
103
|
+
if (s.condition === 'above' && price >= s.triggerPriceUsd)
|
|
104
|
+
return 'fill';
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
export function limitLegFor(s) {
|
|
108
|
+
return s.side === 'buy'
|
|
109
|
+
? { inputMint: s.quoteMint, outputMint: s.mint, amount: s.amount, reason: `limit buy (${s.condition} ${s.triggerPriceUsd})` }
|
|
110
|
+
: { inputMint: s.mint, outputMint: s.quoteMint, amount: s.amount, reason: `limit sell (${s.condition} ${s.triggerPriceUsd})` };
|
|
111
|
+
}
|
|
95
112
|
export const MAX_CONSECUTIVE_FAILURES = 3;
|
|
96
113
|
const round = (n) => Math.floor(n * 1e9) / 1e9;
|
|
97
114
|
const pct = (n) => n.toFixed(1);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { executeSwap } from '../trade.js';
|
|
2
2
|
import { log } from '../redact.js';
|
|
3
|
-
import { dcaIsComplete, dcaLegFor, isDue, MAX_CONSECUTIVE_FAILURES, nextRunAfter, newId, rebalancePlan, solSpentToday, tpslDecision } from './engine.js';
|
|
3
|
+
import { dcaIsComplete, dcaLegFor, isDue, limitDecision, limitLegFor, MAX_CONSECUTIVE_FAILURES, nextRunAfter, newId, rebalancePlan, solSpentToday, tpslDecision } from './engine.js';
|
|
4
4
|
export class Scheduler {
|
|
5
5
|
store;
|
|
6
6
|
trade;
|
|
@@ -59,6 +59,8 @@ export class Scheduler {
|
|
|
59
59
|
continue;
|
|
60
60
|
if (s.kind === 'tpsl')
|
|
61
61
|
await this.checkTpSl(s);
|
|
62
|
+
else if (s.kind === 'limit')
|
|
63
|
+
await this.checkLimit(s);
|
|
62
64
|
else if (isDue(s, now))
|
|
63
65
|
await (s.kind === 'dca' ? this.runDca(s) : this.runRebalance(s));
|
|
64
66
|
}
|
|
@@ -137,6 +139,27 @@ export class Scheduler {
|
|
|
137
139
|
if (out.kind === 'ok' || out.kind === 'dry-run')
|
|
138
140
|
this.complete(s.id);
|
|
139
141
|
}
|
|
142
|
+
// ---- Limit orders (P13.10)
|
|
143
|
+
async checkLimit(s) {
|
|
144
|
+
let price;
|
|
145
|
+
try {
|
|
146
|
+
price = (await this.trade.api.prices([s.mint]))[s.mint];
|
|
147
|
+
}
|
|
148
|
+
catch {
|
|
149
|
+
price = undefined;
|
|
150
|
+
}
|
|
151
|
+
const d = limitDecision(s, price, this.now());
|
|
152
|
+
if (d === 'expired') {
|
|
153
|
+
this.record(s, 'check', 'completed', 'Expired unfilled.');
|
|
154
|
+
this.complete(s.id);
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
if (d !== 'fill')
|
|
158
|
+
return;
|
|
159
|
+
const out = await this.execute(s, limitLegFor(s));
|
|
160
|
+
if (out.kind === 'ok' || out.kind === 'dry-run')
|
|
161
|
+
this.complete(s.id);
|
|
162
|
+
}
|
|
140
163
|
// ---- shared leg execution with caps, run log, failure counting (P11.6–P11.8, P11.13)
|
|
141
164
|
async execute(s, leg) {
|
|
142
165
|
const now = this.now();
|
|
@@ -205,7 +228,7 @@ export class Scheduler {
|
|
|
205
228
|
advance(id) {
|
|
206
229
|
this.store.update((st) => {
|
|
207
230
|
const cur = st.strategies.find((x) => x.id === id);
|
|
208
|
-
if (cur && cur.status === 'active' && cur.kind !== 'tpsl')
|
|
231
|
+
if (cur && cur.status === 'active' && cur.kind !== 'tpsl' && cur.kind !== 'limit')
|
|
209
232
|
cur.nextRunAt = nextRunAfter(cur, this.now());
|
|
210
233
|
});
|
|
211
234
|
}
|
package/dist/strategies/tools.js
CHANGED
|
@@ -110,6 +110,25 @@ export function registerStrategyTools(server, deps) {
|
|
|
110
110
|
const s = { ...base('tpsl', a.maxSlippageBps), kind: 'tpsl', mint: a.mint, sellPct: a.sellPct, exitMint: a.exitMint, entryPrice: entry, highWater: entry, takeProfitPct: a.takeProfitPct, stopLossPct: a.stopLossPct, trailingPct: a.trailingPct, nextRunAt: null };
|
|
111
111
|
return created(s, `TP/SL created at entry ${entry}: ${[a.takeProfitPct && `TP +${a.takeProfitPct}%`, a.stopLossPct && `SL −${a.stopLossPct}%`, a.trailingPct && `trailing ${a.trailingPct}%`].filter(Boolean).join(', ')}, sells ${a.sellPct}%.`);
|
|
112
112
|
});
|
|
113
|
+
server.registerTool('limit_order_create', {
|
|
114
|
+
title: 'Create a limit order (price-triggered swap)',
|
|
115
|
+
description: 'Buy or sell a token when its USD price crosses a level. Runs through the strategy scheduler (needs this host open or the daemon). Fills once at the best route with the normal fee and points, then completes.',
|
|
116
|
+
inputSchema: {
|
|
117
|
+
side: z.enum(['buy', 'sell']), mint: MINT, quoteMint: MINT.default(SOL_MINT),
|
|
118
|
+
amount: z.number().positive().describe('Input amount in UI units: quote (SOL) amount for buys, token amount for sells'),
|
|
119
|
+
triggerPriceUsd: z.number().positive(), condition: z.enum(['above', 'below']),
|
|
120
|
+
expiresAt: z.string().datetime().optional(), maxSlippageBps: z.number().int().min(1).max(5000).optional(),
|
|
121
|
+
confirm: z.boolean().default(false),
|
|
122
|
+
},
|
|
123
|
+
}, async (a) => {
|
|
124
|
+
const g = guard(a.confirm);
|
|
125
|
+
if (g)
|
|
126
|
+
return g;
|
|
127
|
+
if (a.mint === a.quoteMint)
|
|
128
|
+
return fail('mint and quoteMint must differ. Nothing was created.');
|
|
129
|
+
const s = { ...base('limit', a.maxSlippageBps), kind: 'limit', side: a.side, mint: a.mint, quoteMint: a.quoteMint, amount: a.amount, triggerPriceUsd: a.triggerPriceUsd, condition: a.condition, expiresAt: a.expiresAt, nextRunAt: null };
|
|
130
|
+
return created(s, `Limit ${a.side} created: ${a.amount} ${a.side === 'buy' ? 'quote' : 'tokens'} when price ${a.condition} $${a.triggerPriceUsd}${a.expiresAt ? ` until ${a.expiresAt}` : ''}.`);
|
|
131
|
+
});
|
|
113
132
|
server.registerTool('strategy_list', { title: 'List strategies', description: 'All strategies with status, next run, totals and last result.', inputSchema: {} }, async () => {
|
|
114
133
|
const st = store.load();
|
|
115
134
|
const rows = st.strategies.map((s) => ({ ...s, lastRun: runsOf(st, s.id, 1)[0] ?? null }));
|
|
@@ -118,7 +137,7 @@ export function registerStrategyTools(server, deps) {
|
|
|
118
137
|
const byId = { id: z.string().min(3) };
|
|
119
138
|
server.registerTool('strategy_pause', { title: 'Pause a strategy', description: 'Stops future legs; keeps history.', inputSchema: byId }, async ({ id }) => mutate(id, (s) => { s.status = 'paused'; s.pausedReason = 'paused by user'; s.nextRunAt = null; }));
|
|
120
139
|
server.registerTool('strategy_resume', { title: 'Resume a strategy', description: 'Re-activates a paused strategy; the next run is scheduled from now.', inputSchema: byId }, async ({ id }) => mutate(id, (s) => { if (s.status === 'completed')
|
|
121
|
-
throw new Error('Completed strategies cannot be resumed; create a new one.'); s.status = 'active'; s.pausedReason = undefined; s.consecutiveFailures = 0; s.nextRunAt = s.kind === 'tpsl' ? null : nextRunAfter(s, now()); }));
|
|
140
|
+
throw new Error('Completed strategies cannot be resumed; create a new one.'); s.status = 'active'; s.pausedReason = undefined; s.consecutiveFailures = 0; s.nextRunAt = s.kind === 'tpsl' || s.kind === 'limit' ? null : nextRunAfter(s, now()); }));
|
|
122
141
|
server.registerTool('strategy_delete', { title: 'Delete a strategy', description: 'Removes the strategy; its run history is kept.', inputSchema: byId }, async ({ id }) => {
|
|
123
142
|
const existed = store.update((st) => { const n = st.strategies.length; st.strategies = st.strategies.filter((s) => s.id !== id); return n !== st.strategies.length; });
|
|
124
143
|
return existed ? ok({ deleted: id }, `Deleted ${id}.`) : fail(`No strategy ${id}.`);
|
|
@@ -41,7 +41,17 @@ export interface TpSlStrategy extends Base {
|
|
|
41
41
|
stopLossPct?: number;
|
|
42
42
|
trailingPct?: number;
|
|
43
43
|
}
|
|
44
|
-
export
|
|
44
|
+
export interface LimitStrategy extends Base {
|
|
45
|
+
kind: 'limit';
|
|
46
|
+
side: 'buy' | 'sell';
|
|
47
|
+
mint: string;
|
|
48
|
+
quoteMint: string;
|
|
49
|
+
amount: number;
|
|
50
|
+
triggerPriceUsd: number;
|
|
51
|
+
condition: 'above' | 'below';
|
|
52
|
+
expiresAt?: string;
|
|
53
|
+
}
|
|
54
|
+
export type Strategy = DcaStrategy | RebalanceStrategy | TpSlStrategy | LimitStrategy;
|
|
45
55
|
export type RunOutcome = 'ok' | 'skipped' | 'failed' | 'dry-run' | 'no-op' | 'completed';
|
|
46
56
|
export interface Run {
|
|
47
57
|
id: string;
|
package/dist/trade.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Swap execution shared by the `swap` tool (P10) and the strategy scheduler (P11).
|
|
2
2
|
// One path: quote → limits → fresh order as taker → local signature → execute.
|
|
3
3
|
import { VersionedTransaction } from '@solana/web3.js';
|
|
4
|
-
import { NO_WALLET, SOL_MINT } from './config.js';
|
|
4
|
+
import { NO_WALLET, SOL_MINT, TOKEN_NOT_ALLOWED } from './config.js';
|
|
5
5
|
import { checkLimits } from './limits.js';
|
|
6
6
|
import { explorer, fromRaw, toRaw } from './format.js';
|
|
7
7
|
import { describeOrderError, friendlyError } from './shared/errors.js';
|
|
@@ -31,6 +31,9 @@ export async function executeSwap(ctx, a) {
|
|
|
31
31
|
const { api, config, signer } = ctx;
|
|
32
32
|
if (!signer)
|
|
33
33
|
return { kind: 'no-wallet', message: NO_WALLET };
|
|
34
|
+
if (config.allowedTokens.size && (!config.allowedTokens.has(a.inputMint) || !config.allowedTokens.has(a.outputMint))) {
|
|
35
|
+
return { kind: 'failed', message: TOKEN_NOT_ALLOWED };
|
|
36
|
+
}
|
|
34
37
|
try {
|
|
35
38
|
const quote = await buildQuote(api, a);
|
|
36
39
|
const prices = await api.prices([SOL_MINT, a.inputMint]).catch(() => ({}));
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { Connection, type Commitment } from '@solana/web3.js';
|
|
2
|
+
import { type Config } from './config.js';
|
|
3
|
+
import type { Signer } from './wallet.js';
|
|
4
|
+
/** Minimal RPC surface so tests can mock it. */
|
|
5
|
+
export interface Rpc {
|
|
6
|
+
getLatestBlockhash(commitment?: Commitment): Promise<{
|
|
7
|
+
blockhash: string;
|
|
8
|
+
lastValidBlockHeight: number;
|
|
9
|
+
}>;
|
|
10
|
+
getAccountOwner(address: string): Promise<string | null>;
|
|
11
|
+
sendRawTransaction(raw: Uint8Array): Promise<string>;
|
|
12
|
+
confirmTransaction(sig: string, blockhash: string, lastValidBlockHeight: number): Promise<{
|
|
13
|
+
err: unknown | null;
|
|
14
|
+
}>;
|
|
15
|
+
}
|
|
16
|
+
export declare function rpcFromConnection(conn: Connection): Rpc;
|
|
17
|
+
export interface TransferArgs {
|
|
18
|
+
to: string;
|
|
19
|
+
mint?: string;
|
|
20
|
+
amount: number;
|
|
21
|
+
decimals?: number;
|
|
22
|
+
confirm: boolean;
|
|
23
|
+
}
|
|
24
|
+
export type TransferOutcome = {
|
|
25
|
+
kind: 'no-wallet' | 'refused' | 'failed';
|
|
26
|
+
message: string;
|
|
27
|
+
} | {
|
|
28
|
+
kind: 'quote-only';
|
|
29
|
+
summary: string;
|
|
30
|
+
} | {
|
|
31
|
+
kind: 'dry-run';
|
|
32
|
+
summary: string;
|
|
33
|
+
} | {
|
|
34
|
+
kind: 'ok';
|
|
35
|
+
signature: string;
|
|
36
|
+
explorerUrl: string;
|
|
37
|
+
summary: string;
|
|
38
|
+
};
|
|
39
|
+
export interface TransferContext {
|
|
40
|
+
config: Config;
|
|
41
|
+
signer: Signer | null;
|
|
42
|
+
rpc: Rpc;
|
|
43
|
+
balances: (wallet: string) => Promise<Record<string, number>>;
|
|
44
|
+
prices: (mints: string[]) => Promise<Record<string, number>>;
|
|
45
|
+
decimalsOf: (mint: string) => Promise<number>;
|
|
46
|
+
/** Program that owns the mint (Token vs Token-2022); defaults to the classic Token program. */
|
|
47
|
+
getMintOwner?: (mint: string) => Promise<string | null>;
|
|
48
|
+
}
|
|
49
|
+
export declare function executeTransfer(ctx: TransferContext, a: TransferArgs): Promise<TransferOutcome>;
|
package/dist/transfer.js
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// PRD P13.2/P13.3 — SOL and SPL transfers built locally, no Potswap fee, recipient allowlist + caps.
|
|
2
|
+
import { Connection, PublicKey, SystemProgram, Transaction } from '@solana/web3.js';
|
|
3
|
+
import { createAssociatedTokenAccountIdempotentInstruction, createTransferCheckedInstruction, getAssociatedTokenAddressSync, TOKEN_2022_PROGRAM_ID, TOKEN_PROGRAM_ID } from '@solana/spl-token';
|
|
4
|
+
import { DEFAULTS, NO_WALLET, RECIPIENT_NOT_ALLOWED, SOL_MINT } from './config.js';
|
|
5
|
+
import { toRaw } from './format.js';
|
|
6
|
+
export function rpcFromConnection(conn) {
|
|
7
|
+
return {
|
|
8
|
+
getLatestBlockhash: (c) => conn.getLatestBlockhash(c ?? 'confirmed'),
|
|
9
|
+
getAccountOwner: async (a) => { const info = await conn.getAccountInfo(new PublicKey(a)); return info ? info.owner.toBase58() : null; },
|
|
10
|
+
sendRawTransaction: (raw) => conn.sendRawTransaction(raw, { skipPreflight: false, maxRetries: 3 }),
|
|
11
|
+
confirmTransaction: async (signature, blockhash, lastValidBlockHeight) => { const r = await conn.confirmTransaction({ signature, blockhash, lastValidBlockHeight }, 'confirmed'); return { err: r.value.err }; },
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
const BASE58 = /^[1-9A-HJ-NP-Za-km-z]{32,44}$/;
|
|
15
|
+
export async function executeTransfer(ctx, a) {
|
|
16
|
+
const { config, signer } = ctx;
|
|
17
|
+
if (!signer)
|
|
18
|
+
return { kind: 'no-wallet', message: NO_WALLET };
|
|
19
|
+
if (!BASE58.test(a.to))
|
|
20
|
+
return { kind: 'refused', message: 'Invalid recipient address. No funds were debited.' };
|
|
21
|
+
if (config.allowedRecipients.size && !config.allowedRecipients.has(a.to))
|
|
22
|
+
return { kind: 'refused', message: RECIPIENT_NOT_ALLOWED };
|
|
23
|
+
if (!(a.amount > 0))
|
|
24
|
+
return { kind: 'refused', message: 'Amount must be positive. No funds were debited.' };
|
|
25
|
+
const mint = a.mint ?? SOL_MINT;
|
|
26
|
+
const isSol = mint === SOL_MINT;
|
|
27
|
+
// Caps: per-transfer in SOL-equivalent, SOL reserve, balance.
|
|
28
|
+
let prices = {};
|
|
29
|
+
try {
|
|
30
|
+
prices = await ctx.prices([SOL_MINT, mint]);
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
prices = {};
|
|
34
|
+
}
|
|
35
|
+
const solEq = isSol ? a.amount : prices[mint] && prices[SOL_MINT] ? (a.amount * prices[mint]) / prices[SOL_MINT] : undefined;
|
|
36
|
+
if (solEq !== undefined && solEq > config.maxTradeSol)
|
|
37
|
+
return { kind: 'refused', message: `Refused by safety limit: ≈ ${solEq.toFixed(3)} SOL is above POTSWAP_MAX_TRADE_SOL (${config.maxTradeSol}). No funds were debited.` };
|
|
38
|
+
let bal = {};
|
|
39
|
+
try {
|
|
40
|
+
bal = await ctx.balances(signer.publicKey);
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
bal = {};
|
|
44
|
+
}
|
|
45
|
+
const have = bal[mint] ?? 0;
|
|
46
|
+
const spendable = isSol ? Math.max(have - DEFAULTS.solReserve, 0) : have;
|
|
47
|
+
if (a.amount > spendable)
|
|
48
|
+
return { kind: 'refused', message: isSol ? `Insufficient SOL: balance ${have.toFixed(4)}, keep at least ${DEFAULTS.solReserve} SOL for fees. No funds were debited.` : `Insufficient balance (${have}). No funds were debited.` };
|
|
49
|
+
const summary = `${a.amount} ${isSol ? 'SOL' : mint.slice(0, 4) + '…'} → ${a.to}`;
|
|
50
|
+
if (!a.confirm)
|
|
51
|
+
return { kind: 'quote-only', summary: `Transfer not sent (confirm=true to send): ${summary}` };
|
|
52
|
+
if (config.dryRun)
|
|
53
|
+
return { kind: 'dry-run', summary: `DRY RUN, nothing signed: ${summary}` };
|
|
54
|
+
try {
|
|
55
|
+
const from = new PublicKey(signer.publicKey);
|
|
56
|
+
const to = new PublicKey(a.to);
|
|
57
|
+
const tx = new Transaction();
|
|
58
|
+
if (isSol) {
|
|
59
|
+
tx.add(SystemProgram.transfer({ fromPubkey: from, toPubkey: to, lamports: BigInt(toRaw(a.amount, 9)) }));
|
|
60
|
+
}
|
|
61
|
+
else {
|
|
62
|
+
const mintPk = new PublicKey(mint);
|
|
63
|
+
const decimals = a.decimals ?? (await ctx.decimalsOf(mint));
|
|
64
|
+
const owner = await ctx.getMintOwner?.(mint);
|
|
65
|
+
const program = owner === TOKEN_2022_PROGRAM_ID.toBase58() ? TOKEN_2022_PROGRAM_ID : TOKEN_PROGRAM_ID;
|
|
66
|
+
const src = getAssociatedTokenAddressSync(mintPk, from, false, program);
|
|
67
|
+
const dst = getAssociatedTokenAddressSync(mintPk, to, true, program);
|
|
68
|
+
if (!(await ctx.rpc.getAccountOwner(dst.toBase58())))
|
|
69
|
+
tx.add(createAssociatedTokenAccountIdempotentInstruction(from, dst, to, mintPk, program));
|
|
70
|
+
tx.add(createTransferCheckedInstruction(src, mintPk, dst, from, BigInt(toRaw(a.amount, decimals)), decimals, [], program));
|
|
71
|
+
}
|
|
72
|
+
const { blockhash, lastValidBlockHeight } = await ctx.rpc.getLatestBlockhash('confirmed');
|
|
73
|
+
tx.recentBlockhash = blockhash;
|
|
74
|
+
tx.feePayer = from;
|
|
75
|
+
signer.signLegacy(tx);
|
|
76
|
+
const signature = await ctx.rpc.sendRawTransaction(tx.serialize());
|
|
77
|
+
const conf = await ctx.rpc.confirmTransaction(signature, blockhash, lastValidBlockHeight);
|
|
78
|
+
if (conf.err)
|
|
79
|
+
return { kind: 'failed', message: `Transfer failed on-chain: ${JSON.stringify(conf.err)}. Check https://solscan.io/tx/${signature}` };
|
|
80
|
+
return { kind: 'ok', signature, explorerUrl: `https://solscan.io/tx/${signature}`, summary: `Sent ${summary}` };
|
|
81
|
+
}
|
|
82
|
+
catch (e) {
|
|
83
|
+
const m = e instanceof Error ? e.message : String(e);
|
|
84
|
+
return { kind: 'failed', message: `${m}${/blockhash|expired/i.test(m) ? ' The transaction expired before landing; no funds were debited.' : ''}` };
|
|
85
|
+
}
|
|
86
|
+
}
|
package/dist/wallet.d.ts
CHANGED
|
@@ -1,10 +1,22 @@
|
|
|
1
|
-
import { type VersionedTransaction } from '@solana/web3.js';
|
|
1
|
+
import { type VersionedTransaction, type Transaction } from '@solana/web3.js';
|
|
2
2
|
export interface Signer {
|
|
3
3
|
publicKey: string;
|
|
4
4
|
sign(tx: VersionedTransaction): VersionedTransaction;
|
|
5
|
+
signLegacy(tx: Transaction): Transaction;
|
|
5
6
|
}
|
|
7
|
+
export declare const WALLET_FILE = "wallet.json";
|
|
6
8
|
export declare function parseSecret(raw: string): Uint8Array;
|
|
9
|
+
/**
|
|
10
|
+
* Resolution order: SOLANA_PRIVATE_KEY → SOLANA_KEYPAIR_PATH → `<stateDir>/wallet.json`
|
|
11
|
+
* (created by `wallet_create`). Returns null when none exists.
|
|
12
|
+
*/
|
|
7
13
|
export declare function loadSigner(opts: {
|
|
8
14
|
privateKey?: string;
|
|
9
15
|
keypairPath?: string;
|
|
16
|
+
stateDir?: string;
|
|
10
17
|
}): Signer | null;
|
|
18
|
+
/** PRD P13.1 — creates `<stateDir>/wallet.json` (0600). Refuses to overwrite. */
|
|
19
|
+
export declare function createWallet(stateDir: string): {
|
|
20
|
+
signer: Signer;
|
|
21
|
+
file: string;
|
|
22
|
+
};
|
package/dist/wallet.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
|
-
// PRD P10.2/P10.9 — load the agent's keypair; the secret never leaves this module.
|
|
2
|
-
import { readFileSync } from 'node:fs';
|
|
1
|
+
// PRD P10.2 / P10.9 / P13.1 — load or create the agent's keypair; the secret never leaves this module.
|
|
2
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
3
4
|
import { Keypair } from '@solana/web3.js';
|
|
4
5
|
import bs58 from 'bs58';
|
|
5
6
|
import { registerSecret } from './redact.js';
|
|
7
|
+
export const WALLET_FILE = 'wallet.json';
|
|
6
8
|
export function parseSecret(raw) {
|
|
7
9
|
const s = raw.trim();
|
|
8
10
|
if (s.startsWith('[')) {
|
|
@@ -24,6 +26,21 @@ export function parseSecret(raw) {
|
|
|
24
26
|
return Keypair.fromSeed(bytes).secretKey;
|
|
25
27
|
throw new Error(`Private key has ${bytes.length} bytes; expected 64 (or a 32-byte seed)`);
|
|
26
28
|
}
|
|
29
|
+
function toSigner(kp, raw) {
|
|
30
|
+
// Register every encoding the secret could leak in, not just the one supplied.
|
|
31
|
+
registerSecret(raw.trim());
|
|
32
|
+
registerSecret(bs58.encode(kp.secretKey));
|
|
33
|
+
registerSecret(JSON.stringify(Array.from(kp.secretKey)));
|
|
34
|
+
return {
|
|
35
|
+
publicKey: kp.publicKey.toBase58(),
|
|
36
|
+
sign(tx) { tx.sign([kp]); return tx; },
|
|
37
|
+
signLegacy(tx) { tx.sign(kp); return tx; },
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Resolution order: SOLANA_PRIVATE_KEY → SOLANA_KEYPAIR_PATH → `<stateDir>/wallet.json`
|
|
42
|
+
* (created by `wallet_create`). Returns null when none exists.
|
|
43
|
+
*/
|
|
27
44
|
export function loadSigner(opts) {
|
|
28
45
|
let raw = opts.privateKey;
|
|
29
46
|
if (!raw && opts.keypairPath) {
|
|
@@ -31,18 +48,30 @@ export function loadSigner(opts) {
|
|
|
31
48
|
raw = readFileSync(opts.keypairPath, 'utf8');
|
|
32
49
|
}
|
|
33
50
|
catch {
|
|
34
|
-
throw new Error(
|
|
51
|
+
throw new Error('Cannot read keypair file at SOLANA_KEYPAIR_PATH');
|
|
35
52
|
}
|
|
36
53
|
}
|
|
54
|
+
if (!raw && opts.stateDir) {
|
|
55
|
+
const file = join(opts.stateDir, WALLET_FILE);
|
|
56
|
+
if (existsSync(file))
|
|
57
|
+
raw = readFileSync(file, 'utf8');
|
|
58
|
+
}
|
|
37
59
|
if (!raw)
|
|
38
60
|
return null;
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
61
|
+
return toSigner(Keypair.fromSecretKey(parseSecret(raw)), raw);
|
|
62
|
+
}
|
|
63
|
+
/** PRD P13.1 — creates `<stateDir>/wallet.json` (0600). Refuses to overwrite. */
|
|
64
|
+
export function createWallet(stateDir) {
|
|
65
|
+
mkdirSync(stateDir, { recursive: true });
|
|
66
|
+
const file = join(stateDir, WALLET_FILE);
|
|
67
|
+
if (existsSync(file))
|
|
68
|
+
throw new Error(`A wallet already exists at ${file}. Back it up and remove it first if you really want a new one.`);
|
|
69
|
+
const kp = Keypair.generate();
|
|
70
|
+
const raw = JSON.stringify(Array.from(kp.secretKey));
|
|
71
|
+
writeFileSync(file, raw, { mode: 0o600 });
|
|
72
|
+
try {
|
|
73
|
+
chmodSync(file, 0o600);
|
|
74
|
+
}
|
|
75
|
+
catch { /* Windows */ }
|
|
76
|
+
return { signer: toSigner(kp, raw), file };
|
|
48
77
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@potswap_io/mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Potswap MCP server: best-route Solana swaps, DCA / rebalance / take-profit strategies for AI agents, non-custodial, earns POT.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -41,6 +41,7 @@
|
|
|
41
41
|
],
|
|
42
42
|
"dependencies": {
|
|
43
43
|
"@modelcontextprotocol/sdk": "^1.32.2",
|
|
44
|
+
"@solana/spl-token": "^0.4.15",
|
|
44
45
|
"@solana/web3.js": "^1.99.0",
|
|
45
46
|
"bs58": "^6.0.0",
|
|
46
47
|
"zod": "^4.6.7"
|